fix: render clinical handoff Markdown with a concise heading
This commit is contained in:
parent
40e7dd5206
commit
9902ee07a6
5 changed files with 46 additions and 9 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'); });
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue