fix: render clinical handoff Markdown with a concise heading
All checks were successful
Forgejo Android APK / Root app tests (push) Successful in 26s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s

This commit is contained in:
Daniel 2026-09-07 14:23:56 +02:00
parent 40e7dd5206
commit 9902ee07a6
5 changed files with 46 additions and 9 deletions

View file

@ -45,8 +45,9 @@
<p id="assistant-context-budget" class="assistant-muted" aria-live="polite">Loading conversation limit; history and draft are counted in UTF-16 code units. The server validates each request.</p>
<div id="assistant-context-warning" role="alert" hidden></div>
<div id="assistant-handoff-panel" hidden>
<label for="assistant-handoff-text">Requested handoff — conversation context, not verified clinical evidence</label>
<textarea id="assistant-handoff-text" rows="8" readonly></textarea>
<h3 id="assistant-handoff-heading">Clinical handoff</h3>
<div id="assistant-handoff-preview" class="assistant-bubble" role="region" aria-labelledby="assistant-handoff-heading"></div>
<textarea id="assistant-handoff-text" hidden readonly></textarea>
<button id="btn-assistant-copy-handoff" class="btn-sm btn-ghost" type="button">Copy handoff</button>
<p class="assistant-muted">Review this summary before using it. Your original chat is unchanged; nothing starts a new chat automatically.</p>
</div>

View file

@ -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'); });

View file

@ -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.');

View file

@ -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);

View file

@ -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 => {