pediatric-ai-scribe-v3/src/routes/clinicalAssistant.js
2026-05-07 17:44:06 +02:00

1498 lines
66 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 { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { callAI, callAIStream } = require('../utils/ai');
var { gatewayUrl } = require('../utils/errors');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto');
var redisCache = require('../utils/redis');
router.use(authMiddleware);
var MCP_URLS = buildMcpUrls();
var _lastGoodMcpUrl = MCP_URLS[0];
var MCP_INITIALIZE_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS, 30000);
var MCP_REQUEST_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS, 90000);
var MCP_SESSION_TTL_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS, 10 * 60 * 1000);
var _mcpSession = null;
var _mcpSessionPromise = null;
var _mcpCallQueue = Promise.resolve();
var _mcpRequestId = 1;
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 and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move 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_S3_ENDPOINT = process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_ENDPOINT || '';
var MULTIMODAL_S3_ACCESS_KEY = process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_ACCESS_KEY || '';
var MULTIMODAL_S3_SECRET_KEY = process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_SECRET_KEY || '';
var _s3Client = null;
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: [
'How should acute pediatric asthma exacerbation severity guide SABA frequency, ipratropium, steroids, magnesium sulfate, and disposition?',
'When should magnesium sulfate, continuous albuterol, noninvasive ventilation, ICU transfer, or intubation be considered in status asthmaticus?',
'How can bronchiolitis, viral-induced wheeze, and early asthma be distinguished in infants and preschool children?',
'What discharge criteria, observation period, relapse risks, and follow-up are recommended after an ED asthma exacerbation?',
'When should controller therapy be started or stepped up after an ED asthma visit?'
],
bronchiolitis: [
'What supportive care, suctioning, hydration, oxygen targets, and feeding guidance are recommended for infant bronchiolitis?',
'When is a bronchodilator trial reasonable in bronchiolitis, and when should it be stopped?',
'What apnea risk, work of breathing, oxygen saturation, hydration, and social factors support admission for bronchiolitis?',
'How can bronchiolitis be distinguished from early asthma, pneumonia, cardiac disease, or foreign body aspiration?',
'When should high-flow nasal cannula, CPAP, ICU transfer, or escalation be considered in bronchiolitis?'
],
fever: [
'How should fever without source be evaluated by age group in neonates, young infants, and older children?',
'What cultures, inflammatory markers, LP decisions, empiric antibiotics, and disposition are recommended for febrile neonates?',
'Which clinical and laboratory features stratify risk for serious bacterial infection in febrile infants?',
'When is lumbar puncture indicated or avoidable in febrile infants by age and risk category?',
'How should fever be approached in immunocompromised children, central lines, or incomplete immunization?'
],
seizure: [
'What is the timed pediatric status epilepticus algorithm including benzodiazepine dosing and second-line choices?',
'How should simple versus complex febrile seizures be evaluated, treated, and explained to caregivers?',
'When should levetiracetam, fosphenytoin, phenobarbital, valproate, anesthetic infusion, or ICU transfer be used?',
'Which seizure presentations require neuroimaging, labs, toxicology, LP, EEG, or admission?',
'What common seizure mimics in children should be considered and how are they distinguished?'
],
croup: [
'How should croup severity guide dexamethasone, nebulized epinephrine, observation, and disposition?',
'When is nebulized epinephrine indicated for croup and how long should the child be observed afterward?',
'What dexamethasone dose and route are recommended for mild, moderate, and severe croup?',
'What discharge, admission, ICU, and airway escalation criteria apply after croup treatment?',
'What alternative diagnoses should be considered in a child with stridor or atypical croup?'
],
dehydration: [
'Which clinical signs best estimate mild, moderate, and severe dehydration in children?',
'What oral rehydration protocol, volumes, antiemetic use, and reassessment are recommended for gastroenteritis?',
'When are IV boluses, maintenance fluids, deficit replacement, glucose, or electrolyte checks indicated?',
'How should hypernatremic dehydration be recognized and corrected safely?',
'What discharge, admission, and follow-up criteria apply after pediatric gastroenteritis or dehydration?'
]
};
function positiveInt(value, fallback) {
var n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
if (process.env.CLINICAL_ASSISTANT_MCP_WARMUP !== 'false') {
setTimeout(function() {
getMcpSession().catch(function(e) {
console.warn('[clinical-assistant] MCP warmup skipped:', e.message);
});
}, positiveInt(process.env.CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS, 5000));
}
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 {
res.set('Cache-Control', 'private, max-age=3600');
res.type('png');
var raw = base64UrlDecode(req.params.token || '');
if (isS3Uri(raw)) {
var obj = await getS3Object(raw);
if (!obj) return res.status(404).send('Not found');
if (obj.Body && typeof obj.Body.pipe === 'function') return obj.Body.pipe(res);
var chunks = [];
for await (var chunk of obj.Body) chunks.push(chunk);
return res.send(Buffer.concat(chunks));
}
var imagePath = resolveRawMultimodalImagePath(raw);
if (!imagePath) return res.status(404).send('Not found');
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 prepared = await prepareAssistantChat(req.body);
if (prepared.direct) return res.json(prepared.direct);
var ai = await callAI(prepared.messages, {
model: prepared.chatModel || undefined,
temperature: 0.15,
maxTokens: 1800
});
var answer = stripModelSourcesSection(String(ai.content || '').trim());
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(prepared.sources),
model: ai.model || prepared.chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
search: prepared.search
});
} catch (e) {
console.error('[clinical-assistant]', e.message, e.stack || '');
res.status(500).json({ error: assistantErrorMessage(e) });
}
});
router.post('/clinical-assistant/chat/stream', async function(req, res) {
var started = Date.now();
var streamOpen = false;
function sendEvent(type, data) {
if (!streamOpen) return;
res.write('event: ' + type + '\n');
res.write('data: ' + JSON.stringify(data || {}) + '\n\n');
}
try {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
if (typeof res.flushHeaders === 'function') res.flushHeaders();
streamOpen = true;
sendEvent('status', { message: 'Looking up sources...' });
var prepared = await prepareAssistantChat(req.body);
if (prepared.direct) {
sendEvent('done', Object.assign({ duration: Date.now() - started }, prepared.direct));
return res.end();
}
sendEvent('sources', { sources: sanitizeSourcesForClient(prepared.sources), search: prepared.search });
sendEvent('status', { message: 'Generating answer...' });
var ai = await callAIStream(prepared.messages, {
model: prepared.chatModel || undefined,
temperature: 0.15,
maxTokens: 1800
}, function(delta) {
sendEvent('token', { token: delta });
});
var answer = stripModelSourcesSection(String(ai.content || '').trim());
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant streaming query', req, {
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
});
sendEvent('done', {
success: true,
answer: answer,
sources: sanitizeSourcesForClient(prepared.sources),
model: ai.model || prepared.chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
search: prepared.search
});
res.end();
} catch (e) {
console.error('[clinical-assistant stream]', e.message, e.stack || '');
if (!streamOpen) return res.status(500).json({ error: assistantErrorMessage(e) });
sendEvent('error', { error: assistantErrorMessage(e) });
res.end();
}
});
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' });
}
});
async function prepareAssistantChat(body) {
body = body || {};
var message = String(body.message || '').trim();
if (!message) {
var emptyErr = new Error('Question is required');
emptyErr.statusCode = 400;
throw emptyErr;
}
if (message.length > 4000) {
var longErr = new Error('Question too long');
longErr.statusCode = 400;
throw longErr;
}
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 = body.includeContext !== false;
if (GREETING_RE.test(message)) {
return { direct: {
success: true,
answer: 'What clinical question would you like me to look up?',
sources: [],
model: null,
search: { skipped: true, reason: 'low_information_input' }
} };
}
var history = Array.isArray(body.history) ? body.history.slice(-8) : [];
var contextualHistory = priorClinicalHistory(history, message);
var suggestions = getTopicSuggestions(message);
if (!contextualHistory.length && suggestions.length > 0 && message.split(/\s+/).length < 4) {
return { direct: {
success: true,
answer: buildSuggestionAnswer(message, suggestions),
suggestions: suggestions,
sources: [],
model: null,
search: { skipped: true, reason: 'topic_suggestions' }
} };
}
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);
console.info('[clinical-assistant] retrieval counts:', {
text: rawTextResults.length,
multimodal: rawMultimodalResults.length,
query: searchQuery
});
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 { direct: {
success: true,
answer: 'I could not find relevant sources 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);
return {
message: message,
chatModel: chatModel,
sources: sources,
messages: [
{ role: 'system', content: buildSystemPrompt(behavior) },
{ role: 'user', content: buildUserPrompt(message, context, history, searchQuery) }
],
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
}
};
}
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 deterministic = deterministicFollowupQuery(message, history);
if (deterministic) return deterministic;
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);
}
function priorClinicalHistory(history, message) {
var latest = String(message || '').trim();
return (Array.isArray(history) ? history : []).filter(function(m) {
return m && (m.role === 'user' || m.role === 'assistant') && m.content && String(m.content).trim() !== latest;
});
}
function deterministicFollowupQuery(message, history) {
var text = String(message || '').trim().toLowerCase();
if (!/^(dose|dosing|what dose|dose\?|how much|med dose|medication dose)$/i.test(text)) return '';
var prior = previousUserQuestion(history) || previousAssistantTopic(history);
if (!prior) return '';
return 'medication dosing and immediate treatment details for: ' + prior;
}
function previousUserQuestion(history) {
for (var i = history.length - 1; i >= 0; i--) {
if (history[i] && history[i].role === 'user' && history[i].content) return clip(history[i].content, 300);
}
return '';
}
function previousAssistantTopic(history) {
for (var i = history.length - 1; i >= 0; i--) {
if (history[i] && history[i].role === 'assistant' && history[i].content) return clip(history[i].content, 300);
}
return '';
}
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 indexedTopicSuggestions(limit) {
return callMcpTool('nc_indexed_topic_suggestions', {
limit: limit || 12,
sample_size: 1500,
doc_type: 'file'
});
}
async function callMcpTool(name, args) {
var queued = _mcpCallQueue.then(function() {
return callMcpToolUnlocked(name, args);
});
_mcpCallQueue = queued.catch(function() {});
return queued;
}
async function callMcpToolUnlocked(name, args) {
var session = await getMcpSession();
var payload = {
jsonrpc: '2.0',
id: nextMcpRequestId(),
method: 'tools/call',
params: {
name: name,
arguments: args
}
};
var search;
try {
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
} catch (e) {
if (!isInvalidMcpSessionError(e)) throw e;
_mcpSession = null;
session = await getMcpSession();
payload.id = nextMcpRequestId();
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
}
return search.result || search;
}
function nextMcpRequestId() {
_mcpRequestId += 1;
if (_mcpRequestId > 1000000000) _mcpRequestId = 2;
return _mcpRequestId;
}
async function getMcpSession() {
var now = Date.now();
if (_mcpSession && _mcpSession.sessionId && _mcpSession.expiresAt > now) return _mcpSession;
if (_mcpSessionPromise) return _mcpSessionPromise;
_mcpSessionPromise = initializeMcpSession().then(function(session) {
_mcpSession = session;
return session;
}).finally(function() {
_mcpSessionPromise = null;
});
return _mcpSessionPromise;
}
async function initializeMcpSession() {
var session = await mcpRequest({
jsonrpc: '2.0',
id: nextMcpRequestId(),
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
}
});
session.expiresAt = Date.now() + MCP_SESSION_TTL_MS;
return session;
}
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 ? MCP_INITIALIZE_TIMEOUT_MS : MCP_REQUEST_TIMEOUT_MS,
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;
}
function isInvalidMcpSessionError(e) {
var status = e && e.response && e.response.status;
var msg = String(e && e.message || '');
return status === 400 || status === 404 || status === 410 || /session|mcp-session-id/i.test(msg);
}
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);
var caption = clip(cleanSourceExcerpt(r.visual_caption || ''), 900);
var labels = Array.isArray(r.visual_labels) ? r.visual_labels.filter(Boolean).slice(0, 20) : [];
var excerptParts = [];
if (caption) excerptParts.push('[Visual caption] ' + caption);
if (labels.length) excerptParts.push('[Visual labels] ' + labels.join(', '));
if (nearby) excerptParts.push('[Page text] ' + nearby);
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,
visual_caption: caption,
visual_labels: labels,
visual_caption_source: r.visual_caption_source || '',
is_cover_page: Boolean(r.is_cover_page || r.isCoverPage),
is_title_page: Boolean(r.is_title_page || r.isTitlePage),
reject_for_visual_search: Boolean(r.reject_for_visual_search || r.rejectForVisualSearch),
excerpt: excerptParts.length ? '[Page-image match] ' + excerptParts.join('\n') : '[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 Map();
var out = [];
results.forEach(function(r) {
var key = sourceDedupeKey(r);
var existingIndex = seen.get(key);
if (existingIndex != null) {
out[existingIndex] = mergeDuplicateSource(out[existingIndex], r);
return;
}
seen.set(key, out.length);
out.push(Object.assign({}, r, { number: out.length + 1 }));
});
return out;
}
function sourceDedupeKey(source) {
var title = cleanTitle(source && source.title || '').toLowerCase();
var page = source && source.page ? String(source.page) : '';
var kind = source && source.source_type ? source.source_type : 'text';
if (title && page) return [kind, title, page].join('|');
return [kind, title, source && source.id || source && source.chunk_index || ''].join('|');
}
function mergeDuplicateSource(existing, duplicate) {
var merged = Object.assign({}, existing);
var existingExcerpt = cleanSourceExcerpt(existing.excerpt || '');
var duplicateExcerpt = cleanSourceExcerpt(duplicate.excerpt || '');
if (duplicateExcerpt && existingExcerpt.indexOf(duplicateExcerpt.slice(0, 160)) === -1) {
merged.excerpt = clip([existingExcerpt, duplicateExcerpt].filter(Boolean).join('\n'), 1800);
}
merged.score = Math.max(Number(existing.score) || 0, Number(duplicate.score) || 0) || existing.score || duplicate.score;
return merged;
}
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 the exact source numbers from the retrieved indexed sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3].\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- If a claim is not directly supported by retrieved sources, omit it or state that the indexed sources are insufficient.\n- Do not cite a source number that is not provided.\n- If sources disagree or are insufficient, say so.\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 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);
candidates = candidates.filter(function(candidate) { return !shouldRejectVisualSource(query, candidate); });
if (!candidates.length) return [];
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 [];
results = results.filter(function(r) { return !shouldRejectVisualSource(query, r); });
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.visual_caption, (source.visual_labels || []).join(' '), 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 (looksLikeFrontMatterPage(source)) score -= 0.5;
if (looksLikeTextOnlyPage(source)) score -= 0.18;
return score;
}
function shouldRejectVisualSource(query, source) {
if (!source) return false;
if (allowsFrontMatterQuery(query)) return false;
return Boolean(source.reject_for_visual_search || source.is_cover_page || source.is_title_page || looksLikeFrontMatterPage(source));
}
function allowsFrontMatterQuery(query) {
return /\b(cover|front cover|title page|copyright|table of contents|contents|preface|foreword|publisher|isbn)\b/i.test(String(query || ''));
}
function looksLikeFrontMatterPage(source) {
var page = Number(source && source.page) || 0;
var pageCount = Number(source && source.page_count) || 0;
var earlyLimit = Math.min(8, Math.max(2, pageCount || 8));
if (!page || page > earlyLimit) return false;
var haystack = [source.title, source.excerpt, source.visual_caption, (source.visual_labels || []).join(' '), source.file_path].filter(Boolean).join(' ');
var hasVisualSignal = /\b(figure|fig\.?|radiograph|x-?ray|cxr|ultrasound|ct|mri|photograph|photo|image|diagram|flowchart|algorithm|histology|microscopy|gross pathology)\b/i.test(haystack);
if (hasVisualSignal) return false;
var frontMatterSignal = /\b(cover|front cover|title page|copyright|all rights reserved|isbn|library of congress|table of contents|contents|preface|foreword|dedication|contributors?|textbook of|manual of|review of|board review|edition|authors?|editor|publisher)\b/i.test(haystack);
return frontMatterSignal || page <= 2;
}
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 isS3Uri(value) {
return /^s3:\/\/[^/]+\/.+/.test(String(value || ''));
}
function parseS3Uri(uri) {
var m = String(uri || '').match(/^s3:\/\/([^/]+)\/(.+)$/);
if (!m) return null;
return { bucket: m[1], key: m[2] };
}
function getS3Client() {
if (_s3Client) return _s3Client;
if (!MULTIMODAL_S3_ENDPOINT || !MULTIMODAL_S3_ACCESS_KEY || !MULTIMODAL_S3_SECRET_KEY) return null;
_s3Client = new S3Client({
endpoint: MULTIMODAL_S3_ENDPOINT,
region: process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_REGION || 'us-east-1',
forcePathStyle: true,
credentials: {
accessKeyId: MULTIMODAL_S3_ACCESS_KEY,
secretAccessKey: MULTIMODAL_S3_SECRET_KEY
}
});
return _s3Client;
}
async function getS3Object(uri) {
var parsed = parseS3Uri(uri);
var client = getS3Client();
if (!parsed || !client) return null;
return client.send(new GetObjectCommand({ Bucket: parsed.bucket, Key: parsed.key }));
}
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 cached = await redisCache.getJson('clinical-assistant:examples:v1').catch(function() { return null; });
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
_exampleCache = { expiresAt: now + EXAMPLE_CACHE_MS, examples: cached.examples };
return cached.examples;
}
var indexedExamples = await getIndexedTopicExamples().catch(function(e) {
console.warn('[clinical-assistant] indexed topic suggestions skipped:', e.message);
return [];
});
if (indexedExamples.length >= 3) {
_exampleCache = { expiresAt: now + EXAMPLE_CACHE_MS, examples: indexedExamples };
redisCache.setJson('clinical-assistant:examples:v1', { examples: indexedExamples }, 1800).catch(function() {});
return indexedExamples;
}
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
};
redisCache.setJson('clinical-assistant:examples:v1', { examples: examples }, 1800).catch(function() {});
return examples;
}
async function getIndexedTopicExamples() {
var response = await indexedTopicSuggestions(12);
var data = response && (response.structuredContent || response.data || response);
if ((!data || !Array.isArray(data.suggestions)) && response && Array.isArray(response.content)) {
for (var i = 0; i < response.content.length; i++) {
var c = response.content[i];
if (c && c.type === 'text' && c.text) {
try {
var parsed = JSON.parse(c.text);
if (parsed && Array.isArray(parsed.suggestions)) data = parsed;
} catch (e) {}
}
}
}
if (!data || !Array.isArray(data.suggestions)) return [];
return data.suggestions.slice(0, 12).map(function(item) {
return {
label: item.label || 'Indexed topic',
prompt: item.prompt || '',
sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '',
category: item.category || ''
};
}).filter(function(item) { return item.prompt; });
}
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 renderedPrompt = imagePromptForCanvas(prompt);
var size = process.env.CLINICAL_ASSISTANT_IMAGE_SIZE || 'auto';
var resp = await generateImageRequest(model, renderedPrompt, size, headers).catch(async function(e) {
if (!isInvalidImageSizeError(e) || size === '1024x1024') throw e;
return generateImageRequest(model, renderedPrompt, '1024x1024', headers);
});
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 };
}
function generateImageRequest(model, prompt, size, headers) {
return axios.post(gatewayUrl('/images/generations'), {
model: model,
prompt: prompt,
size: size
}, { headers: headers, timeout: 120000 });
}
function imagePromptForCanvas(prompt) {
var text = String(prompt || '');
var guidance = ' Compose as a single complete medical teaching poster. Keep every element fully inside the canvas with a 10% safe margin on all sides. Do not crop boxes, arrows, labels, legends, or body parts. Use fewer words per box, large readable type, and generous spacing.';
if (/\b(flow\s*chart|flowchart|algorithm|pathway|timeline|vertical|stepwise|decision\s*tree|age\s*group|0-21|22-28|29-60)\b/i.test(text)) {
guidance += ' Use a tall portrait layout with top-to-bottom flow, no more than 6-8 main nodes, and ample spacing between decision nodes.';
}
if (/\b(table|matrix|comparison|wide|landscape|side-by-side)\b/i.test(text)) {
guidance += ' Use a wide landscape layout with compact columns, ample horizontal spacing, and no text near the edges.';
}
return text.trim() + guidance;
}
function isInvalidImageSizeError(e) {
var detail = e && e.response && e.response.data ? JSON.stringify(e.response.data) : (e && e.message ? e.message : '');
return /invalid size|unsupported size|supported sizes/i.test(detail);
}
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 [
'What initial ED evaluation, severity assessment, and immediate stabilization steps are recommended for pediatric ' + normalized + '?',
'Which red flags, differential diagnoses, and complications should not be missed in pediatric ' + normalized + '?',
'What treatment escalation, medication dosing, and monitoring are recommended for pediatric ' + normalized + '?',
'What admission, discharge, observation, and follow-up criteria are recommended for pediatric ' + normalized + '?',
'How do recommendations differ by age group, severity, comorbidity, or high-risk features in pediatric ' + normalized + '?'
];
}
return [];
}
function buildSuggestionAnswer(topic, suggestions) {
return 'I can look up **' + topic + '**. Pick a specific clinical question so I retrieve the right cited pages instead of giving a broad summary:\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;