feat: improve clinical assistant visual sources

This commit is contained in:
Daniel 2026-05-07 02:45:17 +02:00
parent 39675eedc0
commit 18a69d2a13
10 changed files with 1191 additions and 105 deletions

1
.gitignore vendored
View file

@ -38,3 +38,4 @@ e2e/test-results/
e2e/playwright-report/
.codex
.firecrawl/

View file

@ -10,6 +10,7 @@ services:
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp
volumes:
- scribe-logs:/app/data/logs
- clinical-assistant-mcp-data:/app/mcp-data:ro
depends_on:
postgres:
condition: service_healthy
@ -48,6 +49,9 @@ services:
volumes:
pgdata:
scribe-logs:
clinical-assistant-mcp-data:
external: true
name: mcp-server_mcp-data
networks:
mcp-server_default:

View file

@ -0,0 +1,21 @@
/**
* Optional saved clinical assistant chats. These are user-triggered saves,
* encrypted at rest like personal_notes because answers may contain PHI.
*/
exports.up = (pgm) => {
pgm.createTable('clinical_assistant_chats', {
id: { type: 'serial', primaryKey: true },
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
title: { type: 'text', notNull: true },
payload: { type: 'text', notNull: true, default: '{}' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
});
pgm.createIndex('clinical_assistant_chats', 'user_id');
pgm.createIndex('clinical_assistant_chats', ['user_id', 'updated_at']);
};
exports.down = (pgm) => {
pgm.dropTable('clinical_assistant_chats');
};

View file

@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "node --test test/",
"test": "node --test test/*.test.js",
"e2e": "./scripts/e2e.sh",
"maint:check": "node scripts/maintenance.js check",
"maint:reindex": "node scripts/maintenance.js reindex",

View file

@ -19,6 +19,7 @@
<div class="assistant-toolbar-actions">
<button id="btn-assistant-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> Clear</button>
<button id="btn-assistant-copy" class="btn-sm btn-ghost" type="button"><i class="fas fa-copy"></i> Copy answer</button>
<button id="btn-assistant-save" class="btn-sm btn-ghost" type="button"><i class="fas fa-bookmark"></i> Save chat</button>
<button id="btn-assistant-export-pdf" class="btn-sm btn-ghost" type="button"><i class="fas fa-file-pdf"></i> Export PDF</button>
</div>
</div>
@ -31,7 +32,7 @@
<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>
<button type="button" data-assistant-example="Compare bronchiolitis and asthma management in infants, citing pediatric references.">Bronchiolitis vs asthma</button>
<button type="button" data-assistant-example="Compare bronchiolitis and asthma management in infants, citing clinical references.">Bronchiolitis vs asthma</button>
</div>
</div>
</div>
@ -50,11 +51,29 @@
<div class="card-header"><h3><i class="fas fa-wand-magic-sparkles"></i> Image / Graph</h3></div>
<div class="assistant-side-body">
<textarea id="assistant-image-prompt" rows="4" placeholder="Generate a teaching image, pathway, or infographic from the current answer..."></textarea>
<button id="btn-assistant-image" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Generate image</button>
<div class="assistant-image-buttons">
<button id="btn-assistant-image" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Generate image</button>
<button id="btn-assistant-image-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> Clear image</button>
</div>
<div id="assistant-visual-output" class="assistant-visual-output"></div>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-bookmark"></i> Saved Chats</h3></div>
<div id="assistant-saved-chats" class="assistant-saved-chats">
<p class="assistant-muted">Saved chats appear here after you click Save chat.</p>
</div>
<div id="assistant-save-panel" class="assistant-save-panel" hidden>
<label for="assistant-save-title">Save chat as</label>
<input id="assistant-save-title" type="text" maxlength="160" autocomplete="off">
<div class="assistant-save-actions">
<button id="btn-assistant-save-confirm" class="btn-sm btn-primary" type="button">Save</button>
<button id="btn-assistant-save-cancel" class="btn-sm btn-ghost" type="button">Cancel</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-quote-right"></i> Sources</h3></div>
<div id="assistant-sources" class="assistant-sources">
@ -123,6 +142,7 @@
.assistant-side { display:grid; gap:12px; }
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
.assistant-visual-output { display:grid; gap:8px; }
.assistant-image-buttons { display:flex; gap:8px; flex-wrap:wrap; }
.assistant-visual-output img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
.assistant-generated-image { display:grid; gap:8px; }
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
@ -132,9 +152,23 @@
.assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); }
.assistant-image-modal-close { position:absolute; top:-12px; right:-12px; width:34px; height:34px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:22px; line-height:1; cursor:pointer; box-shadow:var(--shadow); }
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; }
.assistant-saved-chats { padding:10px 12px; display:grid; gap:8px; max-height:220px; overflow-y:auto; }
.assistant-saved-chat { border:1px solid var(--g200); border-radius:10px; padding:8px; background:white; display:grid; gap:5px; }
.assistant-saved-chat-title { font-size:12px; font-weight:700; color:var(--g800); line-height:1.35; }
.assistant-saved-chat-meta { font-size:11px; color:var(--g500); }
.assistant-saved-chat-actions { display:flex; gap:6px; flex-wrap:wrap; }
.assistant-save-panel { border-top:1px solid var(--g200); padding:10px 12px; display:grid; gap:7px; }
.assistant-save-panel[hidden] { display:none; }
.assistant-save-panel label { font-size:11px; font-weight:700; color:var(--g500); text-transform:uppercase; letter-spacing:.04em; }
.assistant-save-panel input { width:100%; border:1.5px solid var(--g300); border-radius:9px; padding:8px 10px; font-size:12px; outline:none; }
.assistant-save-panel input:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
.assistant-save-actions { display:flex; gap:6px; flex-wrap:wrap; }
.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-preview { margin-top:8px; }
.assistant-source-preview button { border:0; padding:0; background:transparent; cursor:pointer; width:100%; display:block; }
.assistant-source-preview img { width:100%; max-height:220px; object-fit:contain; border:1px solid var(--g200); border-radius:10px; background:white; display:block; }
.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; }

View file

@ -0,0 +1,191 @@
export function renderAssistantMarkdown(md, sources, options) {
var opts = options || {};
var text = normalizeMarkdownText(md);
var codeBlocks = [];
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var idx = codeBlocks.length;
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
return '\n@@CODEBLOCK_' + idx + '@@\n';
});
text = stripSourcesSection(text);
text = renderLatexText(text, opts.katex);
text = normalizeAdjacentCitationClusters(text, sources || []);
var html;
if (opts.marked && typeof opts.marked.parse === 'function') {
html = opts.marked.parse(text, { breaks: true, gfm: true });
} else {
html = fallbackMarkdown(text);
}
html = renderCitationLinks(html, sources || []);
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 typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
}
export function renderCitationLinks(html, sources) {
return String(html || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) {
var nums = cluster.split(',').map(function (n) { return Number(n.trim()); }).filter(function (n) { return Number.isInteger(n) && n > 0; });
if (!nums.length || nums.some(function (n) { return !sources[n - 1]; })) return match;
return '[' + nums.map(function (n) {
var source = sources[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>';
}).join(', ') + ']';
});
}
export function normalizeAdjacentCitationClusters(text, sources) {
var available = Array.isArray(sources) ? sources : [];
var normalized = String(text || '').replace(/((?:\[(?:\d+\s*,\s*)*\d+\]\s*)+)\[((?:\d+\s*,\s*)*\d+)(?=$|[.,;:])/g, function(match, completeClusters, trailingCluster) {
var nums = citationNumbers(completeClusters).concat(parseCitationCluster(trailingCluster));
if (!allCitationsAvailable(nums, available)) return match;
return formatCitationCluster(nums);
});
return normalized.replace(/(?:\[(?:\d+\s*,\s*)*\d+\]\s*){2,}/g, function(match) {
var nums = citationNumbers(match);
if (!allCitationsAvailable(nums, available)) return match;
return formatCitationCluster(nums);
});
}
function citationNumbers(text) {
var clusters = String(text || '').match(/\[((?:\d+\s*,\s*)*\d+)\]/g) || [];
var nums = [];
clusters.forEach(function(cluster) {
nums = nums.concat(parseCitationCluster(cluster.slice(1, -1)));
});
return nums;
}
function parseCitationCluster(cluster) {
return String(cluster || '').split(',').map(function(n) { return Number(n.trim()); }).filter(function(n) { return Number.isInteger(n) && n > 0; });
}
function allCitationsAvailable(nums, sources) {
return nums.length > 0 && nums.every(function(n) { return sources[n - 1]; });
}
function formatCitationCluster(nums) {
nums = Array.from(new Set(nums)).sort(function(a, b) { return a - b; });
return '[' + nums.join(', ') + ']';
}
export function stripSourcesSection(text) {
return String(text || '')
.replace(/\s*(?:-{3,}\s*)?(?:#{1,6}\s*)?(?:Sources|References)\s*:?\s*[\s\S]*$/i, '')
.trim();
}
export function normalizeMarkdownText(text) {
return String(text || '')
.replace(/\r\n/g, '\n')
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*-\s+/g, '$1\n- ')
.replace(/([.!?])\s*-\s+(\*\*)?/g, '$1\n- $2')
.replace(/([^\n])\s+(#{1,4}\s+)/g, '$1\n\n$2')
.replace(/(#{1,4}\s+[^\n]+?)\s+(-\s+)/g, '$1\n\n$2')
.replace(/(#{1,4}\s+[^\n]+)\n(-\s+)/g, '$1\n\n$2')
.replace(/([^\n])\s+(-\s+(?:Mainstay|Medications|Hospitalization|Other therapies|Prevention|Short-acting|Anticholinergics|Systemic|Adjuncts|Long-term|Infants|Differentiating|Persistent|Severe|Need for|Inadequate)\b)/g, '$1\n$2')
.replace(/([^\n])\s+(\|\s*Aspect\s*\|)/gi, '$1\n\n$2')
.replace(/([^\n])\s+(\|\s*[-:]+\s*\|)/g, '$1\n$2')
.replace(/\s+(\|\s*[^\n|]+\s*\|\s*[^\n|]+\s*\|)/g, '\n$1')
.replace(/([^\n])\s+(-\s+[^\n])/g, '$1\n$2')
.trim();
}
export 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>');
html = html.split(/\n{2,}/).map(function (block) {
if (/^\s*<h\d/.test(block) && block.indexOf('\n') !== -1) {
var parts = block.split('\n');
return parts.shift() + fallbackMarkdown(parts.join('\n'));
}
if (/^\s*<(h\d|ul|ol|pre|div)/.test(block)) return block;
var lines = block.split('\n');
if (isMarkdownTable(lines)) return renderFallbackTable(lines);
if (lines.some(function (l) { return /^\s*[-*] /.test(l); })) return renderMixedList(lines);
if (lines.every(function (l) { return /^\s*[-*] /.test(l) || !l.trim(); })) {
return '<ul>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*[-*] /, '') + '</li>'; }).join('') + '</ul>';
}
if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) {
return '<ol>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*\d+\. /, '') + '</li>'; }).join('') + '</ol>';
}
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
}).join('');
return html;
}
function renderMixedList(lines) {
var html = '';
var list = [];
var paragraph = [];
lines.forEach(function(line) {
if (/^\s*[-*] /.test(line)) {
if (paragraph.length) {
html += '<p>' + paragraph.join('<br>') + '</p>';
paragraph = [];
}
list.push(line.replace(/^\s*[-*] /, ''));
return;
}
if (list.length) {
html += '<ul>' + list.map(function(item) { return '<li>' + item + '</li>'; }).join('') + '</ul>';
list = [];
}
if (line.trim()) paragraph.push(line);
});
if (paragraph.length) html += '<p>' + paragraph.join('<br>') + '</p>';
if (list.length) html += '<ul>' + list.map(function(item) { return '<li>' + item + '</li>'; }).join('') + '</ul>';
return html;
}
function isMarkdownTable(lines) {
return lines.length >= 2 && /^\s*\|.*\|\s*$/.test(lines[0]) && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[1]);
}
function renderFallbackTable(lines) {
var header = tableCells(lines[0]);
var rows = lines.slice(2).filter(function(line) { return /^\s*\|.*\|\s*$/.test(line); }).map(tableCells);
return '<table><thead><tr>' + header.map(function(cell) { return '<th>' + cell + '</th>'; }).join('') + '</tr></thead><tbody>' +
rows.map(function(row) { return '<tr>' + row.map(function(cell) { return '<td>' + cell + '</td>'; }).join('') + '</tr>'; }).join('') +
'</tbody></table>';
}
function tableCells(line) {
return String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function(cell) { return cell.trim(); });
}
export function renderLatexText(text, katex) {
if (!katex) return text;
return String(text || '')
.replace(/\$\$([\s\S]+?)\$\$/g, function(_, expr) { return safeKatex(katex, expr, true); })
.replace(/\\\[([\s\S]+?)\\\]/g, function(_, expr) { return safeKatex(katex, expr, true); })
.replace(/\$([^$\n]+?)\$/g, function(_, expr) { return safeKatex(katex, expr, false); })
.replace(/\\\((.+?)\\\)/g, function(_, expr) { return safeKatex(katex, expr, false); });
}
function safeKatex(katex, expr, displayMode) {
try { return katex.renderToString(expr, { displayMode: displayMode, throwOnError: false }); }
catch (e) { return escapeHtml(expr); }
}
export function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;');
}

View file

@ -2,7 +2,7 @@ export var EMPTY_PROMPT_SETS = [
[
{ label: 'Status asthma escalation', prompt: 'In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?' },
{ label: 'Bilious vomiting', prompt: 'What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?' },
{ label: 'Bronchiolitis vs asthma', prompt: 'Compare bronchiolitis and asthma management in infants, citing pediatric references.' }
{ label: 'Bronchiolitis vs asthma', prompt: 'Compare bronchiolitis and asthma management in infants, citing clinical references.' }
],
[
{ label: 'Febrile neonate', prompt: 'What initial workup and empiric antibiotics are recommended for a well-appearing febrile neonate?' },

View file

@ -4,6 +4,7 @@
// server can call native MCP directly without routing through mcpo.
// ============================================================
import { EMPTY_PROMPT_SETS } from './assistant/data.js';
import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection } from './assistant/citations.js';
(function () {
'use strict';
@ -16,6 +17,9 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
var dynamicExamples = [];
var generatedImages = {};
var generatedImageSeq = 0;
var lastGeneratedImageSrc = '';
var exportCacheKey = '';
var exportCacheItems = null;
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
@ -35,21 +39,30 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
var form = document.getElementById('assistant-form');
var clearBtn = document.getElementById('btn-assistant-clear');
var copyBtn = document.getElementById('btn-assistant-copy');
var saveBtn = document.getElementById('btn-assistant-save');
var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm');
var saveCancelBtn = document.getElementById('btn-assistant-save-cancel');
var exportBtn = document.getElementById('btn-assistant-export-pdf');
var imageBtn = document.getElementById('btn-assistant-image');
var imageClearBtn = document.getElementById('btn-assistant-image-clear');
var input = document.getElementById('assistant-input');
if (form) form.addEventListener('submit', onAsk);
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
if (saveBtn) saveBtn.addEventListener('click', showSavePanel);
if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat);
if (saveCancelBtn) saveCancelBtn.addEventListener('click', hideSavePanel);
if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf);
if (imageBtn) imageBtn.addEventListener('click', generateImage);
if (imageClearBtn) imageClearBtn.addEventListener('click', clearGeneratedImage);
document.addEventListener('click', onAssistantDocumentClick);
if (input) input.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e);
});
bindExampleButtons(document);
loadSavedChats();
}
function loadStatus() {
@ -92,6 +105,8 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
}
appendMessage('user', text);
exportCacheKey = '';
exportCacheItems = null;
if (input) input.value = '';
if (isImageRequest(text)) {
@ -100,7 +115,7 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
}
setBusy(true, 'Searching indexed resources...');
var loading = appendLoadingMessage('Searching indexed resources', 'Retrieving and synthesizing cited pediatric references...');
var loading = appendLoadingMessage('Searching indexed resources', 'Retrieving and synthesizing cited references...');
fetch('/api/clinical-assistant/chat', {
method: 'POST',
@ -168,7 +183,7 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
renderEmbeddedBlocks(bubble);
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
messages.push({ role: 'assistant', content: content });
messages.push({ role: 'assistant', content: content, sources: sources || [] });
}
function appendMessage(role, content, sources, suggestions, rawHtml) {
@ -177,7 +192,7 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
var empty = wrap.querySelector('.assistant-empty');
if (empty) empty.remove();
messages.push({ role: role, content: content });
messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [] });
var row = document.createElement('div');
row.className = 'assistant-msg ' + role;
var label = document.createElement('div');
@ -214,59 +229,11 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
}
function renderMarkdown(md, sources) {
var text = String(md || '');
var codeBlocks = [];
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var idx = codeBlocks.length;
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
return '\n@@CODEBLOCK_' + idx + '@@\n';
return renderAssistantMarkdown(md, sources || [], {
marked: window.marked,
katex: window.katex,
sanitize: sanitize
});
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+\s*,\s*)*\d+)\]/g, function (match, cluster) {
var nums = cluster.split(',').map(function (n) { return Number(n.trim()); }).filter(function (n) { return Number.isInteger(n) && n > 0; });
if (!nums.length || nums.some(function (n) { return !sources[n - 1]; })) return match;
return '[' + nums.map(function (n) {
var source = sources[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>';
}).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);
}
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>');
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');
if (lines.every(function (l) { return /^\s*[-*] /.test(l) || !l.trim(); })) {
return '<ul>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*[-*] /, '') + '</li>'; }).join('') + '</ul>';
}
if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) {
return '<ol>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*\d+\. /, '') + '</li>'; }).join('') + '</ol>';
}
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
}).join('');
return html;
}
function renderSources(sources) {
@ -287,11 +254,24 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
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 class="assistant-source-excerpt"><p>' + escapeHtml(String(s.excerpt).slice(0, 900)) + '</p></div>' : '') +
renderSourceImage(s, n) +
(s.excerpt ? '<div class="assistant-source-excerpt"><p>' + escapeHtml(cleanSourceExcerpt(s.excerpt).slice(0, 900)) + '</p></div>' : '') +
'</div>';
}).join('');
}
function renderSourceImage(source, number) {
var src = source && (source.image_url || source.imageUrl);
if (!src || source.source_type !== 'multimodal_page') return '';
var id = 'source-img-' + number + '-' + (++generatedImageSeq);
generatedImages[id] = src;
return '<div class="assistant-source-preview">' +
'<button type="button" data-assistant-open-image="' + escapeAttr(id) + '" aria-label="Preview visual page source">' +
'<img src="' + escapeAttr(src) + '" alt="Visual PDF page source preview" loading="lazy">' +
'</button>' +
'</div>';
}
function renderEmbeddedBlocks(root) {
root.querySelectorAll('[data-mermaid]').forEach(function (el) {
ensureMermaid().then(function () {
@ -349,6 +329,9 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
if (!data.success) throw new Error(data.error || 'Image generation failed');
var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (!src) throw new Error('No image returned');
lastGeneratedImageSrc = src;
exportCacheKey = '';
exportCacheItems = null;
if (out) out.innerHTML = renderGeneratedImage(src, 'Generated clinical visual');
if (fromChat) {
setBusy(false, 'Ready');
@ -375,6 +358,18 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
}
function onAssistantDocumentClick(e) {
var loadBtn = e.target.closest('[data-assistant-load-chat]');
if (loadBtn) {
e.preventDefault();
loadSavedChat(loadBtn.getAttribute('data-assistant-load-chat'));
return;
}
var deleteBtn = e.target.closest('[data-assistant-delete-chat]');
if (deleteBtn) {
e.preventDefault();
deleteSavedChat(deleteBtn.getAttribute('data-assistant-delete-chat'));
return;
}
var openBtn = e.target.closest('[data-assistant-open-image]');
if (openBtn) {
e.preventDefault();
@ -400,6 +395,16 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
document.querySelectorAll('.assistant-image-modal').forEach(function (el) { el.remove(); });
}
function clearGeneratedImage() {
lastGeneratedImageSrc = '';
generatedImages = {};
var out = document.getElementById('assistant-visual-output');
if (out) out.innerHTML = '';
closeImagePreview();
exportCacheKey = '';
exportCacheItems = null;
}
function buildContextualImagePrompt(request) {
var prompt = String(request || '').trim();
if (!lastAnswer) return prompt;
@ -424,12 +429,17 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
messages = [];
lastAnswer = '';
lastSources = [];
lastGeneratedImageSrc = '';
exportCacheKey = '';
exportCacheItems = null;
var wrap = document.getElementById('assistant-messages');
if (wrap) {
wrap.innerHTML = renderEmptyState();
bindExampleButtons(wrap);
}
renderSources([]);
clearGeneratedImage();
loadSavedChats();
}
function renderEmptyState() {
@ -479,66 +489,291 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
function exportAnswerPdf() {
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to export', 'error'); return; }
var exportItems = collectExportItems();
var cacheKey = buildExportCacheKey(exportItems, lastGeneratedImageSrc);
if (exportCacheKey === cacheKey && exportCacheItems) {
openPrintableChatExport(exportCacheItems, lastGeneratedImageSrc);
return;
}
setBusy(true, 'Preparing PDF...');
fetch('/api/clinical-assistant/export-summary', {
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin',
body: JSON.stringify({ answer: lastAnswer, sources: lastSources })
body: JSON.stringify({ items: exportItems })
})
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
.then(function (data) {
setBusy(false, 'Ready');
openPrintableExport(data.success && data.summary ? data.summary : lastAnswer, lastSources);
var items = data.success && Array.isArray(data.items) && data.items.length ? data.items : exportItems;
exportCacheKey = cacheKey;
exportCacheItems = items;
openPrintableChatExport(items, lastGeneratedImageSrc);
})
.catch(function () {
setBusy(false, 'Ready');
openPrintableExport(lastAnswer, lastSources);
openPrintableChatExport(exportItems, lastGeneratedImageSrc);
});
}
function openPrintableExport(answer, sources) {
function openPrintableChatExport(items, imageSrc) {
var doc = window.open('', '_blank', 'width=900,height=1100');
if (!doc) { if (typeof showToast === 'function') showToast('Allow popups to export PDF', 'error'); return; }
var refs = (sources || []).map(function (s, idx) {
var n = s.number || idx + 1;
var title = s.title || s.resource || 'Untitled source';
var page = s.page || s.page_number || s.pageNumber;
return '<li id="ref-' + n + '"><strong>[' + n + ']</strong> ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.</li>';
items = Array.isArray(items) && items.length ? items : collectExportItems();
var imageHtml = imageSrc ? '<h2>Generated Image</h2><div class="export-image"><img src="' + escapeAttr(imageSrc) + '" alt="Generated clinical visual"></div>' : '';
var sections = items.map(function (item, idx) {
var sources = Array.isArray(item.sources) ? item.sources : [];
var refs = renderExportRefs(sources, idx + 1);
var heading = item.heading || deriveExportHeading(item.question, idx);
var summary = item.summary || '';
var answer = stripSourcesSection(item.answer || '');
return '<section class="export-section">' +
'<h2>' + escapeHtml(heading) + '</h2>' +
'<div class="question"><strong>Question:</strong> ' + escapeHtml(item.question || '') + '</div>' +
(summary ? '<h3>Summary</h3><div class="answer">' + renderMarkdown(summary, sources) + '</div>' : '') +
'<h3>Full Generated Answer</h3><div class="answer full-answer">' + renderMarkdown(answer, sources) + '</div>' +
(refs ? '<h3>References</h3><ol class="refs">' + refs + '</ol>' : '') +
'</section>';
}).join('');
var html = '<!doctype html><html><head><title>Clinical Assistant Export</title>' +
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin-top:24px;border-bottom:1px solid #e5e7eb;padding-bottom:4px}.meta{font-size:12px;color:#6b7280;margin-bottom:20px}.answer{font-size:13px}.refs{font-size:12px;padding-left:20px}.refs li{margin:6px 0}.assistant-cite{color:#4f46e5;text-decoration:none;font-weight:700}@media print{button{display:none}body{margin:24mm}}</style>' +
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px}h3{font-size:14px;margin:16px 0 6px}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{break-inside:avoid-page;margin-top:20px}.full-answer{page-break-before:auto}.export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}.refs{font-size:12px;padding-left:20px}.refs li{margin:6px 0}.assistant-cite{color:#4f46e5;text-decoration:none;font-weight:700}@media print{button{display:none}body{margin:24mm}}</style>' +
'</head><body><button onclick="window.print()" style="float:right;padding:8px 12px">Print / Save PDF</button><h1>Clinical Assistant Export</h1>' +
'<div class="meta">Abridged export generated ' + escapeHtml(new Date().toLocaleString()) + '</div>' +
'<h2>Summary</h2><div class="answer">' + renderMarkdown(answer, sources || []) + '</div>' +
'<h2>References</h2><ol class="refs">' + refs + '</ol></body></html>';
'<div class="meta">Export generated ' + escapeHtml(new Date().toLocaleString()) + '</div>' +
imageHtml +
sections + '</body></html>';
doc.document.open();
doc.document.write(html);
doc.document.close();
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
}
function collectExportItems() {
var items = [];
var pendingQuestion = '';
messages.forEach(function (m) {
if (m.role === 'user') {
pendingQuestion = m.content || pendingQuestion;
return;
}
if (m.role !== 'assistant' || !m.content) return;
if (isUtilityAssistantMessage(m.content)) return;
items.push({
question: pendingQuestion || 'Clinical question',
heading: deriveExportHeading(pendingQuestion, items.length),
summary: '',
answer: stripSourcesSection(m.content),
sources: Array.isArray(m.sources) && m.sources.length ? m.sources : lastSources
});
pendingQuestion = '';
});
if (!items.length && lastAnswer) {
items.push({ question: 'Clinical question', heading: 'Clinical Answer', summary: '', answer: stripSourcesSection(lastAnswer), sources: lastSources });
}
return items;
}
function renderExportRefs(sources, sectionNumber) {
return (sources || []).map(function (s, idx) {
var n = s.number || idx + 1;
var title = s.title || s.resource || 'Untitled source';
var page = s.page || s.page_number || s.pageNumber;
return '<li id="ref-' + sectionNumber + '-' + n + '"><strong>[' + n + ']</strong> ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.</li>';
}).join('');
}
function deriveExportHeading(question, idx) {
var text = String(question || '').replace(/\s+/g, ' ').trim();
if (!text || /^(what|which|when|why|how|and|also|what dose\??|dose\??)$/i.test(text)) return 'Clinical Question ' + (idx + 1);
return text.replace(/[?!.]+$/, '').slice(0, 90);
}
function isUtilityAssistantMessage(content) {
return /^I prepared the image prompt in the \*\*Image \/ Graph\*\* box/i.test(String(content || ''));
}
function saveCurrentChat() {
if (!messages.length || !lastAnswer) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
var titleEl = document.getElementById('assistant-save-title');
var title = String(titleEl && titleEl.value || deriveChatTitle()).trim() || deriveChatTitle();
setBusy(true, 'Saving chat...');
fetch('/api/clinical-assistant/chats', {
method: 'POST',
headers: getAuthHeaders(),
credentials: 'same-origin',
body: JSON.stringify({
title: title,
messages: messages,
sources: lastSources,
lastAnswer: lastAnswer,
generatedImage: imageForSavedChatPayload(lastGeneratedImageSrc)
})
})
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
.then(function (data) {
setBusy(false, 'Ready');
if (!data.success) throw new Error(data.error || 'Save failed');
hideSavePanel();
if (typeof showToast === 'function') showToast('Saved chat', 'success');
loadSavedChats();
})
.catch(function (err) {
setBusy(false, 'Error', true);
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
function showSavePanel() {
if (!messages.length || !lastAnswer) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
var panel = document.getElementById('assistant-save-panel');
var titleEl = document.getElementById('assistant-save-title');
if (!panel || !titleEl) return saveCurrentChat();
titleEl.value = deriveChatTitle();
panel.hidden = false;
titleEl.focus();
titleEl.select();
}
function hideSavePanel() {
var panel = document.getElementById('assistant-save-panel');
if (panel) panel.hidden = true;
}
function loadSavedChats() {
fetch('/api/clinical-assistant/chats', { headers: getAuthHeaders(), credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) { if (data.success) renderSavedChats(data.chats || []); })
.catch(function () {});
}
function renderSavedChats(chats) {
var wrap = document.getElementById('assistant-saved-chats');
if (!wrap) return;
if (!chats.length) {
wrap.innerHTML = '<p class="assistant-muted">Saved chats appear here after you click Save chat.</p>';
return;
}
wrap.innerHTML = chats.map(function (chat) {
return '<div class="assistant-saved-chat">' +
'<div class="assistant-saved-chat-title">' + escapeHtml(chat.title || 'Saved chat') + '</div>' +
'<div class="assistant-saved-chat-meta">' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '</div>' +
'<div class="assistant-saved-chat-actions">' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-load-chat="' + escapeAttr(chat.id) + '">Load</button>' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-delete-chat="' + escapeAttr(chat.id) + '">Delete</button>' +
'</div></div>';
}).join('');
}
function loadSavedChat(id) {
fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), { headers: getAuthHeaders(), credentials: 'same-origin' })
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Load failed');
restoreSavedChat(data.chat && data.chat.payload || {});
if (typeof showToast === 'function') showToast('Loaded saved chat', 'success');
})
.catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); });
}
function deleteSavedChat(id) {
var btn = document.querySelector('[data-assistant-delete-chat="' + cssEscape(id) + '"]');
if (btn && btn.getAttribute('data-confirm-delete') !== 'true') {
btn.setAttribute('data-confirm-delete', 'true');
btn.textContent = 'Confirm delete';
setTimeout(function () {
if (btn.getAttribute('data-confirm-delete') === 'true') {
btn.removeAttribute('data-confirm-delete');
btn.textContent = 'Delete';
}
}, 5000);
return;
}
fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), {
method: 'DELETE', headers: getAuthHeaders(), credentials: 'same-origin'
})
.then(function (r) { return r.json(); })
.then(function () { loadSavedChats(); if (typeof showToast === 'function') showToast('Deleted saved chat', 'success'); })
.catch(function () { if (typeof showToast === 'function') showToast('Delete failed', 'error'); });
}
function restoreSavedChat(payload) {
messages = Array.isArray(payload.messages) ? payload.messages.map(function (m) {
return { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [] };
}) : [];
lastSources = Array.isArray(payload.sources) ? payload.sources : [];
lastAnswer = String(payload.lastAnswer || lastAssistantMessage(messages) || '');
lastGeneratedImageSrc = String(payload.generatedImage || '');
var wrap = document.getElementById('assistant-messages');
if (wrap) {
wrap.innerHTML = '';
messages.forEach(function (m) { appendMessageNode(m.role, m.content, m.sources && m.sources.length ? m.sources : lastSources); });
wrap.scrollTop = wrap.scrollHeight;
}
renderSources(lastSources);
var out = document.getElementById('assistant-visual-output');
if (out) out.innerHTML = lastGeneratedImageSrc ? renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : '';
}
function appendMessageNode(role, content, sources) {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return;
var row = document.createElement('div');
row.className = 'assistant-msg ' + role;
var label = document.createElement('div');
label.className = 'assistant-msg-label';
label.textContent = role === 'user' ? 'You' : 'Assistant';
var bubble = document.createElement('div');
bubble.className = 'assistant-bubble';
bubble.innerHTML = role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content);
row.appendChild(label);
row.appendChild(bubble);
wrap.appendChild(row);
renderEmbeddedBlocks(bubble);
}
function deriveChatTitle() {
var first = messages.find(function (m) { return m.role === 'user' && m.content; });
return String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim().slice(0, 80);
}
function cleanSourceExcerpt(text) {
return String(text || '')
.replace(/^\[Page-image match\]\s*/i, '')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\*\*/g, '')
.replace(/\|\s*-{2,}\s*/g, ' ')
.replace(/\|/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function imageForSavedChatPayload(src) {
src = String(src || '');
return /^https?:\/\//i.test(src) ? src : '';
}
function buildExportCacheKey(items, imageSrc) {
return JSON.stringify({ items: items.map(function (item) {
return { q: item.question, a: item.answer, s: (item.sources || []).map(function (s) { return [s.number, s.title, s.page]; }) };
}), image: imageSrc ? '1' : '' });
}
function lastAssistantMessage(items) {
for (var i = items.length - 1; i >= 0; i--) if (items[i].role === 'assistant') return items[i].content;
return '';
}
function formatSavedDate(value) {
try { return new Date(value).toLocaleString(); } catch (e) { return ''; }
}
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
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');
@ -554,7 +789,5 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js';
}
}
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, { ADD_ATTR: ['target'] }) : html; }
})();

View file

@ -7,12 +7,15 @@
var express = require('express');
var axios = require('axios');
var fs = require('fs');
var path = require('path');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { callAI } = require('../utils/ai');
var { gatewayUrl } = require('../utils/errors');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto');
router.use(authMiddleware);
@ -22,6 +25,15 @@ var DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retr
var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|ok|okay|sup)[\s.!?]*$/i;
var EXAMPLE_CACHE_MS = 10 * 60 * 1000;
var _exampleCache = { expiresAt: 0, examples: [] };
var MAX_SAVED_CHATS_PER_USER = 100;
var MAX_SAVED_CHAT_PAYLOAD = 250000;
var MAX_SAVED_CHAT_TITLE = 160;
var MCP_MULTIMODAL_IMAGE_ROOT = path.resolve(process.env.CLINICAL_ASSISTANT_MULTIMODAL_IMAGE_ROOT || '/app/mcp-data/multimodal-pages');
var MCP_MULTIMODAL_INTERNAL_ROOT = path.resolve(process.env.CLINICAL_ASSISTANT_MULTIMODAL_INTERNAL_ROOT || '/app/data/multimodal-pages');
var MULTIMODAL_EMBEDDING_URL = process.env.CLINICAL_ASSISTANT_MULTIMODAL_EMBEDDING_URL || 'http://multimodal-embeddings:7999/embed';
var VISUAL_CLASSIFIER_ENABLED = process.env.CLINICAL_ASSISTANT_VISUAL_CLASSIFIER !== 'false';
var VISUAL_CLASSIFIER_LIMIT = 6;
var VISUAL_CLASSIFIER_MIN_CONFIDENCE = Number(process.env.CLINICAL_ASSISTANT_VISUAL_CLASSIFIER_MIN_CONFIDENCE || '0.005');
var EXAMPLE_CANDIDATES = [
{ label: 'Status asthma escalation', prompt: 'In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?' },
{ label: 'Asthma ED disposition', prompt: 'What clinical features support discharge versus admission after a pediatric asthma exacerbation?' },
@ -109,6 +121,86 @@ router.get('/clinical-assistant/examples', async function(req, res) {
}
});
router.get('/clinical-assistant/source-image/:token', async function(req, res) {
try {
var imagePath = resolveMultimodalImagePath(req.params.token || '');
if (!imagePath) return res.status(404).send('Not found');
res.set('Cache-Control', 'private, max-age=3600');
res.type('png');
res.sendFile(imagePath);
} catch (e) {
console.warn('[clinical-assistant] source image rejected:', e.message);
res.status(404).send('Not found');
}
});
router.get('/clinical-assistant/chats', async function(req, res) {
try {
var rows = await db.all(
'SELECT id, title, created_at, updated_at FROM clinical_assistant_chats WHERE user_id = $1 ORDER BY updated_at DESC LIMIT 100',
[req.user.id]
);
rows.forEach(function(row) {
try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {}
});
res.json({ success: true, chats: rows });
} catch (e) {
logger.error('GET /clinical-assistant/chats', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.get('/clinical-assistant/chats/:id', async function(req, res) {
try {
var row = await db.get(
'SELECT id, title, payload, created_at, updated_at FROM clinical_assistant_chats WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!row) return res.status(404).json({ error: 'Saved chat not found' });
try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {}
try { row.payload = JSON.parse(cryptoUtil.decryptString(row.payload) || '{}'); } catch (e) { row.payload = {}; }
res.json({ success: true, chat: row });
} catch (e) {
logger.error('GET /clinical-assistant/chats/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.post('/clinical-assistant/chats', async function(req, res) {
try {
var title = cleanSavedChatTitle(req.body.title || firstUserMessage(req.body.messages) || 'Clinical assistant chat');
var payload = buildSavedChatPayload(req.body);
var payloadText = JSON.stringify(payload);
if (payloadText.length > MAX_SAVED_CHAT_PAYLOAD) return res.status(400).json({ error: 'Saved chat is too large' });
var count = await db.get('SELECT COUNT(*) as cnt FROM clinical_assistant_chats WHERE user_id = $1', [req.user.id]);
if (count && Number(count.cnt) >= MAX_SAVED_CHATS_PER_USER) {
return res.status(400).json({ error: 'Maximum ' + MAX_SAVED_CHATS_PER_USER + ' saved chats per user' });
}
var result = await db.run(
'INSERT INTO clinical_assistant_chats (user_id, title, payload) VALUES ($1, $2, $3) RETURNING id',
[req.user.id, cryptoUtil.encryptString(title), cryptoUtil.encryptString(payloadText)]
);
logger.audit(req.user.id, 'clinical_assistant_chat_save', 'Saved clinical assistant chat', req, { category: 'clinical' });
res.json({ success: true, id: result.lastInsertRowid, title: title });
} catch (e) {
logger.error('POST /clinical-assistant/chats', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.delete('/clinical-assistant/chats/:id', async function(req, res) {
try {
await db.run('DELETE FROM clinical_assistant_chats WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
logger.audit(req.user.id, 'clinical_assistant_chat_delete', 'Deleted clinical assistant chat', req, { category: 'clinical' });
res.json({ success: true });
} catch (e) {
logger.error('DELETE /clinical-assistant/chats/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
router.post('/clinical-assistant/chat', async function(req, res) {
var started = Date.now();
try {
@ -144,17 +236,27 @@ router.post('/clinical-assistant/chat', async function(req, res) {
});
}
var searchResponse = await semanticSearch(message, {
var history = Array.isArray(req.body.history) ? req.body.history.slice(-8) : [];
var searchQuery = await rewriteSearchQuery(message, history, chatModel).catch(function(e) {
console.warn('[clinical-assistant] query rewrite skipped:', e.message);
return message;
});
var searchResponse = await semanticSearch(searchQuery, {
limit: searchLimit,
includeContext: includeContext,
contextChars: contextChars
});
var multimodalResponse = await multimodalSearch(message, { limit: 3 }).catch(function(e) {
var visualQuery = isVisualSourceQuery(message) || isVisualSourceQuery(searchQuery);
var multimodalResponse = visualQuery ? await multimodalSearch(buildMultimodalSearchQuery(searchQuery), { limit: 8 }).catch(function(e) {
console.warn('[clinical-assistant] multimodal search skipped:', e.message);
return null;
});
}) : null;
var rawTextResults = normalizeMcpSearchResponse(searchResponse);
var rawMultimodalResults = normalizeMcpMultimodalResponse(multimodalResponse);
var rawMultimodalResults = await classifyAndRerankMultimodalResults(
message + ' ' + searchQuery,
normalizeMcpMultimodalResponse(multimodalResponse)
);
var rawResults = rawTextResults.concat(rawMultimodalResults);
var visualSlots = rawMultimodalResults.length ? Math.min(2, Math.max(1, Math.floor(searchLimit / 4))) : 0;
var textSlots = searchLimit - visualSlots;
@ -175,10 +277,9 @@ router.post('/clinical-assistant/chat', async function(req, res) {
}
var context = formatSourcesForPrompt(sources);
var history = Array.isArray(req.body.history) ? req.body.history.slice(-6) : [];
var messages = [
{ role: 'system', content: buildSystemPrompt(behavior) },
{ role: 'user', content: buildUserPrompt(message, context, history) }
{ role: 'user', content: buildUserPrompt(message, context, history, searchQuery) }
];
var ai = await callAI(messages, {
@ -198,13 +299,15 @@ router.post('/clinical-assistant/chat', async function(req, res) {
res.json({
success: true,
answer: answer,
sources: sources,
sources: sanitizeSourcesForClient(sources),
model: ai.model || chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
search: {
totalFound: rawResults.length,
multimodalFound: rawMultimodalResults.length,
query: searchQuery,
rewritten: searchQuery !== message,
verifiedChunkCount: searchResponse.verified_chunk_count || searchResponse.verifiedChunkCount || 0,
droppedDocumentCount: searchResponse.dropped_document_count || searchResponse.droppedDocumentCount || 0
}
@ -233,6 +336,11 @@ router.post('/clinical-assistant/image', async function(req, res) {
router.post('/clinical-assistant/export-summary', async function(req, res) {
try {
if (Array.isArray(req.body.items) && req.body.items.length) {
var exportItems = await summarizeExportItems(req.body.items);
return res.json({ success: true, items: exportItems });
}
var answer = String(req.body.answer || '').trim();
var sources = Array.isArray(req.body.sources) ? req.body.sources.slice(0, 20) : [];
if (!answer) return res.status(400).json({ error: 'Answer is required' });
@ -264,6 +372,101 @@ router.post('/clinical-assistant/export-summary', async function(req, res) {
}
});
async function rewriteSearchQuery(message, history, chatModel) {
message = String(message || '').trim();
history = Array.isArray(history) ? history.filter(function(m) { return m && (m.role === 'user' || m.role === 'assistant') && m.content; }).slice(-8) : [];
if (history.length && history[history.length - 1].role === 'user' && String(history[history.length - 1].content || '').trim() === message) {
history = history.slice(0, -1);
}
if (!history.length || !needsContextualRewrite(message)) return message;
var hist = history.map(function(m) {
return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 900);
}).join('\n');
var ai = await callAI([
{
role: 'system',
content: 'Rewrite short or ambiguous clinical follow-up questions into one standalone search query for medical document retrieval. Preserve the user intent and clinical facts from the conversation. Do not answer. Do not add facts not supported by the conversation. Return only the rewritten query.'
},
{
role: 'user',
content: 'Conversation:\n' + hist + '\n\nLatest user question:\n' + message + '\n\nStandalone search query:'
}
], {
model: chatModel || undefined,
temperature: 0,
maxTokens: 80
});
var rewritten = String(ai.content || '').replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ').trim();
if (!rewritten || rewritten.length < 6 || rewritten.length > 300) return message;
if (/^(yes|no|maybe|i don'?t know)$/i.test(rewritten)) return message;
return rewritten;
}
function needsContextualRewrite(message) {
var text = String(message || '').trim();
if (!text) return false;
var words = text.split(/\s+/).filter(Boolean);
return words.length <= 8 || /\b(it|this|that|they|them|he|she|dose|dosing|how much|what about|next|admit|discharge|criteria|side effects?|contraindications?|monitor|monitoring)\b/i.test(text);
}
async function summarizeExportItems(items) {
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var clean = items.slice(0, 20).map(function(item, idx) {
return {
question: clip(item.question || ('Question ' + (idx + 1)), 1000),
answer: clip(item.answer || '', 12000),
sources: Array.isArray(item.sources) ? item.sources.slice(0, 25) : []
};
}).filter(function(item) { return item.answer; });
var out = [];
for (var i = 0; i < clean.length; i++) {
out.push(await summarizeExportItem(clean[i], i, chatModel));
}
return out;
}
async function summarizeExportItem(item, idx, chatModel) {
var sourceList = item.sources.map(function(s, sidx) {
var n = s.number || sidx + 1;
return '[' + n + '] ' + cleanTitle(s.title || s.resource || 'Source') + (s.page ? ', page ' + s.page : '');
}).join('\n');
try {
var ai = await callAI([
{
role: 'system',
content: 'Create a clinical export section heading and abridged summary. Return strict JSON only: {"heading":"...","summary":"..."}. The heading must be specific and clinically meaningful, not a vague follow-up like "What dose?". Preserve factual meaning. Use only citation numbers already present in the answer and provided source list. Do not invent, renumber, merge, or move citations. No references section.'
},
{
role: 'user',
content: 'User question:\n' + item.question + '\n\nAnswer:\n' + item.answer + '\n\nAvailable cited sources:\n' + sourceList
}
], {
model: chatModel || undefined,
temperature: 0.05,
maxTokens: 700
});
var parsed = parseJsonObject(String(ai.content || ''));
return {
question: item.question,
heading: cleanExportHeading(parsed.heading || item.question, idx),
summary: stripModelSourcesSection(String(parsed.summary || '').trim()) || item.answer,
answer: item.answer,
sources: item.sources
};
} catch (e) {
return {
question: item.question,
heading: cleanExportHeading(item.question, idx),
summary: item.answer,
answer: item.answer,
sources: item.sources
};
}
}
async function semanticSearch(query, opts) {
return callMcpTool('nc_semantic_search', {
query: query,
@ -459,18 +662,23 @@ function normalizeMcpMultimodalResponse(result) {
}
if (!data || !Array.isArray(data.results)) return [];
return data.results.map(function(r, idx) {
var nearby = clip(r.nearby_text || '', 1400);
var nearby = clip(cleanSourceExcerpt(r.nearby_text || ''), 1400);
return {
number: idx + 1,
id: r.id,
doc_type: r.doc_type || 'file',
source_type: 'multimodal_page',
title: cleanTitle(r.title || r.file_path || 'PDF page image'),
file_path: r.file_path || '',
category: r.category || '',
subcategory: r.subcategory || '',
category_path: r.category_path || '',
page: r.page_number || r.pageNumber || null,
page_count: r.page_count || r.pageCount || null,
excerpt: nearby ? '[Page-image match] ' + nearby : '[Page-image match] Rendered PDF page matched the visual/text query.',
score: r.score,
image_path: r.image_path || null,
image_path_internal: r.image_path || '',
image_url: multimodalImageUrl(r.image_path),
chunk_index: 'page-image-' + (r.page_number || idx + 1),
total_chunks: r.page_count || null
};
@ -492,7 +700,7 @@ function dedupeSources(results) {
function formatSourcesForPrompt(sources) {
return sources.map(function(s) {
var label = s.source_type === 'multimodal_page' ? ' [visual PDF page match]' : '';
return '[' + s.number + '] ' + s.title + (s.page ? ', page ' + s.page : '') + label + '\n' + s.excerpt;
return '[' + s.number + '] ' + s.title + (s.page ? ', page ' + s.page : '') + label + '\n' + cleanSourceExcerpt(s.excerpt);
}).join('\n\n---\n\n');
}
@ -500,10 +708,11 @@ 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- 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) {
function buildUserPrompt(question, context, history, searchQuery) {
var hist = history.filter(function(m) { return m && (m.role === 'user' || m.role === 'assistant') && m.content; })
.map(function(m) { return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 1000); }).join('\n');
return 'Question:\n' + question + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved indexed sources:\n' + context + '\n\nWrite the answer now.';
var searchNote = searchQuery && searchQuery !== question ? ('\n\nStandalone retrieval query used:\n' + searchQuery) : '';
return 'Question:\n' + question + searchNote + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved indexed sources:\n' + context + '\n\nWrite the answer now.';
}
function stripModelSourcesSection(answer) {
@ -551,6 +760,261 @@ function parseCitationCluster(cluster) {
return String(cluster || '').split(',').map(function(n) { return Number(n.trim()); }).filter(function(n) { return Number.isInteger(n) && n > 0; });
}
function isVisualSourceQuery(query) {
return /\b(visual|image|figure|photo|picture|diagram|graph|chart|radiology|radiologic|radiograph|x-?ray|cxr|imaging|ultrasound|ct|mri|scan|film)\b/i.test(String(query || ''));
}
function isRadiologyQuery(query) {
return /\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film)\b/i.test(String(query || ''));
}
function buildMultimodalSearchQuery(query) {
query = String(query || '').trim();
if (isRadiologyQuery(query)) return query + ' radiograph x-ray chest imaging radiology';
return query;
}
async function classifyAndRerankMultimodalResults(query, results) {
results = Array.isArray(results) ? results : [];
if (!results.length) return [];
var candidates = results.slice(0, VISUAL_CLASSIFIER_LIMIT);
if (!VISUAL_CLASSIFIER_ENABLED) return selectMultimodalResults(query, candidates);
var radiology = isRadiologyQuery(query);
var classified = [];
for (var i = 0; i < candidates.length; i++) {
var candidate = candidates[i];
var classification = await classifyVisualSource(candidate).catch(function(e) {
console.warn('[clinical-assistant] visual classifier skipped candidate:', e.message);
return null;
});
if (!classification) continue;
var metaScore = visualMetadataScore(query, candidate);
var confident = classification.confidence >= VISUAL_CLASSIFIER_MIN_CONFIDENCE || metaScore > 0.1;
var accepted = radiology ? (classification.kind === 'radiology' && confident) : (classification.kind !== 'text_page' && confident);
if (!accepted) continue;
candidate.visual_kind = classification.kind;
candidate.visual_confidence = classification.confidence;
candidate.visual_score = (Number(candidate.score) || 0) + (classification.confidence * 0.35) + metaScore;
classified.push(candidate);
}
if (classified.length) {
return classified.sort(function(a, b) { return (b.visual_score || 0) - (a.visual_score || 0); });
}
return [];
}
function selectMultimodalResults(query, results) {
results = Array.isArray(results) ? results : [];
if (!results.length) return [];
if (!isRadiologyQuery(query)) return results.filter(function(r) { return !looksLikeTextOnlyPage(r); });
var radiologyTerms = /\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film|pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i;
return results.filter(function(r) {
return radiologyTerms.test([r.title, r.excerpt, r.file_path, r.category, r.subcategory, r.category_path].filter(Boolean).join(' '));
});
}
async function classifyVisualSource(source) {
var imagePath = resolveRawMultimodalImagePath(source.image_path_internal || '');
if (!imagePath) throw new Error('No local page image');
var imageBase64 = fs.readFileSync(imagePath).toString('base64');
var labels = visualClassifierLabels();
var inputs = [{ text: 'Classify this medical document page image.', image_base64: imageBase64 }].concat(
labels.map(function(label) { return { text: label.prompt }; })
);
var resp = await axios.post(MULTIMODAL_EMBEDDING_URL, { inputs: inputs }, { timeout: 45000 });
var vectors = resp.data && Array.isArray(resp.data.embeddings) ? resp.data.embeddings : [];
if (vectors.length < labels.length + 1) throw new Error('Classifier embedding response was incomplete');
var imageVector = vectors[0];
var scored = labels.map(function(label, idx) {
return Object.assign({}, label, { score: cosineSimilarity(imageVector, vectors[idx + 1]) });
}).sort(function(a, b) { return b.score - a.score; });
var best = scored[0];
var second = scored[1] || { score: best.score };
return {
kind: best.kind,
confidence: Math.max(0, best.score - second.score),
score: best.score,
scores: scored
};
}
function visualClassifierLabels() {
return [
{ kind: 'radiology', prompt: 'A medical radiology image: x-ray radiograph, chest x-ray, CT scan, ultrasound, MRI, or diagnostic imaging film.' },
{ kind: 'diagram', prompt: 'A medical diagram, clinical illustration, anatomy figure, pathway, chart, flowchart, or infographic.' },
{ kind: 'photo', prompt: 'A clinical photograph, gross pathology image, microscopy image, skin finding, wound image, or bedside photograph.' },
{ kind: 'text_page', prompt: 'A plain textbook or article page that is mostly text, paragraphs, references, bullet points, or tables without a diagnostic image.' }
];
}
function visualMetadataScore(query, source) {
var haystack = [source.title, source.excerpt, source.file_path, source.category, source.subcategory, source.category_path].filter(Boolean).join(' ');
var score = 0;
if (isRadiologyQuery(query) && /\b(radiology|radiograph|x-?ray|cxr|imaging|ct|mri|ultrasound|film)\b/i.test(haystack)) score += 0.12;
if (/\b(pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i.test(haystack)) score += 0.06;
if (looksLikeTextOnlyPage(source)) score -= 0.18;
return score;
}
function looksLikeTextOnlyPage(source) {
var text = String(source && source.excerpt || '');
return text.length > 900 && !/\b(figure|image|radiograph|x-?ray|cxr|ultrasound|ct|mri|scan|film|diagram|illustration)\b/i.test(text);
}
function cosineSimilarity(a, b) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return 0;
var dot = 0;
var aa = 0;
var bb = 0;
for (var i = 0; i < a.length; i++) {
var av = Number(a[i]) || 0;
var bv = Number(b[i]) || 0;
dot += av * bv;
aa += av * av;
bb += bv * bv;
}
if (!aa || !bb) return 0;
return dot / (Math.sqrt(aa) * Math.sqrt(bb));
}
function multimodalImageUrl(imagePath) {
imagePath = String(imagePath || '').trim();
if (!imagePath) return '';
return '/api/clinical-assistant/source-image/' + base64UrlEncode(imagePath);
}
function resolveMultimodalImagePath(token) {
var raw = base64UrlDecode(token);
return resolveRawMultimodalImagePath(raw);
}
function resolveRawMultimodalImagePath(raw) {
if (!raw || raw.indexOf('\0') !== -1) return '';
var incoming = path.resolve(raw);
var internalRoot = withTrailingSeparator(MCP_MULTIMODAL_INTERNAL_ROOT);
var servedRoot = withTrailingSeparator(MCP_MULTIMODAL_IMAGE_ROOT);
var servedPath = incoming;
if (incoming === MCP_MULTIMODAL_INTERNAL_ROOT || incoming.indexOf(internalRoot) === 0) {
servedPath = path.resolve(MCP_MULTIMODAL_IMAGE_ROOT, path.relative(MCP_MULTIMODAL_INTERNAL_ROOT, incoming));
}
if (servedPath !== MCP_MULTIMODAL_IMAGE_ROOT && servedPath.indexOf(servedRoot) !== 0) return '';
if (path.extname(servedPath).toLowerCase() !== '.png') return '';
if (!fs.existsSync(servedPath)) return '';
return servedPath;
}
function sanitizeSourcesForClient(sources) {
return (Array.isArray(sources) ? sources : []).map(function(source) {
var out = Object.assign({}, source);
delete out.image_path_internal;
delete out.file_path;
delete out.category;
delete out.subcategory;
delete out.category_path;
return out;
});
}
function withTrailingSeparator(p) {
p = path.resolve(p);
return p.endsWith(path.sep) ? p : p + path.sep;
}
function base64UrlEncode(value) {
return Buffer.from(String(value), 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlDecode(value) {
value = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
while (value.length % 4) value += '=';
return Buffer.from(value, 'base64').toString('utf8');
}
function buildSavedChatPayload(body) {
var messages = Array.isArray(body.messages) ? body.messages.slice(-80).map(function(m) {
return {
role: m && m.role === 'assistant' ? 'assistant' : 'user',
content: clip(m && m.content, 12000),
sources: Array.isArray(m && m.sources) ? m.sources.slice(0, 30).map(function(s, idx) {
return {
number: Number(s.number || idx + 1),
title: clip(s.title || s.resource || 'Source', 500),
resource: clip(s.resource || '', 500),
page: s.page || s.page_number || s.pageNumber || null,
source_type: clip(s.source_type || '', 80),
doc_type: clip(s.doc_type || s.type || '', 80),
image_url: safeSourceImageUrl(s.image_url || s.imageUrl),
excerpt: clip(s.excerpt || '', 1800),
score: s.score == null ? null : Number(s.score)
};
}) : []
};
}).filter(function(m) { return m.content; }) : [];
var sources = Array.isArray(body.sources) ? body.sources.slice(0, 30).map(function(s, idx) {
return {
number: Number(s.number || idx + 1),
title: clip(s.title || s.resource || 'Source', 500),
resource: clip(s.resource || '', 500),
page: s.page || s.page_number || s.pageNumber || null,
source_type: clip(s.source_type || '', 80),
doc_type: clip(s.doc_type || s.type || '', 80),
image_url: safeSourceImageUrl(s.image_url || s.imageUrl),
excerpt: clip(s.excerpt || '', 1800),
score: s.score == null ? null : Number(s.score)
};
}) : [];
return {
version: 1,
messages: messages,
sources: sources,
lastAnswer: clip(body.lastAnswer || '', 30000),
generatedImage: safeImageForSave(body.generatedImage),
savedAt: new Date().toISOString()
};
}
function safeImageForSave(image) {
image = String(image || '');
if (!image) return '';
if (/^https?:\/\//i.test(image)) return image.substring(0, 5000);
return '';
}
function safeSourceImageUrl(url) {
url = String(url || '');
if (url.indexOf('/api/clinical-assistant/source-image/') === 0) return url.substring(0, 2000);
return '';
}
function firstUserMessage(messages) {
if (!Array.isArray(messages)) return '';
for (var i = 0; i < messages.length; i++) {
if (messages[i] && messages[i].role === 'user' && messages[i].content) return messages[i].content;
}
return '';
}
function cleanSavedChatTitle(title) {
return clip(title, MAX_SAVED_CHAT_TITLE).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim() || 'Clinical assistant chat';
}
function parseJsonObject(text) {
text = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
try { return JSON.parse(text); } catch (e) {}
var start = text.indexOf('{');
var end = text.lastIndexOf('}');
if (start !== -1 && end > start) {
try { return JSON.parse(text.slice(start, end + 1)); } catch (e2) {}
}
return {};
}
function cleanExportHeading(heading, idx) {
heading = clip(heading, 120).replace(/^#+\s*/, '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
if (!heading || /^(what|which|when|why|how|and|also|dose\??|what dose\??)$/i.test(heading)) return 'Clinical Question ' + (idx + 1);
return heading;
}
async function getAvailableExamples() {
var now = Date.now();
if (_exampleCache.expiresAt > now && _exampleCache.examples.length) return _exampleCache.examples;
@ -621,6 +1085,16 @@ function cleanTitle(s) {
.replace(/\s*\(z-library\.sk,\s*1lib\.sk,\s*z-lib\.sk\)\s*/ig, '')
.trim();
}
function cleanSourceExcerpt(text) {
return String(text || '')
.replace(/^\[Page-image match\]\s*/i, '')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\*\*/g, '')
.replace(/\|\s*-{2,}\s*/g, ' ')
.replace(/\|/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function assistantErrorMessage(e) {
var msg = e && e.message ? e.message : String(e);
if (/ECONNREFUSED|fetch failed|Failed to open SSE|MCP/i.test(msg)) return 'Could not reach the MCP search server. Check CLINICAL_ASSISTANT_MCP_URL or the MCP container.';

View file

@ -0,0 +1,128 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
let modulePromise;
async function loadCitationModule() {
if (!modulePromise) {
modulePromise = fs.readFile(path.join(__dirname, '..', 'public', 'js', 'assistant', 'citations.js'), 'utf8')
.then((source) => import('data:text/javascript;charset=utf-8,' + encodeURIComponent(source)));
}
return modulePromise;
}
const sources = [
{ title: 'Nelson Textbook of Pediatrics' },
{ title: 'Pediatric Asthma Guideline' },
{ resource: 'Emergency Medicine Review' }
];
test('renders citation clusters as links to matching source cards', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Give magnesium for severe exacerbation [1, 2].', sources);
assert.match(html, /href="#assistant-source-1"/);
assert.match(html, /href="#assistant-source-2"/);
assert.match(html, /title="Nelson Textbook of Pediatrics"/);
assert.match(html, /\[<a class="assistant-cite"[^>]*>1<\/a>, <a class="assistant-cite"[^>]*>2<\/a>\]/);
});
test('leaves unknown citation clusters untouched rather than guessing', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Dose statement [4].', sources);
assert.match(html, /\[4\]/);
assert.doesNotMatch(html, /assistant-source-4/);
});
test('does not merge separate citations across separate claims', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Use oxygen [1]. Give bronchodilator [2].', sources);
assert.match(html, /oxygen \[<a class="assistant-cite"[^>]*>1<\/a>\]/);
assert.match(html, /bronchodilator \[<a class="assistant-cite"[^>]*>2<\/a>\]/);
assert.doesNotMatch(html, />1<\/a>, <a class="assistant-cite"[^>]*>2<\/a>/);
});
test('sorts adjacent citation tokens into one safe Vancouver cluster', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Deteriorating course [1][4][2][3].', [
{ title: 'A' }, { title: 'B' }, { title: 'C' }, { title: 'D' }
]);
assert.match(html, />1<\/a>, <a class="assistant-cite"[^>]*>2<\/a>, <a class="assistant-cite"[^>]*>3<\/a>, <a class="assistant-cite"[^>]*>4<\/a>/);
assert.doesNotMatch(html, /\]<span|\]\[/);
});
test('repairs a clearly adjacent trailing citation missing its closing bracket', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Deteriorating course [1][4][2][3', [
{ title: 'A' }, { title: 'B' }, { title: 'C' }, { title: 'D' }
]);
assert.match(html, />1<\/a>, <a class="assistant-cite"[^>]*>2<\/a>, <a class="assistant-cite"[^>]*>3<\/a>, <a class="assistant-cite"[^>]*>4<\/a>/);
});
test('does not normalize adjacent citations if any source number is unknown', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Unsupported claim [1][9].', sources);
assert.match(html, /\[9\]/);
assert.doesNotMatch(html, /assistant-source-9/);
});
test('escapes source titles inside citation link titles', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Unsafe title [1].', [{ title: 'Bad "Title" <script>' }]);
assert.match(html, /title="Bad &quot;Title&quot; &lt;script&gt;"/);
assert.doesNotMatch(html, /<script>/);
});
test('strips model-generated Sources and References sections', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Answer body [1].\n\n## Sources\n[1] duplicate source', sources);
assert.match(html, /Answer body/);
assert.doesNotMatch(html, /duplicate source/);
});
test('strips colon-style References sections', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Answer body [1].\n\n--- References:\n[1] duplicate source', sources);
assert.match(html, /Answer body/);
assert.doesNotMatch(html, /duplicate source/);
});
test('normalizes inline markdown headings before rendering', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Intro ### Management\n- oxygen', sources);
assert.match(html, /<h3>Management<\/h3>/);
assert.match(html, /<li>oxygen<\/li>/);
});
test('normalizes old saved assistant answer formatting', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Comparison of Bronchiolitis and Asthma Management in Infants ### Bronchiolitis Management - Mainstay: Supportive care [1, 2]. - Medications: bronchodilators are not routine [2]. ### Key Differences | Aspect | Bronchiolitis | Asthma | |---|---|---| | Therapy | Supportive | SABA | --- References: [1] duplicate', sources);
assert.match(html, /<h3>Bronchiolitis Management<\/h3>/);
assert.match(html, /<li>Mainstay: Supportive care/);
assert.match(html, /<table>/);
assert.doesNotMatch(html, /duplicate/);
});
test('strips inline model References after a cited sentence', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Infants <2 years often do not respond [2, 3]. --- References: [2] Book p.1 [3] Other p.2', sources);
assert.match(html, /Infants &lt;2 years/);
assert.doesNotMatch(html, /References/);
assert.doesNotMatch(html, /Book p\.1/);
});
test('repairs smashed admission bullet list after citations', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Admission is indicated for: Apnoea [1, 2]- **Persistent oxygen saturation <90%** [1, 2]- **Severe respiratory distress** [1]', sources);
assert.match(html, /<li><strong>Persistent oxygen saturation &lt;90%<\/strong>/);
assert.match(html, /<li><strong>Severe respiratory distress<\/strong>/);
assert.doesNotMatch(html, /\]-/);
});
test('preserves code block contents without creating citation links inside code', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('```js\nconst ref = "[1]";\n```', sources);
assert.match(html, /<pre><code>const ref = &quot;\[1\]&quot;/);
assert.doesNotMatch(html, /assistant-source-1/);
});