pediatric-ai-scribe-v3/src/routes/clinicalAssistant.js
Daniel 1f06a19007
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: a text-only model can ask a model that can see; and the image regex is gone
**The regex is gone.** The route ran a pattern over the user's message and
enqueued an image from the answer text when the model had not called the tool.
It was a compatibility path for models without tool calling and it did more harm
than good: it decided in English only, it could not see the conversation, and
"image summary" fell through it while reading as an obvious image request to the
model itself — which was measured, not assumed. A second and worse
decision-maker sitting behind the first. Whether a message deserves a picture is
now the model's call, made from the tool description, which is the only place it
ever belonged.

**Lending eyes.** The same shape, for a different capability. When someone
attaches a photograph and the chat model cannot accept image input, the
attachment was either refused by the provider or silently dropped — an answer
about a picture nobody had looked at, which is worse than a refusal.

The chat model is now offered look_at_image beside the image tool and decides
when to use it. The attachment goes to clinical_assistant.vision_model, whose
description comes back as a tool result, and the chat model answers in its own
voice with its own sources. Only the seeing is delegated; the clinical reasoning
stays with the model an administrator chose. The seeing model is told to report
and not to diagnose, because it has a picture and no context and an opinion from
it would carry weight it has not earned.

Delegation triggers only on an explicit supports_vision: false from the gateway.
An unknown is left alone — most of a roster reports nothing, and treating
silence as blindness would route good models through a detour. The capability
lookup moved to its own module, is cached for five minutes because it runs on
exactly the requests that are already slowest, and is never inferred from the
model id. liteLLMBaseUrl moved from the admin route to litellm.js, where the
other gateway helpers live.

The new setting is guarded like the slide reviewer: a model the gateway calls
text-only cannot be saved as the one that looks at images.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 15:43:36 +02:00

909 lines
42 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 visionTool = require('../utils/visionTool');
var modelVision = require('../utils/modelVision');
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,
indexedTopicSuggestions,
getMcpHealth,
warmMcpSession
} = require('../utils/clinicalMcpClient');
var {
cleanSourceExcerpt,
normalizeMcpSearchResponse,
dedupeSources,
} = require('../utils/clinicalRetrieval');
var {
buildSystemPrompt,
stripCitationMarkers,
buildUserPrompt,
assistantGenerationOptions,
finalizeAssistantAnswer
} = require('../utils/clinicalAnswer');
var { conversationBudget, conversationLimit, 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');
// Scoped to this router's own prefix. Mounted on /api, a bare
// router.use(authMiddleware) gates every /api path, including routes owned by
// routers mounted after it in server.js.
router.use('/clinical-assistant', authMiddleware);
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,
showSources: await showSourcesEnabled(),
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 });
}
});
// Citation quality tracking. Loaded on demand and allowed to be absent: it is
// observation, not part of producing an answer, so it must never be able to
// fail one — including in a harness that stubs this route's module graph.
function citationTracker() {
try {
return require('../utils/citationAudit');
} catch (e) {
return null;
}
}
function trackCitations(req, question, answer, sources) {
var tracker = citationTracker();
if (!tracker) return;
var result = tracker.record(answer, sources);
// Not awaited: the clinician is waiting for this answer.
tracker.store(req.user.id, question, result, sources);
}
// A model that cannot be shown the attachment is given a tool to ask about it
// instead, and the attachment is withheld from its own request — sending it
// would either be refused by the provider or silently dropped, and an answer
// about a picture nobody looked at is worse than a refusal.
function assistantToolset(prepared) {
return prepared.delegateVision
? { tools: imageTool.tools.concat(visionTool.tools), images: undefined }
: { tools: imageTool.tools, images: prepared.images };
}
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 toolset = assistantToolset(prepared);
var ai = await callAI(prepared.messages, assistantGenerationOptions({
model: prepared.chatModel || undefined,
temperature: 0.15,
tools: toolset.tools,
maxTokens: 2600,
images: toolset.images
}));
ai = await visionTool.dispatch(ai, {
images: prepared.images, visionModel: prepared.visionModel, callAI: callAI,
messages: prepared.messages,
options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15, maxTokens: 2600 })
});
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 });
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: assistantToolset(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
});
// This route is the fallback the client uses when streaming fails, so it
// produces answers a clinician reads. Auditing only the streaming route
// would make exactly the answers produced under failure invisible.
var fallbackSources = sanitizeSourcesForClient(prepared.sources);
trackCitations(req, prepared.message, answer, fallbackSources);
res.json({
success: true,
answer: prepared.showSources ? answer : stripCitationMarkers(answer),
imageJobs: ai.imageJobs || [],
showSources: prepared.showSources,
sources: prepared.showSources ? fallbackSources : [],
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 toolset = assistantToolset(prepared);
if (prepared.delegateVision) sendEvent('status', { message: 'Looking at your image…' });
var ai = await callAIStream(prepared.messages, assistantGenerationOptions({
model: prepared.chatModel || undefined,
temperature: 0.15,
tools: toolset.tools,
maxTokens: 2600,
images: toolset.images
}), function(delta) {
sendEvent('token', { token: delta });
});
ai = await visionTool.dispatch(ai, {
images: prepared.images, visionModel: prepared.visionModel, callAI: callAI,
messages: prepared.messages,
options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15, maxTokens: 2600 })
});
{
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 });
}
if (ai.imageToolHandled) sendEvent('status', { message: 'Generating image…' });
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: assistantToolset(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_streaming_query', 'Clinical assistant streaming query', req, {
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
});
// Quality tracking: does every citation the model wrote point at a source
// that actually came back? Counted always; the answer is recorded only when
// something did not resolve. Not awaited — the clinician is waiting for
// this answer and a slow write must not hold it up.
trackCitations(req, prepared.message, answer, safeSources);
sendEvent('done', {
success: true,
answer: prepared.showSources ? answer : stripCitationMarkers(answer),
imageJobs: ai.imageJobs || [],
showSources: prepared.showSources,
sources: prepared.showSources ? safeSources : [],
model: ai.model || prepared.chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
search: prepared.search
});
res.end();
} catch (e) {
logger.error('[clinical-assistant stream] ' + e.message, { code: e.code, stack: String(e.stack || '').slice(0, 800) });
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
// Whether a message deserves a picture is the model's decision, made from the
// system prompt and the tool description. There is no pattern here any more.
//
// There used to be one: a regex over the user's message that queued an image
// from the answer text when the model had not called the tool. It was a
// compatibility path for models without tool calling, and it did more harm than
// good — it decided in English only, it could not see the conversation, and
// "image summary" fell through it while reading as an obvious image request to
// the model itself. A second, worse decision-maker sitting behind the first.
//
// Typing "Окей" after an image turn produced another image every time: the model
// saw its own "I'll generate an educational image…" in the history and replayed
// it verbatim. Detecting acknowledgements by vocabulary cannot cover every
// language, so the signal used here is the loop itself — an answer that repeats
// the previous one is the model echoing, not responding, and its image is
// suppressed. This needs no word list in any language.
function normalizeAnswerForRepeatCheck(text) {
return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
}
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);
// The model that gets shown an attachment when the chat model cannot be.
var visionModel = String(await getSetting('clinical_assistant.vision_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 behavior = await getSetting('clinical_assistant.system_behavior', DEFAULT_BEHAVIOR) || DEFAULT_BEHAVIOR;
var includeContext = body.includeContext !== false;
var showSources = await showSourcesEnabled();
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
});
// Retrieval is text-only. The multimodal path called nc_multimodal_search
// against a second hardcoded collection with an embedding service that was
// never deployed, so it only ever logged "multimodal search skipped".
var rawResults = normalizeMcpSearchResponse(searchResponse);
console.info('[clinical-assistant] retrieval count:', rawResults.length);
// No visual/text slot split any more: every result is text, so the whole
// search limit goes to it.
var sources = dedupeSources(rawResults).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,
showSources: showSources,
images: images,
imageContext: generatedImages.imageContext(message, history),
// Whether this model can be shown the attachment itself. Only an explicit
// "no" from the gateway triggers delegation: a model the gateway says
// nothing about is given the image, which is how it has always worked.
delegateVision: images.length > 0 && visionModel
&& (await modelVision.supportsVision(chatModel || await getSetting('models.default', ''))) === false,
visionModel: visionModel,
history: 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,
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) {
return '[' + s.number + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\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;
}
// Display-only admin switch. The prompt, retrieval, grounding and the stored
// answer are all identical either way — branching the prompt on a display
// setting would change how the model reasons, which is the bias this avoids.
// Only what the client is sent differs.
async function showSourcesEnabled() {
var value = await getSetting('clinical_assistant.show_sources', '');
if (value === '' || value == null) {
// Legacy key from when this also changed the prompt.
value = await getSetting('clinical_assistant.citations_enabled', 'true');
}
return String(value) !== 'false';
}
async function getConversationLimit() {
// Admin-set value wins over the environment so the administrator can test
// the warning/refusal behavior with a lower limit. Same validator the admin
// endpoint uses, so a value that saved cleanly is a value that applies.
var override = await getSetting('clinical_assistant.conversation_chars', '');
if (override !== '' && override != null) {
try {
return conversationLimit(override);
} catch (_) {
logger.warn('Ignoring unusable clinical_assistant.conversation_chars override: ' + String(override));
}
}
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;