From 9902ee07a682e9c52520cd4722ff4724c13737dc Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Sep 2026 14:23:56 +0200 Subject: [PATCH] fix: render clinical handoff Markdown with a concise heading --- public/components/assistant.html | 5 +++-- public/js/clinicalAssistant.js | 20 ++++++++++++++------ src/routes/clinicalAssistant.js | 2 +- test/clinical-conversation.test.js | 2 ++ test/frontend-prompt-env.test.js | 26 ++++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/public/components/assistant.html b/public/components/assistant.html index 01402271..c27a596c 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -45,8 +45,9 @@

Loading conversation limit; history and draft are counted in UTF-16 code units. The server validates each request.

diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 0386eb04..c451a1c9 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -614,8 +614,7 @@ import { } renderSources([]); clearGeneratedImage(); - document.getElementById('assistant-handoff-panel').hidden = true; - document.getElementById('assistant-handoff-text').value = ''; + setHandoff(''); updateConversationBudget(); loadSavedChats(); } @@ -811,8 +810,7 @@ import { var out = document.getElementById('assistant-visual-output'); if (out) out.innerHTML = lastGeneratedImageSrc ? imageStore.renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : ''; exporter.invalidate(); - document.getElementById('assistant-handoff-panel').hidden = true; - document.getElementById('assistant-handoff-text').value = ''; + setHandoff(''); updateConversationBudget(); } @@ -895,6 +893,17 @@ import { setTimeout(function() { URL.revokeObjectURL(url); }, 60000); } + function setHandoff(summary) { + var text = String(summary || ''); + document.getElementById('assistant-handoff-text').value = text; // Keep exact copy text. + var preview = document.getElementById('assistant-handoff-preview'); + if (preview) { + preview.innerHTML = text ? renderMarkdown(text, []) : ''; + if (text) renderEmbeddedBlocks(preview); + } + document.getElementById('assistant-handoff-panel').hidden = !text; + } + function requestHandoff() { if (assistantBusy || !messages.length) return; if (conversationChars !== null && conversationSize('') > conversationChars) { @@ -910,8 +919,7 @@ import { updateConversationBudget(); throw new Error(data.error || 'Handoff failed. Your conversation is unchanged.'); } - document.getElementById('assistant-handoff-text').value = data.summary || ''; - document.getElementById('assistant-handoff-panel').hidden = false; + setHandoff(data.summary); }) .catch(function(error) { if (typeof showToast === 'function') showToast(error.message, 'error'); }) .finally(function() { setBusy(false, 'Ready'); }); diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 9c970384..6d9339fc 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -298,7 +298,7 @@ router.post('/clinical-assistant/handoff', async function(req, res) { if (!checked.history.length) return res.status(400).json({ error: 'There is no conversation to summarize.' }); var model = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', ''); var ai = await callAI([ - { role: 'system', content: 'Create a concise handoff summary only because the user explicitly requested it. Preserve user-reported clinical facts, age, units, medication names/doses, corrections, contradictions and uncertainty. Distinguish user facts from prior assistant suggestions; prior AI output is not evidence. Do not add facts or clinical recommendations. Include unresolved questions. Label this as conversation context, not a verified clinical source. Do not silently resolve contradictions. This does not start or replace a chat.' }, + { role: 'system', content: 'Create a concise handoff summary only because the user explicitly requested it. Preserve user-reported clinical facts, age, units, medication names/doses, corrections, contradictions and uncertainty. Distinguish user facts from prior assistant suggestions; prior AI output is not evidence. Do not add facts or clinical recommendations. Include unresolved questions. Use the heading "Clinical handoff". Do not silently resolve contradictions. This does not start or replace a chat.' }, { role: 'user', content: checked.history.map(function(m) { return m.role.toUpperCase() + ': ' + m.content; }).join('\n\n') } ], assistantGenerationOptions({ model: model || undefined, temperature: 0, maxTokens: 1200 })); if (!ai || !String(ai.content || '').trim()) throw new Error('No handoff summary was returned. Your conversation is unchanged.'); diff --git a/test/clinical-conversation.test.js b/test/clinical-conversation.test.js index 11da9b12..3a683ac7 100644 --- a/test/clinical-conversation.test.js +++ b/test/clinical-conversation.test.js @@ -151,6 +151,8 @@ test('explicit handoff uses full context, does not retrieve/replace a chat, and assert.equal(result.statusCode, 200); assert.ok(app.calls.ai[0].messages[1].content.includes(history[0].content)); assert.match(app.calls.ai[0].messages[0].content, /prior AI output is not evidence/); + assert.match(app.calls.ai[0].messages[0].content, /heading.*Clinical handoff/); + assert.doesNotMatch(app.calls.ai[0].messages[0].content, /Label this as conversation context, not a verified clinical source/); assert.equal(app.calls.search.length + app.calls.writes.length, 0); assert.equal((await server({ emptyAI: true }).request('post', '/clinical-assistant/handoff', { history })).statusCode, 500); assert.equal((await server({ finishReason: 'length' }).request('post', '/clinical-assistant/handoff', { history })).statusCode, 503); diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js index 240a68b4..510530d3 100644 --- a/test/frontend-prompt-env.test.js +++ b/test/frontend-prompt-env.test.js @@ -278,6 +278,32 @@ test('explicit handoff at the exact cap excludes but preserves the draft, and ke assert.equal(ui.document.getElementById('assistant-handoff-text').value, 'Explicit synthetic handoff'); }); +test('handoff renders Markdown while preserving exact copy text and clearing on saved-chat load', async t => { + const summary = '## Clinical handoff\n\n**Plan**\n\n- Follow up\n\n| Item | Value |\n| --- | --- |\n| Synthetic | 1.25 mg |\n'; + const history = [{ role: 'user', content: 'Synthetic facts.' }]; + const ui = await browser(t, 'assistant', url => { + if (url === '/api/clinical-assistant/chats') return json({ success: true, chats: [{ id: 1 }] }); + if (url === '/api/clinical-assistant/chats/1') return json({ success: true, chat: { payload: { messages: history } } }); + if (url.endsWith('/handoff')) return json({ success: true, summary }); + }); + ui.window.DOMPurify = require('dompurify')(ui.window); + ui.window.marked = await import('marked'); + await loadChat(ui); + ui.document.getElementById('btn-assistant-handoff').click(); await tick(); + const raw = ui.document.getElementById('assistant-handoff-text'); + const preview = ui.document.getElementById('assistant-handoff-preview'); + assert.equal(raw.value, summary); + assert.equal(raw.hidden, true); + assert.equal(preview.querySelector('h2').textContent, 'Clinical handoff'); + assert.equal(preview.querySelector('strong').textContent, 'Plan'); + assert.equal(preview.querySelector('li').textContent, 'Follow up'); + assert.equal(preview.querySelector('tbody td:last-child').textContent, '1.25 mg'); + assert.doesNotMatch(ui.document.getElementById('assistant-handoff-panel').textContent, /not.*verified clinical/i); + await loadChat(ui); + assert.equal(raw.value, ''); + assert.equal(preview.textContent, ''); +}); + test('missing/invalid metadata never fabricates a cap; authoritative refusal keeps the draft and updates the counter', async t => { for (const metadata of [{ success: false }, { success: true, conversationChars: 1000001 }, { success: true }]) { await t.test(JSON.stringify(metadata), async t => {