From 18a69d2a13711adc7a021d1314fb3c0cedc8ff6e Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 7 May 2026 02:45:17 +0200 Subject: [PATCH] feat: improve clinical assistant visual sources --- .gitignore | 1 + docker-compose.yml | 4 + ...7600000000_add-clinical-assistant-chats.js | 21 + package.json | 2 +- public/components/assistant.html | 38 +- public/js/assistant/citations.js | 191 +++++++ public/js/assistant/data.js | 2 +- public/js/clinicalAssistant.js | 411 +++++++++++---- src/routes/clinicalAssistant.js | 498 +++++++++++++++++- test/assistant-citations.test.js | 128 +++++ 10 files changed, 1191 insertions(+), 105 deletions(-) create mode 100644 migrations/1777600000000_add-clinical-assistant-chats.js create mode 100644 public/js/assistant/citations.js create mode 100644 test/assistant-citations.test.js diff --git a/.gitignore b/.gitignore index 49bb099..94490b5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ e2e/test-results/ e2e/playwright-report/ .codex +.firecrawl/ diff --git a/docker-compose.yml b/docker-compose.yml index bd13b9e..0792ea1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/migrations/1777600000000_add-clinical-assistant-chats.js b/migrations/1777600000000_add-clinical-assistant-chats.js new file mode 100644 index 0000000..f3f4030 --- /dev/null +++ b/migrations/1777600000000_add-clinical-assistant-chats.js @@ -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'); +}; diff --git a/package.json b/package.json index edb8aab..c7ebb4c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/components/assistant.html b/public/components/assistant.html index 1c2208e..d679cd6 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -19,6 +19,7 @@
+
@@ -31,7 +32,7 @@
- +
@@ -50,11 +51,29 @@

Image / Graph

- +
+ + +
+
+

Saved Chats

+
+

Saved chats appear here after you click Save chat.

+
+ +
+

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; } diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js new file mode 100644 index 0000000..8f33d4e --- /dev/null +++ b/public/js/assistant/citations.js @@ -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 '
Rendering graph...
'; + if (block.lang === 'chart' || block.lang === 'chartjs') return ''; + return '
' + escapeHtml(block.code) + '
'; + }); + + 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 '' + n + ''; + }).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, '

$1

') + .replace(/^## (.*)$/gm, '

$1

') + .replace(/^# (.*)$/gm, '

$1

') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1'); + html = html.split(/\n{2,}/).map(function (block) { + if (/^\s*' + lines.filter(Boolean).map(function (l) { return '
  • ' + l.replace(/^\s*[-*] /, '') + '
  • '; }).join('') + ''; + } + if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) { + return '
      ' + lines.filter(Boolean).map(function (l) { return '
    1. ' + l.replace(/^\s*\d+\. /, '') + '
    2. '; }).join('') + '
    '; + } + return '

    ' + block.replace(/\n/g, '
    ') + '

    '; + }).join(''); + return html; +} + +function renderMixedList(lines) { + var html = ''; + var list = []; + var paragraph = []; + lines.forEach(function(line) { + if (/^\s*[-*] /.test(line)) { + if (paragraph.length) { + html += '

    ' + paragraph.join('
    ') + '

    '; + paragraph = []; + } + list.push(line.replace(/^\s*[-*] /, '')); + return; + } + if (list.length) { + html += '
      ' + list.map(function(item) { return '
    • ' + item + '
    • '; }).join('') + '
    '; + list = []; + } + if (line.trim()) paragraph.push(line); + }); + if (paragraph.length) html += '

    ' + paragraph.join('
    ') + '

    '; + if (list.length) html += '
      ' + list.map(function(item) { return '
    • ' + item + '
    • '; }).join('') + '
    '; + 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 '' + header.map(function(cell) { return ''; }).join('') + '' + + rows.map(function(row) { return '' + row.map(function(cell) { return ''; }).join('') + ''; }).join('') + + '
    ' + cell + '
    ' + cell + '
    '; +} + +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, '&').replace(//g, '>').replace(/"/g, '"'); +} + +export function escapeAttr(s) { + return escapeHtml(s).replace(/'/g, '''); +} diff --git a/public/js/assistant/data.js b/public/js/assistant/data.js index 43dbea1..fcb13eb 100644 --- a/public/js/assistant/data.js +++ b/public/js/assistant/data.js @@ -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?' }, diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index aa5113c..697e594 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -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 '' + n + ''; - }).join(', ') + ']'; - }); - html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) { - var block = codeBlocks[Number(idx)] || { lang: '', code: '' }; - if (block.lang === 'mermaid') return '
    Rendering graph...
    '; - if (block.lang === 'chart' || block.lang === 'chartjs') return ''; - return '
    ' + escapeHtml(block.code) + '
    '; - }); - return sanitize(html); - } - - function fallbackMarkdown(text) { - var html = escapeHtml(text) - .replace(/^### (.*)$/gm, '

    $1

    ') - .replace(/^## (.*)$/gm, '

    $1

    ') - .replace(/^# (.*)$/gm, '

    $1

    ') - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/\*(.*?)\*/g, '$1') - .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'); - if (lines.every(function (l) { return /^\s*[-*] /.test(l) || !l.trim(); })) { - return '
      ' + lines.filter(Boolean).map(function (l) { return '
    • ' + l.replace(/^\s*[-*] /, '') + '
    • '; }).join('') + '
    '; - } - if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) { - return '
      ' + lines.filter(Boolean).map(function (l) { return '
    1. ' + l.replace(/^\s*\d+\. /, '') + '
    2. '; }).join('') + '
    '; - } - return '

    ' + block.replace(/\n/g, '
    ') + '

    '; - }).join(''); - return html; } function renderSources(sources) { @@ -287,11 +254,24 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js'; return '
    ' + '[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '' + '
    ' + escapeHtml(meta.join(' ยท ') || 'indexed source') + '
    ' + - (s.excerpt ? '

    ' + escapeHtml(String(s.excerpt).slice(0, 900)) + '

    ' : '') + + renderSourceImage(s, n) + + (s.excerpt ? '

    ' + escapeHtml(cleanSourceExcerpt(s.excerpt).slice(0, 900)) + '

    ' : '') + '
    '; }).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 '
    ' + + '' + + '
    '; + } + 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 '
  • [' + n + '] ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.
  • '; + items = Array.isArray(items) && items.length ? items : collectExportItems(); + var imageHtml = imageSrc ? '

    Generated Image

    Generated clinical visual
    ' : ''; + 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 '
    ' + + '

    ' + escapeHtml(heading) + '

    ' + + '
    Question: ' + escapeHtml(item.question || '') + '
    ' + + (summary ? '

    Summary

    ' + renderMarkdown(summary, sources) + '
    ' : '') + + '

    Full Generated Answer

    ' + renderMarkdown(answer, sources) + '
    ' + + (refs ? '

    References

      ' + refs + '
    ' : '') + + '
    '; }).join(''); var html = 'Clinical Assistant Export' + - '' + + '' + '

    Clinical Assistant Export

    ' + - '
    Abridged export generated ' + escapeHtml(new Date().toLocaleString()) + '
    ' + - '

    Summary

    ' + renderMarkdown(answer, sources || []) + '
    ' + - '

    References

      ' + refs + '
    '; + '
    Export generated ' + escapeHtml(new Date().toLocaleString()) + '
    ' + + imageHtml + + sections + ''; 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 '
  • [' + n + '] ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.
  • '; + }).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 = '

    Saved chats appear here after you click Save chat.

    '; + return; + } + wrap.innerHTML = chats.map(function (chat) { + return '
    ' + + '
    ' + escapeHtml(chat.title || 'Saved chat') + '
    ' + + '
    ' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '
    ' + + '
    ' + + '' + + '' + + '
    '; + }).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(//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, '&').replace(//g, '>').replace(/"/g, '"'); } - function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '''); } 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 7f0c76f..1c9f6d3 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -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(//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.'; diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js new file mode 100644 index 0000000..2f8e4bc --- /dev/null +++ b/test/assistant-citations.test.js @@ -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, /\[]*>1<\/a>, ]*>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 \[]*>1<\/a>\]/); + assert.match(html, /bronchodilator \[]*>2<\/a>\]/); + assert.doesNotMatch(html, />1<\/a>, ]*>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>, ]*>2<\/a>, ]*>3<\/a>, ]*>4<\/a>/); + assert.doesNotMatch(html, /\] { + 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>, ]*>2<\/a>, ]*>3<\/a>, ]*>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"