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 - .env
environment: environment:
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp 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: volumes:
- scribe-logs:/app/data/logs - scribe-logs:/app/data/logs
- clinical-assistant-mcp-data:/app/mcp-data:ro - clinical-assistant-mcp-data:/app/mcp-data:ro
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
container_name: pediatric-ai-scribe container_name: pediatric-ai-scribe
networks: networks:
@ -46,9 +52,26 @@ services:
retries: 5 retries: 5
start_period: 10s 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: volumes:
pgdata: pgdata:
scribe-logs: scribe-logs:
redis-data:
clinical-assistant-mcp-data: clinical-assistant-mcp-data:
external: true external: true
name: mcp-server_mcp-data name: mcp-server_mcp-data

106
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.0.0", "version": "7.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.0.0", "version": "7.7.0",
"dependencies": { "dependencies": {
"@marp-team/marp-cli": "^4.3.1", "@marp-team/marp-cli": "^4.3.1",
"@marp-team/marp-core": "^4.3.0", "@marp-team/marp-core": "^4.3.0",
@ -28,6 +28,7 @@
"helmet": "^8.0.0", "helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mammoth": "^1.8.0", "mammoth": "^1.8.0",
"markdown-it": "^14.1.1",
"marked": "^18.0.2", "marked": "^18.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-pg-migrate": "^7.7.0", "node-pg-migrate": "^7.7.0",
@ -38,6 +39,7 @@
"pg": "^8.13.0", "pg": "^8.13.0",
"pptxgenjs": "^4.0.1", "pptxgenjs": "^4.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"redis": "^4.7.1",
"speakeasy": "^2.0.0" "speakeasy": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -2471,6 +2473,65 @@
"node": ">=12" "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": { "node_modules/@remirror/core-constants": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
@ -4276,6 +4337,15 @@
"wrap-ansi": "^6.2.0" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -5322,6 +5392,15 @@
"node": ">=14" "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": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -7746,6 +7825,23 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -8745,6 +8841,12 @@
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC" "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": { "node_modules/yargs": {
"version": "15.4.1", "version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",

View file

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

View file

@ -132,7 +132,8 @@
.assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; } .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 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; } } @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 { 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, .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); } .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" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script> crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"> <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> <script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" defer></script>
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb"> <meta name="theme-color" content="#2563eb">

View file

@ -665,7 +665,7 @@
(function() { (function() {
var loaded = false; var loaded = false;
var defaults = { 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) { document.addEventListener('tabChanged', function(e) {

View file

@ -1,6 +1,6 @@
export function renderAssistantMarkdown(md, sources, options) { export function renderAssistantMarkdown(md, sources, options) {
var opts = options || {}; var opts = options || {};
var text = normalizeMarkdownText(md); var text = stripOrphanMarkdownMarkers(repairTabSeparatedTables(repairMarkdownTables(normalizeMarkdownText(md))));
var codeBlocks = []; var codeBlocks = [];
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) { text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var idx = codeBlocks.length; var idx = codeBlocks.length;
@ -12,13 +12,15 @@ export function renderAssistantMarkdown(md, sources, options) {
text = normalizeAdjacentCitationClusters(text, sources || []); text = normalizeAdjacentCitationClusters(text, sources || []);
var html; 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 }); html = opts.marked.parse(text, { breaks: true, gfm: true });
} else { } else {
html = fallbackMarkdown(text); html = fallbackMarkdown(text);
} }
html = renderCitationLinks(html, sources || []); html = renderCitationLinks(html, sources || [], opts);
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) { html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
var block = codeBlocks[Number(idx)] || { lang: '', code: '' }; var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>'; if (block.lang === '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; 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) { 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; }); 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; 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 source = sources[n - 1];
var title = source ? source.title || source.resource || 'Source' : 'Source'; var title = source ? source.title || source.resource || 'Source' : 'Source';
return '<a class="assistant-cite" href="#assistant-source-' + n + '" title="' + escapeHtml(title) + '">' + n + '</a>'; var page = source && (source.page || source.page_number || source.pageNumber);
}).join(', ') + ']'; 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) { export function normalizeMarkdownText(text) {
return String(text || '') return stripOrphanMarkdownMarkers(String(text || '')
.replace(/\r\n/g, '\n') .replace(/\r\n/g, '\n')
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*-\s+/g, '$1\n- ') .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(/(:)\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(/([^\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]+?)\s+(-\s+)/g, '$1\n\n$2')
.replace(/(#{1,4}\s+[^\n]+)\n(-\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(/([^\n])\s+(\|\s*[-:]+\s*\|)/g, '$1\n$2')
.replace(/\s+(\|\s*[^\n|]+\s*\|\s*[^\n|]+\s*\|)/g, '\n$1') .replace(/\s+(\|\s*[^\n|]+\s*\|\s*[^\n|]+\s*\|)/g, '\n$1')
.replace(/([^\n])\s+(-\s+[^\n])/g, '$1\n$2') .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) { export function fallbackMarkdown(text) {

View file

@ -20,6 +20,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
var lastGeneratedImageSrc = ''; var lastGeneratedImageSrc = '';
var exportCacheKey = ''; var exportCacheKey = '';
var exportCacheItems = null; var exportCacheItems = null;
var markdownRenderer = null;
document.addEventListener('tabChanged', function (e) { document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'assistant') initIfNeeded(); if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
@ -143,7 +144,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
}) })
.catch(function (err) { .catch(function (err) {
setBusy(false, 'Error', true); 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'); if (typeof showToast === 'function') showToast(err.message, 'error');
}); });
} }
@ -228,14 +229,30 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
return wrap; return wrap;
} }
function renderMarkdown(md, sources) { function renderMarkdown(md, sources, options) {
var opts = options || {};
return renderAssistantMarkdown(md, sources || [], { return renderAssistantMarkdown(md, sources || [], {
marked: window.marked, markdownIt: getMarkdownRenderer(),
katex: window.katex, 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) { function renderSources(sources) {
var wrap = document.getElementById('assistant-sources'); var wrap = document.getElementById('assistant-sources');
if (!wrap) return; 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; } if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to export', 'error'); return; }
var exportItems = collectExportItems(); var exportItems = collectExportItems();
var cacheKey = buildExportCacheKey(exportItems, lastGeneratedImageSrc); 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) { if (exportCacheKey === cacheKey && exportCacheItems) {
openPrintableChatExport(exportCacheItems, lastGeneratedImageSrc); writePrintableChatExport(doc, exportCacheItems, lastGeneratedImageSrc);
return; return;
} }
setBusy(true, 'Preparing PDF...'); exportCacheKey = cacheKey;
fetch('/api/clinical-assistant/export-summary', { exportCacheItems = exportItems;
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin', writePrintableChatExport(doc, exportItems, lastGeneratedImageSrc);
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);
});
} }
function openPrintableChatExport(items, imageSrc) { function openExportWindow() {
var doc = window.open('', '_blank', 'width=900,height=1100'); 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(); 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 imageHtml = imageSrc ? '<h2>Generated Image</h2><div class="export-image"><img src="' + escapeAttr(imageSrc) + '" alt="Generated clinical visual"></div>' : '';
var sections = items.map(function (item, idx) { var sections = items.map(function (item, idx) {
@ -545,20 +558,24 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
return '<section class="export-section">' + return '<section class="export-section">' +
'<h2>' + escapeHtml(heading) + '</h2>' + '<h2>' + escapeHtml(heading) + '</h2>' +
'<div class="question"><strong>Question:</strong> ' + escapeHtml(item.question || '') + '</div>' + '<div class="question"><strong>Question:</strong> ' + escapeHtml(item.question || '') + '</div>' +
(summary ? '<h3>Summary</h3><div class="answer">' + renderMarkdown(summary, 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) + '</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>' : '') + (refs ? '<h3>References</h3><ol class="refs">' + refs + '</ol>' : '') +
'</section>'; '</section>';
}).join(''); }).join('');
var html = '<!doctype html><html><head><title>Clinical Assistant Export</title>' + 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>' + '<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 onclick="window.print()" style="float:right;padding:8px 12px">Print / Save PDF</button><h1>Clinical Assistant Export</h1>' + '</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>' + '<div class="meta">Export generated ' + escapeHtml(new Date().toLocaleString()) + '</div>' +
imageHtml + imageHtml +
sections + '</body></html>'; sections + '</body></html>';
doc.document.open(); doc.document.open();
doc.document.write(html); doc.document.write(html);
doc.document.close(); 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); 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.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); });
app.use(loggingMiddleware); 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'), { app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, filePath) => { setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) { if (filePath.endsWith('.html')) {

View file

@ -9,6 +9,7 @@ var express = require('express');
var axios = require('axios'); var axios = require('axios');
var fs = require('fs'); var fs = require('fs');
var path = require('path'); var path = require('path');
var { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
var router = express.Router(); var router = express.Router();
var db = require('../db/database'); var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth'); var { authMiddleware } = require('../middleware/auth');
@ -16,12 +17,20 @@ var { callAI } = require('../utils/ai');
var { gatewayUrl } = require('../utils/errors'); var { gatewayUrl } = require('../utils/errors');
var logger = require('../utils/logger'); var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto'); var cryptoUtil = require('../utils/crypto');
var redisCache = require('../utils/redis');
router.use(authMiddleware); router.use(authMiddleware);
var MCP_URLS = buildMcpUrls(); var MCP_URLS = buildMcpUrls();
var _lastGoodMcpUrl = MCP_URLS[0]; 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 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 EXAMPLE_CACHE_MS = 10 * 60 * 1000;
var _exampleCache = { expiresAt: 0, examples: [] }; var _exampleCache = { expiresAt: 0, examples: [] };
@ -30,6 +39,10 @@ var MAX_SAVED_CHAT_PAYLOAD = 250000;
var MAX_SAVED_CHAT_TITLE = 160; 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_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 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 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_ENABLED = process.env.CLINICAL_ASSISTANT_VISUAL_CLASSIFIER !== 'false';
var VISUAL_CLASSIFIER_LIMIT = 6; var VISUAL_CLASSIFIER_LIMIT = 6;
@ -58,49 +71,62 @@ var EXAMPLE_CANDIDATES = [
]; ];
var TOPIC_SUGGESTIONS = { var TOPIC_SUGGESTIONS = {
asthma: [ asthma: [
'Management of acute asthma exacerbation by severity', 'How should acute pediatric asthma exacerbation severity guide SABA frequency, ipratropium, steroids, magnesium sulfate, and disposition?',
'When to give magnesium sulfate in status asthmaticus', 'When should magnesium sulfate, continuous albuterol, noninvasive ventilation, ICU transfer, or intubation be considered in status asthmaticus?',
'Bronchiolitis versus asthma in infants with wheeze', 'How can bronchiolitis, viral-induced wheeze, and early asthma be distinguished in infants and preschool children?',
'Discharge criteria after pediatric asthma exacerbation', 'What discharge criteria, observation period, relapse risks, and follow-up are recommended after an ED asthma exacerbation?',
'Controller therapy step-up after ED asthma visit' 'When should controller therapy be started or stepped up after an ED asthma visit?'
], ],
bronchiolitis: [ bronchiolitis: [
'Supportive management of bronchiolitis', 'What supportive care, suctioning, hydration, oxygen targets, and feeding guidance are recommended for infant bronchiolitis?',
'When bronchodilator trial is appropriate in bronchiolitis', 'When is a bronchodilator trial reasonable in bronchiolitis, and when should it be stopped?',
'Admission and oxygen criteria for bronchiolitis', 'What apnea risk, work of breathing, oxygen saturation, hydration, and social factors support admission for bronchiolitis?',
'Bronchiolitis versus early asthma', 'How can bronchiolitis be distinguished from early asthma, pneumonia, cardiac disease, or foreign body aspiration?',
'High-flow nasal cannula use in bronchiolitis' 'When should high-flow nasal cannula, CPAP, ICU transfer, or escalation be considered in bronchiolitis?'
], ],
fever: [ fever: [
'Fever without source by age group', 'How should fever without source be evaluated by age group in neonates, young infants, and older children?',
'Febrile neonate initial workup and antibiotics', 'What cultures, inflammatory markers, LP decisions, empiric antibiotics, and disposition are recommended for febrile neonates?',
'Risk stratification for serious bacterial infection', 'Which clinical and laboratory features stratify risk for serious bacterial infection in febrile infants?',
'When to do lumbar puncture in febrile infants', 'When is lumbar puncture indicated or avoidable in febrile infants by age and risk category?',
'Fever in immunocompromised children' 'How should fever be approached in immunocompromised children, central lines, or incomplete immunization?'
], ],
seizure: [ seizure: [
'Status epilepticus first-line management', 'What is the timed pediatric status epilepticus algorithm including benzodiazepine dosing and second-line choices?',
'Febrile seizure evaluation and counseling', 'How should simple versus complex febrile seizures be evaluated, treated, and explained to caregivers?',
'Second-line antiseizure medications in children', 'When should levetiracetam, fosphenytoin, phenobarbital, valproate, anesthetic infusion, or ICU transfer be used?',
'When neuroimaging is indicated after seizure', 'Which seizure presentations require neuroimaging, labs, toxicology, LP, EEG, or admission?',
'Seizure mimics in pediatrics' 'What common seizure mimics in children should be considered and how are they distinguished?'
], ],
croup: [ croup: [
'Croup severity scoring and treatment', 'How should croup severity guide dexamethasone, nebulized epinephrine, observation, and disposition?',
'When to use nebulized epinephrine for croup', 'When is nebulized epinephrine indicated for croup and how long should the child be observed afterward?',
'Dexamethasone dosing in croup', 'What dexamethasone dose and route are recommended for mild, moderate, and severe croup?',
'Observation after racemic epinephrine', 'What discharge, admission, ICU, and airway escalation criteria apply after croup treatment?',
'Differential diagnosis for stridor' 'What alternative diagnoses should be considered in a child with stridor or atypical croup?'
], ],
dehydration: [ dehydration: [
'Clinical assessment of pediatric dehydration', 'Which clinical signs best estimate mild, moderate, and severe dehydration in children?',
'Oral rehydration therapy protocol', 'What oral rehydration protocol, volumes, antiemetic use, and reassessment are recommended for gastroenteritis?',
'IV fluid bolus and maintenance strategy', 'When are IV boluses, maintenance fluids, deficit replacement, glucose, or electrolyte checks indicated?',
'Hypernatremic dehydration precautions', 'How should hypernatremic dehydration be recognized and corrected safely?',
'Discharge criteria after gastroenteritis' '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) { router.get('/clinical-assistant/status', async function(req, res) {
try { try {
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', ''); 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) { router.get('/clinical-assistant/source-image/:token', async function(req, res) {
try { 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.set('Cache-Control', 'private, max-age=3600');
res.type('png'); 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); res.sendFile(imagePath);
} catch (e) { } catch (e) {
console.warn('[clinical-assistant] source image rejected:', e.message); 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); 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({ return res.json({
success: true, success: true,
answer: buildSuggestionAnswer(message, suggestions), 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) { var searchQuery = await rewriteSearchQuery(message, history, chatModel).catch(function(e) {
console.warn('[clinical-assistant] query rewrite skipped:', e.message); console.warn('[clinical-assistant] query rewrite skipped:', e.message);
return message; return message;
@ -268,6 +305,11 @@ router.post('/clinical-assistant/chat', async function(req, res) {
normalizeMcpMultimodalResponse(multimodalResponse) normalizeMcpMultimodalResponse(multimodalResponse)
); );
var rawResults = rawTextResults.concat(rawMultimodalResults); 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 visualSlots = rawMultimodalResults.length ? Math.min(2, Math.max(1, Math.floor(searchLimit / 4))) : 0;
var textSlots = searchLimit - visualSlots; var textSlots = searchLimit - visualSlots;
var sources = dedupeSources( var sources = dedupeSources(
@ -298,9 +340,6 @@ router.post('/clinical-assistant/chat', async function(req, res) {
maxTokens: 1800 maxTokens: 1800
}); });
var answer = stripModelSourcesSection(String(ai.content || '').trim()); 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, { logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
category: 'clinical', model: ai.model || chatModel, duration: Date.now() - started 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; if (!history.length || !needsContextualRewrite(message)) return message;
var deterministic = deterministicFollowupQuery(message, history);
if (deterministic) return deterministic;
var hist = history.map(function(m) { var hist = history.map(function(m) {
return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 900); return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 900);
}).join('\n'); }).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); 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) { async function summarizeExportItems(items) {
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', ''); var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var clean = items.slice(0, 20).map(function(item, idx) { var clean = items.slice(0, 20).map(function(item, idx) {
@ -498,16 +569,67 @@ async function multimodalSearch(query, opts) {
async function indexedTopicSuggestions(limit) { async function indexedTopicSuggestions(limit) {
return callMcpTool('nc_indexed_topic_suggestions', { return callMcpTool('nc_indexed_topic_suggestions', {
limit: limit || 9, limit: limit || 12,
sample_size: 900, sample_size: 1500,
doc_type: 'file' doc_type: 'file'
}); });
} }
async function callMcpTool(name, args) { 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({ var session = await mcpRequest({
jsonrpc: '2.0', jsonrpc: '2.0',
id: 1, id: nextMcpRequestId(),
method: 'initialize', method: 'initialize',
params: { params: {
protocolVersion: '2024-11-05', protocolVersion: '2024-11-05',
@ -515,16 +637,8 @@ async function callMcpTool(name, args) {
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' } clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
} }
}); });
var search = await mcpRequest({ session.expiresAt = Date.now() + MCP_SESSION_TTL_MS;
jsonrpc: '2.0', return session;
id: 2,
method: 'tools/call',
params: {
name: name,
arguments: args
}
}, session.sessionId, session.mcpUrl);
return search.result || search;
} }
async function mcpRequest(payload, sessionId, preferredUrl) { async function mcpRequest(payload, sessionId, preferredUrl) {
@ -551,7 +665,7 @@ async function postMcpWithRetry(payload, headers, preferredUrl) {
try { try {
var resp = await axios.post(url, payload, { var resp = await axios.post(url, payload, {
headers: headers, headers: headers,
timeout: isInitialize ? 8000 : 90000, timeout: isInitialize ? MCP_INITIALIZE_TIMEOUT_MS : MCP_REQUEST_TIMEOUT_MS,
responseType: 'text', responseType: 'text',
transformResponse: [function(data) { return data; }] 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; 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() { async function getMcpHealth() {
var results = []; var results = [];
var urls = orderedMcpUrls(); var urls = orderedMcpUrls();
@ -707,6 +827,9 @@ function normalizeMcpMultimodalResponse(result) {
visual_caption: caption, visual_caption: caption,
visual_labels: labels, visual_labels: labels,
visual_caption_source: r.visual_caption_source || '', 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.', excerpt: excerptParts.length ? '[Page-image match] ' + excerptParts.join('\n') : '[Page-image match] Rendered PDF page matched the visual/text query.',
score: r.score, score: r.score,
image_path_internal: r.image_path || '', image_path_internal: r.image_path || '',
@ -718,17 +841,40 @@ function normalizeMcpMultimodalResponse(result) {
} }
function dedupeSources(results) { function dedupeSources(results) {
var seen = new Set(); var seen = new Map();
var out = []; var out = [];
results.forEach(function(r) { results.forEach(function(r) {
var key = [r.title, r.page || '', r.chunk_index || ''].join('|'); var key = sourceDedupeKey(r);
if (seen.has(key)) return; var existingIndex = seen.get(key);
seen.add(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 })); out.push(Object.assign({}, r, { number: out.length + 1 }));
}); });
return out; 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) { function formatSourcesForPrompt(sources) {
return sources.map(function(s) { return sources.map(function(s) {
var label = s.source_type === 'multimodal_page' ? ' [visual PDF page match]' : ''; var label = s.source_type === 'multimodal_page' ? ' [visual PDF page match]' : '';
@ -737,7 +883,7 @@ function formatSourcesForPrompt(sources) {
} }
function buildSystemPrompt(behavior) { 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) { function buildUserPrompt(question, context, history, searchQuery) {
@ -754,44 +900,6 @@ function stripModelSourcesSection(answer) {
.trim(); .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) { function isVisualSourceQuery(query) {
return visualIntent(query).wanted.length > 0; return visualIntent(query).wanted.length > 0;
} }
@ -813,6 +921,8 @@ async function classifyAndRerankMultimodalResults(query, results) {
results = Array.isArray(results) ? results : []; results = Array.isArray(results) ? results : [];
if (!results.length) return []; if (!results.length) return [];
var candidates = results.slice(0, VISUAL_CLASSIFIER_LIMIT); 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); if (!VISUAL_CLASSIFIER_ENABLED) return selectMultimodalResults(query, candidates);
var intent = visualIntent(query); var intent = visualIntent(query);
var classified = []; var classified = [];
@ -842,6 +952,8 @@ async function classifyAndRerankMultimodalResults(query, results) {
function selectMultimodalResults(query, results) { function selectMultimodalResults(query, results) {
results = Array.isArray(results) ? results : []; results = Array.isArray(results) ? results : [];
if (!results.length) return []; 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); }); 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; 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 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('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 (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 (/\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; if (looksLikeTextOnlyPage(source)) score -= 0.18;
return score; 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) { function looksLikeTextOnlyPage(source) {
var text = String(source && source.excerpt || ''); 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); 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; 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) { function sanitizeSourcesForClient(sources) {
return (Array.isArray(sources) ? sources : []).map(function(source) { return (Array.isArray(sources) ? sources : []).map(function(source) {
var out = Object.assign({}, source); var out = Object.assign({}, source);
@ -1072,6 +1239,11 @@ function cleanExportHeading(heading, idx) {
async function getAvailableExamples() { async function getAvailableExamples() {
var now = Date.now(); var now = Date.now();
if (_exampleCache.expiresAt > now && _exampleCache.examples.length) return _exampleCache.examples; 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) { var indexedExamples = await getIndexedTopicExamples().catch(function(e) {
console.warn('[clinical-assistant] indexed topic suggestions skipped:', e.message); console.warn('[clinical-assistant] indexed topic suggestions skipped:', e.message);
@ -1079,6 +1251,7 @@ async function getAvailableExamples() {
}); });
if (indexedExamples.length >= 3) { if (indexedExamples.length >= 3) {
_exampleCache = { expiresAt: now + EXAMPLE_CACHE_MS, examples: indexedExamples }; _exampleCache = { expiresAt: now + EXAMPLE_CACHE_MS, examples: indexedExamples };
redisCache.setJson('clinical-assistant:examples:v1', { examples: indexedExamples }, 1800).catch(function() {});
return indexedExamples; return indexedExamples;
} }
@ -1110,11 +1283,12 @@ async function getAvailableExamples() {
expiresAt: now + EXAMPLE_CACHE_MS, expiresAt: now + EXAMPLE_CACHE_MS,
examples: examples examples: examples
}; };
redisCache.setJson('clinical-assistant:examples:v1', { examples: examples }, 1800).catch(function() {});
return examples; return examples;
} }
async function getIndexedTopicExamples() { async function getIndexedTopicExamples() {
var response = await indexedTopicSuggestions(9); var response = await indexedTopicSuggestions(12);
var data = response && (response.structuredContent || response.data || response); var data = response && (response.structuredContent || response.data || response);
if ((!data || !Array.isArray(data.suggestions)) && response && Array.isArray(response.content)) { if ((!data || !Array.isArray(data.suggestions)) && response && Array.isArray(response.content)) {
for (var i = 0; i < response.content.length; i++) { for (var i = 0; i < response.content.length; i++) {
@ -1128,7 +1302,7 @@ async function getIndexedTopicExamples() {
} }
} }
if (!data || !Array.isArray(data.suggestions)) return []; 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 { return {
label: item.label || 'Indexed topic', label: item.label || 'Indexed topic',
prompt: item.prompt || '', 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'); if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation');
var headers = { 'Content-Type': 'application/json' }; var headers = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY; 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 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; 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] : {}; 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 }; 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) { function imagePromptForCanvas(prompt) {
var text = String(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)) { 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)) { 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; return text.trim() + guidance;
} }
@ -1233,18 +1408,18 @@ function getTopicSuggestions(message) {
} }
if (normalized.split(/\s+/).length <= 2) { if (normalized.split(/\s+/).length <= 2) {
return [ return [
'Management of ' + normalized, 'What initial ED evaluation, severity assessment, and immediate stabilization steps are recommended for pediatric ' + normalized + '?',
'Red flags and differential diagnosis for ' + normalized, 'Which red flags, differential diagnoses, and complications should not be missed in pediatric ' + normalized + '?',
'Initial ED evaluation for ' + normalized, 'What treatment escalation, medication dosing, and monitoring are recommended for pediatric ' + normalized + '?',
'Admission versus discharge criteria for ' + normalized, 'What admission, discharge, observation, and follow-up criteria are recommended for pediatric ' + normalized + '?',
'Medication dosing and escalation for ' + normalized 'How do recommendations differ by age group, severity, comorbidity, or high-risk features in pediatric ' + normalized + '?'
]; ];
} }
return []; return [];
} }
function buildSuggestionAnswer(topic, suggestions) { 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') + 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".'; '\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 assert = require('node:assert/strict');
const fs = require('node:fs/promises'); const fs = require('node:fs/promises');
const path = require('node:path'); const path = require('node:path');
const MarkdownIt = require('markdown-it');
let modulePromise; 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); const html = renderAssistantMarkdown('Give magnesium for severe exacerbation [1, 2].', sources);
assert.match(html, /href="#assistant-source-1"/); assert.match(html, /href="#assistant-source-1"/);
assert.match(html, /href="#assistant-source-2"/); assert.match(html, /href="#assistant-source-2"/);
assert.match(html, /title="Nelson Textbook of Pediatrics"/); assert.match(html, /title="Source 1: Nelson Textbook of Pediatrics"/);
assert.match(html, /\[<a class="assistant-cite"[^>]*>1<\/a>, <a class="assistant-cite"[^>]*>2<\/a>\]/); 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 () => { 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 () => { test('does not merge separate citations across separate claims', async () => {
const { renderAssistantMarkdown } = await loadCitationModule(); const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Use oxygen [1]. Give bronchodilator [2].', sources); const html = renderAssistantMarkdown('Use oxygen [1]. Give bronchodilator [2].', sources);
assert.match(html, /oxygen \[<a class="assistant-cite"[^>]*>1<\/a>\]/); assert.match(html, /oxygen <a class="assistant-cite"[^>]*data-source-number="1"[^>]*>src<\/a>/);
assert.match(html, /bronchodilator \[<a class="assistant-cite"[^>]*>2<\/a>\]/); assert.match(html, /bronchodilator <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>src<\/a>/);
assert.doesNotMatch(html, />1<\/a>, <a class="assistant-cite"[^>]*>2<\/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 () => { 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].', [ const html = renderAssistantMarkdown('Deteriorating course [1][4][2][3].', [
{ title: 'A' }, { title: 'B' }, { title: 'C' }, { title: 'D' } { 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|\]\[/); 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', [ const html = renderAssistantMarkdown('Deteriorating course [1][4][2][3', [
{ title: 'A' }, { title: 'B' }, { title: 'C' }, { title: 'D' } { 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 () => { 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 () => { test('escapes source titles inside citation link titles', async () => {
const { renderAssistantMarkdown } = await loadCitationModule(); const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Unsafe title [1].', [{ title: 'Bad "Title" <script>' }]); 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>/); assert.doesNotMatch(html, /<script>/);
}); });
@ -120,9 +129,76 @@ test('repairs smashed admission bullet list after citations', async () => {
assert.doesNotMatch(html, /\]-/); 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 () => { test('preserves code block contents without creating citation links inside code', async () => {
const { renderAssistantMarkdown } = await loadCitationModule(); const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('```js\nconst ref = "[1]";\n```', sources); const html = renderAssistantMarkdown('```js\nconst ref = "[1]";\n```', sources);
assert.match(html, /<pre><code>const ref = &quot;\[1\]&quot;/); assert.match(html, /<pre><code>const ref = &quot;\[1\]&quot;/);
assert.doesNotMatch(html, /assistant-source-1/); 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/);
});