1201 lines
55 KiB
JavaScript
1201 lines
55 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 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');
|
|
var { createClinicalPromptPool } = require('../utils/clinicalPromptPool');
|
|
|
|
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, 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 MAX_SAVED_CHATS_PER_USER = 100;
|
|
var MAX_SAVED_CHAT_PAYLOAD = 250000;
|
|
var MAX_SAVED_CHAT_TITLE = 160;
|
|
var MULTIMODAL_CANDIDATE_LIMIT = 6;
|
|
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?'
|
|
]
|
|
};
|
|
|
|
var promptPool = createClinicalPromptPool({
|
|
redisCache: redisCache,
|
|
callAI: callAI,
|
|
getSetting: getSetting,
|
|
semanticSearch: semanticSearch,
|
|
dedupeSources: dedupeSources,
|
|
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
|
|
getIndexedTopicExamples: getIndexedTopicExamples,
|
|
exampleCandidates: EXAMPLE_CANDIDATES,
|
|
topicSuggestions: TOPIC_SUGGESTIONS
|
|
});
|
|
|
|
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));
|
|
}
|
|
|
|
setTimeout(function() {
|
|
promptPool.refreshIfNeeded(false).catch(function(e) {
|
|
console.warn('[clinical-assistant] prompt pool warmup skipped:', e.message);
|
|
});
|
|
}, positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_WARMUP_DELAY_MS, 15000));
|
|
|
|
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/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: 2600
|
|
});
|
|
var answer = stripModelSourcesSection(String(ai.content || '').trim());
|
|
if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
|
|
console.warn('[clinical-assistant] answer looked truncated; regenerating', { finishReason: ai.finishReason, chars: answer.length });
|
|
var completed = await callAI(prepared.messages, {
|
|
model: prepared.chatModel || undefined,
|
|
temperature: 0.15,
|
|
maxTokens: 5000
|
|
});
|
|
answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
|
|
ai.model = completed.model || ai.model;
|
|
ai.provider = completed.provider || ai.provider;
|
|
ai.finishReason = completed.finishReason || ai.finishReason;
|
|
}
|
|
|
|
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
|
|
category: 'clinical', model: ai.model || prepared.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();
|
|
}
|
|
|
|
var safeSources = sanitizeSourcesForClient(prepared.sources);
|
|
sendEvent('sources', { sources: safeSources, search: prepared.search });
|
|
sendEvent('status', { message: 'Generating answer...' });
|
|
|
|
var ai = await callAIStream(prepared.messages, {
|
|
model: prepared.chatModel || undefined,
|
|
temperature: 0.15,
|
|
maxTokens: 2600
|
|
}, function(delta) {
|
|
sendEvent('token', { token: delta });
|
|
});
|
|
|
|
var answer = stripModelSourcesSection(String(ai.content || '').trim());
|
|
if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
|
|
console.warn('[clinical-assistant] streamed answer looked truncated; regenerating final answer', { finishReason: ai.finishReason, chars: answer.length });
|
|
sendEvent('status', { message: 'Completing answer...' });
|
|
var completed = await callAI(prepared.messages, {
|
|
model: prepared.chatModel || undefined,
|
|
temperature: 0.15,
|
|
maxTokens: 5000
|
|
});
|
|
answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
|
|
ai.model = completed.model || ai.model;
|
|
ai.provider = completed.provider || ai.provider;
|
|
ai.finishReason = completed.finishReason || ai.finishReason;
|
|
}
|
|
|
|
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: safeSources,
|
|
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(e.statusCode || 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 a clear match for that question. Please 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
|
|
}
|
|
};
|
|
}
|
|
|
|
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 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,
|
|
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- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when the retrieved sources do not indicate any plausible concept.\n- Use the exact source numbers from the retrieved 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 say the available sources are insufficient.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\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- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\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 sources:\n' + context + '\n\nWrite the answer now. If the question is a short misspelled or partial term and the sources point to a likely concept, answer the likely concept rather than asking for clarification.';
|
|
}
|
|
|
|
function stripModelSourcesSection(answer) {
|
|
return cleanDanglingSourceLeadIn(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 cleanDanglingSourceLeadIn(answer) {
|
|
answer = String(answer || '').trim();
|
|
return answer
|
|
.replace(/([.!?])\s+(?:however,?\s*)?(?:but\s*)?(?:the\s*)?available(?:\s+(?:sources?|retrieved\s+sources?|evidence|material))?\s*$/i, '$1')
|
|
.replace(/([.!?])\s+(?:however,?\s*)?(?:but\s*)?(?:based\s+on\s+)?(?:the\s*)?available\s*$/i, '$1')
|
|
.replace(/(?:^|\n\n)(?:however,?\s*)?(?:but\s*)?(?:the\s*)?available(?:\s+(?:sources?|retrieved\s+sources?|evidence|material))?\s*$/i, '')
|
|
.replace(/(?:^|\n\n)(?:however,?\s*)?(?:but\s*)?(?:based\s+on\s+)?(?:the\s*)?available\s*$/i, '')
|
|
.trim();
|
|
}
|
|
|
|
function shouldRegenerateTruncatedAnswer(answer, finishReason) {
|
|
answer = String(answer || '').trim();
|
|
if (!answer) return false;
|
|
if (finishReason === 'length') return true;
|
|
if (/\b(the|a|an|and|or|but|with|without|for|to|of|in|on|as|by|from|because|therefore|however|some|many|most|few|several|additional|other|further|including|such|available)$/i.test(answer)) return true;
|
|
if (/[,:;\-(]$/.test(answer)) return true;
|
|
var lines = answer.split(/\n+/).map(function(line) { return line.trim(); }).filter(Boolean);
|
|
var last = lines.length ? lines[lines.length - 1] : answer;
|
|
if (last.length > 24 && /[A-Za-z]$/.test(last) && !/[.!?\])"']$/.test(last)) return true;
|
|
return false;
|
|
}
|
|
|
|
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, MULTIMODAL_CANDIDATE_LIMIT);
|
|
return selectMultimodalResults(query, candidates);
|
|
}
|
|
|
|
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)) };
|
|
}
|
|
|
|
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 sanitizeSourcesForClient(sources) {
|
|
return (Array.isArray(sources) ? sources : []).map(function(source) {
|
|
var out = Object.assign({}, source);
|
|
delete out.image_path;
|
|
delete out.file_path;
|
|
return out;
|
|
});
|
|
}
|
|
|
|
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),
|
|
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),
|
|
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 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';
|
|
}
|
|
|
|
async function getAvailableExamples() {
|
|
return promptPool.getAvailableExamples();
|
|
}
|
|
|
|
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; });
|
|
}
|
|
|
|
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]];
|
|
}
|
|
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;
|