From 60d726a293c1b08c6b2e76907fe77f338ccce8d8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 7 May 2026 17:26:00 +0200 Subject: [PATCH] feat: stream clinical assistant responses --- public/js/clinicalAssistant.js | 132 +++++++++++++--- src/routes/clinicalAssistant.js | 265 ++++++++++++++++++++------------ src/utils/ai.js | 83 +++++++--- 3 files changed, 340 insertions(+), 140 deletions(-) diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index aa9825e..9cbed0b 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -118,30 +118,11 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection } setBusy(true, 'Searching indexed resources...'); var loading = appendLoadingMessage('Searching indexed resources', 'Retrieving and synthesizing cited references...'); - fetch('/api/clinical-assistant/chat', { - method: 'POST', - headers: getAuthHeaders(), - credentials: 'same-origin', - body: JSON.stringify({ - message: text, - history: messages.slice(-8), - includeContext: !includeContext || includeContext.checked - }) - }) - .then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); }) - .then(function (data) { - setBusy(false, 'Ready'); - if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')')); - var answer = data.answer || data.markdown || ''; - lastAnswer = answer; - lastSources = data.sources || data.citations || []; - replaceLoadingMessage(loading, answer, lastSources, data.suggestions || []); - renderSources(lastSources); - if (data.model) { - var label = document.getElementById('assistant-model-label'); - if (label) label.textContent = 'Chat: ' + data.model; - } - }) + streamAssistantResponse({ + message: text, + history: messages.slice(-8), + includeContext: !includeContext || includeContext.checked + }, loading) .catch(function (err) { setBusy(false, 'Error', true); replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.'); @@ -149,6 +130,109 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection } }); } + async function streamAssistantResponse(payload, loading) { + var response = await fetch('/api/clinical-assistant/chat/stream', { + method: 'POST', + headers: getAuthHeaders(), + credentials: 'same-origin', + body: JSON.stringify(payload) + }); + if (!response.ok || !response.body) { + var fallback = await response.json().catch(function () { return {}; }); + throw new Error(fallback.error || ('Request failed (' + response.status + ')')); + } + + var partial = ''; + var streamSources = []; + var streamSuggestions = []; + var lastRender = 0; + var bubble = loading ? loading.querySelector('.assistant-bubble') : null; + var decoder = new TextDecoder(); + var buffer = ''; + var doneData = null; + + function renderPartial(force) { + var now = Date.now(); + if (!force && now - lastRender < 180) return; + lastRender = now; + if (!bubble) return; + loading.classList.remove('assistant-loading-msg'); + bubble.classList.remove('assistant-thinking'); + bubble.innerHTML = partial ? renderMarkdown(partial, streamSources) : '

Generating answer...

'; + var wrap = document.getElementById('assistant-messages'); + if (wrap) wrap.scrollTop = wrap.scrollHeight; + } + + function handleEvent(type, data) { + if (type === 'status') { + updateLoadingMessage(loading, data.message || 'Working...'); + return; + } + if (type === 'sources') { + streamSources = data.sources || []; + renderSources(streamSources); + return; + } + if (type === 'token') { + partial += data.token || ''; + renderPartial(false); + return; + } + if (type === 'done') { + doneData = data || {}; + streamSuggestions = doneData.suggestions || []; + return; + } + if (type === 'error') throw new Error(data.error || 'Assistant stream failed'); + } + + var reader = response.body.getReader(); + while (true) { + var chunk = await reader.read(); + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }); + var parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; + parts.forEach(function (part) { + var parsed = parseSseEvent(part); + if (parsed) handleEvent(parsed.type, parsed.data); + }); + } + if (buffer.trim()) { + var tail = parseSseEvent(buffer); + if (tail) handleEvent(tail.type, tail.data); + } + + setBusy(false, 'Ready'); + var answer = (doneData && (doneData.answer || doneData.markdown)) || partial; + lastAnswer = answer || ''; + lastSources = (doneData && (doneData.sources || doneData.citations)) || streamSources; + replaceLoadingMessage(loading, lastAnswer, lastSources, streamSuggestions); + renderSources(lastSources); + if (doneData && doneData.model) { + var label = document.getElementById('assistant-model-label'); + if (label) label.textContent = 'Chat: ' + doneData.model; + } + } + + function parseSseEvent(block) { + var type = 'message'; + var data = ''; + String(block || '').split(/\r?\n/).forEach(function (line) { + if (line.indexOf('event:') === 0) type = line.substring(6).trim(); + if (line.indexOf('data:') === 0) data += line.substring(5).trim(); + }); + if (!data) return null; + try { return { type: type, data: JSON.parse(data) }; } + catch (e) { return null; } + } + + function updateLoadingMessage(row, detail) { + if (!row) return; + var el = row.querySelector('.assistant-thinking-detail'); + if (el) el.textContent = detail; + } + function appendLoadingMessage(title, detail) { var wrap = document.getElementById('assistant-messages'); if (!wrap) return null; diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index c7d9984..6c82719 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -13,7 +13,7 @@ var { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'); var router = express.Router(); var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); -var { callAI } = require('../utils/ai'); +var { callAI, callAIStream } = require('../utils/ai'); var { gatewayUrl } = require('../utils/errors'); var logger = require('../utils/logger'); var cryptoUtil = require('../utils/crypto'); @@ -249,93 +249,11 @@ router.delete('/clinical-assistant/chats/:id', async function(req, res) { router.post('/clinical-assistant/chat', async function(req, res) { var started = Date.now(); try { - var message = String(req.body.message || '').trim(); - if (!message) return res.status(400).json({ error: 'Question is required' }); - if (message.length > 4000) return res.status(400).json({ error: 'Question too long' }); + var prepared = await prepareAssistantChat(req.body); + if (prepared.direct) return res.json(prepared.direct); - 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 = req.body.includeContext !== false; - - if (GREETING_RE.test(message)) { - return res.json({ - success: true, - answer: 'What clinical question would you like me to look up in the indexed pediatric resources?', - sources: [], - model: null, - search: { skipped: true, reason: 'low_information_input' } - }); - } - - var history = Array.isArray(req.body.history) ? req.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 res.json({ - 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 res.json({ - success: true, - answer: 'I could not find relevant indexed Nextcloud resources for that question. Try a more specific clinical term, diagnosis, medication, age group, or textbook topic.', - sources: [], - model: null, - search: { totalFound: 0 } - }); - } - - var context = formatSourcesForPrompt(sources); - var messages = [ - { role: 'system', content: buildSystemPrompt(behavior) }, - { role: 'user', content: buildUserPrompt(message, context, history, searchQuery) } - ]; - - var ai = await callAI(messages, { - model: chatModel || undefined, + var ai = await callAI(prepared.messages, { + model: prepared.chatModel || undefined, temperature: 0.15, maxTokens: 1800 }); @@ -348,18 +266,11 @@ router.post('/clinical-assistant/chat', async function(req, res) { res.json({ success: true, answer: answer, - sources: sanitizeSourcesForClient(sources), - model: ai.model || chatModel || null, + sources: sanitizeSourcesForClient(prepared.sources), + model: ai.model || prepared.chatModel || null, provider: ai.provider || null, duration: Date.now() - started, - 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 - } + search: prepared.search }); } catch (e) { console.error('[clinical-assistant]', e.message, e.stack || ''); @@ -367,6 +278,59 @@ router.post('/clinical-assistant/chat', async function(req, res) { } }); +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: 'Searching indexed resources...' }); + + var prepared = await prepareAssistantChat(req.body); + if (prepared.direct) { + sendEvent('done', Object.assign({ duration: Date.now() - started }, prepared.direct)); + return res.end(); + } + sendEvent('sources', { sources: sanitizeSourcesForClient(prepared.sources), search: prepared.search }); + sendEvent('status', { message: 'Generating answer...' }); + + var ai = await callAIStream(prepared.messages, { + model: prepared.chatModel || undefined, + temperature: 0.15, + maxTokens: 1800 + }, function(delta) { + sendEvent('token', { token: delta }); + }); + var answer = stripModelSourcesSection(String(ai.content || '').trim()); + logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant streaming query', req, { + category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started + }); + sendEvent('done', { + success: true, + answer: answer, + sources: sanitizeSourcesForClient(prepared.sources), + model: ai.model || prepared.chatModel || null, + provider: ai.provider || null, + duration: Date.now() - started, + search: prepared.search + }); + res.end(); + } catch (e) { + console.error('[clinical-assistant stream]', e.message, e.stack || ''); + if (!streamOpen) return res.status(500).json({ error: assistantErrorMessage(e) }); + sendEvent('error', { error: assistantErrorMessage(e) }); + res.end(); + } +}); + router.post('/clinical-assistant/image', async function(req, res) { try { var prompt = String(req.body.prompt || '').trim(); @@ -383,6 +347,113 @@ router.post('/clinical-assistant/image', async function(req, res) { } }); +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 in the indexed pediatric resources?', + sources: [], + model: null, + search: { skipped: true, reason: 'low_information_input' } + } }; + } + + var history = Array.isArray(body.history) ? body.history.slice(-8) : []; + var contextualHistory = priorClinicalHistory(history, message); + var suggestions = getTopicSuggestions(message); + if (!contextualHistory.length && suggestions.length > 0 && message.split(/\s+/).length < 4) { + return { direct: { + success: true, + answer: buildSuggestionAnswer(message, suggestions), + suggestions: suggestions, + sources: [], + model: null, + search: { skipped: true, reason: 'topic_suggestions' } + } }; + } + + var searchQuery = await rewriteSearchQuery(message, history, chatModel).catch(function(e) { + console.warn('[clinical-assistant] query rewrite skipped:', e.message); + return message; + }); + var searchResponse = await semanticSearch(searchQuery, { + limit: searchLimit, + includeContext: includeContext, + contextChars: contextChars + }); + var visualQuery = isVisualSourceQuery(message) || isVisualSourceQuery(searchQuery); + var multimodalResponse = visualQuery ? await multimodalSearch(buildMultimodalSearchQuery(searchQuery), { limit: 8 }).catch(function(e) { + console.warn('[clinical-assistant] multimodal search skipped:', e.message); + return null; + }) : null; + var rawTextResults = normalizeMcpSearchResponse(searchResponse); + var rawMultimodalResults = await classifyAndRerankMultimodalResults( + message + ' ' + searchQuery, + normalizeMcpMultimodalResponse(multimodalResponse) + ); + var rawResults = rawTextResults.concat(rawMultimodalResults); + console.info('[clinical-assistant] retrieval counts:', { + text: rawTextResults.length, + multimodal: rawMultimodalResults.length, + query: searchQuery + }); + var visualSlots = rawMultimodalResults.length ? Math.min(2, Math.max(1, Math.floor(searchLimit / 4))) : 0; + var textSlots = searchLimit - visualSlots; + var sources = dedupeSources( + dedupeSources(rawTextResults).slice(0, textSlots).concat( + dedupeSources(rawMultimodalResults).slice(0, visualSlots) + ) + ).slice(0, searchLimit); + + if (sources.length === 0) { + return { direct: { + success: true, + answer: 'I could not find relevant indexed Nextcloud resources for that question. Try a more specific clinical term, diagnosis, medication, age group, or textbook topic.', + sources: [], + model: null, + search: { totalFound: 0 } + } }; + } + + var context = formatSourcesForPrompt(sources); + return { + message: message, + chatModel: chatModel, + sources: sources, + messages: [ + { role: 'system', content: buildSystemPrompt(behavior) }, + { role: 'user', content: buildUserPrompt(message, context, history, searchQuery) } + ], + search: { + totalFound: rawResults.length, + multimodalFound: rawMultimodalResults.length, + query: searchQuery, + rewritten: searchQuery !== message, + verifiedChunkCount: searchResponse.verified_chunk_count || searchResponse.verifiedChunkCount || 0, + droppedDocumentCount: searchResponse.dropped_document_count || searchResponse.droppedDocumentCount || 0 + } + }; +} + router.post('/clinical-assistant/export-summary', async function(req, res) { try { if (Array.isArray(req.body.items) && req.body.items.length) { diff --git a/src/utils/ai.js b/src/utils/ai.js index 1eda32a..035b91c 100644 --- a/src/utils/ai.js +++ b/src/utils/ai.js @@ -395,6 +395,68 @@ async function callLiteLLM(messages, model, temperature, maxTokens) { }; } +async function assertModelAllowed(requestedModel, options) { + options = options || {}; + if (!requestedModel || options.skipAllowlistCheck) return; + try { + var db = require('../db/database'); + var { getAllowedModelIds } = require('./models'); + var allowed = await getAllowedModelIds(db); + if (allowed && allowed.size > 0 && !allowed.has(requestedModel)) { + var err = new Error('Model not permitted'); + err.code = 'model_not_permitted'; + err.model = requestedModel; + throw err; + } + } catch (e) { + if (e && e.code === 'model_not_permitted') throw e; + console.warn('[callAI] Allow-list check skipped:', e && e.message); + } +} + +async function callAIStream(messages, options, onToken) { + options = options || {}; + var requestedModel = options.model; + var model = requestedModel || DEFAULT_MODEL; + var temperature = options.temperature || 0.3; + var maxTokens = options.maxTokens || 4000; + var startTime = Date.now(); + await assertModelAllowed(requestedModel, options); + + var client = null; + var provider = null; + if (activeProvider === 'litellm' && litellmClient) { + client = litellmClient; + provider = 'litellm'; + } else if (activeProvider === 'openrouter' && openrouter) { + client = openrouter; + provider = 'openrouter'; + } else if (activeProvider === 'azure' && azureClient) { + client = azureClient; + provider = 'azure'; + model = process.env.AZURE_DEPLOYMENT_NAME || model; + } + if (!client) throw new Error('Streaming is only configured for OpenAI-compatible providers'); + + var content = ''; + var stream = await client.chat.completions.create({ + model: model, + messages: messages, + temperature: temperature, + max_tokens: maxTokens, + stream: true + }); + for await (var part of stream) { + var delta = part && part.choices && part.choices[0] && part.choices[0].delta ? part.choices[0].delta.content : ''; + if (!delta) continue; + content += delta; + if (typeof onToken === 'function') onToken(delta); + } + var duration = Date.now() - startTime; + logger.apiCall(null, provider + '/' + model, { model: model, duration: duration, statusCode: 200 }); + return { success: true, content: content, model: model, provider: provider, duration: duration }; +} + // ============================================================ // MAIN CALL AI FUNCTION — Routes to correct provider // ============================================================ @@ -411,24 +473,7 @@ async function callAI(messages, options) { // the budget on a reasoning model outside the configured roster. // Falls back silently to DEFAULT_MODEL when no model was provided. // Admin test endpoints pass skipAllowlistCheck to test before adding. - if (requestedModel && !options.skipAllowlistCheck) { - try { - var db = require('../db/database'); - var { getAllowedModelIds } = require('./models'); - var allowed = await getAllowedModelIds(db); - if (allowed && allowed.size > 0 && !allowed.has(requestedModel)) { - var err = new Error('Model not permitted'); - err.code = 'model_not_permitted'; - err.model = requestedModel; - throw err; - } - } catch (e) { - if (e && e.code === 'model_not_permitted') throw e; - // DB lookup failure — fall through, let the provider reject if it - // doesn't recognize the model. Log for visibility. - console.warn('[callAI] Allow-list check skipped:', e && e.message); - } - } + await assertModelAllowed(requestedModel, options); try { var result; @@ -632,4 +677,4 @@ async function discoverModels() { return discovered; } -module.exports = { callAI, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient }; +module.exports = { callAI, callAIStream, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient };