212 lines
9.8 KiB
JavaScript
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 };
|