feat: improve clinical assistant export and citations

This commit is contained in:
Daniel 2026-05-07 17:15:09 +02:00
parent 2afa3a5ff2
commit 84100b1863
12 changed files with 778 additions and 160 deletions

View file

@ -8,12 +8,18 @@ services:
- .env
environment:
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp
CLINICAL_ASSISTANT_MULTIMODAL_S3_ENDPOINT: http://minio:9000
CLINICAL_ASSISTANT_MULTIMODAL_S3_ACCESS_KEY: ${MULTIMODAL_OBJECT_STORE_ACCESS_KEY:-minioadmin}
CLINICAL_ASSISTANT_MULTIMODAL_S3_SECRET_KEY: ${MULTIMODAL_OBJECT_STORE_SECRET_KEY:-minioadmin}
REDIS_URL: redis://ped-ai-redis:6379
volumes:
- scribe-logs:/app/data/logs
- clinical-assistant-mcp-data:/app/mcp-data:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
container_name: pediatric-ai-scribe
networks:
@ -46,9 +52,26 @@ services:
retries: 5
start_period: 10s
redis:
image: redis:8-alpine
command: redis-server --appendonly yes
restart: unless-stopped
container_name: ped-ai-redis
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- default
- mcp-server_default
volumes:
pgdata:
scribe-logs:
redis-data:
clinical-assistant-mcp-data:
external: true
name: mcp-server_mcp-data

106
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "pediatric-ai-scribe",
"version": "7.0.0",
"version": "7.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pediatric-ai-scribe",
"version": "7.0.0",
"version": "7.7.0",
"dependencies": {
"@marp-team/marp-cli": "^4.3.1",
"@marp-team/marp-core": "^4.3.0",
@ -28,6 +28,7 @@
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.8.0",
"markdown-it": "^14.1.1",
"marked": "^18.0.2",
"multer": "^1.4.5-lts.1",
"node-pg-migrate": "^7.7.0",
@ -38,6 +39,7 @@
"pg": "^8.13.0",
"pptxgenjs": "^4.0.1",
"qrcode": "^1.5.4",
"redis": "^4.7.1",
"speakeasy": "^2.0.0"
},
"devDependencies": {
@ -2471,6 +2473,65 @@
"node": ">=12"
}
},
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@redis/graph": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/json": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/search": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/time-series": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
@ -4276,6 +4337,15 @@
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -5322,6 +5392,15 @@
"node": ">=14"
}
},
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -7746,6 +7825,23 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
"license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": {
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.1",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -8745,6 +8841,12 @@
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",

View file

@ -36,6 +36,7 @@
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.8.0",
"markdown-it": "^14.1.1",
"marked": "^18.0.2",
"multer": "^1.4.5-lts.1",
"node-pg-migrate": "^7.7.0",
@ -46,6 +47,7 @@
"pg": "^8.13.0",
"pptxgenjs": "^4.0.1",
"qrcode": "^1.5.4",
"redis": "^4.7.1",
"speakeasy": "^2.0.0"
},
"optionalDependencies": {

View file

@ -132,7 +132,8 @@
.assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; }
@keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } }
@keyframes assistantShimmer { 0% { background-position:100% 0; } 100% { background-position:-100% 0; } }
.assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:11px; font-weight:700; text-decoration:none; }
.assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; padding:0 6px; margin:0 1px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:10px; font-weight:800; text-decoration:none; vertical-align:baseline; border:1px solid rgba(124,58,237,.18); text-transform:uppercase; letter-spacing:.03em; }
.assistant-cite:hover { background:var(--purple); color:white; text-decoration:none; }
.assistant-composer { border-top:1px solid var(--g200); padding:12px; background:white; display:grid; gap:8px; }
.assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; }
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }

View file

@ -16,7 +16,7 @@
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked@14.1.3/marked.min.js" defer></script>
<script src="/vendor/markdown-it/markdown-it.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" defer></script>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">

View file

@ -665,7 +665,7 @@
(function() {
var loaded = false;
var defaults = {
behavior: 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources, cite claims with numbered citations like [1], and list sources at the bottom by title/resource and page. Do not invent citations.'
behavior: 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations.'
};
document.addEventListener('tabChanged', function(e) {

View file

@ -1,6 +1,6 @@
export function renderAssistantMarkdown(md, sources, options) {
var opts = options || {};
var text = normalizeMarkdownText(md);
var text = stripOrphanMarkdownMarkers(repairTabSeparatedTables(repairMarkdownTables(normalizeMarkdownText(md))));
var codeBlocks = [];
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var idx = codeBlocks.length;
@ -12,13 +12,15 @@ export function renderAssistantMarkdown(md, sources, options) {
text = normalizeAdjacentCitationClusters(text, sources || []);
var html;
if (opts.marked && typeof opts.marked.parse === 'function') {
if (opts.markdownIt && typeof opts.markdownIt.render === 'function') {
html = opts.markdownIt.render(text);
} else 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 = renderCitationLinks(html, sources || [], opts);
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>';
@ -29,15 +31,19 @@ export function renderAssistantMarkdown(md, sources, options) {
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
}
export function renderCitationLinks(html, sources) {
export function renderCitationLinks(html, sources, options) {
var opts = options || {};
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) {
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(', ') + ']';
var page = source && (source.page || source.page_number || source.pageNumber);
var label = 'Source ' + n + ': ' + title + (page ? ', page ' + page : '');
var text = opts.citationLabel === 'number' ? String(n) : 'src';
return '<a class="assistant-cite" href="#assistant-source-' + n + '" data-source-number="' + n + '" title="' + escapeHtml(label) + '" aria-label="' + escapeAttr(label) + '">' + text + '</a>';
}).join(' ');
});
}
@ -85,10 +91,14 @@ export function stripSourcesSection(text) {
}
export function normalizeMarkdownText(text) {
return String(text || '')
return stripOrphanMarkdownMarkers(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(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s*/g, '$1\n- ')
.replace(/([.!?])\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
.replace(/(:)\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
.replace(/(\[(?:\d+\s*,\s*)*\d+\]\.)\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
.replace(/([.!?])\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
.replace(/(\[(?:\d+\s*,\s*)*\d+\])(?=\s*(?:[A-Z][A-Za-z]+\s+){1,4}(?:deficits?|distress|apnoea|apnea|vomiting|seizures?|signs?|symptoms?|criteria|indications?|risk|oxygen|saturation|dehydration|lethargy|toxicity)\b)/g, '$1\n')
.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')
@ -97,7 +107,173 @@ export function normalizeMarkdownText(text) {
.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();
.trim());
}
export function stripOrphanMarkdownMarkers(text) {
return String(text || '').replace(/(?:\n|^)\s*(?:\*\*|__|\*|_)\s*$/g, '').trim();
}
export function repairTabSeparatedTables(text) {
var lines = String(text || '').split('\n');
var out = [];
var i = 0;
while (i < lines.length) {
if (!/\t/.test(lines[i]) || i + 1 >= lines.length || !/\t/.test(lines[i + 1])) {
out.push(lines[i]);
i += 1;
continue;
}
if (out.length && out[out.length - 1].trim()) out.push('');
var tableLines = [];
while (i < lines.length && shouldKeepTabTableLine(lines[i], tableLines.length)) {
tableLines.push(lines[i]);
i += 1;
}
var header = tableLines[0].split('\t').map(function(cell) { return cell.trim(); });
var rows = tabTableRows(tableLines.slice(1), header.length);
out.push('| ' + header.join(' | ') + ' |');
out.push('| ' + header.map(function() { return '---'; }).join(' | ') + ' |');
rows.forEach(function(cells) {
out.push('| ' + cells.map(cleanTableCell).join(' | ') + ' |');
});
}
return out.join('\n');
}
function shouldKeepTabTableLine(line, count) {
if (/\t/.test(line)) return true;
if (!count || !String(line || '').trim()) return false;
if (/^\s*(#{1,6}\s+|References\b|Sources\b)/i.test(line)) return false;
return /^\s*(?:\d+\s*){1,4}$/.test(line) || /^[^.!?]{1,90}$/.test(line);
}
function tabTableRows(lines, width) {
var rows = [];
var pending = null;
lines.forEach(function(line) {
var cells = String(line || '').split('\t').map(function(cell) { return cell.trim(); });
if (cells.length === 1 && pending) {
pending[pending.length - 1] = [pending[pending.length - 1], cells[0]].filter(Boolean).join(' ');
return;
}
if (pending && cells.length >= width && looksCitationCell(cells[0]) && cells[width - 1]) {
pending[pending.length - 1] = [pending[pending.length - 1], cells[0]].filter(Boolean).join(' ');
rows.push(normalizeTabRow(pending, width));
pending = [cells[width - 1]];
return;
}
if (pending && cells.length >= width) {
rows.push(normalizeTabRow(pending, width));
pending = null;
}
if (cells.length >= width) {
rows.push(normalizeTabRow(cells, width));
return;
}
if (pending) {
pending[pending.length - 1] = [pending[pending.length - 1], cells.join(' ')].filter(Boolean).join(' ');
} else {
pending = cells;
}
});
if (pending) rows.push(normalizeTabRow(pending, width));
return rows;
}
function normalizeTabRow(cells, width) {
cells = cells.slice();
while (cells.length < width) cells.push('');
if (width === 3 && looksCitationCell(cells[0]) && cells[2]) {
return [cells[2], '', cells[0]];
}
if (width === 3 && /\b(\d+\s*){1,4}\s+(?:Nonoperative|Surgical|Diagnostic|Recurrence|Initial|Further)\b/i.test(cells[1] || '')) {
var split = splitCitationPrefix(cells[1]);
cells[1] = split.citation;
if (split.rest && cells[2]) cells[2] = split.rest + ': ' + cells[2];
}
return cells.slice(0, width);
}
function splitCitationPrefix(text) {
var m = String(text || '').match(/^((?:\d+\s*){1,4})\s+(.+)$/);
return m ? { citation: m[1].trim(), rest: m[2].trim() } : { citation: text, rest: '' };
}
function looksCitationCell(text) {
return /^(?:\d+\s*){1,5}$/.test(String(text || '').trim());
}
function cleanTableCell(cell) {
return String(cell || '').replace(/(?:^|\s+)If you need[\s\S]*$/i, '').trim();
}
export function repairMarkdownTables(text) {
var lines = String(text || '').split('\n');
var repaired = [];
var i = 0;
while (i < lines.length) {
if (isSplitTableHeader(lines, i)) {
lines[i] = mergeSplitTableRows(lines[i], lines[i + 1]);
lines.splice(i + 1, 1);
}
if (!isPotentialTableHeader(lines, i)) {
repaired.push(lines[i]);
i += 1;
continue;
}
var expected = tableCells(lines[i]).length;
if (repaired.length && repaired[repaired.length - 1].trim()) repaired.push('');
repaired.push(lines[i], normalizeTableSeparator(lines[i + 1], expected));
i += 2;
while (i < lines.length) {
var row = lines[i];
if (!row.trim()) { i += 1; continue; }
if (!/^\s*\|/.test(row)) break;
var merged = row;
var guard = 0;
while (tableCells(merged).length < expected && i + 1 < lines.length && /^\s*\|/.test(lines[i + 1]) && guard < 4) {
merged = mergeSplitTableRows(merged, lines[i + 1]);
i += 1;
guard += 1;
}
repaired.push(trimTableCellsToExpected(merged, expected));
i += 1;
}
continue;
}
return repaired.join('\n');
}
function isPotentialTableHeader(lines, idx) {
return idx + 1 < lines.length && /^\s*\|.*\|\s*$/.test(lines[idx]) && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[idx + 1]);
}
function isSplitTableHeader(lines, idx) {
return idx + 2 < lines.length && /^\s*\|.*\|?\s*$/.test(lines[idx]) && /^\s*\|.*\|\s*$/.test(lines[idx + 1]) && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[idx + 2]);
}
function normalizeTableSeparator(line, expected) {
var cells = tableCells(line);
while (cells.length < expected) cells.push('---');
return '| ' + cells.slice(0, expected).map(function(cell) { return /^:?-{2,}:?$/.test(cell) ? cell : '---'; }).join(' | ') + ' |';
}
function mergeSplitTableRows(left, right) {
var a = tableCells(left);
var b = tableCells(right);
if (!a.length) return right;
a[a.length - 1] = (a[a.length - 1] + ' ' + (b.shift() || '')).trim();
return '| ' + a.concat(b).join(' | ') + ' |';
}
function trimTableCellsToExpected(line, expected) {
var cells = tableCells(line);
while (cells.length < expected) cells.push('');
return '| ' + cells.slice(0, expected).join(' | ') + ' |';
}
export function fallbackMarkdown(text) {

View file

@ -20,6 +20,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
var lastGeneratedImageSrc = '';
var exportCacheKey = '';
var exportCacheItems = null;
var markdownRenderer = null;
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
@ -143,7 +144,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
})
.catch(function (err) {
setBusy(false, 'Error', true);
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nIf this is the first run, the backend `/api/clinical-assistant/chat` route still needs to be wired to direct MCP search.');
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.');
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
@ -228,14 +229,30 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
return wrap;
}
function renderMarkdown(md, sources) {
function renderMarkdown(md, sources, options) {
var opts = options || {};
return renderAssistantMarkdown(md, sources || [], {
marked: window.marked,
markdownIt: getMarkdownRenderer(),
katex: window.katex,
sanitize: sanitize
sanitize: sanitize,
citationLabel: opts.citationLabel
});
}
function getMarkdownRenderer() {
if (markdownRenderer) return markdownRenderer;
if (typeof window.markdownit === 'function') {
markdownRenderer = window.markdownit({
html: false,
linkify: true,
typographer: true,
breaks: true
});
return markdownRenderer;
}
return null;
}
function renderSources(sources) {
var wrap = document.getElementById('assistant-sources');
if (!wrap) return;
@ -508,32 +525,28 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to export', 'error'); return; }
var exportItems = collectExportItems();
var cacheKey = buildExportCacheKey(exportItems, lastGeneratedImageSrc);
var doc = openExportWindow();
if (!doc) { if (typeof showToast === 'function') showToast('Allow popups to export PDF', 'error'); return; }
if (exportCacheKey === cacheKey && exportCacheItems) {
openPrintableChatExport(exportCacheItems, lastGeneratedImageSrc);
writePrintableChatExport(doc, exportCacheItems, lastGeneratedImageSrc);
return;
}
setBusy(true, 'Preparing PDF...');
fetch('/api/clinical-assistant/export-summary', {
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin',
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');
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');
openPrintableChatExport(exportItems, lastGeneratedImageSrc);
});
exportCacheKey = cacheKey;
exportCacheItems = exportItems;
writePrintableChatExport(doc, exportItems, lastGeneratedImageSrc);
}
function openPrintableChatExport(items, imageSrc) {
function openExportWindow() {
var doc = window.open('', '_blank', 'width=900,height=1100');
if (!doc) { if (typeof showToast === 'function') showToast('Allow popups to export PDF', 'error'); return; }
if (!doc) return null;
doc.document.open();
doc.document.write('<!doctype html><html><head><title>Preparing Clinical Assistant Export</title><style>body{font-family:Arial,sans-serif;color:#111827;margin:36px;line-height:1.5}.spinner{width:18px;height:18px;border:3px solid #e5e7eb;border-top-color:#7c3aed;border-radius:50%;display:inline-block;vertical-align:middle;margin-right:8px;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}</style></head><body><p><span class="spinner"></span>Preparing PDF export...</p></body></html>');
doc.document.close();
return doc;
}
function writePrintableChatExport(doc, items, imageSrc) {
if (!doc || doc.closed) return;
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) {
@ -545,20 +558,24 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
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>' +
(summary ? '<h3>Summary</h3><div class="answer">' + renderMarkdown(summary, sources, { citationLabel: 'number' }) + '</div>' : '') +
'<h3>Full Generated Answer</h3><div class="answer full-answer">' + renderMarkdown(answer, sources, { citationLabel: 'number' }) + '</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: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>' +
'<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;break-after:avoid}h3{font-size:14px;margin:18px 0 8px;break-after:avoid}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{margin-top:20px;break-before:auto}.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;margin-top:8px;break-inside:auto}.refs li{margin:6px 0;break-inside:avoid}.assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}.answer th,.answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}.answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer th:last-child,.answer td:last-child{width:1%;white-space:nowrap}.answer tr{break-inside:avoid;page-break-inside:avoid}.answer thead{display:table-header-group}.answer tbody{display:table-row-group}.answer p{margin:8px 0}.answer ul,.answer ol{padding-left:20px}.answer li{margin:4px 0}@media print{button{display:none}body{margin:24mm}.export-section{break-inside:auto}.refs{break-before:avoid}}</style>' +
'</head><body><button id="assistant-export-print" type="button" style="float:right;padding:8px 12px">Print / Save PDF</button><h1>Clinical Assistant Export</h1>' +
'<div class="meta">Export generated ' + escapeHtml(new Date().toLocaleString()) + '</div>' +
imageHtml +
sections + '</body></html>';
doc.document.open();
doc.document.write(html);
doc.document.close();
try {
var printBtn = doc.document.getElementById('assistant-export-print');
if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); });
} catch (e) {}
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
}

View file

@ -208,6 +208,11 @@ app.get(['/', '/index.html'], function(req, res) {
app.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); });
app.use(loggingMiddleware);
app.use('/vendor/markdown-it', express.static(path.join(__dirname, 'node_modules', 'markdown-it', 'dist'), {
setHeaders: function(res) {
res.setHeader('Cache-Control', 'public, max-age=3600');
}
}));
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {

View file

@ -9,6 +9,7 @@ var express = require('express');
var axios = require('axios');
var fs = require('fs');
var path = require('path');
var { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
@ -16,12 +17,20 @@ var { callAI } = require('../utils/ai');
var { gatewayUrl } = require('../utils/errors');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto');
var redisCache = require('../utils/redis');
router.use(authMiddleware);
var MCP_URLS = buildMcpUrls();
var _lastGoodMcpUrl = MCP_URLS[0];
var DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources, cite claims with numbered citations like [1], and list sources at the bottom by title/resource and page. Do not invent citations.';
var MCP_INITIALIZE_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS, 30000);
var MCP_REQUEST_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS, 90000);
var MCP_SESSION_TTL_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS, 10 * 60 * 1000);
var _mcpSession = null;
var _mcpSessionPromise = null;
var _mcpCallQueue = Promise.resolve();
var _mcpRequestId = 1;
var DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations.';
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: [] };
@ -30,6 +39,10 @@ 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_S3_ENDPOINT = process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_ENDPOINT || '';
var MULTIMODAL_S3_ACCESS_KEY = process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_ACCESS_KEY || '';
var MULTIMODAL_S3_SECRET_KEY = process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_SECRET_KEY || '';
var _s3Client = null;
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;
@ -58,49 +71,62 @@ var EXAMPLE_CANDIDATES = [
];
var TOPIC_SUGGESTIONS = {
asthma: [
'Management of acute asthma exacerbation by severity',
'When to give magnesium sulfate in status asthmaticus',
'Bronchiolitis versus asthma in infants with wheeze',
'Discharge criteria after pediatric asthma exacerbation',
'Controller therapy step-up after ED asthma visit'
'How should acute pediatric asthma exacerbation severity guide SABA frequency, ipratropium, steroids, magnesium sulfate, and disposition?',
'When should magnesium sulfate, continuous albuterol, noninvasive ventilation, ICU transfer, or intubation be considered in status asthmaticus?',
'How can bronchiolitis, viral-induced wheeze, and early asthma be distinguished in infants and preschool children?',
'What discharge criteria, observation period, relapse risks, and follow-up are recommended after an ED asthma exacerbation?',
'When should controller therapy be started or stepped up after an ED asthma visit?'
],
bronchiolitis: [
'Supportive management of bronchiolitis',
'When bronchodilator trial is appropriate in bronchiolitis',
'Admission and oxygen criteria for bronchiolitis',
'Bronchiolitis versus early asthma',
'High-flow nasal cannula use in bronchiolitis'
'What supportive care, suctioning, hydration, oxygen targets, and feeding guidance are recommended for infant bronchiolitis?',
'When is a bronchodilator trial reasonable in bronchiolitis, and when should it be stopped?',
'What apnea risk, work of breathing, oxygen saturation, hydration, and social factors support admission for bronchiolitis?',
'How can bronchiolitis be distinguished from early asthma, pneumonia, cardiac disease, or foreign body aspiration?',
'When should high-flow nasal cannula, CPAP, ICU transfer, or escalation be considered in bronchiolitis?'
],
fever: [
'Fever without source by age group',
'Febrile neonate initial workup and antibiotics',
'Risk stratification for serious bacterial infection',
'When to do lumbar puncture in febrile infants',
'Fever in immunocompromised children'
'How should fever without source be evaluated by age group in neonates, young infants, and older children?',
'What cultures, inflammatory markers, LP decisions, empiric antibiotics, and disposition are recommended for febrile neonates?',
'Which clinical and laboratory features stratify risk for serious bacterial infection in febrile infants?',
'When is lumbar puncture indicated or avoidable in febrile infants by age and risk category?',
'How should fever be approached in immunocompromised children, central lines, or incomplete immunization?'
],
seizure: [
'Status epilepticus first-line management',
'Febrile seizure evaluation and counseling',
'Second-line antiseizure medications in children',
'When neuroimaging is indicated after seizure',
'Seizure mimics in pediatrics'
'What is the timed pediatric status epilepticus algorithm including benzodiazepine dosing and second-line choices?',
'How should simple versus complex febrile seizures be evaluated, treated, and explained to caregivers?',
'When should levetiracetam, fosphenytoin, phenobarbital, valproate, anesthetic infusion, or ICU transfer be used?',
'Which seizure presentations require neuroimaging, labs, toxicology, LP, EEG, or admission?',
'What common seizure mimics in children should be considered and how are they distinguished?'
],
croup: [
'Croup severity scoring and treatment',
'When to use nebulized epinephrine for croup',
'Dexamethasone dosing in croup',
'Observation after racemic epinephrine',
'Differential diagnosis for stridor'
'How should croup severity guide dexamethasone, nebulized epinephrine, observation, and disposition?',
'When is nebulized epinephrine indicated for croup and how long should the child be observed afterward?',
'What dexamethasone dose and route are recommended for mild, moderate, and severe croup?',
'What discharge, admission, ICU, and airway escalation criteria apply after croup treatment?',
'What alternative diagnoses should be considered in a child with stridor or atypical croup?'
],
dehydration: [
'Clinical assessment of pediatric dehydration',
'Oral rehydration therapy protocol',
'IV fluid bolus and maintenance strategy',
'Hypernatremic dehydration precautions',
'Discharge criteria after gastroenteritis'
'Which clinical signs best estimate mild, moderate, and severe dehydration in children?',
'What oral rehydration protocol, volumes, antiemetic use, and reassessment are recommended for gastroenteritis?',
'When are IV boluses, maintenance fluids, deficit replacement, glucose, or electrolyte checks indicated?',
'How should hypernatremic dehydration be recognized and corrected safely?',
'What discharge, admission, and follow-up criteria apply after pediatric gastroenteritis or dehydration?'
]
};
function positiveInt(value, fallback) {
var n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
if (process.env.CLINICAL_ASSISTANT_MCP_WARMUP !== 'false') {
setTimeout(function() {
getMcpSession().catch(function(e) {
console.warn('[clinical-assistant] MCP warmup skipped:', e.message);
});
}, positiveInt(process.env.CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS, 5000));
}
router.get('/clinical-assistant/status', async function(req, res) {
try {
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
@ -133,10 +159,19 @@ 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');
var raw = base64UrlDecode(req.params.token || '');
if (isS3Uri(raw)) {
var obj = await getS3Object(raw);
if (!obj) return res.status(404).send('Not found');
if (obj.Body && typeof obj.Body.pipe === 'function') return obj.Body.pipe(res);
var chunks = [];
for await (var chunk of obj.Body) chunks.push(chunk);
return res.send(Buffer.concat(chunks));
}
var imagePath = resolveRawMultimodalImagePath(raw);
if (!imagePath) return res.status(404).send('Not found');
res.sendFile(imagePath);
} catch (e) {
console.warn('[clinical-assistant] source image rejected:', e.message);
@ -234,8 +269,11 @@ router.post('/clinical-assistant/chat', async function(req, res) {
});
}
var history = Array.isArray(req.body.history) ? req.body.history.slice(-8) : [];
var contextualHistory = priorClinicalHistory(history, message);
var suggestions = getTopicSuggestions(message);
if (suggestions.length > 0 && message.split(/\s+/).length < 4) {
if (!contextualHistory.length && suggestions.length > 0 && message.split(/\s+/).length < 4) {
return res.json({
success: true,
answer: buildSuggestionAnswer(message, suggestions),
@ -246,7 +284,6 @@ router.post('/clinical-assistant/chat', async function(req, res) {
});
}
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;
@ -268,6 +305,11 @@ router.post('/clinical-assistant/chat', async function(req, res) {
normalizeMcpMultimodalResponse(multimodalResponse)
);
var rawResults = rawTextResults.concat(rawMultimodalResults);
console.info('[clinical-assistant] retrieval counts:', {
text: rawTextResults.length,
multimodal: rawMultimodalResults.length,
query: searchQuery
});
var visualSlots = rawMultimodalResults.length ? Math.min(2, Math.max(1, Math.floor(searchLimit / 4))) : 0;
var textSlots = searchLimit - visualSlots;
var sources = dedupeSources(
@ -298,9 +340,6 @@ router.post('/clinical-assistant/chat', async function(req, res) {
maxTokens: 1800
});
var answer = stripModelSourcesSection(String(ai.content || '').trim());
var normalized = normalizeCitationOrder(answer, sources);
answer = normalized.answer;
sources = normalized.sources;
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
category: 'clinical', model: ai.model || chatModel, duration: Date.now() - started
@ -390,6 +429,9 @@ async function rewriteSearchQuery(message, history, chatModel) {
}
if (!history.length || !needsContextualRewrite(message)) return message;
var deterministic = deterministicFollowupQuery(message, history);
if (deterministic) return deterministic;
var hist = history.map(function(m) {
return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 900);
}).join('\n');
@ -420,6 +462,35 @@ function needsContextualRewrite(message) {
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);
}
function priorClinicalHistory(history, message) {
var latest = String(message || '').trim();
return (Array.isArray(history) ? history : []).filter(function(m) {
return m && (m.role === 'user' || m.role === 'assistant') && m.content && String(m.content).trim() !== latest;
});
}
function deterministicFollowupQuery(message, history) {
var text = String(message || '').trim().toLowerCase();
if (!/^(dose|dosing|what dose|dose\?|how much|med dose|medication dose)$/i.test(text)) return '';
var prior = previousUserQuestion(history) || previousAssistantTopic(history);
if (!prior) return '';
return 'medication dosing and immediate treatment details for: ' + prior;
}
function previousUserQuestion(history) {
for (var i = history.length - 1; i >= 0; i--) {
if (history[i] && history[i].role === 'user' && history[i].content) return clip(history[i].content, 300);
}
return '';
}
function previousAssistantTopic(history) {
for (var i = history.length - 1; i >= 0; i--) {
if (history[i] && history[i].role === 'assistant' && history[i].content) return clip(history[i].content, 300);
}
return '';
}
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) {
@ -498,16 +569,67 @@ async function multimodalSearch(query, opts) {
async function indexedTopicSuggestions(limit) {
return callMcpTool('nc_indexed_topic_suggestions', {
limit: limit || 9,
sample_size: 900,
limit: limit || 12,
sample_size: 1500,
doc_type: 'file'
});
}
async function callMcpTool(name, args) {
var queued = _mcpCallQueue.then(function() {
return callMcpToolUnlocked(name, args);
});
_mcpCallQueue = queued.catch(function() {});
return queued;
}
async function callMcpToolUnlocked(name, args) {
var session = await getMcpSession();
var payload = {
jsonrpc: '2.0',
id: nextMcpRequestId(),
method: 'tools/call',
params: {
name: name,
arguments: args
}
};
var search;
try {
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
} catch (e) {
if (!isInvalidMcpSessionError(e)) throw e;
_mcpSession = null;
session = await getMcpSession();
payload.id = nextMcpRequestId();
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
}
return search.result || search;
}
function nextMcpRequestId() {
_mcpRequestId += 1;
if (_mcpRequestId > 1000000000) _mcpRequestId = 2;
return _mcpRequestId;
}
async function getMcpSession() {
var now = Date.now();
if (_mcpSession && _mcpSession.sessionId && _mcpSession.expiresAt > now) return _mcpSession;
if (_mcpSessionPromise) return _mcpSessionPromise;
_mcpSessionPromise = initializeMcpSession().then(function(session) {
_mcpSession = session;
return session;
}).finally(function() {
_mcpSessionPromise = null;
});
return _mcpSessionPromise;
}
async function initializeMcpSession() {
var session = await mcpRequest({
jsonrpc: '2.0',
id: 1,
id: nextMcpRequestId(),
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
@ -515,16 +637,8 @@ async function callMcpTool(name, args) {
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
}
});
var search = await mcpRequest({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: name,
arguments: args
}
}, session.sessionId, session.mcpUrl);
return search.result || search;
session.expiresAt = Date.now() + MCP_SESSION_TTL_MS;
return session;
}
async function mcpRequest(payload, sessionId, preferredUrl) {
@ -551,7 +665,7 @@ async function postMcpWithRetry(payload, headers, preferredUrl) {
try {
var resp = await axios.post(url, payload, {
headers: headers,
timeout: isInitialize ? 8000 : 90000,
timeout: isInitialize ? MCP_INITIALIZE_TIMEOUT_MS : MCP_REQUEST_TIMEOUT_MS,
responseType: 'text',
transformResponse: [function(data) { return data; }]
});
@ -582,6 +696,12 @@ function isTransientMcpError(e) {
return code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ETIMEDOUT' || status === 502 || status === 503 || status === 504;
}
function isInvalidMcpSessionError(e) {
var status = e && e.response && e.response.status;
var msg = String(e && e.message || '');
return status === 400 || status === 404 || status === 410 || /session|mcp-session-id/i.test(msg);
}
async function getMcpHealth() {
var results = [];
var urls = orderedMcpUrls();
@ -707,6 +827,9 @@ function normalizeMcpMultimodalResponse(result) {
visual_caption: caption,
visual_labels: labels,
visual_caption_source: r.visual_caption_source || '',
is_cover_page: Boolean(r.is_cover_page || r.isCoverPage),
is_title_page: Boolean(r.is_title_page || r.isTitlePage),
reject_for_visual_search: Boolean(r.reject_for_visual_search || r.rejectForVisualSearch),
excerpt: excerptParts.length ? '[Page-image match] ' + excerptParts.join('\n') : '[Page-image match] Rendered PDF page matched the visual/text query.',
score: r.score,
image_path_internal: r.image_path || '',
@ -718,17 +841,40 @@ function normalizeMcpMultimodalResponse(result) {
}
function dedupeSources(results) {
var seen = new Set();
var seen = new Map();
var out = [];
results.forEach(function(r) {
var key = [r.title, r.page || '', r.chunk_index || ''].join('|');
if (seen.has(key)) return;
seen.add(key);
var key = sourceDedupeKey(r);
var existingIndex = seen.get(key);
if (existingIndex != null) {
out[existingIndex] = mergeDuplicateSource(out[existingIndex], r);
return;
}
seen.set(key, out.length);
out.push(Object.assign({}, r, { number: out.length + 1 }));
});
return out;
}
function sourceDedupeKey(source) {
var title = cleanTitle(source && source.title || '').toLowerCase();
var page = source && source.page ? String(source.page) : '';
var kind = source && source.source_type ? source.source_type : 'text';
if (title && page) return [kind, title, page].join('|');
return [kind, title, source && source.id || source && source.chunk_index || ''].join('|');
}
function mergeDuplicateSource(existing, duplicate) {
var merged = Object.assign({}, existing);
var existingExcerpt = cleanSourceExcerpt(existing.excerpt || '');
var duplicateExcerpt = cleanSourceExcerpt(duplicate.excerpt || '');
if (duplicateExcerpt && existingExcerpt.indexOf(duplicateExcerpt.slice(0, 160)) === -1) {
merged.excerpt = clip([existingExcerpt, duplicateExcerpt].filter(Boolean).join('\n'), 1800);
}
merged.score = Math.max(Number(existing.score) || 0, Number(duplicate.score) || 0) || existing.score || duplicate.score;
return merged;
}
function formatSourcesForPrompt(sources) {
return sources.map(function(s) {
var label = s.source_type === 'multimodal_page' ? ' [visual PDF page match]' : '';
@ -737,7 +883,7 @@ function formatSourcesForPrompt(sources) {
}
function buildSystemPrompt(behavior) {
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- Use numbered citations [1], [2] for factual claims, matching the provided source numbers.\n- If sources disagree or are insufficient, say so.\n- Do not cite a source number that is not provided.\n- Keep the main answer concise and clinically useful.\n- 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.';
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- Use the exact source numbers from the retrieved indexed sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3].\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- If a claim is not directly supported by retrieved sources, omit it or state that the indexed sources are insufficient.\n- Do not cite a source number that is not provided.\n- If sources disagree or are insufficient, say so.\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, searchQuery) {
@ -754,44 +900,6 @@ function stripModelSourcesSection(answer) {
.trim();
}
function normalizeCitationOrder(answer, sources) {
sources = Array.isArray(sources) ? sources : [];
var used = [];
var oldToNew = {};
var re = /\[((?:\d+\s*,\s*)*\d+)\]/g;
var m;
while ((m = re.exec(answer))) {
parseCitationCluster(m[1]).forEach(function(oldNum) {
if (!sources[oldNum - 1] || oldToNew[oldNum]) return;
oldToNew[oldNum] = used.length + 1;
used.push(oldNum);
});
}
var reordered = [];
used.forEach(function(oldNum) { reordered.push(sources[oldNum - 1]); });
sources.forEach(function(source, idx) {
if (used.indexOf(idx + 1) === -1) reordered.push(source);
});
reordered = reordered.map(function(source, idx) {
return Object.assign({}, source, { number: idx + 1 });
});
var rewritten = answer.replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function(match, cluster) {
var oldNums = parseCitationCluster(cluster);
var nums = oldNums.map(function(oldNum) { return oldToNew[oldNum]; });
if (!nums.length || nums.some(function(n) { return !n; })) return match;
nums = Array.from(new Set(nums)).sort(function(a, b) { return a - b; });
return '[' + nums.join(', ') + ']';
});
return { answer: rewritten, sources: reordered };
}
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 visualIntent(query).wanted.length > 0;
}
@ -813,6 +921,8 @@ async function classifyAndRerankMultimodalResults(query, results) {
results = Array.isArray(results) ? results : [];
if (!results.length) return [];
var candidates = results.slice(0, VISUAL_CLASSIFIER_LIMIT);
candidates = candidates.filter(function(candidate) { return !shouldRejectVisualSource(query, candidate); });
if (!candidates.length) return [];
if (!VISUAL_CLASSIFIER_ENABLED) return selectMultimodalResults(query, candidates);
var intent = visualIntent(query);
var classified = [];
@ -842,6 +952,8 @@ async function classifyAndRerankMultimodalResults(query, results) {
function selectMultimodalResults(query, results) {
results = Array.isArray(results) ? results : [];
if (!results.length) return [];
results = results.filter(function(r) { return !shouldRejectVisualSource(query, r); });
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) {
@ -909,10 +1021,33 @@ function visualMetadataScore(query, source) {
if (intent.wanted.indexOf('diagram') !== -1 && /\b(figure|diagram|algorithm|flowchart|pathway|chart|illustration|infographic)\b/i.test(haystack)) score += 0.12;
if (intent.wanted.indexOf('photo') !== -1 && /\b(photo|photograph|skin|rash|lesion|wound|gross|microscopy|histology)\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 (looksLikeFrontMatterPage(source)) score -= 0.5;
if (looksLikeTextOnlyPage(source)) score -= 0.18;
return score;
}
function shouldRejectVisualSource(query, source) {
if (!source) return false;
if (allowsFrontMatterQuery(query)) return false;
return Boolean(source.reject_for_visual_search || source.is_cover_page || source.is_title_page || looksLikeFrontMatterPage(source));
}
function allowsFrontMatterQuery(query) {
return /\b(cover|front cover|title page|copyright|table of contents|contents|preface|foreword|publisher|isbn)\b/i.test(String(query || ''));
}
function looksLikeFrontMatterPage(source) {
var page = Number(source && source.page) || 0;
var pageCount = Number(source && source.page_count) || 0;
var earlyLimit = Math.min(8, Math.max(2, pageCount || 8));
if (!page || page > earlyLimit) return false;
var haystack = [source.title, source.excerpt, source.visual_caption, (source.visual_labels || []).join(' '), source.file_path].filter(Boolean).join(' ');
var hasVisualSignal = /\b(figure|fig\.?|radiograph|x-?ray|cxr|ultrasound|ct|mri|photograph|photo|image|diagram|flowchart|algorithm|histology|microscopy|gross pathology)\b/i.test(haystack);
if (hasVisualSignal) return false;
var frontMatterSignal = /\b(cover|front cover|title page|copyright|all rights reserved|isbn|library of congress|table of contents|contents|preface|foreword|dedication|contributors?|textbook of|manual of|review of|board review|edition|authors?|editor|publisher)\b/i.test(haystack);
return frontMatterSignal || page <= 2;
}
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);
@ -960,6 +1095,38 @@ function resolveRawMultimodalImagePath(raw) {
return servedPath;
}
function isS3Uri(value) {
return /^s3:\/\/[^/]+\/.+/.test(String(value || ''));
}
function parseS3Uri(uri) {
var m = String(uri || '').match(/^s3:\/\/([^/]+)\/(.+)$/);
if (!m) return null;
return { bucket: m[1], key: m[2] };
}
function getS3Client() {
if (_s3Client) return _s3Client;
if (!MULTIMODAL_S3_ENDPOINT || !MULTIMODAL_S3_ACCESS_KEY || !MULTIMODAL_S3_SECRET_KEY) return null;
_s3Client = new S3Client({
endpoint: MULTIMODAL_S3_ENDPOINT,
region: process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_REGION || 'us-east-1',
forcePathStyle: true,
credentials: {
accessKeyId: MULTIMODAL_S3_ACCESS_KEY,
secretAccessKey: MULTIMODAL_S3_SECRET_KEY
}
});
return _s3Client;
}
async function getS3Object(uri) {
var parsed = parseS3Uri(uri);
var client = getS3Client();
if (!parsed || !client) return null;
return client.send(new GetObjectCommand({ Bucket: parsed.bucket, Key: parsed.key }));
}
function sanitizeSourcesForClient(sources) {
return (Array.isArray(sources) ? sources : []).map(function(source) {
var out = Object.assign({}, source);
@ -1072,6 +1239,11 @@ function cleanExportHeading(heading, idx) {
async function getAvailableExamples() {
var now = Date.now();
if (_exampleCache.expiresAt > now && _exampleCache.examples.length) return _exampleCache.examples;
var cached = await redisCache.getJson('clinical-assistant:examples:v1').catch(function() { return null; });
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
_exampleCache = { expiresAt: now + EXAMPLE_CACHE_MS, examples: cached.examples };
return cached.examples;
}
var indexedExamples = await getIndexedTopicExamples().catch(function(e) {
console.warn('[clinical-assistant] indexed topic suggestions skipped:', e.message);
@ -1079,6 +1251,7 @@ async function getAvailableExamples() {
});
if (indexedExamples.length >= 3) {
_exampleCache = { expiresAt: now + EXAMPLE_CACHE_MS, examples: indexedExamples };
redisCache.setJson('clinical-assistant:examples:v1', { examples: indexedExamples }, 1800).catch(function() {});
return indexedExamples;
}
@ -1110,11 +1283,12 @@ async function getAvailableExamples() {
expiresAt: now + EXAMPLE_CACHE_MS,
examples: examples
};
redisCache.setJson('clinical-assistant:examples:v1', { examples: examples }, 1800).catch(function() {});
return examples;
}
async function getIndexedTopicExamples() {
var response = await indexedTopicSuggestions(9);
var response = await indexedTopicSuggestions(12);
var data = response && (response.structuredContent || response.data || response);
if ((!data || !Array.isArray(data.suggestions)) && response && Array.isArray(response.content)) {
for (var i = 0; i < response.content.length; i++) {
@ -1128,7 +1302,7 @@ async function getIndexedTopicExamples() {
}
}
if (!data || !Array.isArray(data.suggestions)) return [];
return data.suggestions.slice(0, 9).map(function(item) {
return data.suggestions.slice(0, 12).map(function(item) {
return {
label: item.label || 'Indexed topic',
prompt: item.prompt || '',
@ -1148,10 +1322,11 @@ async function generateImage(prompt, model) {
if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation');
var headers = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY;
var renderedPrompt = imagePromptForCanvas(prompt);
var size = process.env.CLINICAL_ASSISTANT_IMAGE_SIZE || 'auto';
var resp = await generateImageRequest(model, imagePromptForCanvas(prompt), size, headers).catch(async function(e) {
var resp = await generateImageRequest(model, renderedPrompt, size, headers).catch(async function(e) {
if (!isInvalidImageSizeError(e) || size === '1024x1024') throw e;
return generateImageRequest(model, imagePromptForCanvas(prompt), '1024x1024', headers);
return generateImageRequest(model, renderedPrompt, '1024x1024', headers);
});
var item = resp.data && resp.data.data && resp.data.data[0] ? resp.data.data[0] : {};
return { imageUrl: item.url || null, base64: item.b64_json || null, raw: (!item.url && !item.b64_json) ? resp.data : undefined };
@ -1167,12 +1342,12 @@ function generateImageRequest(model, prompt, size, headers) {
function imagePromptForCanvas(prompt) {
var text = String(prompt || '');
var guidance = ' Keep all text and boxes fully inside the canvas with generous margins. Use fewer words per box, large readable type, and avoid cropping at edges.';
var guidance = ' Compose as a single complete medical teaching poster. Keep every element fully inside the canvas with a 10% safe margin on all sides. Do not crop boxes, arrows, labels, legends, or body parts. Use fewer words per box, large readable type, and generous spacing.';
if (/\b(flow\s*chart|flowchart|algorithm|pathway|timeline|vertical|stepwise|decision\s*tree|age\s*group|0-21|22-28|29-60)\b/i.test(text)) {
guidance += ' Prefer a tall portrait canvas with top-to-bottom flow and ample spacing between decision nodes.';
guidance += ' Use a tall portrait layout with top-to-bottom flow, no more than 6-8 main nodes, and ample spacing between decision nodes.';
}
if (/\b(table|matrix|comparison|wide|landscape|side-by-side)\b/i.test(text)) {
guidance += ' Prefer a wide landscape canvas with columns and ample horizontal spacing.';
guidance += ' Use a wide landscape layout with compact columns, ample horizontal spacing, and no text near the edges.';
}
return text.trim() + guidance;
}
@ -1233,18 +1408,18 @@ function getTopicSuggestions(message) {
}
if (normalized.split(/\s+/).length <= 2) {
return [
'Management of ' + normalized,
'Red flags and differential diagnosis for ' + normalized,
'Initial ED evaluation for ' + normalized,
'Admission versus discharge criteria for ' + normalized,
'Medication dosing and escalation for ' + normalized
'What initial ED evaluation, severity assessment, and immediate stabilization steps are recommended for pediatric ' + normalized + '?',
'Which red flags, differential diagnoses, and complications should not be missed in pediatric ' + normalized + '?',
'What treatment escalation, medication dosing, and monitoring are recommended for pediatric ' + normalized + '?',
'What admission, discharge, observation, and follow-up criteria are recommended for pediatric ' + normalized + '?',
'How do recommendations differ by age group, severity, comorbidity, or high-risk features in pediatric ' + normalized + '?'
];
}
return [];
}
function buildSuggestionAnswer(topic, suggestions) {
return 'I can look up **' + topic + '**. Pick a focused question so I can retrieve the most relevant cited pages:\n\n' +
return 'I can look up **' + topic + '**. Pick a specific clinical question so I retrieve the right cited pages instead of giving a broad summary:\n\n' +
suggestions.map(function(s) { return '- ' + s; }).join('\n') +
'\n\nOr type your own specific question, for example: "management of acute asthma exacerbation in a 6-year-old".';
}

41
src/utils/redis.js Normal file
View file

@ -0,0 +1,41 @@
var { createClient } = require('redis');
var client = null;
var connecting = null;
async function getRedis() {
var url = process.env.REDIS_URL;
if (!url) return null;
if (client && client.isOpen) return client;
if (connecting) return connecting;
client = createClient({ url: url });
client.on('error', function(err) {
console.warn('[redis]', err.message);
});
connecting = client.connect().then(function() {
connecting = null;
return client;
}).catch(function(err) {
connecting = null;
console.warn('[redis] unavailable:', err.message);
return null;
});
return connecting;
}
async function getJson(key) {
var redis = await getRedis();
if (!redis) return null;
var value = await redis.get(key);
if (!value) return null;
try { return JSON.parse(value); } catch (e) { return null; }
}
async function setJson(key, value, ttlSeconds) {
var redis = await getRedis();
if (!redis) return false;
await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
return true;
}
module.exports = { getRedis, getJson, setJson };

View file

@ -2,6 +2,7 @@ const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const MarkdownIt = require('markdown-it');
let modulePromise;
@ -24,8 +25,16 @@ test('renders citation clusters as links to matching source cards', async () =>
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>\]/);
assert.match(html, /title="Source 1: Nelson Textbook of Pediatrics"/);
assert.match(html, /data-source-number="1"/);
assert.match(html, /data-source-number="2"/);
assert.match(html, /<a class="assistant-cite"[^>]*>src<\/a> <a class="assistant-cite"[^>]*>src<\/a>/);
});
test('can render citation labels as numbers for PDF export', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Give magnesium for severe exacerbation [1, 2].', sources, { citationLabel: 'number' });
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 () => {
@ -38,9 +47,9 @@ test('leaves unknown citation clusters untouched rather than guessing', async ()
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>/);
assert.match(html, /oxygen <a class="assistant-cite"[^>]*data-source-number="1"[^>]*>src<\/a>/);
assert.match(html, /bronchodilator <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>src<\/a>/);
assert.doesNotMatch(html, /data-source-number="1"[^>]*>src<\/a><a class="assistant-cite"[^>]*data-source-number="2"/);
});
test('sorts adjacent citation tokens into one safe Vancouver cluster', async () => {
@ -48,7 +57,7 @@ test('sorts adjacent citation tokens into one safe Vancouver cluster', async ()
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.match(html, /data-source-number="1"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>src<\/a>/);
assert.doesNotMatch(html, /\]<span|\]\[/);
});
@ -57,7 +66,7 @@ test('repairs a clearly adjacent trailing citation missing its closing bracket',
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.match(html, /data-source-number="1"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>src<\/a>/);
});
test('does not normalize adjacent citations if any source number is unknown', async () => {
@ -70,7 +79,7 @@ test('does not normalize adjacent citations if any source number is unknown', as
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.match(html, /title="Source 1: Bad &quot;Title&quot; &lt;script&gt;"/);
assert.doesNotMatch(html, /<script>/);
});
@ -120,9 +129,76 @@ test('repairs smashed admission bullet list after citations', async () => {
assert.doesNotMatch(html, /\]-/);
});
test('repairs smashed head injury red flag bullets after citation clusters', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('CT is indicated for: [1, 2, 3]- Focal neurologic deficits [1]- Signs of skull fracture [2]- Recurrent vomiting [3]', sources);
assert.match(html, /<li>Focal neurologic deficits/);
assert.match(html, /<li>Signs of skull fracture/);
assert.match(html, /<li>Recurrent vomiting/);
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/);
});
test('repairs model-split markdown tables before rendering', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Summary Table\n| Indication for Use | Dose (IV) | Max Dose\n\n| Infusion Time | Monitoring/Precautions |\n|-----------------------------------------|----------------------------|----------|--------------|---------------------------------------|\n| Severe exacerbation unresponsive to SABA/anticholinergic | 25-75 mg/kg | 2 g\n| 20 min | BP, reflexes, respiratory status[1, 2] |', sources);
assert.match(html, /<table>/);
assert.match(html, /Max Dose Infusion Time/);
assert.match(html, /2 g 20 min/);
assert.doesNotMatch(html, /<p>\| Infusion Time/);
});
test('breaks inline numbered clinical sections onto new lines', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Monitor with serial abdominal exams [1]. 2. Imaging\nImmediate abdominal X-ray for obstruction [1, 2]. 3. Laboratory Studies\nAssess electrolytes if ill-appearing [3]. 4. Surgical Consultation\nUrgent pediatric surgical consultation [1].', sources);
assert.match(html, /2\. Imaging/);
assert.match(html, /3\. Laboratory Studies/);
assert.match(html, /4\. Surgical Consultation/);
assert.doesNotMatch(html, /exams[^<]*2\. Imaging/);
assert.doesNotMatch(html, /obstruction[^<]*3\. Laboratory Studies/);
});
test('strips orphan trailing markdown emphasis markers', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Summary Table\n\nAge Group\tEmpiric Antibiotics\n<1 month\tAmpicillin + cefotaxime\n**', sources);
assert.match(html, /Summary Table/);
assert.doesNotMatch(html, /\*\*/);
});
test('uses markdown-it stack for GFM-style tables when provided', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const html = renderAssistantMarkdown('| Age | Antibiotics |\n|---|---|\n| <1 month | Ampicillin + cefotaxime [1] |', sources, { markdownIt });
assert.match(html, /<table>/);
assert.match(html, /<td>&lt;1 month<\/td>/);
assert.match(html, /assistant-cite/);
});
test('renders tab-separated summary tables and removes trailing emphasis junk', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const input = 'Summary Table\nSituation\tImaging Recommended\tTiming/Notes\nFirst febrile UTI\tRenal/bladder ultrasound\tAfter recovery\nRoutine use of DMSA scan\tNot recommended\t\n**';
const html = renderAssistantMarkdown(input, sources, { markdownIt });
assert.match(html, /<table>/);
assert.match(html, /<th>Situation<\/th>/);
assert.match(html, /<td>Routine use of DMSA scan<\/td>/);
assert.doesNotMatch(html, /\*\*/);
});
test('repairs split tab-separated clinical summary rows', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
const input = 'Summary Table\nStep\tDetails\tSource\nRed Flags\tShock, peritonitis, distension\t3 4 7\nInitial Imaging\tAbdominal X-ray if perforation suspected, then ultrasound\n2 3 7\t\tDiagnostic Gold Std\nAbdominal ultrasound target sign\t2 3 7 Nonoperative Reduction\tAir or contrast enema if stable\nSurgical Indications\tShock, peritonitis, perforation, failed reduction\n1 3 7\t\tRecurrence Rate\n~10% after nonsurgical reduction\t5 6 7\tIf you need details on recurrence management, please specify.';
const html = renderAssistantMarkdown(input, sources, { markdownIt, citationLabel: 'number' });
assert.match(html, /<table>/);
assert.match(html, /<td>Initial Imaging<\/td>/);
assert.match(html, /<td>Diagnostic Gold Std<\/td>/);
assert.match(html, /<td>Recurrence Rate<\/td>/);
assert.doesNotMatch(html, /If you need details/);
});