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

853 lines
38 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 generatedImages = require('../utils/generatedImages');
var imageTool = require('../utils/imageTool');
var imageLinks = require('../utils/generatedImageLinks');
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 {
cleanSourceExcerpt,
normalizeMcpSearchResponse,
normalizeMcpMultimodalResponse,
dedupeSources,
isVisualSourceQuery,
buildMultimodalSearchQuery,
classifyAndRerankMultimodalResults
} = require('../utils/clinicalRetrieval');
var {
buildSystemPrompt,
buildUserPrompt,
assistantGenerationOptions,
finalizeAssistantAnswer
} = require('../utils/clinicalAnswer');
var { conversationBudget, checkConversation, validateAttachments, savedChatPayload } = require('../utils/clinicalConversation');
var clinicalTranslation = require('../utils/clinicalTranslation');
var patientTakehome = require('../utils/patientTakehome');
var translateCache = clinicalTranslation.createTranslationCache();
var translateLanguageCache = clinicalTranslation.createLanguageCache();
var { DEFAULT_BEHAVIOR } = require('../utils/clinicalPrompts');
router.use(authMiddleware);
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_TITLE = 160;
var promptPool = createClinicalPromptPool({
redisCache: redisCache,
callAI: callAI,
getSetting: getSetting,
semanticSearch: semanticSearch,
dedupeSources: dedupeSources,
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
getIndexedTopicExamples: getIndexedTopicExamples,
loadStoredPromptPool: loadLatestPromptPoolSnapshot,
savePromptPool: savePromptPoolSnapshot
});
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 choices = await getAssistantStatusChoices();
var chatModel = choices.chatConfigured;
var imageModel = choices.imageConfigured;
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 translateProvider = String(await getSetting('clinical_assistant.translate_provider', '') || 'libretranslate').toLowerCase();
if (!clinicalTranslation.TRANSLATE_PROVIDERS.includes(translateProvider)) translateProvider = 'libretranslate';
var budget = conversationBudget(process.env);
var mcpHealth = await getMcpHealth();
res.json({
success: true,
chatModel: chatModel,
imageModel: imageModel,
allowedChatModels: choices.allowedChatModels,
allowedImageModels: choices.allowedImageModels,
searchLimit: searchLimit,
contextChars: contextChars,
conversationChars: budget.limit,
conversationUnit: budget.unit,
conversationEnv: budget.env,
conversationSource: budget.source,
conversationMeasure: budget.measure,
translateProvider: translateProvider,
mcp: mcpHealth
});
} catch (e) {
res.status(e.statusCode || 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 {
// Build and validate the payload first: 400/413 semantics fire before any write.
var payload = savedChatPayload(req.body);
await imageLinks.validateChat(db, payload, req.user.id);
var payloadText = JSON.stringify(payload);
var existingId = cleanSavedChatId(req.body.id);
if (existingId) {
var existing = await db.get(
'SELECT id, title FROM clinical_assistant_chats WHERE id = $1 AND user_id = $2',
[existingId, req.user.id]
);
if (!existing) return res.status(404).json({ error: 'Saved chat not found', code: 'CHAT_NOT_FOUND' });
var storedTitle = existing.title;
try { storedTitle = cryptoUtil.decryptString(storedTitle); } catch (e) {}
var title = req.body.title ? cleanSavedChatTitle(req.body.title) : cleanSavedChatTitle(storedTitle);
await db.run(
'UPDATE clinical_assistant_chats SET title = $1, payload = $2, updated_at = NOW() WHERE id = $3 AND user_id = $4',
[cryptoUtil.encryptString(title), cryptoUtil.encryptString(payloadText), existingId, req.user.id]
);
logger.audit(req.user.id, 'clinical_assistant_chat_update', 'Updated clinical assistant chat', req, { category: 'clinical' });
return res.json({ success: true, id: existingId, title: title });
}
var title = cleanSavedChatTitle(req.body.title || firstUserMessage(req.body.messages) || 'Clinical assistant chat');
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) {
if (!e.statusCode || e.statusCode >= 500) logger.error('POST /clinical-assistant/chats', e.message);
res.status(e.statusCode || 500).json({ error: e.statusCode ? e.message : 'Request failed', code: e.code });
}
});
router.patch('/clinical-assistant/chats/:id', async function(req, res) {
try {
var title = cleanSavedChatTitle(req.body.title);
if (!title) return res.status(400).json({ error: 'Title required', code: 'INVALID_TITLE' });
var result = await db.run(
'UPDATE clinical_assistant_chats SET title = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3',
[cryptoUtil.encryptString(title), req.params.id, req.user.id]
);
if (!result.changes) return res.status(404).json({ error: 'Saved chat not found', code: 'CHAT_NOT_FOUND' });
logger.audit(req.user.id, 'clinical_assistant_chat_rename', 'Renamed clinical assistant chat', req, { category: 'clinical' });
res.json({ success: true, id: req.params.id, title: title });
} catch (e) {
if (!e.statusCode || e.statusCode >= 500) logger.error('PATCH /clinical-assistant/chats/:id', e.message);
res.status(e.statusCode || 500).json({ error: e.statusCode ? e.message : 'Request failed', code: e.code });
}
});
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.get('/clinical-assistant/translate/languages', async function(req, res) {
try {
var result = await clinicalTranslation.listAvailableLanguages({
provider: 'libretranslate',
env: process.env,
http: axios,
languageCache: translateLanguageCache
});
res.json({ success: true, languages: result });
} catch (e) {
if (!e.statusCode || e.statusCode >= 500) logger.error('GET /clinical-assistant/translate/languages', e.message);
res.status(e.statusCode || 502).json({ error: e.statusCode ? e.message : 'Translation service unavailable', code: e.code });
}
});
router.post('/clinical-assistant/translate', async function(req, res) {
try {
var result = await clinicalTranslation.translateMessage({
message: req.body.message,
target: req.body.target,
provider: req.body.provider,
format: req.body.format,
userId: req.user.id,
env: process.env,
getSetting: getSetting,
axios: axios,
cache: translateCache
});
res.json({ success: true, translated: result.translated, provider: result.provider, target: String(req.body.target || '').toLowerCase() });
} catch (e) {
if (!e.statusCode || e.statusCode >= 500) logger.error('POST /clinical-assistant/translate', e.message);
res.status(e.statusCode || 502).json({ error: e.statusCode ? e.message : 'Translation service unavailable', code: e.code });
}
});
router.post('/clinical-assistant/patient-takehome', async function(req, res) {
try {
var answer = String(req.body.answer || '').trim();
if (!answer) return res.status(400).json({ error: 'No answer to summarize', code: 'NO_ANSWER' });
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var behavior = process.env.PATIENT_TAKEHOME_BEHAVIOR || await getSetting('clinical_assistant.patient_takehome_behavior', '') || undefined;
var result = await patientTakehome.generatePatientTakehome({ answer: answer, model: chatModel || undefined, behavior: behavior, callAI: callAI });
logger.audit(req.user.id, 'patient_takehome', 'take-home generated', req, { category: 'clinical', chars: result.text.length });
res.json({ success: true, text: result.text });
} catch (e) {
if (!e.statusCode || e.statusCode >= 500) logger.error('POST /clinical-assistant/patient-takehome', e.message);
res.status(e.statusCode || 502).json({ error: e.statusCode ? e.message : 'Take-home generation failed', code: e.code });
}
});
router.post('/clinical-assistant/patient-takehome/email', async function(req, res) {
try {
var to = String(req.body.to || '').trim();
var text = String(req.body.text || '').trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(to)) return res.status(400).json({ error: 'Enter a valid email address', code: 'INVALID_EMAIL' });
if (!text || text.length > 12000) return res.status(400).json({ error: 'Nothing to send', code: 'EMPTY_EMAIL_BODY' });
// Render the sheet with the same markdown engine as the app so the email
// keeps headings, bullets and bold instead of raw asterisks.
var MarkdownIt = require('markdown-it');
var md = new MarkdownIt({ html: false, linkify: true, breaks: false });
var bodyHtml = md.render(String(text || ''));
var sent = await require('./auth').__sendEmail(to, 'Patient Take Home — Pediatric AI Scribe',
'<div style="font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;color:#1f2937;max-width:620px;">' +
'<div style="background:#0f766e;color:#fff;padding:14px 18px;border-radius:10px 10px 0 0;font-size:17px;font-weight:700;">Patient Take Home</div>' +
'<div style="border:1px solid #e5e7eb;border-top:0;border-radius:0 0 10px 10px;padding:18px;">' + bodyHtml + '</div>' +
'<p style="color:#6b7280;font-size:12px;margin-top:14px;">This summary was created for a caregiver. Keep following your care team&rsquo;s instructions.</p></div>');
if (!sent) return res.status(503).json({ error: 'Email is not configured on this server yet', code: 'SMTP_NOT_CONFIGURED' });
logger.audit(req.user.id, 'patient_takehome_email', 'take-home emailed', req, { category: 'clinical', to: to });
res.json({ success: true });
} catch (e) {
if (!e.statusCode || e.statusCode >= 500) logger.error('POST /clinical-assistant/patient-takehome/email', e.message);
res.status(e.statusCode || 502).json({ error: e.statusCode ? e.message : 'Could not send the email', code: e.code });
}
});
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, assistantGenerationOptions({
model: prepared.chatModel || undefined,
temperature: 0.15,
tools: imageTool.tools,
maxTokens: 2600,
images: prepared.images
}));
ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
imageContext: prepared.imageContext, imageModel: prepared.imageModel, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
ai = await dispatchImageRequestFallback(ai, prepared, req);
var finalized = ai.imageToolHandled ? { answer: String(ai.content || ''), ai: ai } : await finalizeAssistantAnswer(ai, {
messages: prepared.messages,
chatModel: prepared.chatModel,
callAI: callAI,
generationOptions: assistantGenerationOptions({ temperature: 0.15, images: prepared.images })
});
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,
imageJobs: ai.imageJobs || [],
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(e.statusCode || 500).json({ error: assistantErrorMessage(e), code: e.code, budget: e.budget });
}
});
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 {
// Validate before opening SSE, rewriting a query or making any paid call.
var prepared = await prepareAssistantChat(req.body);
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: 'Sources checked; preparing answer...' });
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, assistantGenerationOptions({
model: prepared.chatModel || undefined,
temperature: 0.15,
tools: imageTool.tools,
maxTokens: 2600,
images: prepared.images
}), function(delta) {
sendEvent('token', { token: delta });
});
ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
imageContext: prepared.imageContext, imageModel: prepared.imageModel, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
ai = await dispatchImageRequestFallback(ai, prepared, req);
var finalized = ai.imageToolHandled ? { answer: String(ai.content || ''), ai: ai } : await finalizeAssistantAnswer(ai, {
messages: prepared.messages,
chatModel: prepared.chatModel,
callAI: callAI,
generationOptions: assistantGenerationOptions({ temperature: 0.15, images: prepared.images }),
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,
imageJobs: ai.imageJobs || [],
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), code: e.code, budget: e.budget });
sendEvent('error', { error: assistantErrorMessage(e), code: e.code, budget: e.budget });
res.end();
}
});
// Model-agnostic guarantee: when the user explicitly asks for an image and
// the model answered with text instead of invoking the tool, run the job from
// the model's answer text anyway. Deterministic — never depends on tool-call
// support of the serving model.
const IMAGE_REQUEST_PATTERN = /(create|generate|draw|make|illustrate|render|visualize)\b[^.!?\n]{0,160}\b(image|figure|diagram|poster|infographic|illustration|chart|visual|graphic)/i;
function isExplicitImageRequest(message) {
return IMAGE_REQUEST_PATTERN.test(String(message || ''));
}
async function dispatchImageRequestFallback(ai, prepared, req) {
if (!ai || ai.imageToolHandled || (ai.toolCalls && ai.toolCalls.length)) return ai;
if (!isExplicitImageRequest(prepared.message)) return ai;
var text = String(ai.content || '').trim();
if (!text || text.length > 32000) return ai;
try {
var job = await generatedImages.service().enqueue(
req.user.id, 'clinical_assistant',
{ prompt: text },
'img:' + generatedImages.requestKey({ prompt: text, ts: Date.now() }),
true,
prepared.imageContext,
prepared.imageModel);
ai.imageJobs = [job];
ai.imageToolHandled = true;
console.log('[clinical-assistant] image requested via text; queued image job', { jobId: job.jobId, model: job.model });
} catch (e) {
console.warn('[clinical-assistant] image fallback enqueue failed', e && e.message);
}
return ai;
}
async function submitImage(req, res, synchronous) {
try {
const body = req.body || {};
const imageModel = await resolveAssistantImageModel(body);
let job = await generatedImages.service().enqueue(req.user.id, 'clinical_assistant', { prompt: body.prompt, ...(body.layout === undefined ? {} : { layout: body.layout }) }, generatedImages.requestKey(body), false, body.history === undefined ? undefined : generatedImages.imageContext(
checkConversation(body.history, body.prompt, await getConversationLimit()).message, body.history), imageModel);
if (synchronous) {
const deadline = Date.now() + 125000;
while (['pending', 'running'].includes(job.status) && Date.now() < deadline && !res.destroyed) {
job = await generatedImages.service().get(job.jobId, req.user.id, 'clinical_assistant');
if (['pending', 'running'].includes(job.status)) await new Promise(resolve => setTimeout(resolve, 500));
}
if (res.destroyed) return;
if (['pending', 'running'].includes(job.status)) res.status(202); // Poll this same job, never regenerate on timeout.
}
res.json(job);
} catch (e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); }
}
router.post('/clinical-assistant/image', (req, res) => submitImage(req, res, true));
router.post('/clinical-assistant/image/jobs', (req, res) => submitImage(req, res, false));
router.get('/clinical-assistant/image/jobs', async function(req, res) {
try {
var limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 200, 1), 500);
var rows = await db.all(
"SELECT id, stage, model, created_at FROM generated_image_jobs WHERE owner_id=$1 AND workflow='clinical_assistant' ORDER BY created_at DESC LIMIT $2",
[req.user.id, limit]);
res.json({ success: true, jobs: rows.map(function (row) {
var done = row.stage === 'done';
return { jobId: row.id, status: row.stage, createdAt: row.created_at, model: row.model,
imageUrl: done ? '/api/generated-images/' + row.id : null,
downloadUrl: done ? '/api/generated-images/' + row.id + '?download=1' : null };
}) });
} catch (e) {
logger.error('GET /clinical-assistant/image/jobs', e.message);
res.status(503).json({ error: 'Image service unavailable' });
}
});
router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
try { res.json(await generatedImages.service().get(req.params.id, req.user.id, 'clinical_assistant')); }
catch (e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); }
});
router.get('/clinical-assistant/image/jobs/:id/download', async function(req, res) {
try {
await generatedImages.service().get(req.params.id, req.user.id, 'clinical_assistant');
await require('./generatedImages').sendAsset(req, res, true);
} catch (e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); }
});
async function prepareAssistantChat(body) {
body = body || {};
var checked = checkConversation(body.history, body.message, await getConversationLimit());
var message = checked.message;
var history = checked.history;
// Image attachments are validated before any retrieval or provider call.
// The conversation budget counts the text only; images are excluded from
// the UTF-16 count. Once sent, attachments persist with saved chats.
var images = validateAttachments(body.images);
var chatModel = await resolveAssistantChatModel(body);
var imageModel = await resolveAssistantImageModel(body);
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 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
});
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,
images: images,
imageContext: generatedImages.imageContext(message, history),
chatModel: chatModel,
imageModel: imageModel,
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();
if (!history.length || !needsContextualRewrite(message)) return message;
var hist = history.map(function(m) {
return m.role.toUpperCase() + ': ' + m.content;
}).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:'
}
], assistantGenerationOptions({
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 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 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 cleanSavedChatId(value) {
if (value === undefined || value === null || value === '') return null;
var n = Number(value);
if (!Number.isInteger(n) || n <= 0) {
var error = new Error('Invalid saved chat id.');
error.statusCode = 400;
error.code = 'INVALID_SAVED_CHAT';
throw error;
}
return n;
}
async function getAvailableExamples() {
return promptPool.getAvailableExamples();
}
router.refreshPromptPool = function(force, userId) {
return promptPool.refreshIfNeeded(force !== false, { userId: userId || null });
};
router.getPromptPoolMeta = function() {
return promptPool.getMeta();
};
router.getPromptPoolSnapshots = getPromptPoolSnapshots;
router.restorePromptPoolSnapshot = restorePromptPoolSnapshot;
async function savePromptPoolSnapshot(payload, context) {
context = context || {};
var result = await db.run(
'INSERT INTO clinical_prompt_pool_snapshots (generated_at, target, count, payload, restored_from, created_by) VALUES (to_timestamp($1 / 1000.0), $2, $3, $4::jsonb, $5, $6) RETURNING id',
[payload.generatedAt || Date.now(), payload.target || 0, Array.isArray(payload.examples) ? payload.examples.length : 0, JSON.stringify(payload), context.restoredFrom || null, context.userId || null]
);
return result.lastInsertRowid;
}
async function loadLatestPromptPoolSnapshot() {
var row = await db.get('SELECT payload FROM clinical_prompt_pool_snapshots ORDER BY created_at DESC, id DESC LIMIT 1', []);
if (!row || !row.payload) return null;
return typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
}
async function getPromptPoolSnapshots(limit) {
limit = clampInt(limit || 20, 1, 50, 20);
return db.all(
'SELECT id, generated_at, target, count, restored_from, created_at FROM clinical_prompt_pool_snapshots ORDER BY created_at DESC, id DESC LIMIT $1',
[limit]
);
}
async function restorePromptPoolSnapshot(id, userId) {
var row = await db.get('SELECT id, payload FROM clinical_prompt_pool_snapshots WHERE id = $1', [id]);
if (!row || !row.payload) return null;
var original = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
var payload = Object.assign({}, original, { generatedAt: Date.now(), restoredFrom: Number(row.id) });
await promptPool.writePromptPool(payload, { userId: userId || null, restoredFrom: Number(row.id) });
return payload;
}
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(isUsefulIndexedTopicExample);
}
function isUsefulIndexedTopicExample(item) {
var label = String(item && item.label || '');
var prompt = String(item && item.prompt || '');
if (!prompt || prompt.indexOf('?') === -1) return false;
var haystack = (label + ' ' + prompt + ' ' + String(item.sourceTitle || '')).toLowerCase();
if (/\b(start|end) of picture text\b/.test(haystack)) return false;
if (/\b(comparative|cross[-\s]?sectional|retrospective|prospective) study\b/.test(label.toLowerCase())) return false;
if (/\b(prevalence|correlation|association) of\b/.test(label.toLowerCase())) return false;
return true;
}
function getConversationLimit() {
return conversationBudget(process.env).limit;
}
async function getSetting(key, fallback) {
try {
var val = await db.getSetting(key);
return val == null || val === '' ? fallback : val;
} catch (e) { return fallback; }
}
// ── Per-user model selection + admin allowlists ────────────────────────────
// Settings hold comma-separated model IDs:
// clinical_assistant.allowed_models (chat)
// clinical_assistant.allowed_image_models (image)
// clinical_assistant.fallback_image_model (single retry target)
// An empty allowlist means legacy single-model behavior. A non-empty
// allowlist always includes the configured model; other selections 400.
function parseModelAllowlist(value) {
return String(value == null ? '' : value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
}
function modelNotAllowed() {
var err = new Error('The selected model is not available for the Clinical Assistant. Choose an approved model or clear your selection.');
err.statusCode = 400;
err.code = 'model_not_allowed';
return err;
}
function resolveModelSelection(choices, configured, requested) {
if (!choices.length) return configured;
if (requested === undefined || requested === null || requested === '') return configured;
if (typeof requested !== 'string' || requested.length > 200 || choices.indexOf(requested) === -1) throw modelNotAllowed();
return requested;
}
async function resolveAssistantChatModel(body) {
var configured = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var allowed = parseModelAllowlist(await getSetting('clinical_assistant.allowed_models', ''));
var choices = allowed.slice();
if (allowed.length && configured && choices.indexOf(configured) === -1) choices.push(configured);
return resolveModelSelection(choices, configured, body && body.chatModel);
}
async function resolveAssistantImageModel(body) {
var configured = await getSetting('clinical_assistant.image_model', '') || process.env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1';
var allowed = parseModelAllowlist(await getSetting('clinical_assistant.allowed_image_models', ''));
var choices = allowed.slice();
if (allowed.length && configured && choices.indexOf(configured) === -1) choices.push(configured);
return resolveModelSelection(choices, configured, body && body.imageModel);
}
async function getAssistantStatusChoices() {
var chatConfigured = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var imageConfigured = await getSetting('clinical_assistant.image_model', '') || process.env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1';
var chatAllowed = parseModelAllowlist(await getSetting('clinical_assistant.allowed_models', ''));
var imageAllowed = parseModelAllowlist(await getSetting('clinical_assistant.allowed_image_models', ''));
function withConfigured(list, configured) {
if (!list.length) return [];
var out = list.slice();
if (configured && out.indexOf(configured) === -1) out.push(configured);
return out;
}
return {
chatConfigured: chatConfigured,
imageConfigured: imageConfigured,
allowedChatModels: withConfigured(chatAllowed, chatConfigured),
allowedImageModels: withConfigured(imageAllowed, imageConfigured)
};
}
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 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';
}
module.exports = router;