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 Export

Preparing 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

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