pediatric-ai-scribe-v3/src/utils/clinicalPromptPool.js
Daniel 97ddd87449
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
fix mobile assistant table streaming
2026-05-11 16:15:45 +02:00

212 lines
9.8 KiB
JavaScript

function positiveInt(value, fallback) {
var n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
function clip(text, max) {
text = String(text == null ? '' : text);
return text.length > max ? text.substring(0, max - 1).trimEnd() + '…' : text;
}
function parseJsonObject(text) {
text = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
try { return JSON.parse(text); } catch (e) {}
var start = text.indexOf('{');
var end = text.lastIndexOf('}');
if (start !== -1 && end > start) {
try { return JSON.parse(text.slice(start, end + 1)); } catch (e2) {}
}
return {};
}
function createClinicalPromptPool(opts) {
opts = opts || {};
var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000);
var refreshMs = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 24 * 60 * 60 * 1000);
var ttlSeconds = Math.max(3600, Math.ceil(refreshMs / 1000) * 2);
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200);
var redisKey = process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v1';
var memoryCache = { expiresAt: 0, examples: [] };
var refreshPromise = null;
async function getAvailableExamples() {
var now = Date.now();
if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples;
var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
memoryCache = { expiresAt: now + cacheMs, examples: cached.examples };
if (!cached.generatedAt || now - cached.generatedAt > refreshMs) refreshIfNeeded(false).catch(function() {});
return cached.examples;
}
refreshIfNeeded(false).catch(function() {});
var examples = await fallbackIndexedExamples();
memoryCache = { expiresAt: now + cacheMs, examples: examples };
return examples;
}
async function refreshIfNeeded(force) {
var now = Date.now();
var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && cached.generatedAt && now - cached.generatedAt < refreshMs) {
return cached.examples;
}
if (refreshPromise) return refreshPromise;
refreshPromise = buildCorpusPromptPool().then(function(examples) {
refreshPromise = null;
if (!examples.length) return [];
var payload = { generatedAt: Date.now(), examples: examples };
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
return opts.redisCache.setJson(redisKey, payload, ttlSeconds).then(function() { return examples; });
}).catch(function(e) {
refreshPromise = null;
console.warn('[clinical-assistant] prompt pool refresh failed:', e.message);
return [];
});
return refreshPromise;
}
async function buildCorpusPromptPool() {
var snippets = await collectPromptSeedSnippets();
if (!snippets.length) return fallbackIndexedExamples();
var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', '');
var generated = [];
var batches = Math.max(1, Math.min(40, Math.ceil(target / 25)));
for (var i = 0; i < batches && generated.length < target; i++) {
var batchSnippets = rotateExamples(snippets, i * 24).slice(0, 24);
var sourceText = batchSnippets.map(function(s, idx) {
return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 650);
}).join('\n\n');
var ai = await opts.callAI([
{
role: 'system',
content: 'Generate realistic pediatric clinical assistant starter questions from indexed source snippets. Use only the provided titles/snippets as inspiration. Do not answer the questions. Do not mention source names. Prefer broad practical clinical questions involving diagnosis, red flags, severity, treatment, dosing, fluids, admission/discharge, counseling, or differential diagnosis. Return strict JSON only: {"questions":[{"label":"2-5 word label","prompt":"question","category":"emergency|infectious disease|neonates|respiratory|gi|neurology|cardiology|endocrine|hematology|development|toxicology|procedures|dosing/fluids|red flags|differential|admission/discharge|counseling|general","intent":"diagnosis|management|red_flags|differential|counseling|dosing|admission|review"}]}.'
},
{ role: 'user', content: 'Indexed pediatric source snippets:\n\n' + sourceText + '\n\nCreate 35 varied starter questions. Make this batch different from common asthma, croup, and UTI examples when possible. Include a mix of emergencies, outpatient questions, red flags, diagnostics, treatment, counseling, fluids, dosing, admission/discharge, and differentials.' }
], {
model: chatModel || undefined,
temperature: 0.75,
maxTokens: 2600
});
var parsed = parseJsonObject(String(ai.content || ''));
generated = normalizeGeneratedExamples(generated.concat(parsed.questions || []));
}
if (generated.length < 8) return fallbackIndexedExamples();
return generated;
}
async function collectPromptSeedSnippets() {
var seeds = promptSeedQueries();
var seen = new Set();
var snippets = [];
for (var i = 0; i < seeds.length && snippets.length < 120; i++) {
try {
var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 700 });
opts.dedupeSources(opts.normalizeMcpSearchResponse(searchResponse)).forEach(function(source) {
var key = [source.title, source.page, source.chunk_index].join('|');
if (seen.has(key) || snippets.length >= 120) return;
seen.add(key);
snippets.push(source);
});
} catch (e) {
if (snippets.length === 0) throw e;
}
}
return snippets;
}
function promptSeedQueries() {
var seeds = (opts.exampleCandidates || []).map(function(item) { return item.prompt; });
Object.keys(opts.topicSuggestions || {}).forEach(function(key) {
seeds = seeds.concat(opts.topicSuggestions[key]);
});
seeds = seeds.concat([
'pediatric emergency red flags admission criteria treatment dosing',
'neonatal fever jaundice respiratory distress vomiting dehydration',
'childhood infectious diseases empiric antibiotics workup disposition',
'pediatric respiratory illness asthma bronchiolitis pneumonia croup',
'pediatric gastrointestinal dehydration abdominal pain bilious vomiting',
'developmental milestones anemia endocrine neurologic pediatric review'
]);
return shuffle(seeds).slice(0, 30);
}
function fallbackIndexedExamples() {
return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : [];
}
function normalizeGeneratedExamples(items) {
var seen = new Set();
var out = [];
(Array.isArray(items) ? items : []).forEach(function(item) {
var prompt = clip(String(item.prompt || item.question || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(), 220);
if (!isUsefulQuestion(prompt, item)) return;
var key = prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
if (seen.has(key)) return;
seen.add(key);
out.push({
label: cleanExampleLabel(item.label || prompt),
prompt: prompt,
category: clip(String(item.category || 'general'), 40),
intent: clip(String(item.intent || 'review'), 30)
});
});
return out.slice(0, target);
}
function isUsefulQuestion(prompt, item) {
if (!prompt || prompt.length < 25 || prompt.length > 220) return false;
if (prompt.indexOf('?') === -1) return false;
if (!hasPediatricSignal(prompt, item)) return false;
return !/\b(source|snippet|textbook|chapter|document|database)\b/i.test(prompt);
}
function hasPediatricSignal(prompt, item) {
if (/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return true;
var category = String(item && item.category || '').toLowerCase();
var intent = String(item && item.intent || '').toLowerCase();
return /\b(neonates?|development|respiratory|infectious disease|gi|neurology|cardiology|endocrine|hematology|toxicology|procedures|dosing\/fluids|red flags|differential|admission\/discharge|counseling|emergency|general)\b/.test(category) && /\b(diagnosis|management|red_flags|differential|counseling|dosing|admission|review)\b/.test(intent);
}
function cleanExampleLabel(label) {
label = String(label || '').replace(/[?!.:,;]+$/g, '').replace(/\s+/g, ' ').trim();
if (!label || label.length > 42) label = labelFromQuestion(label || 'Clinical question');
return clip(label, 42);
}
function labelFromQuestion(question) {
return String(question || 'Clinical question')
.replace(/^(how|what|when|which|why)\s+(should|do|does|can|are|is)\s+/i, '')
.replace(/\?.*$/, '')
.split(/\s+/)
.slice(0, 4)
.join(' ') || 'Clinical question';
}
function rotateExamples(items, seed) {
if (!items.length) return [];
var copy = items.slice();
var offset = Math.abs(Math.floor(seed)) % copy.length;
return copy.slice(offset).concat(copy.slice(0, offset));
}
function shuffle(items) {
var copy = items.slice();
for (var i = copy.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = copy[i];
copy[i] = copy[j];
copy[j] = tmp;
}
return copy;
}
return {
getAvailableExamples: getAvailableExamples,
refreshIfNeeded: refreshIfNeeded
};
}
module.exports = { createClinicalPromptPool };