diff --git a/docker-compose.yml b/docker-compose.yml index cb86be6..3e14727 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: LITELLM_TTS_MODEL: local-kokoro-tts LITELLM_TTS_VOICE: sherpa/kokoro:am_adam LITELLM_TTS_VOICES: sherpa/kokoro:am_adam,sherpa/kokoro:am_michael,sherpa/kokoro:af_bella,sherpa/kokoro:af_nicole,sherpa/kokoro:bf_emma,sherpa/kokoro:bm_lewis + CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 200 volumes: - scribe-logs:/app/data/logs - clinical-assistant-mcp-data:/app/mcp-data:ro diff --git a/package-lock.json b/package-lock.json index 76ad8ac..11f67ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pediatric-ai-scribe", - "version": "7.14.3", + "version": "7.14.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pediatric-ai-scribe", - "version": "7.14.3", + "version": "7.14.4", "dependencies": { "@marp-team/marp-cli": "^4.3.1", "@marp-team/marp-core": "^4.3.0", diff --git a/package.json b/package.json index e06b2c7..d93009d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pediatric-ai-scribe", - "version": "7.14.3", + "version": "7.14.4", "description": "AI-powered pediatric clinical documentation platform", "main": "server.js", "scripts": { diff --git a/public/components/assistant.html b/public/components/assistant.html index 53f6638..8c17933 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -41,6 +41,7 @@ diff --git a/public/js/assistant/api.js b/public/js/assistant/api.js index bd5d7cf..b9d6a84 100644 --- a/public/js/assistant/api.js +++ b/public/js/assistant/api.js @@ -19,20 +19,24 @@ export function fetchAssistantExamples() { .then(function(r) { return r.json(); }); } -export function openAssistantStream(payload) { +export function openAssistantStream(payload, options) { + options = options || {}; return fetch('/api/clinical-assistant/chat/stream', { method: 'POST', headers: authHeaders(), credentials: 'same-origin', + signal: options.signal, body: JSON.stringify(payload) }); } -export function fetchAssistantChat(payload) { +export function fetchAssistantChat(payload, options) { + options = options || {}; return fetch('/api/clinical-assistant/chat', { method: 'POST', headers: authHeaders(), credentials: 'same-origin', + signal: options.signal, body: JSON.stringify(payload) }).then(parseJsonWithStatus); } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index fdee74d..bc9fcb0 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -29,6 +29,8 @@ import { var lastGeneratedImageSrc = ''; var markdownRenderer = null; var assistantBusy = false; + var activeAssistantRequest = null; + var STREAM_MARKDOWN_LIMIT = 3500; var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, showToast: window.showToast }); var imageStore = createAssistantImageStore(); @@ -49,6 +51,7 @@ import { function bindEvents() { var form = document.getElementById('assistant-form'); var clearBtn = document.getElementById('btn-assistant-clear'); + var cancelBtn = document.getElementById('btn-assistant-cancel'); var copyBtn = document.getElementById('btn-assistant-copy'); var saveBtn = document.getElementById('btn-assistant-save'); var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm'); @@ -60,6 +63,7 @@ import { if (form) form.addEventListener('submit', onAsk); if (clearBtn) clearBtn.addEventListener('click', clearConversation); + if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch); if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer); if (saveBtn) saveBtn.addEventListener('click', showSavePanel); if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat); @@ -128,19 +132,38 @@ import { setBusy(true, 'Looking up sources...'); var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...'); + var request = createAssistantRequest(); + request.loading = loading; + activeAssistantRequest = request; streamAssistantResponse({ message: text, history: messages.slice(-8), includeContext: !includeContext || includeContext.checked - }, loading) + }, loading, request) .catch(function (err) { + if (request.cancelled) return; 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.'); if (typeof showToast === 'function') showToast(err.message, 'error'); + }) + .finally(function () { + if (activeAssistantRequest === request) activeAssistantRequest = null; }); } + function createAssistantRequest() { + var controller = typeof AbortController === 'function' ? new AbortController() : null; + return { + cancelled: false, + signal: controller ? controller.signal : undefined, + abort: function () { + this.cancelled = true; + if (controller) controller.abort(); + } + }; + } + async function fetchAssistantResponse(payload, loading) { updateLoadingMessage(loading, 'Looking up sources...'); var data = await fetchAssistantFallback(payload); @@ -155,8 +178,8 @@ import { } } - async function streamAssistantResponse(payload, loading) { - var response = await openAssistantStream(payload); + async function streamAssistantResponse(payload, loading, request) { + var response = await openAssistantStream(payload, { signal: request ? request.signal : undefined }); if (!response.ok || !response.body) { var fallback = await response.json().catch(function () { return {}; }); throw new Error(fallback.error || ('Request failed (' + response.status + ')')); @@ -177,7 +200,7 @@ import { if (!bubble) return; loading.classList.remove('assistant-loading-msg'); bubble.classList.remove('assistant-thinking'); - bubble.innerHTML = partial ? renderAssistantBubbleHtml(partial, streamSources, false) : '

Generating answer...

'; + bubble.innerHTML = partial ? renderStreamingAnswerHtml(partial, streamSources) : '

Generating answer...

'; renderEmbeddedBlocks(bubble); var wrap = document.getElementById('assistant-messages'); if (wrap) wrap.scrollTop = wrap.scrollHeight; @@ -207,6 +230,7 @@ import { var reader = response.body.getReader(); while (true) { + if (request && request.cancelled) return; var chunk = await reader.read(); if (chunk.done) break; buffer += decoder.decode(chunk.value, { stream: true }); @@ -224,9 +248,11 @@ import { if (!finalData) { updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...'); - finalData = await fetchAssistantFallback(payload); + finalData = await fetchAssistantFallback(payload, request); } + if (request && request.cancelled) return; + setBusy(false, 'Ready'); lastAnswer = finalData.answer || finalData.markdown || ''; lastSources = finalData.sources || finalData.citations || streamSources; @@ -238,6 +264,20 @@ import { } } + function renderStreamingAnswerHtml(text, sources) { + if (shouldUseLightweightStreamingRender(text)) { + return '
' + escapeHtml(text) + '
'; + } + return renderAssistantBubbleHtml(text, sources, false); + } + + function shouldUseLightweightStreamingRender(text) { + text = String(text || ''); + if (text.length > STREAM_MARKDOWN_LIMIT) return true; + var pipeRows = text.split('\n').filter(function (line) { return /^\s*\|.*\|\s*$/.test(line); }).length; + return pipeRows >= 8; + } + function parseSseEvent(block) { var type = 'message'; var data = ''; @@ -250,8 +290,15 @@ import { catch (e) { return null; } } - async function fetchAssistantFallback(payload) { - var data = await fetchAssistantChat(payload); + async function fetchAssistantFallback(payload, request) { + var data; + try { + data = await fetchAssistantChat(payload, { signal: request ? request.signal : undefined }); + } catch (e) { + if (request && request.cancelled) throw e; + if (e && e.name === 'AbortError') throw new Error('Assistant request cancelled.'); + throw e; + } if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')')); return data; } @@ -547,6 +594,17 @@ import { loadSavedChats(); } + function cancelAssistantSearch() { + if (!activeAssistantRequest) return; + var request = activeAssistantRequest; + request.abort(); + activeAssistantRequest = null; + setBusy(false, 'Ready'); + if (request.loading && request.loading.parentNode) { + replaceLoadingMessage(request.loading, 'Search cancelled.'); + } + } + function renderEmptyState() { var examples = pickExamples(); return '
' + @@ -758,6 +816,7 @@ import { var status = document.getElementById('assistant-status'); var label = document.getElementById('assistant-status-text'); var send = document.getElementById('btn-assistant-send'); + var cancel = document.getElementById('btn-assistant-cancel'); var input = document.getElementById('assistant-input'); if (status) { status.classList.toggle('busy', !!isBusy); @@ -768,6 +827,10 @@ import { send.disabled = !!isBusy; send.innerHTML = isBusy ? ' Searching' : ' Ask'; } + if (cancel) { + cancel.hidden = !isBusy; + cancel.disabled = !isBusy; + } if (input) input.disabled = !!isBusy; } diff --git a/src/utils/clinicalPromptPool.js b/src/utils/clinicalPromptPool.js index 2c7ae96..e8f38cf 100644 --- a/src/utils/clinicalPromptPool.js +++ b/src/utils/clinicalPromptPool.js @@ -73,9 +73,9 @@ function createClinicalPromptPool(opts) { if (!snippets.length) return fallbackIndexedExamples(); var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', ''); var generated = []; - var batches = Math.max(1, Math.min(20, Math.ceil(target / 25))); + var batches = Math.max(1, Math.min(40, Math.ceil(target / 25))); for (var i = 0; i < batches && generated.length < target; i++) { - var batchSnippets = rotateExamples(snippets, Date.now() + i * 997).slice(0, 24); + var batchSnippets = rotateExamples(snippets, i * 24).slice(0, 24); var sourceText = batchSnippets.map(function(s, idx) { return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 650); }).join('\n\n'); @@ -142,7 +142,7 @@ function createClinicalPromptPool(opts) { var out = []; (Array.isArray(items) ? items : []).forEach(function(item) { var prompt = clip(String(item.prompt || item.question || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(), 220); - if (!isUsefulQuestion(prompt)) return; + if (!isUsefulQuestion(prompt, item)) return; var key = prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); if (seen.has(key)) return; seen.add(key); @@ -156,13 +156,20 @@ function createClinicalPromptPool(opts) { return out.slice(0, target); } - function isUsefulQuestion(prompt) { + function isUsefulQuestion(prompt, item) { if (!prompt || prompt.length < 25 || prompt.length > 220) return false; if (prompt.indexOf('?') === -1) return false; - if (!/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return false; + if (!hasPediatricSignal(prompt, item)) return false; return !/\b(source|snippet|textbook|chapter|document|database)\b/i.test(prompt); } + function hasPediatricSignal(prompt, item) { + if (/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return true; + var category = String(item && item.category || '').toLowerCase(); + var intent = String(item && item.intent || '').toLowerCase(); + return /\b(neonates?|development|respiratory|infectious disease|gi|neurology|cardiology|endocrine|hematology|toxicology|procedures|dosing\/fluids|red flags|differential|admission\/discharge|counseling|emergency|general)\b/.test(category) && /\b(diagnosis|management|red_flags|differential|counseling|dosing|admission|review)\b/.test(intent); + } + function cleanExampleLabel(label) { label = String(label || '').replace(/[?!.:,;]+$/g, '').replace(/\s+/g, ' ').trim(); if (!label || label.length > 42) label = labelFromQuestion(label || 'Clinical question'); @@ -181,7 +188,7 @@ function createClinicalPromptPool(opts) { function rotateExamples(items, seed) { if (!items.length) return []; var copy = items.slice(); - var offset = Math.floor(seed / cacheMs) % copy.length; + var offset = Math.abs(Math.floor(seed)) % copy.length; return copy.slice(offset).concat(copy.slice(0, offset)); } diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js index 66c0076..bb3d943 100644 --- a/test/assistant-citations.test.js +++ b/test/assistant-citations.test.js @@ -244,3 +244,11 @@ test('does not repair malformed tab-separated clinical summary rows into a table assert.match(html, /Initial Imaging/); assert.match(html, /If you need details/); }); + +test('clinical assistant streams long table answers as lightweight text before final render', async () => { + const source = await fs.readFile(path.join(__dirname, '..', 'public', 'js', 'clinicalAssistant.js'), 'utf8'); + assert.match(source, /STREAM_MARKDOWN_LIMIT/); + assert.match(source, /renderStreamingAnswerHtml\(partial, streamSources\)/); + assert.match(source, /assistant-streaming-text/); + assert.match(source, /pipeRows >= 8/); +}); diff --git a/test/clinical-assistant-prompt-pool.test.js b/test/clinical-assistant-prompt-pool.test.js index ede8971..2bc8192 100644 --- a/test/clinical-assistant-prompt-pool.test.js +++ b/test/clinical-assistant-prompt-pool.test.js @@ -17,6 +17,9 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', () assert.match(pool, /opts\.redisCache\.getJson\(redisKey\)/); assert.match(pool, /opts\.redisCache\.setJson\(redisKey, payload, ttlSeconds\)/); assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200/); + assert.match(pool, /Math\.min\(40, Math\.ceil\(target \/ 25\)\)/); + assert.match(pool, /rotateExamples\(snippets, i \* 24\)/); + assert.match(pool, /function hasPediatricSignal\(prompt, item\)/); assert.match(pool, /var examples = await fallbackIndexedExamples\(\)/); });