diff --git a/docker-compose.yml b/docker-compose.yml
index c15949a..82da200 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 1224367..25c1e12 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 06711b2..e804167 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/public/components/assistant.html b/public/components/assistant.html
index 66a3cee..7d7580e 100644
--- a/public/components/assistant.html
+++ b/public/components/assistant.html
@@ -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); }
diff --git a/public/index.html b/public/index.html
index f9a99a4..650c607 100644
--- a/public/index.html
+++ b/public/index.html
@@ -16,7 +16,7 @@
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
crossorigin="anonymous" referrerpolicy="no-referrer" defer>
-
+
diff --git a/public/js/admin.js b/public/js/admin.js
index acfbe84..2a56794 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -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) {
diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js
index 8f33d4e..ff6c7f6 100644
--- a/public/js/assistant/citations.js
+++ b/public/js/assistant/citations.js
@@ -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 '
Rendering graph...
';
@@ -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 '' + n + '';
- }).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 '' + text + '';
+ }).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) {
diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index 722a7db..aa9825e 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -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('Preparing Clinical Assistant ExportPreparing PDF export...
');
+ 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 ? 'Generated Image
' : '';
var sections = items.map(function (item, idx) {
@@ -545,20 +558,24 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
return '' +
'' + escapeHtml(heading) + '
' +
'Question: ' + escapeHtml(item.question || '') + '
' +
- (summary ? 'Summary
' + renderMarkdown(summary, sources) + '
' : '') +
- 'Full Generated Answer
' + renderMarkdown(answer, sources) + '
' +
+ (summary ? 'Summary
' + renderMarkdown(summary, sources, { citationLabel: 'number' }) + '
' : '') +
+ 'Full Generated Answer
' + renderMarkdown(answer, sources, { citationLabel: 'number' }) + '
' +
(refs ? 'References
' + refs + '
' : '') +
'';
}).join('');
var html = 'Clinical Assistant Export' +
- '' +
- 'Clinical Assistant Export
' +
+ '' +
+ 'Clinical Assistant Export
' +
'Export generated ' + escapeHtml(new Date().toLocaleString()) + '
' +
imageHtml +
sections + '';
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);
}
diff --git a/server.js b/server.js
index 4e79775..5748df3 100644
--- a/server.js
+++ b/server.js
@@ -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')) {
diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js
index 0abc3e6..c7d9984 100644
--- a/src/routes/clinicalAssistant.js
+++ b/src/routes/clinicalAssistant.js
@@ -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".';
}
diff --git a/src/utils/redis.js b/src/utils/redis.js
new file mode 100644
index 0000000..e6702ef
--- /dev/null
+++ b/src/utils/redis.js
@@ -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 };
diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js
index 2f8e4bc..2933a1f 100644
--- a/test/assistant-citations.test.js
+++ b/test/assistant-citations.test.js
@@ -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, /\[]*>1<\/a>, ]*>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, /]*>src<\/a> ]*>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, /]*>1<\/a> ]*>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 \[]*>1<\/a>\]/);
- assert.match(html, /bronchodilator \[]*>2<\/a>\]/);
- assert.doesNotMatch(html, />1<\/a>, ]*>2<\/a>/);
+ assert.match(html, /oxygen ]*data-source-number="1"[^>]*>src<\/a>/);
+ assert.match(html, /bronchodilator ]*data-source-number="2"[^>]*>src<\/a>/);
+ assert.doesNotMatch(html, /data-source-number="1"[^>]*>src<\/a>]*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>, ]*>2<\/a>, ]*>3<\/a>, ]*>4<\/a>/);
+ assert.match(html, /data-source-number="1"[^>]*>src<\/a> ]*data-source-number="2"[^>]*>src<\/a> ]*data-source-number="3"[^>]*>src<\/a> ]*data-source-number="4"[^>]*>src<\/a>/);
assert.doesNotMatch(html, /\]1<\/a>, ]*>2<\/a>, ]*>3<\/a>, ]*>4<\/a>/);
+ assert.match(html, /data-source-number="1"[^>]*>src<\/a> ]*data-source-number="2"[^>]*>src<\/a> ]*data-source-number="3"[^>]*>src<\/a> ]*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"