diff --git a/public/components/assistant.html b/public/components/assistant.html index f7fc179..c8d3ad4 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -1,7 +1,7 @@
Ask focused clinical questions over indexed Nextcloud books/resources. Answers should synthesize retrieved evidence and cite book/resource titles with pages.
+Ask focused clinical questions over indexed Nextcloud books/resources, or ask for a teaching image directly in the chat box.
Ask a real clinical question. Short greetings or vague inputs get a short clarifying response instead of unnecessary textbook output.
+Ask a real clinical question. Citations stay linked to the source cards on the right.
Citations appear here after an answer.
Answers should be concise, clinically practical, and citation-grounded.
-If the input is only a greeting or too vague, the assistant should ask what you want to look up.
-' + escapeHtml(block.code) + '';
+ });
+ return sanitize(html);
+ }
+
+ function fallbackMarkdown(text) {
var html = escapeHtml(text)
.replace(/^### (.*)$/gm, '$1')
- .replace(/\[(\d+)\]/g, function (_, n) {
- var source = sources[Number(n) - 1];
- var title = source ? source.title || source.resource || 'Source' : 'Source';
- return '[' + n + ']';
- });
+ .replace(/`([^`]+)`/g, '$1');
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 '' + block.replace(/\n/g, '
') + '
' + escapeHtml(block.code) + '';
- });
- return sanitize(html);
+ return html;
}
function renderSources(sources) {
@@ -209,7 +228,7 @@
return 'Generating image...
'; 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 = '' + escapeHtml(err.message) + '
'; + 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, '&').replace(//g, '>').replace(/"/g, '"'); } function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '''); } - 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; } })(); diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 76c7fa3..ca44f62 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -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) {