737 lines
33 KiB
JavaScript
737 lines
33 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');
|
|
var {
|
|
semanticSearch,
|
|
multimodalSearch,
|
|
indexedTopicSuggestions,
|
|
getMcpHealth,
|
|
warmMcpSession
|
|
} = require('../utils/clinicalMcpClient');
|
|
var {
|
|
normalizeMcpSearchResponse,
|
|
normalizeMcpMultimodalResponse,
|
|
dedupeSources,
|
|
isVisualSourceQuery,
|
|
buildMultimodalSearchQuery,
|
|
classifyAndRerankMultimodalResults
|
|
} = require('../utils/clinicalRetrieval');
|
|
var {
|
|
buildSystemPrompt,
|
|
buildUserPrompt,
|
|
finalizeAssistantAnswer
|
|
} = require('../utils/clinicalAnswer');
|
|
|
|
router.use(authMiddleware);
|
|
|
|
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 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() {
|
|
warmMcpSession().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 finalized = await finalizeAssistantAnswer(ai, { messages: prepared.messages, chatModel: prepared.chatModel, callAI: callAI });
|
|
var answer = finalized.answer;
|
|
ai = finalized.ai;
|
|
|
|
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 finalized = await finalizeAssistantAnswer(ai, {
|
|
messages: prepared.messages,
|
|
chatModel: prepared.chatModel,
|
|
callAI: callAI,
|
|
streamed: true,
|
|
onRegenerating: function() { sendEvent('status', { message: 'Completing answer...' }); }
|
|
});
|
|
var answer = finalized.answer;
|
|
ai = finalized.ai;
|
|
|
|
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 '';
|
|
}
|
|
|
|
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 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;
|