feat: improve clinical assistant export and citations
This commit is contained in:
parent
2afa3a5ff2
commit
84100b1863
12 changed files with 778 additions and 160 deletions
|
|
@ -8,12 +8,18 @@ services:
|
|||
- .env
|
||||
environment:
|
||||
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp
|
||||
CLINICAL_ASSISTANT_MULTIMODAL_S3_ENDPOINT: http://minio:9000
|
||||
CLINICAL_ASSISTANT_MULTIMODAL_S3_ACCESS_KEY: ${MULTIMODAL_OBJECT_STORE_ACCESS_KEY:-minioadmin}
|
||||
CLINICAL_ASSISTANT_MULTIMODAL_S3_SECRET_KEY: ${MULTIMODAL_OBJECT_STORE_SECRET_KEY:-minioadmin}
|
||||
REDIS_URL: redis://ped-ai-redis:6379
|
||||
volumes:
|
||||
- scribe-logs:/app/data/logs
|
||||
- clinical-assistant-mcp-data:/app/mcp-data:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
container_name: pediatric-ai-scribe
|
||||
networks:
|
||||
|
|
@ -46,9 +52,26 @@ services:
|
|||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
command: redis-server --appendonly yes
|
||||
restart: unless-stopped
|
||||
container_name: ped-ai-redis
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- default
|
||||
- mcp-server_default
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
scribe-logs:
|
||||
redis-data:
|
||||
clinical-assistant-mcp-data:
|
||||
external: true
|
||||
name: mcp-server_mcp-data
|
||||
|
|
|
|||
106
package-lock.json
generated
106
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@14.1.3/marked.min.js" defer></script>
|
||||
<script src="/vendor/markdown-it/markdown-it.min.js" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" defer></script>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export function renderAssistantMarkdown(md, sources, options) {
|
||||
var opts = options || {};
|
||||
var text = normalizeMarkdownText(md);
|
||||
var text = stripOrphanMarkdownMarkers(repairTabSeparatedTables(repairMarkdownTables(normalizeMarkdownText(md))));
|
||||
var codeBlocks = [];
|
||||
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
|
||||
var idx = codeBlocks.length;
|
||||
|
|
@ -12,13 +12,15 @@ export function renderAssistantMarkdown(md, sources, options) {
|
|||
text = normalizeAdjacentCitationClusters(text, sources || []);
|
||||
|
||||
var html;
|
||||
if (opts.marked && typeof opts.marked.parse === 'function') {
|
||||
if (opts.markdownIt && typeof opts.markdownIt.render === 'function') {
|
||||
html = opts.markdownIt.render(text);
|
||||
} else if (opts.marked && typeof opts.marked.parse === 'function') {
|
||||
html = opts.marked.parse(text, { breaks: true, gfm: true });
|
||||
} else {
|
||||
html = fallbackMarkdown(text);
|
||||
}
|
||||
|
||||
html = renderCitationLinks(html, sources || []);
|
||||
html = renderCitationLinks(html, sources || [], opts);
|
||||
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
|
||||
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
|
||||
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
|
||||
|
|
@ -29,15 +31,19 @@ export function renderAssistantMarkdown(md, sources, options) {
|
|||
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
|
||||
}
|
||||
|
||||
export function renderCitationLinks(html, sources) {
|
||||
export function renderCitationLinks(html, sources, options) {
|
||||
var opts = options || {};
|
||||
return String(html || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) {
|
||||
var nums = cluster.split(',').map(function (n) { return Number(n.trim()); }).filter(function (n) { return Number.isInteger(n) && n > 0; });
|
||||
if (!nums.length || nums.some(function (n) { return !sources[n - 1]; })) return match;
|
||||
return '[' + nums.map(function (n) {
|
||||
return nums.map(function (n) {
|
||||
var source = sources[n - 1];
|
||||
var title = source ? source.title || source.resource || 'Source' : 'Source';
|
||||
return '<a class="assistant-cite" href="#assistant-source-' + n + '" title="' + escapeHtml(title) + '">' + n + '</a>';
|
||||
}).join(', ') + ']';
|
||||
var page = source && (source.page || source.page_number || source.pageNumber);
|
||||
var label = 'Source ' + n + ': ' + title + (page ? ', page ' + page : '');
|
||||
var text = opts.citationLabel === 'number' ? String(n) : 'src';
|
||||
return '<a class="assistant-cite" href="#assistant-source-' + n + '" data-source-number="' + n + '" title="' + escapeHtml(label) + '" aria-label="' + escapeAttr(label) + '">' + text + '</a>';
|
||||
}).join(' ');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -85,10 +91,14 @@ export function stripSourcesSection(text) {
|
|||
}
|
||||
|
||||
export function normalizeMarkdownText(text) {
|
||||
return String(text || '')
|
||||
return stripOrphanMarkdownMarkers(String(text || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*-\s+/g, '$1\n- ')
|
||||
.replace(/([.!?])\s*-\s+(\*\*)?/g, '$1\n- $2')
|
||||
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s*/g, '$1\n- ')
|
||||
.replace(/([.!?])\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
|
||||
.replace(/(:)\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
|
||||
.replace(/(\[(?:\d+\s*,\s*)*\d+\]\.)\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
|
||||
.replace(/([.!?])\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
|
||||
.replace(/(\[(?:\d+\s*,\s*)*\d+\])(?=\s*(?:[A-Z][A-Za-z]+\s+){1,4}(?:deficits?|distress|apnoea|apnea|vomiting|seizures?|signs?|symptoms?|criteria|indications?|risk|oxygen|saturation|dehydration|lethargy|toxicity)\b)/g, '$1\n')
|
||||
.replace(/([^\n])\s+(#{1,4}\s+)/g, '$1\n\n$2')
|
||||
.replace(/(#{1,4}\s+[^\n]+?)\s+(-\s+)/g, '$1\n\n$2')
|
||||
.replace(/(#{1,4}\s+[^\n]+)\n(-\s+)/g, '$1\n\n$2')
|
||||
|
|
@ -97,7 +107,173 @@ export function normalizeMarkdownText(text) {
|
|||
.replace(/([^\n])\s+(\|\s*[-:]+\s*\|)/g, '$1\n$2')
|
||||
.replace(/\s+(\|\s*[^\n|]+\s*\|\s*[^\n|]+\s*\|)/g, '\n$1')
|
||||
.replace(/([^\n])\s+(-\s+[^\n])/g, '$1\n$2')
|
||||
.trim();
|
||||
.trim());
|
||||
}
|
||||
|
||||
export function stripOrphanMarkdownMarkers(text) {
|
||||
return String(text || '').replace(/(?:\n|^)\s*(?:\*\*|__|\*|_)\s*$/g, '').trim();
|
||||
}
|
||||
|
||||
export function repairTabSeparatedTables(text) {
|
||||
var lines = String(text || '').split('\n');
|
||||
var out = [];
|
||||
var i = 0;
|
||||
while (i < lines.length) {
|
||||
if (!/\t/.test(lines[i]) || i + 1 >= lines.length || !/\t/.test(lines[i + 1])) {
|
||||
out.push(lines[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (out.length && out[out.length - 1].trim()) out.push('');
|
||||
var tableLines = [];
|
||||
while (i < lines.length && shouldKeepTabTableLine(lines[i], tableLines.length)) {
|
||||
tableLines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
var header = tableLines[0].split('\t').map(function(cell) { return cell.trim(); });
|
||||
var rows = tabTableRows(tableLines.slice(1), header.length);
|
||||
out.push('| ' + header.join(' | ') + ' |');
|
||||
out.push('| ' + header.map(function() { return '---'; }).join(' | ') + ' |');
|
||||
rows.forEach(function(cells) {
|
||||
out.push('| ' + cells.map(cleanTableCell).join(' | ') + ' |');
|
||||
});
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function shouldKeepTabTableLine(line, count) {
|
||||
if (/\t/.test(line)) return true;
|
||||
if (!count || !String(line || '').trim()) return false;
|
||||
if (/^\s*(#{1,6}\s+|References\b|Sources\b)/i.test(line)) return false;
|
||||
return /^\s*(?:\d+\s*){1,4}$/.test(line) || /^[^.!?]{1,90}$/.test(line);
|
||||
}
|
||||
|
||||
function tabTableRows(lines, width) {
|
||||
var rows = [];
|
||||
var pending = null;
|
||||
lines.forEach(function(line) {
|
||||
var cells = String(line || '').split('\t').map(function(cell) { return cell.trim(); });
|
||||
if (cells.length === 1 && pending) {
|
||||
pending[pending.length - 1] = [pending[pending.length - 1], cells[0]].filter(Boolean).join(' ');
|
||||
return;
|
||||
}
|
||||
if (pending && cells.length >= width && looksCitationCell(cells[0]) && cells[width - 1]) {
|
||||
pending[pending.length - 1] = [pending[pending.length - 1], cells[0]].filter(Boolean).join(' ');
|
||||
rows.push(normalizeTabRow(pending, width));
|
||||
pending = [cells[width - 1]];
|
||||
return;
|
||||
}
|
||||
if (pending && cells.length >= width) {
|
||||
rows.push(normalizeTabRow(pending, width));
|
||||
pending = null;
|
||||
}
|
||||
if (cells.length >= width) {
|
||||
rows.push(normalizeTabRow(cells, width));
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
pending[pending.length - 1] = [pending[pending.length - 1], cells.join(' ')].filter(Boolean).join(' ');
|
||||
} else {
|
||||
pending = cells;
|
||||
}
|
||||
});
|
||||
if (pending) rows.push(normalizeTabRow(pending, width));
|
||||
return rows;
|
||||
}
|
||||
|
||||
function normalizeTabRow(cells, width) {
|
||||
cells = cells.slice();
|
||||
while (cells.length < width) cells.push('');
|
||||
if (width === 3 && looksCitationCell(cells[0]) && cells[2]) {
|
||||
return [cells[2], '', cells[0]];
|
||||
}
|
||||
if (width === 3 && /\b(\d+\s*){1,4}\s+(?:Nonoperative|Surgical|Diagnostic|Recurrence|Initial|Further)\b/i.test(cells[1] || '')) {
|
||||
var split = splitCitationPrefix(cells[1]);
|
||||
cells[1] = split.citation;
|
||||
if (split.rest && cells[2]) cells[2] = split.rest + ': ' + cells[2];
|
||||
}
|
||||
return cells.slice(0, width);
|
||||
}
|
||||
|
||||
function splitCitationPrefix(text) {
|
||||
var m = String(text || '').match(/^((?:\d+\s*){1,4})\s+(.+)$/);
|
||||
return m ? { citation: m[1].trim(), rest: m[2].trim() } : { citation: text, rest: '' };
|
||||
}
|
||||
|
||||
function looksCitationCell(text) {
|
||||
return /^(?:\d+\s*){1,5}$/.test(String(text || '').trim());
|
||||
}
|
||||
|
||||
function cleanTableCell(cell) {
|
||||
return String(cell || '').replace(/(?:^|\s+)If you need[\s\S]*$/i, '').trim();
|
||||
}
|
||||
|
||||
export function repairMarkdownTables(text) {
|
||||
var lines = String(text || '').split('\n');
|
||||
var repaired = [];
|
||||
var i = 0;
|
||||
while (i < lines.length) {
|
||||
if (isSplitTableHeader(lines, i)) {
|
||||
lines[i] = mergeSplitTableRows(lines[i], lines[i + 1]);
|
||||
lines.splice(i + 1, 1);
|
||||
}
|
||||
if (!isPotentialTableHeader(lines, i)) {
|
||||
repaired.push(lines[i]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
var expected = tableCells(lines[i]).length;
|
||||
if (repaired.length && repaired[repaired.length - 1].trim()) repaired.push('');
|
||||
repaired.push(lines[i], normalizeTableSeparator(lines[i + 1], expected));
|
||||
i += 2;
|
||||
|
||||
while (i < lines.length) {
|
||||
var row = lines[i];
|
||||
if (!row.trim()) { i += 1; continue; }
|
||||
if (!/^\s*\|/.test(row)) break;
|
||||
|
||||
var merged = row;
|
||||
var guard = 0;
|
||||
while (tableCells(merged).length < expected && i + 1 < lines.length && /^\s*\|/.test(lines[i + 1]) && guard < 4) {
|
||||
merged = mergeSplitTableRows(merged, lines[i + 1]);
|
||||
i += 1;
|
||||
guard += 1;
|
||||
}
|
||||
repaired.push(trimTableCellsToExpected(merged, expected));
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return repaired.join('\n');
|
||||
}
|
||||
|
||||
function isPotentialTableHeader(lines, idx) {
|
||||
return idx + 1 < lines.length && /^\s*\|.*\|\s*$/.test(lines[idx]) && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[idx + 1]);
|
||||
}
|
||||
|
||||
function isSplitTableHeader(lines, idx) {
|
||||
return idx + 2 < lines.length && /^\s*\|.*\|?\s*$/.test(lines[idx]) && /^\s*\|.*\|\s*$/.test(lines[idx + 1]) && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[idx + 2]);
|
||||
}
|
||||
|
||||
function normalizeTableSeparator(line, expected) {
|
||||
var cells = tableCells(line);
|
||||
while (cells.length < expected) cells.push('---');
|
||||
return '| ' + cells.slice(0, expected).map(function(cell) { return /^:?-{2,}:?$/.test(cell) ? cell : '---'; }).join(' | ') + ' |';
|
||||
}
|
||||
|
||||
function mergeSplitTableRows(left, right) {
|
||||
var a = tableCells(left);
|
||||
var b = tableCells(right);
|
||||
if (!a.length) return right;
|
||||
a[a.length - 1] = (a[a.length - 1] + ' ' + (b.shift() || '')).trim();
|
||||
return '| ' + a.concat(b).join(' | ') + ' |';
|
||||
}
|
||||
|
||||
function trimTableCellsToExpected(line, expected) {
|
||||
var cells = tableCells(line);
|
||||
while (cells.length < expected) cells.push('');
|
||||
return '| ' + cells.slice(0, expected).join(' | ') + ' |';
|
||||
}
|
||||
|
||||
export function fallbackMarkdown(text) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
var lastGeneratedImageSrc = '';
|
||||
var exportCacheKey = '';
|
||||
var exportCacheItems = null;
|
||||
var markdownRenderer = null;
|
||||
|
||||
document.addEventListener('tabChanged', function (e) {
|
||||
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
|
||||
|
|
@ -143,7 +144,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
})
|
||||
.catch(function (err) {
|
||||
setBusy(false, 'Error', true);
|
||||
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nIf this is the first run, the backend `/api/clinical-assistant/chat` route still needs to be wired to direct MCP search.');
|
||||
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.');
|
||||
if (typeof showToast === 'function') showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
|
@ -228,14 +229,30 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
return wrap;
|
||||
}
|
||||
|
||||
function renderMarkdown(md, sources) {
|
||||
function renderMarkdown(md, sources, options) {
|
||||
var opts = options || {};
|
||||
return renderAssistantMarkdown(md, sources || [], {
|
||||
marked: window.marked,
|
||||
markdownIt: getMarkdownRenderer(),
|
||||
katex: window.katex,
|
||||
sanitize: sanitize
|
||||
sanitize: sanitize,
|
||||
citationLabel: opts.citationLabel
|
||||
});
|
||||
}
|
||||
|
||||
function getMarkdownRenderer() {
|
||||
if (markdownRenderer) return markdownRenderer;
|
||||
if (typeof window.markdownit === 'function') {
|
||||
markdownRenderer = window.markdownit({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true
|
||||
});
|
||||
return markdownRenderer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSources(sources) {
|
||||
var wrap = document.getElementById('assistant-sources');
|
||||
if (!wrap) return;
|
||||
|
|
@ -508,32 +525,28 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to export', 'error'); return; }
|
||||
var exportItems = collectExportItems();
|
||||
var cacheKey = buildExportCacheKey(exportItems, lastGeneratedImageSrc);
|
||||
var doc = openExportWindow();
|
||||
if (!doc) { if (typeof showToast === 'function') showToast('Allow popups to export PDF', 'error'); return; }
|
||||
if (exportCacheKey === cacheKey && exportCacheItems) {
|
||||
openPrintableChatExport(exportCacheItems, lastGeneratedImageSrc);
|
||||
writePrintableChatExport(doc, exportCacheItems, lastGeneratedImageSrc);
|
||||
return;
|
||||
}
|
||||
setBusy(true, 'Preparing PDF...');
|
||||
fetch('/api/clinical-assistant/export-summary', {
|
||||
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin',
|
||||
body: JSON.stringify({ items: exportItems })
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
|
||||
.then(function (data) {
|
||||
setBusy(false, 'Ready');
|
||||
var items = data.success && Array.isArray(data.items) && data.items.length ? data.items : exportItems;
|
||||
exportCacheKey = cacheKey;
|
||||
exportCacheItems = items;
|
||||
openPrintableChatExport(items, lastGeneratedImageSrc);
|
||||
})
|
||||
.catch(function () {
|
||||
setBusy(false, 'Ready');
|
||||
openPrintableChatExport(exportItems, lastGeneratedImageSrc);
|
||||
});
|
||||
exportCacheKey = cacheKey;
|
||||
exportCacheItems = exportItems;
|
||||
writePrintableChatExport(doc, exportItems, lastGeneratedImageSrc);
|
||||
}
|
||||
|
||||
function openPrintableChatExport(items, imageSrc) {
|
||||
function openExportWindow() {
|
||||
var doc = window.open('', '_blank', 'width=900,height=1100');
|
||||
if (!doc) { if (typeof showToast === 'function') showToast('Allow popups to export PDF', 'error'); return; }
|
||||
if (!doc) return null;
|
||||
doc.document.open();
|
||||
doc.document.write('<!doctype html><html><head><title>Preparing Clinical Assistant Export</title><style>body{font-family:Arial,sans-serif;color:#111827;margin:36px;line-height:1.5}.spinner{width:18px;height:18px;border:3px solid #e5e7eb;border-top-color:#7c3aed;border-radius:50%;display:inline-block;vertical-align:middle;margin-right:8px;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}</style></head><body><p><span class="spinner"></span>Preparing PDF export...</p></body></html>');
|
||||
doc.document.close();
|
||||
return doc;
|
||||
}
|
||||
|
||||
function writePrintableChatExport(doc, items, imageSrc) {
|
||||
if (!doc || doc.closed) return;
|
||||
items = Array.isArray(items) && items.length ? items : collectExportItems();
|
||||
var imageHtml = imageSrc ? '<h2>Generated Image</h2><div class="export-image"><img src="' + escapeAttr(imageSrc) + '" alt="Generated clinical visual"></div>' : '';
|
||||
var sections = items.map(function (item, idx) {
|
||||
|
|
@ -545,20 +558,24 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
return '<section class="export-section">' +
|
||||
'<h2>' + escapeHtml(heading) + '</h2>' +
|
||||
'<div class="question"><strong>Question:</strong> ' + escapeHtml(item.question || '') + '</div>' +
|
||||
(summary ? '<h3>Summary</h3><div class="answer">' + renderMarkdown(summary, sources) + '</div>' : '') +
|
||||
'<h3>Full Generated Answer</h3><div class="answer full-answer">' + renderMarkdown(answer, sources) + '</div>' +
|
||||
(summary ? '<h3>Summary</h3><div class="answer">' + renderMarkdown(summary, sources, { citationLabel: 'number' }) + '</div>' : '') +
|
||||
'<h3>Full Generated Answer</h3><div class="answer full-answer">' + renderMarkdown(answer, sources, { citationLabel: 'number' }) + '</div>' +
|
||||
(refs ? '<h3>References</h3><ol class="refs">' + refs + '</ol>' : '') +
|
||||
'</section>';
|
||||
}).join('');
|
||||
var html = '<!doctype html><html><head><title>Clinical Assistant Export</title>' +
|
||||
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px}h3{font-size:14px;margin:16px 0 6px}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{break-inside:avoid-page;margin-top:20px}.full-answer{page-break-before:auto}.export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}.refs{font-size:12px;padding-left:20px}.refs li{margin:6px 0}.assistant-cite{color:#4f46e5;text-decoration:none;font-weight:700}@media print{button{display:none}body{margin:24mm}}</style>' +
|
||||
'</head><body><button onclick="window.print()" style="float:right;padding:8px 12px">Print / Save PDF</button><h1>Clinical Assistant Export</h1>' +
|
||||
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px;break-after:avoid}h3{font-size:14px;margin:18px 0 8px;break-after:avoid}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{margin-top:20px;break-before:auto}.full-answer{page-break-before:auto}.export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}.refs{font-size:12px;padding-left:20px;margin-top:8px;break-inside:auto}.refs li{margin:6px 0;break-inside:avoid}.assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}.answer th,.answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}.answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer th:last-child,.answer td:last-child{width:1%;white-space:nowrap}.answer tr{break-inside:avoid;page-break-inside:avoid}.answer thead{display:table-header-group}.answer tbody{display:table-row-group}.answer p{margin:8px 0}.answer ul,.answer ol{padding-left:20px}.answer li{margin:4px 0}@media print{button{display:none}body{margin:24mm}.export-section{break-inside:auto}.refs{break-before:avoid}}</style>' +
|
||||
'</head><body><button id="assistant-export-print" type="button" style="float:right;padding:8px 12px">Print / Save PDF</button><h1>Clinical Assistant Export</h1>' +
|
||||
'<div class="meta">Export generated ' + escapeHtml(new Date().toLocaleString()) + '</div>' +
|
||||
imageHtml +
|
||||
sections + '</body></html>';
|
||||
doc.document.open();
|
||||
doc.document.write(html);
|
||||
doc.document.close();
|
||||
try {
|
||||
var printBtn = doc.document.getElementById('assistant-export-print');
|
||||
if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); });
|
||||
} catch (e) {}
|
||||
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ var express = require('express');
|
|||
var axios = require('axios');
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
var router = express.Router();
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
|
|
@ -16,12 +17,20 @@ var { callAI } = require('../utils/ai');
|
|||
var { gatewayUrl } = require('../utils/errors');
|
||||
var logger = require('../utils/logger');
|
||||
var cryptoUtil = require('../utils/crypto');
|
||||
var redisCache = require('../utils/redis');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
var MCP_URLS = buildMcpUrls();
|
||||
var _lastGoodMcpUrl = MCP_URLS[0];
|
||||
var DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources, cite claims with numbered citations like [1], and list sources at the bottom by title/resource and page. Do not invent citations.';
|
||||
var MCP_INITIALIZE_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS, 30000);
|
||||
var MCP_REQUEST_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS, 90000);
|
||||
var MCP_SESSION_TTL_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS, 10 * 60 * 1000);
|
||||
var _mcpSession = null;
|
||||
var _mcpSessionPromise = null;
|
||||
var _mcpCallQueue = Promise.resolve();
|
||||
var _mcpRequestId = 1;
|
||||
var DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations.';
|
||||
var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|ok|okay|sup)[\s.!?]*$/i;
|
||||
var EXAMPLE_CACHE_MS = 10 * 60 * 1000;
|
||||
var _exampleCache = { expiresAt: 0, examples: [] };
|
||||
|
|
@ -30,6 +39,10 @@ var MAX_SAVED_CHAT_PAYLOAD = 250000;
|
|||
var MAX_SAVED_CHAT_TITLE = 160;
|
||||
var MCP_MULTIMODAL_IMAGE_ROOT = path.resolve(process.env.CLINICAL_ASSISTANT_MULTIMODAL_IMAGE_ROOT || '/app/mcp-data/multimodal-pages');
|
||||
var MCP_MULTIMODAL_INTERNAL_ROOT = path.resolve(process.env.CLINICAL_ASSISTANT_MULTIMODAL_INTERNAL_ROOT || '/app/data/multimodal-pages');
|
||||
var MULTIMODAL_S3_ENDPOINT = process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_ENDPOINT || '';
|
||||
var MULTIMODAL_S3_ACCESS_KEY = process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_ACCESS_KEY || '';
|
||||
var MULTIMODAL_S3_SECRET_KEY = process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_SECRET_KEY || '';
|
||||
var _s3Client = null;
|
||||
var MULTIMODAL_EMBEDDING_URL = process.env.CLINICAL_ASSISTANT_MULTIMODAL_EMBEDDING_URL || 'http://multimodal-embeddings:7999/embed';
|
||||
var VISUAL_CLASSIFIER_ENABLED = process.env.CLINICAL_ASSISTANT_VISUAL_CLASSIFIER !== 'false';
|
||||
var VISUAL_CLASSIFIER_LIMIT = 6;
|
||||
|
|
@ -58,49 +71,62 @@ var EXAMPLE_CANDIDATES = [
|
|||
];
|
||||
var TOPIC_SUGGESTIONS = {
|
||||
asthma: [
|
||||
'Management of acute asthma exacerbation by severity',
|
||||
'When to give magnesium sulfate in status asthmaticus',
|
||||
'Bronchiolitis versus asthma in infants with wheeze',
|
||||
'Discharge criteria after pediatric asthma exacerbation',
|
||||
'Controller therapy step-up after ED asthma visit'
|
||||
'How should acute pediatric asthma exacerbation severity guide SABA frequency, ipratropium, steroids, magnesium sulfate, and disposition?',
|
||||
'When should magnesium sulfate, continuous albuterol, noninvasive ventilation, ICU transfer, or intubation be considered in status asthmaticus?',
|
||||
'How can bronchiolitis, viral-induced wheeze, and early asthma be distinguished in infants and preschool children?',
|
||||
'What discharge criteria, observation period, relapse risks, and follow-up are recommended after an ED asthma exacerbation?',
|
||||
'When should controller therapy be started or stepped up after an ED asthma visit?'
|
||||
],
|
||||
bronchiolitis: [
|
||||
'Supportive management of bronchiolitis',
|
||||
'When bronchodilator trial is appropriate in bronchiolitis',
|
||||
'Admission and oxygen criteria for bronchiolitis',
|
||||
'Bronchiolitis versus early asthma',
|
||||
'High-flow nasal cannula use in bronchiolitis'
|
||||
'What supportive care, suctioning, hydration, oxygen targets, and feeding guidance are recommended for infant bronchiolitis?',
|
||||
'When is a bronchodilator trial reasonable in bronchiolitis, and when should it be stopped?',
|
||||
'What apnea risk, work of breathing, oxygen saturation, hydration, and social factors support admission for bronchiolitis?',
|
||||
'How can bronchiolitis be distinguished from early asthma, pneumonia, cardiac disease, or foreign body aspiration?',
|
||||
'When should high-flow nasal cannula, CPAP, ICU transfer, or escalation be considered in bronchiolitis?'
|
||||
],
|
||||
fever: [
|
||||
'Fever without source by age group',
|
||||
'Febrile neonate initial workup and antibiotics',
|
||||
'Risk stratification for serious bacterial infection',
|
||||
'When to do lumbar puncture in febrile infants',
|
||||
'Fever in immunocompromised children'
|
||||
'How should fever without source be evaluated by age group in neonates, young infants, and older children?',
|
||||
'What cultures, inflammatory markers, LP decisions, empiric antibiotics, and disposition are recommended for febrile neonates?',
|
||||
'Which clinical and laboratory features stratify risk for serious bacterial infection in febrile infants?',
|
||||
'When is lumbar puncture indicated or avoidable in febrile infants by age and risk category?',
|
||||
'How should fever be approached in immunocompromised children, central lines, or incomplete immunization?'
|
||||
],
|
||||
seizure: [
|
||||
'Status epilepticus first-line management',
|
||||
'Febrile seizure evaluation and counseling',
|
||||
'Second-line antiseizure medications in children',
|
||||
'When neuroimaging is indicated after seizure',
|
||||
'Seizure mimics in pediatrics'
|
||||
'What is the timed pediatric status epilepticus algorithm including benzodiazepine dosing and second-line choices?',
|
||||
'How should simple versus complex febrile seizures be evaluated, treated, and explained to caregivers?',
|
||||
'When should levetiracetam, fosphenytoin, phenobarbital, valproate, anesthetic infusion, or ICU transfer be used?',
|
||||
'Which seizure presentations require neuroimaging, labs, toxicology, LP, EEG, or admission?',
|
||||
'What common seizure mimics in children should be considered and how are they distinguished?'
|
||||
],
|
||||
croup: [
|
||||
'Croup severity scoring and treatment',
|
||||
'When to use nebulized epinephrine for croup',
|
||||
'Dexamethasone dosing in croup',
|
||||
'Observation after racemic epinephrine',
|
||||
'Differential diagnosis for stridor'
|
||||
'How should croup severity guide dexamethasone, nebulized epinephrine, observation, and disposition?',
|
||||
'When is nebulized epinephrine indicated for croup and how long should the child be observed afterward?',
|
||||
'What dexamethasone dose and route are recommended for mild, moderate, and severe croup?',
|
||||
'What discharge, admission, ICU, and airway escalation criteria apply after croup treatment?',
|
||||
'What alternative diagnoses should be considered in a child with stridor or atypical croup?'
|
||||
],
|
||||
dehydration: [
|
||||
'Clinical assessment of pediatric dehydration',
|
||||
'Oral rehydration therapy protocol',
|
||||
'IV fluid bolus and maintenance strategy',
|
||||
'Hypernatremic dehydration precautions',
|
||||
'Discharge criteria after gastroenteritis'
|
||||
'Which clinical signs best estimate mild, moderate, and severe dehydration in children?',
|
||||
'What oral rehydration protocol, volumes, antiemetic use, and reassessment are recommended for gastroenteritis?',
|
||||
'When are IV boluses, maintenance fluids, deficit replacement, glucose, or electrolyte checks indicated?',
|
||||
'How should hypernatremic dehydration be recognized and corrected safely?',
|
||||
'What discharge, admission, and follow-up criteria apply after pediatric gastroenteritis or dehydration?'
|
||||
]
|
||||
};
|
||||
|
||||
function positiveInt(value, fallback) {
|
||||
var n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
||||
}
|
||||
|
||||
if (process.env.CLINICAL_ASSISTANT_MCP_WARMUP !== 'false') {
|
||||
setTimeout(function() {
|
||||
getMcpSession().catch(function(e) {
|
||||
console.warn('[clinical-assistant] MCP warmup skipped:', e.message);
|
||||
});
|
||||
}, positiveInt(process.env.CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS, 5000));
|
||||
}
|
||||
|
||||
router.get('/clinical-assistant/status', async function(req, res) {
|
||||
try {
|
||||
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
|
||||
|
|
@ -133,10 +159,19 @@ router.get('/clinical-assistant/examples', async function(req, res) {
|
|||
|
||||
router.get('/clinical-assistant/source-image/:token', async function(req, res) {
|
||||
try {
|
||||
var imagePath = resolveMultimodalImagePath(req.params.token || '');
|
||||
if (!imagePath) return res.status(404).send('Not found');
|
||||
res.set('Cache-Control', 'private, max-age=3600');
|
||||
res.type('png');
|
||||
var raw = base64UrlDecode(req.params.token || '');
|
||||
if (isS3Uri(raw)) {
|
||||
var obj = await getS3Object(raw);
|
||||
if (!obj) return res.status(404).send('Not found');
|
||||
if (obj.Body && typeof obj.Body.pipe === 'function') return obj.Body.pipe(res);
|
||||
var chunks = [];
|
||||
for await (var chunk of obj.Body) chunks.push(chunk);
|
||||
return res.send(Buffer.concat(chunks));
|
||||
}
|
||||
var imagePath = resolveRawMultimodalImagePath(raw);
|
||||
if (!imagePath) return res.status(404).send('Not found');
|
||||
res.sendFile(imagePath);
|
||||
} catch (e) {
|
||||
console.warn('[clinical-assistant] source image rejected:', e.message);
|
||||
|
|
@ -234,8 +269,11 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
});
|
||||
}
|
||||
|
||||
var history = Array.isArray(req.body.history) ? req.body.history.slice(-8) : [];
|
||||
var contextualHistory = priorClinicalHistory(history, message);
|
||||
|
||||
var suggestions = getTopicSuggestions(message);
|
||||
if (suggestions.length > 0 && message.split(/\s+/).length < 4) {
|
||||
if (!contextualHistory.length && suggestions.length > 0 && message.split(/\s+/).length < 4) {
|
||||
return res.json({
|
||||
success: true,
|
||||
answer: buildSuggestionAnswer(message, suggestions),
|
||||
|
|
@ -246,7 +284,6 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
});
|
||||
}
|
||||
|
||||
var history = Array.isArray(req.body.history) ? req.body.history.slice(-8) : [];
|
||||
var searchQuery = await rewriteSearchQuery(message, history, chatModel).catch(function(e) {
|
||||
console.warn('[clinical-assistant] query rewrite skipped:', e.message);
|
||||
return message;
|
||||
|
|
@ -268,6 +305,11 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
normalizeMcpMultimodalResponse(multimodalResponse)
|
||||
);
|
||||
var rawResults = rawTextResults.concat(rawMultimodalResults);
|
||||
console.info('[clinical-assistant] retrieval counts:', {
|
||||
text: rawTextResults.length,
|
||||
multimodal: rawMultimodalResults.length,
|
||||
query: searchQuery
|
||||
});
|
||||
var visualSlots = rawMultimodalResults.length ? Math.min(2, Math.max(1, Math.floor(searchLimit / 4))) : 0;
|
||||
var textSlots = searchLimit - visualSlots;
|
||||
var sources = dedupeSources(
|
||||
|
|
@ -298,9 +340,6 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
maxTokens: 1800
|
||||
});
|
||||
var answer = stripModelSourcesSection(String(ai.content || '').trim());
|
||||
var normalized = normalizeCitationOrder(answer, sources);
|
||||
answer = normalized.answer;
|
||||
sources = normalized.sources;
|
||||
|
||||
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
|
||||
category: 'clinical', model: ai.model || chatModel, duration: Date.now() - started
|
||||
|
|
@ -390,6 +429,9 @@ async function rewriteSearchQuery(message, history, chatModel) {
|
|||
}
|
||||
if (!history.length || !needsContextualRewrite(message)) return message;
|
||||
|
||||
var deterministic = deterministicFollowupQuery(message, history);
|
||||
if (deterministic) return deterministic;
|
||||
|
||||
var hist = history.map(function(m) {
|
||||
return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 900);
|
||||
}).join('\n');
|
||||
|
|
@ -420,6 +462,35 @@ function needsContextualRewrite(message) {
|
|||
return words.length <= 8 || /\b(it|this|that|they|them|he|she|dose|dosing|how much|what about|next|admit|discharge|criteria|side effects?|contraindications?|monitor|monitoring)\b/i.test(text);
|
||||
}
|
||||
|
||||
function priorClinicalHistory(history, message) {
|
||||
var latest = String(message || '').trim();
|
||||
return (Array.isArray(history) ? history : []).filter(function(m) {
|
||||
return m && (m.role === 'user' || m.role === 'assistant') && m.content && String(m.content).trim() !== latest;
|
||||
});
|
||||
}
|
||||
|
||||
function deterministicFollowupQuery(message, history) {
|
||||
var text = String(message || '').trim().toLowerCase();
|
||||
if (!/^(dose|dosing|what dose|dose\?|how much|med dose|medication dose)$/i.test(text)) return '';
|
||||
var prior = previousUserQuestion(history) || previousAssistantTopic(history);
|
||||
if (!prior) return '';
|
||||
return 'medication dosing and immediate treatment details for: ' + prior;
|
||||
}
|
||||
|
||||
function previousUserQuestion(history) {
|
||||
for (var i = history.length - 1; i >= 0; i--) {
|
||||
if (history[i] && history[i].role === 'user' && history[i].content) return clip(history[i].content, 300);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function previousAssistantTopic(history) {
|
||||
for (var i = history.length - 1; i >= 0; i--) {
|
||||
if (history[i] && history[i].role === 'assistant' && history[i].content) return clip(history[i].content, 300);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function summarizeExportItems(items) {
|
||||
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
|
||||
var clean = items.slice(0, 20).map(function(item, idx) {
|
||||
|
|
@ -498,16 +569,67 @@ async function multimodalSearch(query, opts) {
|
|||
|
||||
async function indexedTopicSuggestions(limit) {
|
||||
return callMcpTool('nc_indexed_topic_suggestions', {
|
||||
limit: limit || 9,
|
||||
sample_size: 900,
|
||||
limit: limit || 12,
|
||||
sample_size: 1500,
|
||||
doc_type: 'file'
|
||||
});
|
||||
}
|
||||
|
||||
async function callMcpTool(name, args) {
|
||||
var queued = _mcpCallQueue.then(function() {
|
||||
return callMcpToolUnlocked(name, args);
|
||||
});
|
||||
_mcpCallQueue = queued.catch(function() {});
|
||||
return queued;
|
||||
}
|
||||
|
||||
async function callMcpToolUnlocked(name, args) {
|
||||
var session = await getMcpSession();
|
||||
var payload = {
|
||||
jsonrpc: '2.0',
|
||||
id: nextMcpRequestId(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: name,
|
||||
arguments: args
|
||||
}
|
||||
};
|
||||
var search;
|
||||
try {
|
||||
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
|
||||
} catch (e) {
|
||||
if (!isInvalidMcpSessionError(e)) throw e;
|
||||
_mcpSession = null;
|
||||
session = await getMcpSession();
|
||||
payload.id = nextMcpRequestId();
|
||||
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
|
||||
}
|
||||
return search.result || search;
|
||||
}
|
||||
|
||||
function nextMcpRequestId() {
|
||||
_mcpRequestId += 1;
|
||||
if (_mcpRequestId > 1000000000) _mcpRequestId = 2;
|
||||
return _mcpRequestId;
|
||||
}
|
||||
|
||||
async function getMcpSession() {
|
||||
var now = Date.now();
|
||||
if (_mcpSession && _mcpSession.sessionId && _mcpSession.expiresAt > now) return _mcpSession;
|
||||
if (_mcpSessionPromise) return _mcpSessionPromise;
|
||||
_mcpSessionPromise = initializeMcpSession().then(function(session) {
|
||||
_mcpSession = session;
|
||||
return session;
|
||||
}).finally(function() {
|
||||
_mcpSessionPromise = null;
|
||||
});
|
||||
return _mcpSessionPromise;
|
||||
}
|
||||
|
||||
async function initializeMcpSession() {
|
||||
var session = await mcpRequest({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
id: nextMcpRequestId(),
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
|
|
@ -515,16 +637,8 @@ async function callMcpTool(name, args) {
|
|||
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
var search = await mcpRequest({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: name,
|
||||
arguments: args
|
||||
}
|
||||
}, session.sessionId, session.mcpUrl);
|
||||
return search.result || search;
|
||||
session.expiresAt = Date.now() + MCP_SESSION_TTL_MS;
|
||||
return session;
|
||||
}
|
||||
|
||||
async function mcpRequest(payload, sessionId, preferredUrl) {
|
||||
|
|
@ -551,7 +665,7 @@ async function postMcpWithRetry(payload, headers, preferredUrl) {
|
|||
try {
|
||||
var resp = await axios.post(url, payload, {
|
||||
headers: headers,
|
||||
timeout: isInitialize ? 8000 : 90000,
|
||||
timeout: isInitialize ? MCP_INITIALIZE_TIMEOUT_MS : MCP_REQUEST_TIMEOUT_MS,
|
||||
responseType: 'text',
|
||||
transformResponse: [function(data) { return data; }]
|
||||
});
|
||||
|
|
@ -582,6 +696,12 @@ function isTransientMcpError(e) {
|
|||
return code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ETIMEDOUT' || status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
function isInvalidMcpSessionError(e) {
|
||||
var status = e && e.response && e.response.status;
|
||||
var msg = String(e && e.message || '');
|
||||
return status === 400 || status === 404 || status === 410 || /session|mcp-session-id/i.test(msg);
|
||||
}
|
||||
|
||||
async function getMcpHealth() {
|
||||
var results = [];
|
||||
var urls = orderedMcpUrls();
|
||||
|
|
@ -707,6 +827,9 @@ function normalizeMcpMultimodalResponse(result) {
|
|||
visual_caption: caption,
|
||||
visual_labels: labels,
|
||||
visual_caption_source: r.visual_caption_source || '',
|
||||
is_cover_page: Boolean(r.is_cover_page || r.isCoverPage),
|
||||
is_title_page: Boolean(r.is_title_page || r.isTitlePage),
|
||||
reject_for_visual_search: Boolean(r.reject_for_visual_search || r.rejectForVisualSearch),
|
||||
excerpt: excerptParts.length ? '[Page-image match] ' + excerptParts.join('\n') : '[Page-image match] Rendered PDF page matched the visual/text query.',
|
||||
score: r.score,
|
||||
image_path_internal: r.image_path || '',
|
||||
|
|
@ -718,17 +841,40 @@ function normalizeMcpMultimodalResponse(result) {
|
|||
}
|
||||
|
||||
function dedupeSources(results) {
|
||||
var seen = new Set();
|
||||
var seen = new Map();
|
||||
var out = [];
|
||||
results.forEach(function(r) {
|
||||
var key = [r.title, r.page || '', r.chunk_index || ''].join('|');
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
var key = sourceDedupeKey(r);
|
||||
var existingIndex = seen.get(key);
|
||||
if (existingIndex != null) {
|
||||
out[existingIndex] = mergeDuplicateSource(out[existingIndex], r);
|
||||
return;
|
||||
}
|
||||
seen.set(key, out.length);
|
||||
out.push(Object.assign({}, r, { number: out.length + 1 }));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function sourceDedupeKey(source) {
|
||||
var title = cleanTitle(source && source.title || '').toLowerCase();
|
||||
var page = source && source.page ? String(source.page) : '';
|
||||
var kind = source && source.source_type ? source.source_type : 'text';
|
||||
if (title && page) return [kind, title, page].join('|');
|
||||
return [kind, title, source && source.id || source && source.chunk_index || ''].join('|');
|
||||
}
|
||||
|
||||
function mergeDuplicateSource(existing, duplicate) {
|
||||
var merged = Object.assign({}, existing);
|
||||
var existingExcerpt = cleanSourceExcerpt(existing.excerpt || '');
|
||||
var duplicateExcerpt = cleanSourceExcerpt(duplicate.excerpt || '');
|
||||
if (duplicateExcerpt && existingExcerpt.indexOf(duplicateExcerpt.slice(0, 160)) === -1) {
|
||||
merged.excerpt = clip([existingExcerpt, duplicateExcerpt].filter(Boolean).join('\n'), 1800);
|
||||
}
|
||||
merged.score = Math.max(Number(existing.score) || 0, Number(duplicate.score) || 0) || existing.score || duplicate.score;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function formatSourcesForPrompt(sources) {
|
||||
return sources.map(function(s) {
|
||||
var label = s.source_type === 'multimodal_page' ? ' [visual PDF page match]' : '';
|
||||
|
|
@ -737,7 +883,7 @@ function formatSourcesForPrompt(sources) {
|
|||
}
|
||||
|
||||
function buildSystemPrompt(behavior) {
|
||||
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- Use numbered citations [1], [2] for factual claims, matching the provided source numbers.\n- If sources disagree or are insufficient, say so.\n- Do not cite a source number that is not provided.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
|
||||
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- Use the exact source numbers from the retrieved indexed sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3].\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- If a claim is not directly supported by retrieved sources, omit it or state that the indexed sources are insufficient.\n- Do not cite a source number that is not provided.\n- If sources disagree or are insufficient, say so.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
|
||||
}
|
||||
|
||||
function buildUserPrompt(question, context, history, searchQuery) {
|
||||
|
|
@ -754,44 +900,6 @@ function stripModelSourcesSection(answer) {
|
|||
.trim();
|
||||
}
|
||||
|
||||
function normalizeCitationOrder(answer, sources) {
|
||||
sources = Array.isArray(sources) ? sources : [];
|
||||
var used = [];
|
||||
var oldToNew = {};
|
||||
var re = /\[((?:\d+\s*,\s*)*\d+)\]/g;
|
||||
var m;
|
||||
while ((m = re.exec(answer))) {
|
||||
parseCitationCluster(m[1]).forEach(function(oldNum) {
|
||||
if (!sources[oldNum - 1] || oldToNew[oldNum]) return;
|
||||
oldToNew[oldNum] = used.length + 1;
|
||||
used.push(oldNum);
|
||||
});
|
||||
}
|
||||
|
||||
var reordered = [];
|
||||
used.forEach(function(oldNum) { reordered.push(sources[oldNum - 1]); });
|
||||
sources.forEach(function(source, idx) {
|
||||
if (used.indexOf(idx + 1) === -1) reordered.push(source);
|
||||
});
|
||||
reordered = reordered.map(function(source, idx) {
|
||||
return Object.assign({}, source, { number: idx + 1 });
|
||||
});
|
||||
|
||||
var rewritten = answer.replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function(match, cluster) {
|
||||
var oldNums = parseCitationCluster(cluster);
|
||||
var nums = oldNums.map(function(oldNum) { return oldToNew[oldNum]; });
|
||||
if (!nums.length || nums.some(function(n) { return !n; })) return match;
|
||||
nums = Array.from(new Set(nums)).sort(function(a, b) { return a - b; });
|
||||
return '[' + nums.join(', ') + ']';
|
||||
});
|
||||
|
||||
return { answer: rewritten, sources: reordered };
|
||||
}
|
||||
|
||||
function parseCitationCluster(cluster) {
|
||||
return String(cluster || '').split(',').map(function(n) { return Number(n.trim()); }).filter(function(n) { return Number.isInteger(n) && n > 0; });
|
||||
}
|
||||
|
||||
function isVisualSourceQuery(query) {
|
||||
return visualIntent(query).wanted.length > 0;
|
||||
}
|
||||
|
|
@ -813,6 +921,8 @@ async function classifyAndRerankMultimodalResults(query, results) {
|
|||
results = Array.isArray(results) ? results : [];
|
||||
if (!results.length) return [];
|
||||
var candidates = results.slice(0, VISUAL_CLASSIFIER_LIMIT);
|
||||
candidates = candidates.filter(function(candidate) { return !shouldRejectVisualSource(query, candidate); });
|
||||
if (!candidates.length) return [];
|
||||
if (!VISUAL_CLASSIFIER_ENABLED) return selectMultimodalResults(query, candidates);
|
||||
var intent = visualIntent(query);
|
||||
var classified = [];
|
||||
|
|
@ -842,6 +952,8 @@ async function classifyAndRerankMultimodalResults(query, results) {
|
|||
function selectMultimodalResults(query, results) {
|
||||
results = Array.isArray(results) ? results : [];
|
||||
if (!results.length) return [];
|
||||
results = results.filter(function(r) { return !shouldRejectVisualSource(query, r); });
|
||||
if (!results.length) return [];
|
||||
if (!isRadiologyQuery(query)) return results.filter(function(r) { return !looksLikeTextOnlyPage(r); });
|
||||
var radiologyTerms = /\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film|pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i;
|
||||
return results.filter(function(r) {
|
||||
|
|
@ -909,10 +1021,33 @@ function visualMetadataScore(query, source) {
|
|||
if (intent.wanted.indexOf('diagram') !== -1 && /\b(figure|diagram|algorithm|flowchart|pathway|chart|illustration|infographic)\b/i.test(haystack)) score += 0.12;
|
||||
if (intent.wanted.indexOf('photo') !== -1 && /\b(photo|photograph|skin|rash|lesion|wound|gross|microscopy|histology)\b/i.test(haystack)) score += 0.12;
|
||||
if (/\b(pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i.test(haystack)) score += 0.06;
|
||||
if (looksLikeFrontMatterPage(source)) score -= 0.5;
|
||||
if (looksLikeTextOnlyPage(source)) score -= 0.18;
|
||||
return score;
|
||||
}
|
||||
|
||||
function shouldRejectVisualSource(query, source) {
|
||||
if (!source) return false;
|
||||
if (allowsFrontMatterQuery(query)) return false;
|
||||
return Boolean(source.reject_for_visual_search || source.is_cover_page || source.is_title_page || looksLikeFrontMatterPage(source));
|
||||
}
|
||||
|
||||
function allowsFrontMatterQuery(query) {
|
||||
return /\b(cover|front cover|title page|copyright|table of contents|contents|preface|foreword|publisher|isbn)\b/i.test(String(query || ''));
|
||||
}
|
||||
|
||||
function looksLikeFrontMatterPage(source) {
|
||||
var page = Number(source && source.page) || 0;
|
||||
var pageCount = Number(source && source.page_count) || 0;
|
||||
var earlyLimit = Math.min(8, Math.max(2, pageCount || 8));
|
||||
if (!page || page > earlyLimit) return false;
|
||||
var haystack = [source.title, source.excerpt, source.visual_caption, (source.visual_labels || []).join(' '), source.file_path].filter(Boolean).join(' ');
|
||||
var hasVisualSignal = /\b(figure|fig\.?|radiograph|x-?ray|cxr|ultrasound|ct|mri|photograph|photo|image|diagram|flowchart|algorithm|histology|microscopy|gross pathology)\b/i.test(haystack);
|
||||
if (hasVisualSignal) return false;
|
||||
var frontMatterSignal = /\b(cover|front cover|title page|copyright|all rights reserved|isbn|library of congress|table of contents|contents|preface|foreword|dedication|contributors?|textbook of|manual of|review of|board review|edition|authors?|editor|publisher)\b/i.test(haystack);
|
||||
return frontMatterSignal || page <= 2;
|
||||
}
|
||||
|
||||
function looksLikeTextOnlyPage(source) {
|
||||
var text = String(source && source.excerpt || '');
|
||||
return text.length > 900 && !/\b(figure|image|radiograph|x-?ray|cxr|ultrasound|ct|mri|scan|film|diagram|illustration)\b/i.test(text);
|
||||
|
|
@ -960,6 +1095,38 @@ function resolveRawMultimodalImagePath(raw) {
|
|||
return servedPath;
|
||||
}
|
||||
|
||||
function isS3Uri(value) {
|
||||
return /^s3:\/\/[^/]+\/.+/.test(String(value || ''));
|
||||
}
|
||||
|
||||
function parseS3Uri(uri) {
|
||||
var m = String(uri || '').match(/^s3:\/\/([^/]+)\/(.+)$/);
|
||||
if (!m) return null;
|
||||
return { bucket: m[1], key: m[2] };
|
||||
}
|
||||
|
||||
function getS3Client() {
|
||||
if (_s3Client) return _s3Client;
|
||||
if (!MULTIMODAL_S3_ENDPOINT || !MULTIMODAL_S3_ACCESS_KEY || !MULTIMODAL_S3_SECRET_KEY) return null;
|
||||
_s3Client = new S3Client({
|
||||
endpoint: MULTIMODAL_S3_ENDPOINT,
|
||||
region: process.env.CLINICAL_ASSISTANT_MULTIMODAL_S3_REGION || 'us-east-1',
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: MULTIMODAL_S3_ACCESS_KEY,
|
||||
secretAccessKey: MULTIMODAL_S3_SECRET_KEY
|
||||
}
|
||||
});
|
||||
return _s3Client;
|
||||
}
|
||||
|
||||
async function getS3Object(uri) {
|
||||
var parsed = parseS3Uri(uri);
|
||||
var client = getS3Client();
|
||||
if (!parsed || !client) return null;
|
||||
return client.send(new GetObjectCommand({ Bucket: parsed.bucket, Key: parsed.key }));
|
||||
}
|
||||
|
||||
function sanitizeSourcesForClient(sources) {
|
||||
return (Array.isArray(sources) ? sources : []).map(function(source) {
|
||||
var out = Object.assign({}, source);
|
||||
|
|
@ -1072,6 +1239,11 @@ function cleanExportHeading(heading, idx) {
|
|||
async function getAvailableExamples() {
|
||||
var now = Date.now();
|
||||
if (_exampleCache.expiresAt > now && _exampleCache.examples.length) return _exampleCache.examples;
|
||||
var cached = await redisCache.getJson('clinical-assistant:examples:v1').catch(function() { return null; });
|
||||
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
|
||||
_exampleCache = { expiresAt: now + EXAMPLE_CACHE_MS, examples: cached.examples };
|
||||
return cached.examples;
|
||||
}
|
||||
|
||||
var indexedExamples = await getIndexedTopicExamples().catch(function(e) {
|
||||
console.warn('[clinical-assistant] indexed topic suggestions skipped:', e.message);
|
||||
|
|
@ -1079,6 +1251,7 @@ async function getAvailableExamples() {
|
|||
});
|
||||
if (indexedExamples.length >= 3) {
|
||||
_exampleCache = { expiresAt: now + EXAMPLE_CACHE_MS, examples: indexedExamples };
|
||||
redisCache.setJson('clinical-assistant:examples:v1', { examples: indexedExamples }, 1800).catch(function() {});
|
||||
return indexedExamples;
|
||||
}
|
||||
|
||||
|
|
@ -1110,11 +1283,12 @@ async function getAvailableExamples() {
|
|||
expiresAt: now + EXAMPLE_CACHE_MS,
|
||||
examples: examples
|
||||
};
|
||||
redisCache.setJson('clinical-assistant:examples:v1', { examples: examples }, 1800).catch(function() {});
|
||||
return examples;
|
||||
}
|
||||
|
||||
async function getIndexedTopicExamples() {
|
||||
var response = await indexedTopicSuggestions(9);
|
||||
var response = await indexedTopicSuggestions(12);
|
||||
var data = response && (response.structuredContent || response.data || response);
|
||||
if ((!data || !Array.isArray(data.suggestions)) && response && Array.isArray(response.content)) {
|
||||
for (var i = 0; i < response.content.length; i++) {
|
||||
|
|
@ -1128,7 +1302,7 @@ async function getIndexedTopicExamples() {
|
|||
}
|
||||
}
|
||||
if (!data || !Array.isArray(data.suggestions)) return [];
|
||||
return data.suggestions.slice(0, 9).map(function(item) {
|
||||
return data.suggestions.slice(0, 12).map(function(item) {
|
||||
return {
|
||||
label: item.label || 'Indexed topic',
|
||||
prompt: item.prompt || '',
|
||||
|
|
@ -1148,10 +1322,11 @@ async function generateImage(prompt, model) {
|
|||
if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation');
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY;
|
||||
var renderedPrompt = imagePromptForCanvas(prompt);
|
||||
var size = process.env.CLINICAL_ASSISTANT_IMAGE_SIZE || 'auto';
|
||||
var resp = await generateImageRequest(model, imagePromptForCanvas(prompt), size, headers).catch(async function(e) {
|
||||
var resp = await generateImageRequest(model, renderedPrompt, size, headers).catch(async function(e) {
|
||||
if (!isInvalidImageSizeError(e) || size === '1024x1024') throw e;
|
||||
return generateImageRequest(model, imagePromptForCanvas(prompt), '1024x1024', headers);
|
||||
return generateImageRequest(model, renderedPrompt, '1024x1024', headers);
|
||||
});
|
||||
var item = resp.data && resp.data.data && resp.data.data[0] ? resp.data.data[0] : {};
|
||||
return { imageUrl: item.url || null, base64: item.b64_json || null, raw: (!item.url && !item.b64_json) ? resp.data : undefined };
|
||||
|
|
@ -1167,12 +1342,12 @@ function generateImageRequest(model, prompt, size, headers) {
|
|||
|
||||
function imagePromptForCanvas(prompt) {
|
||||
var text = String(prompt || '');
|
||||
var guidance = ' Keep all text and boxes fully inside the canvas with generous margins. Use fewer words per box, large readable type, and avoid cropping at edges.';
|
||||
var guidance = ' Compose as a single complete medical teaching poster. Keep every element fully inside the canvas with a 10% safe margin on all sides. Do not crop boxes, arrows, labels, legends, or body parts. Use fewer words per box, large readable type, and generous spacing.';
|
||||
if (/\b(flow\s*chart|flowchart|algorithm|pathway|timeline|vertical|stepwise|decision\s*tree|age\s*group|0-21|22-28|29-60)\b/i.test(text)) {
|
||||
guidance += ' Prefer a tall portrait canvas with top-to-bottom flow and ample spacing between decision nodes.';
|
||||
guidance += ' Use a tall portrait layout with top-to-bottom flow, no more than 6-8 main nodes, and ample spacing between decision nodes.';
|
||||
}
|
||||
if (/\b(table|matrix|comparison|wide|landscape|side-by-side)\b/i.test(text)) {
|
||||
guidance += ' Prefer a wide landscape canvas with columns and ample horizontal spacing.';
|
||||
guidance += ' Use a wide landscape layout with compact columns, ample horizontal spacing, and no text near the edges.';
|
||||
}
|
||||
return text.trim() + guidance;
|
||||
}
|
||||
|
|
@ -1233,18 +1408,18 @@ function getTopicSuggestions(message) {
|
|||
}
|
||||
if (normalized.split(/\s+/).length <= 2) {
|
||||
return [
|
||||
'Management of ' + normalized,
|
||||
'Red flags and differential diagnosis for ' + normalized,
|
||||
'Initial ED evaluation for ' + normalized,
|
||||
'Admission versus discharge criteria for ' + normalized,
|
||||
'Medication dosing and escalation for ' + normalized
|
||||
'What initial ED evaluation, severity assessment, and immediate stabilization steps are recommended for pediatric ' + normalized + '?',
|
||||
'Which red flags, differential diagnoses, and complications should not be missed in pediatric ' + normalized + '?',
|
||||
'What treatment escalation, medication dosing, and monitoring are recommended for pediatric ' + normalized + '?',
|
||||
'What admission, discharge, observation, and follow-up criteria are recommended for pediatric ' + normalized + '?',
|
||||
'How do recommendations differ by age group, severity, comorbidity, or high-risk features in pediatric ' + normalized + '?'
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildSuggestionAnswer(topic, suggestions) {
|
||||
return 'I can look up **' + topic + '**. Pick a focused question so I can retrieve the most relevant cited pages:\n\n' +
|
||||
return 'I can look up **' + topic + '**. Pick a specific clinical question so I retrieve the right cited pages instead of giving a broad summary:\n\n' +
|
||||
suggestions.map(function(s) { return '- ' + s; }).join('\n') +
|
||||
'\n\nOr type your own specific question, for example: "management of acute asthma exacerbation in a 6-year-old".';
|
||||
}
|
||||
|
|
|
|||
41
src/utils/redis.js
Normal file
41
src/utils/redis.js
Normal 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 };
|
||||
|
|
@ -2,6 +2,7 @@ const { test } = require('node:test');
|
|||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const MarkdownIt = require('markdown-it');
|
||||
|
||||
let modulePromise;
|
||||
|
||||
|
|
@ -24,8 +25,16 @@ test('renders citation clusters as links to matching source cards', async () =>
|
|||
const html = renderAssistantMarkdown('Give magnesium for severe exacerbation [1, 2].', sources);
|
||||
assert.match(html, /href="#assistant-source-1"/);
|
||||
assert.match(html, /href="#assistant-source-2"/);
|
||||
assert.match(html, /title="Nelson Textbook of Pediatrics"/);
|
||||
assert.match(html, /\[<a class="assistant-cite"[^>]*>1<\/a>, <a class="assistant-cite"[^>]*>2<\/a>\]/);
|
||||
assert.match(html, /title="Source 1: Nelson Textbook of Pediatrics"/);
|
||||
assert.match(html, /data-source-number="1"/);
|
||||
assert.match(html, /data-source-number="2"/);
|
||||
assert.match(html, /<a class="assistant-cite"[^>]*>src<\/a> <a class="assistant-cite"[^>]*>src<\/a>/);
|
||||
});
|
||||
|
||||
test('can render citation labels as numbers for PDF export', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const html = renderAssistantMarkdown('Give magnesium for severe exacerbation [1, 2].', sources, { citationLabel: 'number' });
|
||||
assert.match(html, /<a class="assistant-cite"[^>]*>1<\/a> <a class="assistant-cite"[^>]*>2<\/a>/);
|
||||
});
|
||||
|
||||
test('leaves unknown citation clusters untouched rather than guessing', async () => {
|
||||
|
|
@ -38,9 +47,9 @@ test('leaves unknown citation clusters untouched rather than guessing', async ()
|
|||
test('does not merge separate citations across separate claims', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const html = renderAssistantMarkdown('Use oxygen [1]. Give bronchodilator [2].', sources);
|
||||
assert.match(html, /oxygen \[<a class="assistant-cite"[^>]*>1<\/a>\]/);
|
||||
assert.match(html, /bronchodilator \[<a class="assistant-cite"[^>]*>2<\/a>\]/);
|
||||
assert.doesNotMatch(html, />1<\/a>, <a class="assistant-cite"[^>]*>2<\/a>/);
|
||||
assert.match(html, /oxygen <a class="assistant-cite"[^>]*data-source-number="1"[^>]*>src<\/a>/);
|
||||
assert.match(html, /bronchodilator <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>src<\/a>/);
|
||||
assert.doesNotMatch(html, /data-source-number="1"[^>]*>src<\/a><a class="assistant-cite"[^>]*data-source-number="2"/);
|
||||
});
|
||||
|
||||
test('sorts adjacent citation tokens into one safe Vancouver cluster', async () => {
|
||||
|
|
@ -48,7 +57,7 @@ test('sorts adjacent citation tokens into one safe Vancouver cluster', async ()
|
|||
const html = renderAssistantMarkdown('Deteriorating course [1][4][2][3].', [
|
||||
{ title: 'A' }, { title: 'B' }, { title: 'C' }, { title: 'D' }
|
||||
]);
|
||||
assert.match(html, />1<\/a>, <a class="assistant-cite"[^>]*>2<\/a>, <a class="assistant-cite"[^>]*>3<\/a>, <a class="assistant-cite"[^>]*>4<\/a>/);
|
||||
assert.match(html, /data-source-number="1"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>src<\/a>/);
|
||||
assert.doesNotMatch(html, /\]<span|\]\[/);
|
||||
});
|
||||
|
||||
|
|
@ -57,7 +66,7 @@ test('repairs a clearly adjacent trailing citation missing its closing bracket',
|
|||
const html = renderAssistantMarkdown('Deteriorating course [1][4][2][3', [
|
||||
{ title: 'A' }, { title: 'B' }, { title: 'C' }, { title: 'D' }
|
||||
]);
|
||||
assert.match(html, />1<\/a>, <a class="assistant-cite"[^>]*>2<\/a>, <a class="assistant-cite"[^>]*>3<\/a>, <a class="assistant-cite"[^>]*>4<\/a>/);
|
||||
assert.match(html, /data-source-number="1"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>src<\/a>/);
|
||||
});
|
||||
|
||||
test('does not normalize adjacent citations if any source number is unknown', async () => {
|
||||
|
|
@ -70,7 +79,7 @@ test('does not normalize adjacent citations if any source number is unknown', as
|
|||
test('escapes source titles inside citation link titles', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const html = renderAssistantMarkdown('Unsafe title [1].', [{ title: 'Bad "Title" <script>' }]);
|
||||
assert.match(html, /title="Bad "Title" <script>"/);
|
||||
assert.match(html, /title="Source 1: Bad "Title" <script>"/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
});
|
||||
|
||||
|
|
@ -120,9 +129,76 @@ test('repairs smashed admission bullet list after citations', async () => {
|
|||
assert.doesNotMatch(html, /\]-/);
|
||||
});
|
||||
|
||||
test('repairs smashed head injury red flag bullets after citation clusters', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const html = renderAssistantMarkdown('CT is indicated for: [1, 2, 3]- Focal neurologic deficits [1]- Signs of skull fracture [2]- Recurrent vomiting [3]', sources);
|
||||
assert.match(html, /<li>Focal neurologic deficits/);
|
||||
assert.match(html, /<li>Signs of skull fracture/);
|
||||
assert.match(html, /<li>Recurrent vomiting/);
|
||||
assert.doesNotMatch(html, /\]-/);
|
||||
});
|
||||
|
||||
test('preserves code block contents without creating citation links inside code', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const html = renderAssistantMarkdown('```js\nconst ref = "[1]";\n```', sources);
|
||||
assert.match(html, /<pre><code>const ref = "\[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><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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue