pediatric-ai-scribe-v3/src/routes/clinicalAssistant.js

1176 lines
50 KiB
JavaScript

// ============================================================
// CLINICAL ASSISTANT — OpenEvidence-style query surface.
// Auth-gated app route. Retrieves indexed Nextcloud content through
// native MCP, then synthesizes a citation-grounded answer through the
// app's existing AI provider/LiteLLM configuration.
// ============================================================
var express = require('express');
var axios = require('axios');
var fs = require('fs');
var path = require('path');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { callAI } = require('../utils/ai');
var { gatewayUrl } = require('../utils/errors');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto');
router.use(authMiddleware);
var MCP_URLS = buildMcpUrls();
var _lastGoodMcpUrl = MCP_URLS[0];
var DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources, cite claims with numbered citations like [1], and list sources at the bottom by title/resource and page. Do not invent citations.';
var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|ok|okay|sup)[\s.!?]*$/i;
var EXAMPLE_CACHE_MS = 10 * 60 * 1000;
var _exampleCache = { expiresAt: 0, examples: [] };
var MAX_SAVED_CHATS_PER_USER = 100;
var MAX_SAVED_CHAT_PAYLOAD = 250000;
var MAX_SAVED_CHAT_TITLE = 160;
var MCP_MULTIMODAL_IMAGE_ROOT = path.resolve(process.env.CLINICAL_ASSISTANT_MULTIMODAL_IMAGE_ROOT || '/app/mcp-data/multimodal-pages');
var MCP_MULTIMODAL_INTERNAL_ROOT = path.resolve(process.env.CLINICAL_ASSISTANT_MULTIMODAL_INTERNAL_ROOT || '/app/data/multimodal-pages');
var MULTIMODAL_EMBEDDING_URL = process.env.CLINICAL_ASSISTANT_MULTIMODAL_EMBEDDING_URL || 'http://multimodal-embeddings:7999/embed';
var VISUAL_CLASSIFIER_ENABLED = process.env.CLINICAL_ASSISTANT_VISUAL_CLASSIFIER !== 'false';
var VISUAL_CLASSIFIER_LIMIT = 6;
var VISUAL_CLASSIFIER_MIN_CONFIDENCE = Number(process.env.CLINICAL_ASSISTANT_VISUAL_CLASSIFIER_MIN_CONFIDENCE || '0.005');
var EXAMPLE_CANDIDATES = [
{ label: 'Status asthma escalation', prompt: 'In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?' },
{ label: 'Febrile neonate', prompt: 'What initial workup and empiric antibiotics are recommended for a well-appearing febrile neonate?' },
{ label: 'Sepsis vasoactives', prompt: 'In pediatric septic shock, when should fluid boluses be limited and vasoactives started?' },
{ label: 'Toxic ingestion', prompt: 'How should an unknown pediatric ingestion be initially assessed and when is activated charcoal appropriate?' },
{ label: 'Meningitis antibiotics', prompt: 'What empiric antibiotics are recommended for suspected bacterial meningitis by pediatric age group?' },
{ label: 'Sickle fever', prompt: 'How should fever in a child with sickle cell disease be evaluated and treated?' },
{ label: 'Head injury CT', prompt: 'Which pediatric head injury features should prompt CT imaging or ED observation?' },
{ label: 'Anaphylaxis IM epi', prompt: 'What is the recommended IM epinephrine dose for pediatric anaphylaxis and when should it be repeated?' },
{ label: 'Dehydration fluids', prompt: 'How should dehydration severity guide oral rehydration versus IV fluids in children?' },
{ label: 'Neonatal jaundice', prompt: 'Which neonatal jaundice risk factors change phototherapy thresholds or urgency?' },
{ label: 'UTI imaging', prompt: 'When should children with febrile UTI receive renal ultrasound or further imaging?' },
{ label: 'Kawasaki workup', prompt: 'What clinical and laboratory features support incomplete Kawasaki disease evaluation?' },
{ label: 'Intussusception', prompt: 'What are the red flags, diagnostic steps, and reduction approach for suspected intussusception?' },
{ label: 'NAT red flags', prompt: 'What injury patterns and history features should raise concern for non-accidental trauma?' },
{ label: 'Status epilepticus', prompt: 'Outline first- and second-line treatment for pediatric status epilepticus with typical dosing.' },
{ label: 'Croup escalation', prompt: 'When should a child with croup receive nebulized epinephrine, and how long should they be observed?' },
{ label: 'Bilious vomiting', prompt: 'What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?' },
{ label: 'Bronchiolitis oxygen', prompt: 'What supportive care and oxygen/admission criteria are recommended for infant bronchiolitis?' },
{ label: 'DKA cerebral edema', prompt: 'What warning signs suggest cerebral edema during pediatric DKA treatment, and what immediate treatment is recommended?' },
{ label: 'Neonatal resuscitation', prompt: 'Summarize the first minute steps in neonatal resuscitation and when positive pressure ventilation is indicated.' }
];
var TOPIC_SUGGESTIONS = {
asthma: [
'Management of acute asthma exacerbation by severity',
'When to give magnesium sulfate in status asthmaticus',
'Bronchiolitis versus asthma in infants with wheeze',
'Discharge criteria after pediatric asthma exacerbation',
'Controller therapy step-up after ED asthma visit'
],
bronchiolitis: [
'Supportive management of bronchiolitis',
'When bronchodilator trial is appropriate in bronchiolitis',
'Admission and oxygen criteria for bronchiolitis',
'Bronchiolitis versus early asthma',
'High-flow nasal cannula use in bronchiolitis'
],
fever: [
'Fever without source by age group',
'Febrile neonate initial workup and antibiotics',
'Risk stratification for serious bacterial infection',
'When to do lumbar puncture in febrile infants',
'Fever in immunocompromised children'
],
seizure: [
'Status epilepticus first-line management',
'Febrile seizure evaluation and counseling',
'Second-line antiseizure medications in children',
'When neuroimaging is indicated after seizure',
'Seizure mimics in pediatrics'
],
croup: [
'Croup severity scoring and treatment',
'When to use nebulized epinephrine for croup',
'Dexamethasone dosing in croup',
'Observation after racemic epinephrine',
'Differential diagnosis for stridor'
],
dehydration: [
'Clinical assessment of pediatric dehydration',
'Oral rehydration therapy protocol',
'IV fluid bolus and maintenance strategy',
'Hypernatremic dehydration precautions',
'Discharge criteria after gastroenteritis'
]
};
router.get('/clinical-assistant/status', async function(req, res) {
try {
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var imageModel = await getSetting('clinical_assistant.image_model', '');
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400);
var mcpHealth = await getMcpHealth();
res.json({
success: true,
chatModel: chatModel,
imageModel: imageModel,
searchLimit: searchLimit,
contextChars: contextChars,
mcp: mcpHealth
});
} catch (e) {
res.status(500).json({ error: 'Request failed' });
}
});
router.get('/clinical-assistant/examples', async function(req, res) {
try {
var examples = await getAvailableExamples();
res.json({ success: true, examples: examples });
} catch (e) {
console.warn('[clinical-assistant] example discovery failed:', e.message);
res.json({ success: true, examples: [] });
}
});
router.get('/clinical-assistant/source-image/:token', async function(req, res) {
try {
var imagePath = resolveMultimodalImagePath(req.params.token || '');
if (!imagePath) return res.status(404).send('Not found');
res.set('Cache-Control', 'private, max-age=3600');
res.type('png');
res.sendFile(imagePath);
} catch (e) {
console.warn('[clinical-assistant] source image rejected:', e.message);
res.status(404).send('Not found');
}
});
router.get('/clinical-assistant/chats', async function(req, res) {
try {
var rows = await db.all(
'SELECT id, title, created_at, updated_at FROM clinical_assistant_chats WHERE user_id = $1 ORDER BY updated_at DESC LIMIT 100',
[req.user.id]
);
rows.forEach(function(row) {
try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {}
});
res.json({ success: true, chats: rows });
} catch (e) {
logger.error('GET /clinical-assistant/chats', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.get('/clinical-assistant/chats/:id', async function(req, res) {
try {
var row = await db.get(
'SELECT id, title, payload, created_at, updated_at FROM clinical_assistant_chats WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!row) return res.status(404).json({ error: 'Saved chat not found' });
try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {}
try { row.payload = JSON.parse(cryptoUtil.decryptString(row.payload) || '{}'); } catch (e) { row.payload = {}; }
res.json({ success: true, chat: row });
} catch (e) {
logger.error('GET /clinical-assistant/chats/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.post('/clinical-assistant/chats', async function(req, res) {
try {
var title = cleanSavedChatTitle(req.body.title || firstUserMessage(req.body.messages) || 'Clinical assistant chat');
var payload = buildSavedChatPayload(req.body);
var payloadText = JSON.stringify(payload);
if (payloadText.length > MAX_SAVED_CHAT_PAYLOAD) return res.status(400).json({ error: 'Saved chat is too large' });
var count = await db.get('SELECT COUNT(*) as cnt FROM clinical_assistant_chats WHERE user_id = $1', [req.user.id]);
if (count && Number(count.cnt) >= MAX_SAVED_CHATS_PER_USER) {
return res.status(400).json({ error: 'Maximum ' + MAX_SAVED_CHATS_PER_USER + ' saved chats per user' });
}
var result = await db.run(
'INSERT INTO clinical_assistant_chats (user_id, title, payload) VALUES ($1, $2, $3) RETURNING id',
[req.user.id, cryptoUtil.encryptString(title), cryptoUtil.encryptString(payloadText)]
);
logger.audit(req.user.id, 'clinical_assistant_chat_save', 'Saved clinical assistant chat', req, { category: 'clinical' });
res.json({ success: true, id: result.lastInsertRowid, title: title });
} catch (e) {
logger.error('POST /clinical-assistant/chats', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.delete('/clinical-assistant/chats/:id', async function(req, res) {
try {
await db.run('DELETE FROM clinical_assistant_chats WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
logger.audit(req.user.id, 'clinical_assistant_chat_delete', 'Deleted clinical assistant chat', req, { category: 'clinical' });
res.json({ success: true });
} catch (e) {
logger.error('DELETE /clinical-assistant/chats/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.post('/clinical-assistant/chat', async function(req, res) {
var started = Date.now();
try {
var message = String(req.body.message || '').trim();
if (!message) return res.status(400).json({ error: 'Question is required' });
if (message.length > 4000) return res.status(400).json({ error: 'Question too long' });
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400);
var behavior = await getSetting('clinical_assistant.system_behavior', DEFAULT_BEHAVIOR) || DEFAULT_BEHAVIOR;
var includeContext = req.body.includeContext !== false;
if (GREETING_RE.test(message)) {
return res.json({
success: true,
answer: 'What clinical question would you like me to look up in the indexed pediatric resources?',
sources: [],
model: null,
search: { skipped: true, reason: 'low_information_input' }
});
}
var suggestions = getTopicSuggestions(message);
if (suggestions.length > 0 && message.split(/\s+/).length < 4) {
return res.json({
success: true,
answer: buildSuggestionAnswer(message, suggestions),
suggestions: suggestions,
sources: [],
model: null,
search: { skipped: true, reason: 'topic_suggestions' }
});
}
var history = Array.isArray(req.body.history) ? req.body.history.slice(-8) : [];
var searchQuery = await rewriteSearchQuery(message, history, chatModel).catch(function(e) {
console.warn('[clinical-assistant] query rewrite skipped:', e.message);
return message;
});
var searchResponse = await semanticSearch(searchQuery, {
limit: searchLimit,
includeContext: includeContext,
contextChars: contextChars
});
var visualQuery = isVisualSourceQuery(message) || isVisualSourceQuery(searchQuery);
var multimodalResponse = visualQuery ? await multimodalSearch(buildMultimodalSearchQuery(searchQuery), { limit: 8 }).catch(function(e) {
console.warn('[clinical-assistant] multimodal search skipped:', e.message);
return null;
}) : null;
var rawTextResults = normalizeMcpSearchResponse(searchResponse);
var rawMultimodalResults = await classifyAndRerankMultimodalResults(
message + ' ' + searchQuery,
normalizeMcpMultimodalResponse(multimodalResponse)
);
var rawResults = rawTextResults.concat(rawMultimodalResults);
var visualSlots = rawMultimodalResults.length ? Math.min(2, Math.max(1, Math.floor(searchLimit / 4))) : 0;
var textSlots = searchLimit - visualSlots;
var sources = dedupeSources(
dedupeSources(rawTextResults).slice(0, textSlots).concat(
dedupeSources(rawMultimodalResults).slice(0, visualSlots)
)
).slice(0, searchLimit);
if (sources.length === 0) {
return res.json({
success: true,
answer: 'I could not find relevant indexed Nextcloud resources for that question. Try a more specific clinical term, diagnosis, medication, age group, or textbook topic.',
sources: [],
model: null,
search: { totalFound: 0 }
});
}
var context = formatSourcesForPrompt(sources);
var messages = [
{ role: 'system', content: buildSystemPrompt(behavior) },
{ role: 'user', content: buildUserPrompt(message, context, history, searchQuery) }
];
var ai = await callAI(messages, {
model: chatModel || undefined,
temperature: 0.15,
maxTokens: 1800
});
var answer = stripModelSourcesSection(String(ai.content || '').trim());
var normalized = normalizeCitationOrder(answer, sources);
answer = normalized.answer;
sources = normalized.sources;
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
category: 'clinical', model: ai.model || chatModel, duration: Date.now() - started
});
res.json({
success: true,
answer: answer,
sources: sanitizeSourcesForClient(sources),
model: ai.model || chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
search: {
totalFound: rawResults.length,
multimodalFound: rawMultimodalResults.length,
query: searchQuery,
rewritten: searchQuery !== message,
verifiedChunkCount: searchResponse.verified_chunk_count || searchResponse.verifiedChunkCount || 0,
droppedDocumentCount: searchResponse.dropped_document_count || searchResponse.droppedDocumentCount || 0
}
});
} catch (e) {
console.error('[clinical-assistant]', e.message, e.stack || '');
res.status(500).json({ error: assistantErrorMessage(e) });
}
});
router.post('/clinical-assistant/image', async function(req, res) {
try {
var prompt = String(req.body.prompt || '').trim();
if (!prompt) return res.status(400).json({ error: 'Prompt is required' });
if (prompt.length > 5000) prompt = prompt.substring(0, 5000);
var model = await getSetting('clinical_assistant.image_model', '') || process.env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1';
var image = await generateImage(prompt, model);
logger.audit(req.user.id, 'clinical_assistant_image', 'Generated clinical assistant image', req, { category: 'clinical', model: model });
res.json(Object.assign({ success: true, model: model }, image));
} catch (e) {
var detail = e.response && e.response.data ? JSON.stringify(e.response.data).substring(0, 300) : e.message;
res.status(500).json({ error: detail || 'Image generation failed' });
}
});
router.post('/clinical-assistant/export-summary', async function(req, res) {
try {
if (Array.isArray(req.body.items) && req.body.items.length) {
var exportItems = await summarizeExportItems(req.body.items);
return res.json({ success: true, items: exportItems });
}
var answer = String(req.body.answer || '').trim();
var sources = Array.isArray(req.body.sources) ? req.body.sources.slice(0, 20) : [];
if (!answer) return res.status(400).json({ error: 'Answer is required' });
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var sourceList = sources.map(function(s, idx) {
var n = s.number || idx + 1;
return '[' + n + '] ' + cleanTitle(s.title || s.resource || 'Source') + (s.page ? ', page ' + s.page : '');
}).join('\n');
var ai = await callAI([
{
role: 'system',
content: 'Create an abridged clinical export summary. Preserve factual meaning. Use only citation numbers already present in the original answer and provided source list. Do not invent, renumber, merge, or move citations. If a claim cannot keep its citation, omit the claim. No references section.'
},
{
role: 'user',
content: 'Original answer:\n' + answer.substring(0, 8000) + '\n\nAvailable cited sources:\n' + sourceList + '\n\nReturn a concise export version with the same citation numbers where supported.'
}
], {
model: chatModel || undefined,
temperature: 0.05,
maxTokens: 900
});
var summary = stripModelSourcesSection(String(ai.content || '').trim());
res.json({ success: true, summary: summary || answer, model: ai.model || chatModel || null });
} catch (e) {
res.status(500).json({ error: assistantErrorMessage(e) });
}
});
async function rewriteSearchQuery(message, history, chatModel) {
message = String(message || '').trim();
history = Array.isArray(history) ? history.filter(function(m) { return m && (m.role === 'user' || m.role === 'assistant') && m.content; }).slice(-8) : [];
if (history.length && history[history.length - 1].role === 'user' && String(history[history.length - 1].content || '').trim() === message) {
history = history.slice(0, -1);
}
if (!history.length || !needsContextualRewrite(message)) return message;
var hist = history.map(function(m) {
return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 900);
}).join('\n');
var ai = await callAI([
{
role: 'system',
content: 'Rewrite short or ambiguous clinical follow-up questions into one standalone search query for medical document retrieval. Preserve the user intent and clinical facts from the conversation. Do not answer. Do not add facts not supported by the conversation. Return only the rewritten query.'
},
{
role: 'user',
content: 'Conversation:\n' + hist + '\n\nLatest user question:\n' + message + '\n\nStandalone search query:'
}
], {
model: chatModel || undefined,
temperature: 0,
maxTokens: 80
});
var rewritten = String(ai.content || '').replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ').trim();
if (!rewritten || rewritten.length < 6 || rewritten.length > 300) return message;
if (/^(yes|no|maybe|i don'?t know)$/i.test(rewritten)) return message;
return rewritten;
}
function needsContextualRewrite(message) {
var text = String(message || '').trim();
if (!text) return false;
var words = text.split(/\s+/).filter(Boolean);
return words.length <= 8 || /\b(it|this|that|they|them|he|she|dose|dosing|how much|what about|next|admit|discharge|criteria|side effects?|contraindications?|monitor|monitoring)\b/i.test(text);
}
async function summarizeExportItems(items) {
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var clean = items.slice(0, 20).map(function(item, idx) {
return {
question: clip(item.question || ('Question ' + (idx + 1)), 1000),
answer: clip(item.answer || '', 12000),
sources: Array.isArray(item.sources) ? item.sources.slice(0, 25) : []
};
}).filter(function(item) { return item.answer; });
var out = [];
for (var i = 0; i < clean.length; i++) {
out.push(await summarizeExportItem(clean[i], i, chatModel));
}
return out;
}
async function summarizeExportItem(item, idx, chatModel) {
var sourceList = item.sources.map(function(s, sidx) {
var n = s.number || sidx + 1;
return '[' + n + '] ' + cleanTitle(s.title || s.resource || 'Source') + (s.page ? ', page ' + s.page : '');
}).join('\n');
try {
var ai = await callAI([
{
role: 'system',
content: 'Create a clinical export section heading and abridged summary. Return strict JSON only: {"heading":"...","summary":"..."}. The heading must be specific and clinically meaningful, not a vague follow-up like "What dose?". Preserve factual meaning. Use only citation numbers already present in the answer and provided source list. Do not invent, renumber, merge, or move citations. No references section.'
},
{
role: 'user',
content: 'User question:\n' + item.question + '\n\nAnswer:\n' + item.answer + '\n\nAvailable cited sources:\n' + sourceList
}
], {
model: chatModel || undefined,
temperature: 0.05,
maxTokens: 700
});
var parsed = parseJsonObject(String(ai.content || ''));
return {
question: item.question,
heading: cleanExportHeading(parsed.heading || item.question, idx),
summary: stripModelSourcesSection(String(parsed.summary || '').trim()) || item.answer,
answer: item.answer,
sources: item.sources
};
} catch (e) {
return {
question: item.question,
heading: cleanExportHeading(item.question, idx),
summary: item.answer,
answer: item.answer,
sources: item.sources
};
}
}
async function semanticSearch(query, opts) {
return callMcpTool('nc_semantic_search', {
query: query,
limit: opts.limit,
doc_types: ['file'],
score_threshold: 0,
fusion: 'rrf',
include_context: opts.includeContext,
context_chars: opts.contextChars
});
}
async function multimodalSearch(query, opts) {
return callMcpTool('nc_multimodal_search', {
query: query,
limit: opts.limit
});
}
async function callMcpTool(name, args) {
var session = await mcpRequest({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
}
});
var search = await mcpRequest({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: name,
arguments: args
}
}, session.sessionId, session.mcpUrl);
return search.result || search;
}
async function mcpRequest(payload, sessionId, preferredUrl) {
var headers = {
'Accept': 'application/json, text/event-stream',
'Content-Type': 'application/json'
};
if (sessionId) headers['mcp-session-id'] = sessionId;
var resp = await postMcpWithRetry(payload, headers, preferredUrl);
var parsed = parseMcpResponse(resp.data);
if (parsed.error) throw new Error(parsed.error.message || JSON.stringify(parsed.error));
parsed.sessionId = resp.headers['mcp-session-id'] || sessionId || null;
parsed.mcpUrl = resp.config && resp.config.url ? resp.config.url : preferredUrl || _lastGoodMcpUrl;
return parsed;
}
async function postMcpWithRetry(payload, headers, preferredUrl) {
var lastErr = null;
var urls = orderedMcpUrls(preferredUrl);
var isInitialize = payload && payload.method === 'initialize';
for (var round = 0; round < 2; round++) {
for (var i = 0; i < urls.length; i++) {
var url = urls[i];
try {
var resp = await axios.post(url, payload, {
headers: headers,
timeout: isInitialize ? 8000 : 90000,
responseType: 'text',
transformResponse: [function(data) { return data; }]
});
_lastGoodMcpUrl = url;
return resp;
} catch (e) {
lastErr = e;
if (!isTransientMcpError(e)) throw e;
console.warn('[clinical-assistant] MCP endpoint unavailable:', url, e.code || e.message);
}
}
await sleep(750 * (round + 1));
}
throw lastErr;
}
function orderedMcpUrls(preferredUrl) {
var candidates = [];
[preferredUrl, _lastGoodMcpUrl].concat(MCP_URLS).forEach(function(url) {
if (url && candidates.indexOf(url) === -1) candidates.push(url);
});
return candidates;
}
function isTransientMcpError(e) {
var code = e && (e.code || (e.cause && e.cause.code)) || '';
var status = e && e.response && e.response.status;
return code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ETIMEDOUT' || status === 502 || status === 503 || status === 504;
}
async function getMcpHealth() {
var results = [];
var urls = orderedMcpUrls();
for (var i = 0; i < urls.length; i++) {
var mcpUrl = urls[i];
var healthUrl = mcpUrl.replace(/\/mcp\/?$/, '/health/live');
try {
var resp = await axios.get(healthUrl, { timeout: 3000 });
results.push({ url: mcpUrl, healthUrl: healthUrl, ok: true, status: resp.status, data: resp.data });
_lastGoodMcpUrl = mcpUrl;
return { ok: true, activeUrl: mcpUrl, checked: results, candidates: MCP_URLS };
} catch (e) {
results.push({ url: mcpUrl, healthUrl: healthUrl, ok: false, error: e.code || e.message });
}
}
return { ok: false, activeUrl: _lastGoodMcpUrl || null, checked: results, candidates: MCP_URLS };
}
function buildMcpUrls() {
var raw = [];
if (process.env.CLINICAL_ASSISTANT_MCP_URLS) raw = raw.concat(process.env.CLINICAL_ASSISTANT_MCP_URLS.split(','));
raw.push(process.env.CLINICAL_ASSISTANT_MCP_URL || process.env.MCP_SERVER_URL || '');
raw.push('http://mcp:8000/mcp');
raw.push('http://mcp-server-mcp-1:8000/mcp');
raw.push('http://127.0.0.1:8100/mcp');
var out = [];
raw.forEach(function(url) {
url = String(url || '').trim();
if (!url) return;
if (!/\/mcp\/?$/.test(url)) url = url.replace(/\/$/, '') + '/mcp';
if (out.indexOf(url) === -1) out.push(url);
});
return out;
}
function sleep(ms) {
return new Promise(function(resolve) { setTimeout(resolve, ms); });
}
function parseMcpResponse(body) {
var text = String(body || '').trim();
if (!text) return {};
if (text.charAt(0) === '{') return JSON.parse(text);
var lines = text.split(/\r?\n/);
for (var i = 0; i < lines.length; i++) {
if (lines[i].indexOf('data:') === 0) {
return JSON.parse(lines[i].substring(5).trim());
}
}
throw new Error('Unexpected MCP response format');
}
function normalizeMcpSearchResponse(result) {
var data = result && (result.structuredContent || result.data || result);
if ((!data || !Array.isArray(data.results)) && result && Array.isArray(result.content)) {
for (var i = 0; i < result.content.length; i++) {
var c = result.content[i];
if (c && c.type === 'text' && c.text) {
try {
var parsed = JSON.parse(c.text);
if (parsed && Array.isArray(parsed.results)) data = parsed;
} catch (e) {}
}
}
}
if (!data || !Array.isArray(data.results)) return [];
return data.results.map(function(r, idx) {
var text = [r.before_context, r.excerpt, r.after_context].filter(Boolean).join('\n').trim() || r.marked_text || r.excerpt || '';
return {
number: idx + 1,
id: r.id,
doc_type: r.doc_type,
title: cleanTitle(r.title || 'Untitled resource'),
page: r.page_number || r.pageNumber || null,
page_count: r.page_count || r.pageCount || null,
folder_path: r.folder_path || r.folderPath || '',
category: r.category || '',
source_priority: r.source_priority || r.sourcePriority || '',
source_boost: r.source_boost || r.sourceBoost || null,
tags: Array.isArray(r.tags) ? r.tags : [],
excerpt: clip(text, 1800),
score: r.score,
chunk_index: r.chunk_index,
total_chunks: r.total_chunks
};
}).filter(function(r) { return r.excerpt; });
}
function normalizeMcpMultimodalResponse(result) {
var data = result && (result.structuredContent || result.data || result);
if ((!data || !Array.isArray(data.results)) && result && Array.isArray(result.content)) {
for (var i = 0; i < result.content.length; i++) {
var c = result.content[i];
if (c && c.type === 'text' && c.text) {
try {
var parsed = JSON.parse(c.text);
if (parsed && Array.isArray(parsed.results)) data = parsed;
} catch (e) {}
}
}
}
if (!data || !Array.isArray(data.results)) return [];
return data.results.map(function(r, idx) {
var nearby = clip(cleanSourceExcerpt(r.nearby_text || ''), 1400);
return {
number: idx + 1,
id: r.id,
doc_type: r.doc_type || 'file',
source_type: 'multimodal_page',
title: cleanTitle(r.title || r.file_path || 'PDF page image'),
file_path: r.file_path || '',
category: r.category || '',
subcategory: r.subcategory || '',
category_path: r.category_path || '',
page: r.page_number || r.pageNumber || null,
page_count: r.page_count || r.pageCount || null,
excerpt: nearby ? '[Page-image match] ' + nearby : '[Page-image match] Rendered PDF page matched the visual/text query.',
score: r.score,
image_path_internal: r.image_path || '',
image_url: multimodalImageUrl(r.image_path),
chunk_index: 'page-image-' + (r.page_number || idx + 1),
total_chunks: r.page_count || null
};
}).filter(function(r) { return r.page; });
}
function dedupeSources(results) {
var seen = new Set();
var out = [];
results.forEach(function(r) {
var key = [r.title, r.page || '', r.chunk_index || ''].join('|');
if (seen.has(key)) return;
seen.add(key);
out.push(Object.assign({}, r, { number: out.length + 1 }));
});
return out;
}
function formatSourcesForPrompt(sources) {
return sources.map(function(s) {
var label = s.source_type === 'multimodal_page' ? ' [visual PDF page match]' : '';
return '[' + s.number + '] ' + s.title + (s.page ? ', page ' + s.page : '') + label + '\n' + cleanSourceExcerpt(s.excerpt);
}).join('\n\n---\n\n');
}
function buildSystemPrompt(behavior) {
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- Use numbered citations [1], [2] for factual claims, matching the provided source numbers.\n- If sources disagree or are insufficient, say so.\n- Do not cite a source number that is not provided.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
}
function buildUserPrompt(question, context, history, searchQuery) {
var hist = history.filter(function(m) { return m && (m.role === 'user' || m.role === 'assistant') && m.content; })
.map(function(m) { return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 1000); }).join('\n');
var searchNote = searchQuery && searchQuery !== question ? ('\n\nStandalone retrieval query used:\n' + searchQuery) : '';
return 'Question:\n' + question + searchNote + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved indexed sources:\n' + context + '\n\nWrite the answer now.';
}
function stripModelSourcesSection(answer) {
return String(answer || '')
.replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '')
.replace(/\n\s*>?\s*(⚠️\s*)?Clinical decision support[^\n]*$/gim, '')
.trim();
}
function normalizeCitationOrder(answer, sources) {
sources = Array.isArray(sources) ? sources : [];
var used = [];
var oldToNew = {};
var re = /\[((?:\d+\s*,\s*)*\d+)\]/g;
var m;
while ((m = re.exec(answer))) {
parseCitationCluster(m[1]).forEach(function(oldNum) {
if (!sources[oldNum - 1] || oldToNew[oldNum]) return;
oldToNew[oldNum] = used.length + 1;
used.push(oldNum);
});
}
var reordered = [];
used.forEach(function(oldNum) { reordered.push(sources[oldNum - 1]); });
sources.forEach(function(source, idx) {
if (used.indexOf(idx + 1) === -1) reordered.push(source);
});
reordered = reordered.map(function(source, idx) {
return Object.assign({}, source, { number: idx + 1 });
});
var rewritten = answer.replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function(match, cluster) {
var oldNums = parseCitationCluster(cluster);
var nums = oldNums.map(function(oldNum) { return oldToNew[oldNum]; });
if (!nums.length || nums.some(function(n) { return !n; })) return match;
nums = Array.from(new Set(nums)).sort(function(a, b) { return a - b; });
return '[' + nums.join(', ') + ']';
});
return { answer: rewritten, sources: reordered };
}
function parseCitationCluster(cluster) {
return String(cluster || '').split(',').map(function(n) { return Number(n.trim()); }).filter(function(n) { return Number.isInteger(n) && n > 0; });
}
function isVisualSourceQuery(query) {
return visualIntent(query).wanted.length > 0;
}
function isRadiologyQuery(query) {
return visualIntent(query).wanted.indexOf('radiology') !== -1;
}
function buildMultimodalSearchQuery(query) {
query = String(query || '').trim();
var intent = visualIntent(query);
if (intent.wanted.indexOf('radiology') !== -1) return query + ' radiograph x-ray chest imaging radiology';
if (intent.wanted.indexOf('diagram') !== -1) return query + ' figure diagram pathway flowchart algorithm illustration';
if (intent.wanted.indexOf('photo') !== -1) return query + ' clinical photograph image skin lesion pathology microscopy';
return query;
}
async function classifyAndRerankMultimodalResults(query, results) {
results = Array.isArray(results) ? results : [];
if (!results.length) return [];
var candidates = results.slice(0, VISUAL_CLASSIFIER_LIMIT);
if (!VISUAL_CLASSIFIER_ENABLED) return selectMultimodalResults(query, candidates);
var intent = visualIntent(query);
var classified = [];
for (var i = 0; i < candidates.length; i++) {
var candidate = candidates[i];
var classification = await classifyVisualSource(candidate).catch(function(e) {
console.warn('[clinical-assistant] visual classifier skipped candidate:', e.message);
return null;
});
if (!classification) continue;
var metaScore = visualMetadataScore(query, candidate);
var confident = classification.confidence >= VISUAL_CLASSIFIER_MIN_CONFIDENCE || metaScore > 0.1;
var accepted = intent.wanted.indexOf(classification.kind) !== -1 && confident;
if (!accepted) continue;
candidate.visual_kind = classification.kind;
candidate.visual_confidence = classification.confidence;
candidate.source_priority = classification.kind;
candidate.visual_score = (Number(candidate.score) || 0) + (classification.confidence * 0.35) + metaScore;
classified.push(candidate);
}
if (classified.length) {
return classified.sort(function(a, b) { return (b.visual_score || 0) - (a.visual_score || 0); });
}
return [];
}
function selectMultimodalResults(query, results) {
results = Array.isArray(results) ? results : [];
if (!results.length) return [];
if (!isRadiologyQuery(query)) return results.filter(function(r) { return !looksLikeTextOnlyPage(r); });
var radiologyTerms = /\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film|pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i;
return results.filter(function(r) {
return radiologyTerms.test([r.title, r.excerpt, r.file_path, r.category, r.subcategory, r.category_path].filter(Boolean).join(' '));
});
}
function visualIntent(query) {
var text = String(query || '').toLowerCase();
var wanted = [];
if (/\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film)\b/i.test(text)) {
wanted.push('radiology');
}
if (/\b(algorithm|flow\s*chart|flowchart|pathway|diagram|graph|chart|figure|figures|illustration|infographic|schema|schematic|table)\b/i.test(text)) {
wanted.push('diagram');
}
if (/\b(photo|photograph|picture|skin|rash|lesion|wound|gross|microscopy|histology|pathology image|clinical image)\b/i.test(text)) {
wanted.push('photo');
}
if (!wanted.length && /\b(visual|image|images|show|find|display|preview)\b/i.test(text)) {
wanted.push('diagram', 'photo');
}
return { wanted: Array.from(new Set(wanted)) };
}
async function classifyVisualSource(source) {
var imagePath = resolveRawMultimodalImagePath(source.image_path_internal || '');
if (!imagePath) throw new Error('No local page image');
var imageBase64 = fs.readFileSync(imagePath).toString('base64');
var labels = visualClassifierLabels();
var inputs = [{ text: 'Classify this medical document page image.', image_base64: imageBase64 }].concat(
labels.map(function(label) { return { text: label.prompt }; })
);
var resp = await axios.post(MULTIMODAL_EMBEDDING_URL, { inputs: inputs }, { timeout: 45000 });
var vectors = resp.data && Array.isArray(resp.data.embeddings) ? resp.data.embeddings : [];
if (vectors.length < labels.length + 1) throw new Error('Classifier embedding response was incomplete');
var imageVector = vectors[0];
var scored = labels.map(function(label, idx) {
return Object.assign({}, label, { score: cosineSimilarity(imageVector, vectors[idx + 1]) });
}).sort(function(a, b) { return b.score - a.score; });
var best = scored[0];
var second = scored[1] || { score: best.score };
return {
kind: best.kind,
confidence: Math.max(0, best.score - second.score),
score: best.score,
scores: scored
};
}
function visualClassifierLabels() {
return [
{ kind: 'radiology', prompt: 'A medical radiology image: x-ray radiograph, chest x-ray, CT scan, ultrasound, MRI, or diagnostic imaging film.' },
{ kind: 'diagram', prompt: 'A medical diagram, clinical illustration, anatomy figure, pathway, chart, flowchart, or infographic.' },
{ kind: 'photo', prompt: 'A clinical photograph, gross pathology image, microscopy image, skin finding, wound image, or bedside photograph.' },
{ kind: 'text_page', prompt: 'A plain textbook or article page that is mostly text, paragraphs, references, bullet points, or tables without a diagnostic image.' }
];
}
function visualMetadataScore(query, source) {
var haystack = [source.title, source.excerpt, source.file_path, source.category, source.subcategory, source.category_path].filter(Boolean).join(' ');
var score = 0;
var intent = visualIntent(query);
if (isRadiologyQuery(query) && /\b(radiology|radiograph|x-?ray|cxr|imaging|ct|mri|ultrasound|film)\b/i.test(haystack)) score += 0.12;
if (intent.wanted.indexOf('diagram') !== -1 && /\b(figure|diagram|algorithm|flowchart|pathway|chart|illustration|infographic)\b/i.test(haystack)) score += 0.12;
if (intent.wanted.indexOf('photo') !== -1 && /\b(photo|photograph|skin|rash|lesion|wound|gross|microscopy|histology)\b/i.test(haystack)) score += 0.12;
if (/\b(pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i.test(haystack)) score += 0.06;
if (looksLikeTextOnlyPage(source)) score -= 0.18;
return score;
}
function looksLikeTextOnlyPage(source) {
var text = String(source && source.excerpt || '');
return text.length > 900 && !/\b(figure|image|radiograph|x-?ray|cxr|ultrasound|ct|mri|scan|film|diagram|illustration)\b/i.test(text);
}
function cosineSimilarity(a, b) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return 0;
var dot = 0;
var aa = 0;
var bb = 0;
for (var i = 0; i < a.length; i++) {
var av = Number(a[i]) || 0;
var bv = Number(b[i]) || 0;
dot += av * bv;
aa += av * av;
bb += bv * bv;
}
if (!aa || !bb) return 0;
return dot / (Math.sqrt(aa) * Math.sqrt(bb));
}
function multimodalImageUrl(imagePath) {
imagePath = String(imagePath || '').trim();
if (!imagePath) return '';
return '/api/clinical-assistant/source-image/' + base64UrlEncode(imagePath);
}
function resolveMultimodalImagePath(token) {
var raw = base64UrlDecode(token);
return resolveRawMultimodalImagePath(raw);
}
function resolveRawMultimodalImagePath(raw) {
if (!raw || raw.indexOf('\0') !== -1) return '';
var incoming = path.resolve(raw);
var internalRoot = withTrailingSeparator(MCP_MULTIMODAL_INTERNAL_ROOT);
var servedRoot = withTrailingSeparator(MCP_MULTIMODAL_IMAGE_ROOT);
var servedPath = incoming;
if (incoming === MCP_MULTIMODAL_INTERNAL_ROOT || incoming.indexOf(internalRoot) === 0) {
servedPath = path.resolve(MCP_MULTIMODAL_IMAGE_ROOT, path.relative(MCP_MULTIMODAL_INTERNAL_ROOT, incoming));
}
if (servedPath !== MCP_MULTIMODAL_IMAGE_ROOT && servedPath.indexOf(servedRoot) !== 0) return '';
if (path.extname(servedPath).toLowerCase() !== '.png') return '';
if (!fs.existsSync(servedPath)) return '';
return servedPath;
}
function sanitizeSourcesForClient(sources) {
return (Array.isArray(sources) ? sources : []).map(function(source) {
var out = Object.assign({}, source);
delete out.image_path_internal;
delete out.file_path;
return out;
});
}
function withTrailingSeparator(p) {
p = path.resolve(p);
return p.endsWith(path.sep) ? p : p + path.sep;
}
function base64UrlEncode(value) {
return Buffer.from(String(value), 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlDecode(value) {
value = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
while (value.length % 4) value += '=';
return Buffer.from(value, 'base64').toString('utf8');
}
function buildSavedChatPayload(body) {
var messages = Array.isArray(body.messages) ? body.messages.slice(-80).map(function(m) {
return {
role: m && m.role === 'assistant' ? 'assistant' : 'user',
content: clip(m && m.content, 12000),
sources: Array.isArray(m && m.sources) ? m.sources.slice(0, 30).map(function(s, idx) {
return {
number: Number(s.number || idx + 1),
title: clip(s.title || s.resource || 'Source', 500),
resource: clip(s.resource || '', 500),
page: s.page || s.page_number || s.pageNumber || null,
source_type: clip(s.source_type || '', 80),
doc_type: clip(s.doc_type || s.type || '', 80),
image_url: safeSourceImageUrl(s.image_url || s.imageUrl),
excerpt: clip(s.excerpt || '', 1800),
score: s.score == null ? null : Number(s.score)
};
}) : []
};
}).filter(function(m) { return m.content; }) : [];
var sources = Array.isArray(body.sources) ? body.sources.slice(0, 30).map(function(s, idx) {
return {
number: Number(s.number || idx + 1),
title: clip(s.title || s.resource || 'Source', 500),
resource: clip(s.resource || '', 500),
page: s.page || s.page_number || s.pageNumber || null,
source_type: clip(s.source_type || '', 80),
doc_type: clip(s.doc_type || s.type || '', 80),
image_url: safeSourceImageUrl(s.image_url || s.imageUrl),
excerpt: clip(s.excerpt || '', 1800),
score: s.score == null ? null : Number(s.score)
};
}) : [];
return {
version: 1,
messages: messages,
sources: sources,
lastAnswer: clip(body.lastAnswer || '', 30000),
generatedImage: safeImageForSave(body.generatedImage),
savedAt: new Date().toISOString()
};
}
function safeImageForSave(image) {
image = String(image || '');
if (!image) return '';
if (/^https?:\/\//i.test(image)) return image.substring(0, 5000);
return '';
}
function safeSourceImageUrl(url) {
url = String(url || '');
if (url.indexOf('/api/clinical-assistant/source-image/') === 0) return url.substring(0, 2000);
return '';
}
function firstUserMessage(messages) {
if (!Array.isArray(messages)) return '';
for (var i = 0; i < messages.length; i++) {
if (messages[i] && messages[i].role === 'user' && messages[i].content) return messages[i].content;
}
return '';
}
function cleanSavedChatTitle(title) {
return clip(title, MAX_SAVED_CHAT_TITLE).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim() || 'Clinical assistant chat';
}
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 cleanExportHeading(heading, idx) {
heading = clip(heading, 120).replace(/^#+\s*/, '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
if (!heading || /^(what|which|when|why|how|and|also|dose\??|what dose\??)$/i.test(heading)) return 'Clinical Question ' + (idx + 1);
return heading;
}
async function getAvailableExamples() {
var now = Date.now();
if (_exampleCache.expiresAt > now && _exampleCache.examples.length) return _exampleCache.examples;
var examples = [];
var candidates = rotateExamples(EXAMPLE_CANDIDATES, now);
for (var i = 0; i < candidates.length && examples.length < 9; i++) {
var item = candidates[i];
try {
var searchResponse = await semanticSearch(item.prompt, {
limit: 2,
includeContext: true,
contextChars: 500
});
var source = dedupeSources(normalizeMcpSearchResponse(searchResponse))[0];
if (!source) continue;
examples.push({
label: item.label,
prompt: item.prompt,
sourceTitle: source.title,
page: source.page || null
});
} catch (e) {
if (examples.length === 0) throw e;
break;
}
}
_exampleCache = {
expiresAt: now + EXAMPLE_CACHE_MS,
examples: examples
};
return examples;
}
function rotateExamples(items, seed) {
var copy = items.slice();
var offset = Math.floor(seed / EXAMPLE_CACHE_MS) % copy.length;
return copy.slice(offset).concat(copy.slice(0, offset));
}
async function generateImage(prompt, model) {
if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation');
var headers = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY;
var resp = await axios.post(gatewayUrl('/images/generations'), {
model: model,
prompt: prompt,
size: '1024x1024'
}, { headers: headers, timeout: 120000 });
var item = resp.data && resp.data.data && resp.data.data[0] ? resp.data.data[0] : {};
return { imageUrl: item.url || null, base64: item.b64_json || null, raw: (!item.url && !item.b64_json) ? resp.data : undefined };
}
async function getSetting(key, fallback) {
try {
var val = await db.getSetting(key);
return val == null || val === '' ? fallback : val;
} catch (e) { return fallback; }
}
function clampInt(value, min, max, fallback) {
var n = parseInt(value, 10);
if (!Number.isFinite(n)) return fallback;
return Math.max(min, Math.min(max, n));
}
function clip(s, n) { return String(s || '').replace(/\s+/g, ' ').trim().substring(0, n); }
function cleanTitle(s) {
var title = String(s || '').trim();
try { title = decodeURIComponent(title); } catch (e) {}
return title
.replace(/\.(pdf|docx?|txt|md)$/i, '')
.replace(/\s+/g, ' ')
.replace(/\s*\(z-library\.sk,\s*1lib\.sk,\s*z-lib\.sk\)\s*/ig, '')
.trim();
}
function cleanSourceExcerpt(text) {
return String(text || '')
.replace(/^\[Page-image match\]\s*/i, '')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\*\*/g, '')
.replace(/\|\s*-{2,}\s*/g, ' ')
.replace(/\|/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function assistantErrorMessage(e) {
var msg = e && e.message ? e.message : String(e);
if (/ECONNREFUSED|fetch failed|Failed to open SSE|MCP/i.test(msg)) return 'Could not reach the MCP search server. Check CLINICAL_ASSISTANT_MCP_URL or the MCP container.';
if (/Model not permitted/i.test(msg)) return 'Configured assistant chat model is not enabled in admin model settings.';
return msg || 'Assistant request failed';
}
function getTopicSuggestions(message) {
var normalized = String(message || '').toLowerCase().replace(/[^a-z0-9\s-]/g, ' ').replace(/\s+/g, ' ').trim();
if (!normalized) return [];
var direct = TOPIC_SUGGESTIONS[normalized];
if (direct) return direct;
var keys = Object.keys(TOPIC_SUGGESTIONS);
for (var i = 0; i < keys.length; i++) {
if (normalized.indexOf(keys[i]) !== -1) return TOPIC_SUGGESTIONS[keys[i]];
}
if (normalized.split(/\s+/).length <= 2) {
return [
'Management of ' + normalized,
'Red flags and differential diagnosis for ' + normalized,
'Initial ED evaluation for ' + normalized,
'Admission versus discharge criteria for ' + normalized,
'Medication dosing and escalation for ' + normalized
];
}
return [];
}
function buildSuggestionAnswer(topic, suggestions) {
return 'I can look up **' + topic + '**. Pick a focused question so I can retrieve the most relevant cited pages:\n\n' +
suggestions.map(function(s) { return '- ' + s; }).join('\n') +
'\n\nOr type your own specific question, for example: "management of acute asthma exacerbation in a 6-year-old".';
}
module.exports = router;