fix: improve clinical assistant rendering

This commit is contained in:
Daniel 2026-05-06 08:02:25 +02:00
parent 685f0f93f1
commit 2ca4db1e8d
4 changed files with 98 additions and 42 deletions

View file

@ -1,7 +1,7 @@
<div class="module-header assistant-header">
<div>
<h2><i class="fas fa-brain" style="color:var(--purple);"></i> AI Clinical Assistant</h2>
<p>Ask focused clinical questions over indexed Nextcloud books/resources. Answers should synthesize retrieved evidence and cite book/resource titles with pages.</p>
<p>Ask focused clinical questions over indexed Nextcloud books/resources, or ask for a teaching image directly in the chat box.</p>
</div>
<div class="assistant-status" id="assistant-status">
<span class="assistant-dot"></span>
@ -26,7 +26,7 @@
<div class="assistant-empty">
<i class="fas fa-book-medical"></i>
<h3>Evidence-first pediatric assistant</h3>
<p>Ask a real clinical question. Short greetings or vague inputs get a short clarifying response instead of unnecessary textbook output.</p>
<p>Ask a real clinical question. Citations stay linked to the source cards on the right.</p>
<div class="assistant-examples">
<button type="button" data-assistant-example="In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?">Status asthma escalation</button>
<button type="button" data-assistant-example="What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?">Bilious vomiting</button>
@ -60,14 +60,6 @@
<p class="assistant-muted">Citations appear here after an answer.</p>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-circle-info"></i> Behavior</h3></div>
<div class="assistant-side-body assistant-muted">
<p>Answers should be concise, clinically practical, and citation-grounded.</p>
<p>If the input is only a greeting or too vague, the assistant should ask what you want to look up.</p>
</div>
</div>
</aside>
</div>
@ -95,11 +87,23 @@
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; }
.assistant-msg.user .assistant-bubble { background:var(--blue); color:white; border-color:var(--blue); }
.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); }
.assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; }
.assistant-bubble h1 { font-size:20px; }
.assistant-bubble h2 { font-size:17px; border-bottom:1px solid var(--g200); padding-bottom:4px; }
.assistant-bubble h3 { font-size:15px; }
.assistant-bubble p { margin:0 0 10px; }
.assistant-bubble p:last-child { margin-bottom:0; }
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
.assistant-bubble li { margin:4px 0; }
.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; }
.assistant-bubble table { width:100%; border-collapse:separate; border-spacing:0; margin:12px 0; overflow:hidden; border:1px solid var(--g200); border-radius:10px; font-size:12px; }
.assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; }
.assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); }
.assistant-bubble tr:last-child td { border-bottom:0; }
.assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; }
.assistant-bubble pre { background:var(--g900); color:white; border-radius:8px; padding:10px; overflow:auto; margin:10px 0; }
.assistant-bubble .katex-display { overflow-x:auto; overflow-y:hidden; padding:4px 0; }
.assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:11px; font-weight:700; text-decoration:none; }
.assistant-composer { border-top:1px solid var(--g200); padding:12px; background:white; display:grid; gap:8px; }
.assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; }
@ -111,10 +115,14 @@
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
.assistant-visual-output { display:grid; gap:8px; }
.assistant-visual-output img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:360px; overflow-y:auto; }
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; }
.assistant-source { border:1px solid var(--g200); border-radius:10px; padding:9px; background:white; font-size:12px; line-height:1.5; }
.assistant-source strong { color:var(--g800); }
.assistant-source-meta { color:var(--g500); font-size:11px; margin-top:3px; }
.assistant-source-excerpt { margin-top:7px; color:var(--g600); max-height:170px; overflow:auto; }
.assistant-source-excerpt p { margin:0 0 6px; }
.assistant-source-excerpt ul, .assistant-source-excerpt ol { padding-left:16px; margin:4px 0; }
.assistant-source-excerpt strong { color:var(--g700); }
.assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; }
.assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; }
@media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }

View file

@ -15,6 +15,9 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked@14.1.3/marked.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" defer></script>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<meta name="apple-mobile-web-app-capable" content="yes">

View file

@ -77,6 +77,12 @@
appendMessage('user', text);
if (input) input.value = '';
if (isImageRequest(text)) {
generateImage(text, true);
return;
}
setBusy(true, 'Searching indexed resources...');
fetch('/api/clinical-assistant/chat', {
@ -110,7 +116,7 @@
});
}
function appendMessage(role, content, sources, suggestions) {
function appendMessage(role, content, sources, suggestions, rawHtml) {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return;
var empty = wrap.querySelector('.assistant-empty');
@ -124,7 +130,7 @@
label.textContent = role === 'user' ? 'You' : 'Assistant';
var bubble = document.createElement('div');
bubble.className = 'assistant-bubble';
bubble.innerHTML = role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content);
bubble.innerHTML = rawHtml ? sanitize(String(content || '')) : (role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content));
if (role === 'assistant' && suggestions && suggestions.length) {
bubble.appendChild(renderSuggestionButtons(suggestions));
}
@ -160,18 +166,37 @@
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
return '\n@@CODEBLOCK_' + idx + '@@\n';
});
text = stripSourcesSection(text);
text = renderLatexText(text);
var html;
if (window.marked && typeof window.marked.parse === 'function') {
html = window.marked.parse(text, { breaks: true, gfm: true });
} else {
html = fallbackMarkdown(text);
}
html = html.replace(/\[(\d+)\]/g, function (_, n) {
var source = sources[Number(n) - 1];
if (!source) return '[' + n + ']';
var title = source ? source.title || source.resource || 'Source' : 'Source';
return '<a class="assistant-cite" href="#assistant-source-' + n + '" title="' + escapeHtml(title) + '">[' + n + ']</a>';
});
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
});
return sanitize(html);
}
function fallbackMarkdown(text) {
var html = escapeHtml(text)
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\[(\d+)\]/g, function (_, n) {
var source = sources[Number(n) - 1];
var title = source ? source.title || source.resource || 'Source' : 'Source';
return '<a class="assistant-cite" href="#assistant-source-' + n + '" title="' + escapeHtml(title) + '">[' + n + ']</a>';
});
.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.split(/\n{2,}/).map(function (block) {
if (/^\s*<(h\d|ul|ol|pre|div)/.test(block)) return block;
var lines = block.split('\n');
@ -183,13 +208,7 @@
}
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
}).join('');
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
});
return sanitize(html);
return html;
}
function renderSources(sources) {
@ -209,7 +228,7 @@
return '<div class="assistant-source" id="assistant-source-' + n + '">' +
'<strong>[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '</strong>' +
'<div class="assistant-source-meta">' + escapeHtml(meta.join(' · ') || 'indexed source') + '</div>' +
(s.excerpt ? '<div style="margin-top:6px;color:var(--g600);">' + escapeHtml(s.excerpt).slice(0, 420) + '</div>' : '') +
(s.excerpt ? '<div class="assistant-source-excerpt">' + renderMarkdown(String(s.excerpt).slice(0, 900), []) + '</div>' : '') +
'</div>';
}).join('');
}
@ -253,12 +272,13 @@
mermaidReady = true;
}
function generateImage() {
function generateImage(promptOverride, fromChat) {
var promptEl = document.getElementById('assistant-image-prompt');
var out = document.getElementById('assistant-visual-output');
var prompt = promptEl ? promptEl.value.trim() : '';
var prompt = typeof promptOverride === 'string' ? promptOverride.trim() : (promptEl ? promptEl.value.trim() : '');
if (!prompt && lastAnswer) prompt = 'Create a concise pediatric clinical teaching visual from this answer:\n\n' + lastAnswer.slice(0, 3000);
if (!prompt) { if (typeof showToast === 'function') showToast('Enter an image prompt or ask a question first', 'error'); return; }
if (fromChat) setBusy(true, 'Generating image...');
if (out) out.innerHTML = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image...</p>';
fetch('/api/clinical-assistant/image', {
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin',
@ -270,9 +290,16 @@
var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (!src) throw new Error('No image returned');
if (out) out.innerHTML = '<img src="' + escapeAttr(src) + '" alt="Generated clinical visual"><a class="btn-sm btn-ghost" href="' + escapeAttr(src) + '" target="_blank" rel="noopener"><i class="fas fa-up-right-from-square"></i> Open</a>';
if (fromChat) {
setBusy(false, 'Ready');
var html = '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="Generated clinical visual"><a class="btn-sm btn-ghost" href="' + escapeAttr(src) + '" target="_blank" rel="noopener"><i class="fas fa-up-right-from-square"></i> Open image</a></div>';
appendMessage('assistant', html, [], [], true);
}
})
.catch(function (err) {
if (fromChat) setBusy(false, 'Error', true);
if (out) out.innerHTML = '<p class="assistant-muted">' + escapeHtml(err.message) + '</p>';
if (fromChat) appendMessage('assistant', 'Image generation failed: ' + err.message);
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
@ -295,6 +322,29 @@
});
}
function isImageRequest(text) {
return /\b(generate|create|draw|make|show|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) ||
/\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b[\s\S]{0,80}\b(of|for|about|showing)\b/i.test(text);
}
function stripSourcesSection(text) {
return String(text || '').replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '').trim();
}
function renderLatexText(text) {
if (!window.katex) return text;
return String(text || '')
.replace(/\$\$([\s\S]+?)\$\$/g, function(_, expr) { return safeKatex(expr, true); })
.replace(/\\\[([\s\S]+?)\\\]/g, function(_, expr) { return safeKatex(expr, true); })
.replace(/\$([^$\n]+?)\$/g, function(_, expr) { return safeKatex(expr, false); })
.replace(/\\\((.+?)\\\)/g, function(_, expr) { return safeKatex(expr, false); });
}
function safeKatex(expr, displayMode) {
try { return window.katex.renderToString(expr, { displayMode: displayMode, throwOnError: false }); }
catch (e) { return escapeHtml(expr); }
}
function setBusy(isBusy, text, isError) {
var status = document.getElementById('assistant-status');
var label = document.getElementById('assistant-status-text');
@ -312,5 +362,5 @@
function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '&#39;'); }
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html) : html; }
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : html; }
})();

View file

@ -150,7 +150,7 @@ router.post('/clinical-assistant/chat', async function(req, res) {
temperature: 0.15,
maxTokens: 1800
});
var answer = ensureSourcesSection(String(ai.content || '').trim(), sources);
var answer = stripModelSourcesSection(String(ai.content || '').trim());
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
category: 'clinical', model: ai.model || chatModel, duration: Date.now() - started
@ -379,7 +379,7 @@ function formatSourcesForPrompt(sources) {
}
function buildSystemPrompt(behavior) {
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- Use numbered citations [1], [2] for factual claims, matching the provided source numbers.\n- If sources disagree or are insufficient, say so.\n- Do not cite a source number that is not provided.\n- Keep the main answer concise and clinically useful.\n- Include a final "Sources" section listing cited source numbers with title and page.\n- This is clinical decision support, not a substitute for clinician judgment.';
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- Use numbered citations [1], [2] for factual claims, matching the provided source numbers.\n- If sources disagree or are insufficient, say so.\n- Do not cite a source number that is not provided.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
}
function buildUserPrompt(question, context, history) {
@ -388,16 +388,11 @@ function buildUserPrompt(question, context, history) {
return 'Question:\n' + question + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved indexed sources:\n' + context + '\n\nWrite the answer now.';
}
function ensureSourcesSection(answer, sources) {
if (/\n\s*#{0,3}\s*Sources\b/i.test(answer)) return answer;
var cited = new Set();
var m;
var re = /\[(\d+)\]/g;
while ((m = re.exec(answer))) cited.add(Number(m[1]));
var list = sources.filter(function(s) { return cited.size === 0 ? s.number <= Math.min(5, sources.length) : cited.has(s.number); })
.map(function(s) { return '[' + s.number + '] ' + s.title + (s.page ? ', p. ' + s.page : ''); })
.join('\n');
return answer + '\n\nSources\n' + list;
function stripModelSourcesSection(answer) {
return String(answer || '')
.replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '')
.replace(/\n\s*>?\s*(⚠️\s*)?Clinical decision support[^\n]*$/gim, '')
.trim();
}
async function generateImage(prompt, model) {