refactor clinical assistant prompt handling
This commit is contained in:
parent
6925250e75
commit
baf05198a8
9 changed files with 368 additions and 476 deletions
|
|
@ -8,9 +8,6 @@ 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:-<minio-credential>}
|
||||
CLINICAL_ASSISTANT_MULTIMODAL_S3_SECRET_KEY: ${MULTIMODAL_OBJECT_STORE_SECRET_KEY:-<minio-credential>}
|
||||
REDIS_URL: redis://ped-ai-redis:6379
|
||||
volumes:
|
||||
- scribe-logs:/app/data/logs
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@
|
|||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
|
||||
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">
|
||||
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
|
||||
<script src="/vendor/marked/marked.umd.js" defer></script>
|
||||
<script src="/vendor/markdown-it/markdown-it.min.js" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" defer></script>
|
||||
<script src="/vendor/katex/katex.min.js" defer></script>
|
||||
<script src="/vendor/mathjax/tex-mml-chtml.js" defer></script>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export function renderAssistantMarkdown(md, sources, options) {
|
||||
var opts = options || {};
|
||||
var text = stripOrphanMarkdownMarkers(repairTabSeparatedTables(repairMarkdownTables(normalizeMarkdownText(md))));
|
||||
var text = stripOrphanMarkdownMarkers(normalizeMarkdownText(md));
|
||||
var codeBlocks = [];
|
||||
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
|
||||
var idx = codeBlocks.length;
|
||||
|
|
@ -12,10 +12,10 @@ export function renderAssistantMarkdown(md, sources, options) {
|
|||
text = normalizeAdjacentCitationClusters(text, sources || []);
|
||||
|
||||
var html;
|
||||
if (opts.markdownIt && typeof opts.markdownIt.render === 'function') {
|
||||
html = opts.markdownIt.render(text);
|
||||
} else if (opts.marked && typeof opts.marked.parse === 'function') {
|
||||
if (opts.marked && typeof opts.marked.parse === 'function') {
|
||||
html = opts.marked.parse(text, { breaks: true, gfm: true });
|
||||
} else if (opts.markdownIt && typeof opts.markdownIt.render === 'function') {
|
||||
html = opts.markdownIt.render(text);
|
||||
} else {
|
||||
html = fallbackMarkdown(text);
|
||||
}
|
||||
|
|
@ -103,177 +103,15 @@ export function normalizeMarkdownText(text) {
|
|||
.replace(/(#{1,4}\s+[^\n]+?)\s+(-\s+)/g, '$1\n\n$2')
|
||||
.replace(/(#{1,4}\s+[^\n]+)\n(-\s+)/g, '$1\n\n$2')
|
||||
.replace(/([^\n])\s+(-\s+(?:Mainstay|Medications|Hospitalization|Other therapies|Prevention|Short-acting|Anticholinergics|Systemic|Adjuncts|Long-term|Infants|Differentiating|Persistent|Severe|Need for|Inadequate)\b)/g, '$1\n$2')
|
||||
.replace(/([^\n])\s+(\|\s*Aspect\s*\|)/gi, '$1\n\n$2')
|
||||
.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());
|
||||
}
|
||||
|
||||
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(' | ') + ' |';
|
||||
return String(text || '')
|
||||
.replace(/\s*(?:\*\*|__|\*|_)\s*$/g, '')
|
||||
.replace(/\s*(?:\*\*|__)?\s*(?:Figure|Fig\.)\s*(?:\*\*|__)?\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function fallbackMarkdown(text) {
|
||||
|
|
|
|||
|
|
@ -316,8 +316,10 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
function renderMarkdown(md, sources, options) {
|
||||
var opts = options || {};
|
||||
return renderAssistantMarkdown(md, sources || [], {
|
||||
marked: window.marked,
|
||||
markdownIt: getMarkdownRenderer(),
|
||||
katex: window.katex,
|
||||
mathJax: window.MathJax,
|
||||
sanitize: sanitize,
|
||||
citationLabel: opts.citationLabel
|
||||
});
|
||||
|
|
@ -359,7 +361,6 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
'<strong>[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '</strong>' +
|
||||
renderSourceBadges(s) +
|
||||
'<div class="assistant-source-meta">' + escapeHtml(meta.join(' · ') || 'indexed source') + '</div>' +
|
||||
renderSourceImage(s, n) +
|
||||
(s.excerpt ? '<div class="assistant-source-excerpt"><p>' + escapeHtml(cleanSourceExcerpt(s.excerpt).slice(0, 900)) + '</p></div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
|
@ -378,18 +379,6 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function renderSourceImage(source, number) {
|
||||
var src = source && (source.image_url || source.imageUrl);
|
||||
if (!src || source.source_type !== 'multimodal_page') return '';
|
||||
var id = 'source-img-' + number + '-' + (++generatedImageSeq);
|
||||
generatedImages[id] = src;
|
||||
return '<div class="assistant-source-preview">' +
|
||||
'<button type="button" data-assistant-open-image="' + escapeAttr(id) + '" aria-label="Preview visual page source">' +
|
||||
'<img src="' + escapeAttr(src) + '" alt="Visual PDF page source preview" loading="lazy">' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderEmbeddedBlocks(root) {
|
||||
root.querySelectorAll('[data-mermaid]').forEach(function (el) {
|
||||
ensureMermaid().then(function () {
|
||||
|
|
@ -635,10 +624,11 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
var imageHtml = imageSrc ? '<h2>Generated Image</h2><div class="export-image"><img src="' + escapeAttr(imageSrc) + '" alt="Generated clinical visual"></div>' : '';
|
||||
var sections = items.map(function (item, idx) {
|
||||
var sources = Array.isArray(item.sources) ? item.sources : [];
|
||||
var refs = renderExportRefs(sources, idx + 1);
|
||||
var heading = item.heading || deriveExportHeading(item.question, idx);
|
||||
var summary = item.summary || '';
|
||||
var answer = stripSourcesSection(item.answer || '');
|
||||
var citedNumbers = extractCitedSourceNumbers([summary, answer].filter(Boolean).join('\n\n'));
|
||||
var refs = renderExportRefs(sources, idx + 1, citedNumbers);
|
||||
return '<section class="export-section">' +
|
||||
'<h2>' + escapeHtml(heading) + '</h2>' +
|
||||
'<div class="question"><strong>Question:</strong> ' + escapeHtml(item.question || '') + '</div>' +
|
||||
|
|
@ -688,8 +678,12 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
return items;
|
||||
}
|
||||
|
||||
function renderExportRefs(sources, sectionNumber) {
|
||||
return (sources || []).map(function (s, idx) {
|
||||
function renderExportRefs(sources, sectionNumber, citedNumbers) {
|
||||
var cited = citedNumbers && citedNumbers.length ? new Set(citedNumbers.map(String)) : null;
|
||||
return (sources || []).filter(function (s, idx) {
|
||||
var n = s.number || idx + 1;
|
||||
return !cited || cited.has(String(n));
|
||||
}).map(function (s, idx) {
|
||||
var n = s.number || idx + 1;
|
||||
var title = s.title || s.resource || 'Untitled source';
|
||||
var page = s.page || s.page_number || s.pageNumber;
|
||||
|
|
@ -697,6 +691,18 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
}).join('');
|
||||
}
|
||||
|
||||
function extractCitedSourceNumbers(text) {
|
||||
var found = new Set();
|
||||
String(text || '').replace(/\[(\d+(?:\s*,\s*\d+)*)\]/g, function (_, nums) {
|
||||
nums.split(',').forEach(function (n) {
|
||||
n = String(n || '').trim();
|
||||
if (n) found.add(n);
|
||||
});
|
||||
return _;
|
||||
});
|
||||
return Array.from(found).sort(function (a, b) { return Number(a) - Number(b); });
|
||||
}
|
||||
|
||||
function deriveExportHeading(question, idx) {
|
||||
var text = String(question || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || /^(what|which|when|why|how|and|also|what dose\??|dose\??)$/i.test(text)) return 'Clinical Question ' + (idx + 1);
|
||||
|
|
@ -888,8 +894,8 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
}
|
||||
|
||||
function isImageRequest(text) {
|
||||
return /\b(generate|create|draw|make|show|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) ||
|
||||
/\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b[\s\S]{0,80}\b(of|for|about|showing)\b/i.test(text);
|
||||
return /\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) ||
|
||||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(an?|the)?\s*(algorithm|pathway|poster|teaching visual)\b/i.test(text);
|
||||
}
|
||||
|
||||
function setBusy(isBusy, text, isError) {
|
||||
|
|
|
|||
15
server.js
15
server.js
|
|
@ -213,6 +213,21 @@ app.use('/vendor/markdown-it', express.static(path.join(__dirname, 'node_modules
|
|||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
}));
|
||||
app.use('/vendor/marked', express.static(path.join(__dirname, 'node_modules', 'marked', 'lib'), {
|
||||
setHeaders: function(res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
}));
|
||||
app.use('/vendor/katex', express.static(path.join(__dirname, 'node_modules', 'katex', 'dist'), {
|
||||
setHeaders: function(res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
}));
|
||||
app.use('/vendor/mathjax', express.static(path.join(__dirname, 'node_modules', 'mathjax-full', 'es5'), {
|
||||
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')) {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,6 @@
|
|||
|
||||
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');
|
||||
|
|
@ -18,6 +15,7 @@ var { gatewayUrl } = require('../utils/errors');
|
|||
var logger = require('../utils/logger');
|
||||
var cryptoUtil = require('../utils/crypto');
|
||||
var redisCache = require('../utils/redis');
|
||||
var { createClinicalPromptPool } = require('../utils/clinicalPromptPool');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
|
|
@ -30,23 +28,12 @@ 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 DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting, 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: [] };
|
||||
var MAX_SAVED_CHATS_PER_USER = 100;
|
||||
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;
|
||||
var VISUAL_CLASSIFIER_MIN_CONFIDENCE = Number(process.env.CLINICAL_ASSISTANT_VISUAL_CLASSIFIER_MIN_CONFIDENCE || '0.005');
|
||||
var MULTIMODAL_CANDIDATE_LIMIT = 6;
|
||||
var EXAMPLE_CANDIDATES = [
|
||||
{ label: 'Status asthma escalation', prompt: 'In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?' },
|
||||
{ label: 'Febrile neonate', prompt: 'What initial workup and empiric antibiotics are recommended for a well-appearing febrile neonate?' },
|
||||
|
|
@ -114,6 +101,18 @@ var TOPIC_SUGGESTIONS = {
|
|||
]
|
||||
};
|
||||
|
||||
var promptPool = createClinicalPromptPool({
|
||||
redisCache: redisCache,
|
||||
callAI: callAI,
|
||||
getSetting: getSetting,
|
||||
semanticSearch: semanticSearch,
|
||||
dedupeSources: dedupeSources,
|
||||
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
|
||||
getIndexedTopicExamples: getIndexedTopicExamples,
|
||||
exampleCandidates: EXAMPLE_CANDIDATES,
|
||||
topicSuggestions: TOPIC_SUGGESTIONS
|
||||
});
|
||||
|
||||
function positiveInt(value, fallback) {
|
||||
var n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
||||
|
|
@ -127,6 +126,12 @@ if (process.env.CLINICAL_ASSISTANT_MCP_WARMUP !== 'false') {
|
|||
}, positiveInt(process.env.CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS, 5000));
|
||||
}
|
||||
|
||||
setTimeout(function() {
|
||||
promptPool.refreshIfNeeded(false).catch(function(e) {
|
||||
console.warn('[clinical-assistant] prompt pool warmup skipped:', e.message);
|
||||
});
|
||||
}, positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_WARMUP_DELAY_MS, 15000));
|
||||
|
||||
router.get('/clinical-assistant/status', async function(req, res) {
|
||||
try {
|
||||
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
|
||||
|
|
@ -157,28 +162,6 @@ router.get('/clinical-assistant/examples', async function(req, res) {
|
|||
}
|
||||
});
|
||||
|
||||
router.get('/clinical-assistant/source-image/:token', async function(req, res) {
|
||||
try {
|
||||
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);
|
||||
res.status(404).send('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/clinical-assistant/chats', async function(req, res) {
|
||||
try {
|
||||
var rows = await db.all(
|
||||
|
|
@ -255,7 +238,7 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
var ai = await callAI(prepared.messages, {
|
||||
model: prepared.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
maxTokens: 1800
|
||||
maxTokens: 2600
|
||||
});
|
||||
var answer = stripModelSourcesSection(String(ai.content || '').trim());
|
||||
|
||||
|
|
@ -305,11 +288,23 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
|
|||
var ai = await callAIStream(prepared.messages, {
|
||||
model: prepared.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
maxTokens: 1800
|
||||
maxTokens: 2600
|
||||
}, function(delta) {
|
||||
sendEvent('token', { token: delta });
|
||||
});
|
||||
var answer = stripModelSourcesSection(String(ai.content || '').trim());
|
||||
if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
|
||||
sendEvent('status', { message: 'Completing answer...' });
|
||||
var completed = await callAI(prepared.messages, {
|
||||
model: prepared.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
maxTokens: 3200
|
||||
});
|
||||
answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
|
||||
ai.model = completed.model || ai.model;
|
||||
ai.provider = completed.provider || ai.provider;
|
||||
ai.finishReason = completed.finishReason || ai.finishReason;
|
||||
}
|
||||
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant streaming query', req, {
|
||||
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
|
||||
});
|
||||
|
|
@ -427,7 +422,7 @@ async function prepareAssistantChat(body) {
|
|||
if (sources.length === 0) {
|
||||
return { direct: {
|
||||
success: true,
|
||||
answer: 'I could not find relevant sources for that question. Try a more specific clinical term, diagnosis, medication, age group, or textbook topic.',
|
||||
answer: 'I could not find a clear match for that question. Please try a more specific clinical term, diagnosis, medication, age group, or textbook topic.',
|
||||
sources: [],
|
||||
model: null,
|
||||
search: { totalFound: 0 }
|
||||
|
|
@ -903,8 +898,6 @@ function normalizeMcpMultimodalResponse(result) {
|
|||
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 || '',
|
||||
image_url: multimodalImageUrl(r.image_path),
|
||||
chunk_index: 'page-image-' + (r.page_number || idx + 1),
|
||||
total_chunks: r.page_count || null
|
||||
};
|
||||
|
|
@ -954,14 +947,14 @@ function formatSourcesForPrompt(sources) {
|
|||
}
|
||||
|
||||
function buildSystemPrompt(behavior) {
|
||||
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.';
|
||||
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For short typo-like, misspelled, or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match. Begin with "Assuming you meant ..." and answer that concept. Do not ask for clarification unless the retrieved sources do not indicate any plausible concept.\n- Use the exact source numbers from the retrieved 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 say the available sources are insufficient.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\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- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\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) {
|
||||
var hist = history.filter(function(m) { return m && (m.role === 'user' || m.role === 'assistant') && m.content; })
|
||||
.map(function(m) { return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 1000); }).join('\n');
|
||||
var searchNote = searchQuery && searchQuery !== question ? ('\n\nStandalone retrieval query used:\n' + searchQuery) : '';
|
||||
return 'Question:\n' + question + searchNote + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved indexed sources:\n' + context + '\n\nWrite the answer now.';
|
||||
return 'Question:\n' + question + searchNote + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved sources:\n' + context + '\n\nWrite the answer now. If the question is a short misspelled or partial term and the sources point to a likely concept, answer the likely concept rather than asking for clarification.';
|
||||
}
|
||||
|
||||
function stripModelSourcesSection(answer) {
|
||||
|
|
@ -971,6 +964,15 @@ function stripModelSourcesSection(answer) {
|
|||
.trim();
|
||||
}
|
||||
|
||||
function shouldRegenerateTruncatedAnswer(answer, finishReason) {
|
||||
answer = String(answer || '').trim();
|
||||
if (!answer) return false;
|
||||
if (finishReason === 'length') return true;
|
||||
if (/\b(the|a|an|and|or|but|with|without|for|to|of|in|on|as|by|from|because|therefore|however)$/i.test(answer)) return true;
|
||||
if (/[,:;\-–—(]$/.test(answer)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isVisualSourceQuery(query) {
|
||||
return visualIntent(query).wanted.length > 0;
|
||||
}
|
||||
|
|
@ -991,33 +993,8 @@ function buildMultimodalSearchQuery(query) {
|
|||
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 = [];
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var candidate = candidates[i];
|
||||
var classification = await classifyVisualSource(candidate).catch(function(e) {
|
||||
console.warn('[clinical-assistant] visual classifier skipped candidate:', e.message);
|
||||
return null;
|
||||
});
|
||||
if (!classification) continue;
|
||||
var metaScore = visualMetadataScore(query, candidate);
|
||||
var confident = classification.confidence >= VISUAL_CLASSIFIER_MIN_CONFIDENCE || metaScore > 0.1;
|
||||
var accepted = intent.wanted.indexOf(classification.kind) !== -1 && confident;
|
||||
if (!accepted) continue;
|
||||
candidate.visual_kind = classification.kind;
|
||||
candidate.visual_confidence = classification.confidence;
|
||||
candidate.source_priority = classification.kind;
|
||||
candidate.visual_score = (Number(candidate.score) || 0) + (classification.confidence * 0.35) + metaScore;
|
||||
classified.push(candidate);
|
||||
}
|
||||
if (classified.length) {
|
||||
return classified.sort(function(a, b) { return (b.visual_score || 0) - (a.visual_score || 0); });
|
||||
}
|
||||
return [];
|
||||
var candidates = results.slice(0, MULTIMODAL_CANDIDATE_LIMIT);
|
||||
return selectMultimodalResults(query, candidates);
|
||||
}
|
||||
|
||||
function selectMultimodalResults(query, results) {
|
||||
|
|
@ -1050,40 +1027,6 @@ function visualIntent(query) {
|
|||
return { wanted: Array.from(new Set(wanted)) };
|
||||
}
|
||||
|
||||
async function classifyVisualSource(source) {
|
||||
var imagePath = resolveRawMultimodalImagePath(source.image_path_internal || '');
|
||||
if (!imagePath) throw new Error('No local page image');
|
||||
var imageBase64 = fs.readFileSync(imagePath).toString('base64');
|
||||
var labels = visualClassifierLabels();
|
||||
var inputs = [{ text: 'Classify this medical document page image.', image_base64: imageBase64 }].concat(
|
||||
labels.map(function(label) { return { text: label.prompt }; })
|
||||
);
|
||||
var resp = await axios.post(MULTIMODAL_EMBEDDING_URL, { inputs: inputs }, { timeout: 45000 });
|
||||
var vectors = resp.data && Array.isArray(resp.data.embeddings) ? resp.data.embeddings : [];
|
||||
if (vectors.length < labels.length + 1) throw new Error('Classifier embedding response was incomplete');
|
||||
var imageVector = vectors[0];
|
||||
var scored = labels.map(function(label, idx) {
|
||||
return Object.assign({}, label, { score: cosineSimilarity(imageVector, vectors[idx + 1]) });
|
||||
}).sort(function(a, b) { return b.score - a.score; });
|
||||
var best = scored[0];
|
||||
var second = scored[1] || { score: best.score };
|
||||
return {
|
||||
kind: best.kind,
|
||||
confidence: Math.max(0, best.score - second.score),
|
||||
score: best.score,
|
||||
scores: scored
|
||||
};
|
||||
}
|
||||
|
||||
function visualClassifierLabels() {
|
||||
return [
|
||||
{ kind: 'radiology', prompt: 'A medical radiology image: x-ray radiograph, chest x-ray, CT scan, ultrasound, MRI, or diagnostic imaging film.' },
|
||||
{ kind: 'diagram', prompt: 'A medical diagram, clinical illustration, anatomy figure, pathway, chart, flowchart, or infographic.' },
|
||||
{ kind: 'photo', prompt: 'A clinical photograph, gross pathology image, microscopy image, skin finding, wound image, or bedside photograph.' },
|
||||
{ kind: 'text_page', prompt: 'A plain textbook or article page that is mostly text, paragraphs, references, bullet points, or tables without a diagnostic image.' }
|
||||
];
|
||||
}
|
||||
|
||||
function visualMetadataScore(query, source) {
|
||||
var haystack = [source.title, source.excerpt, source.visual_caption, (source.visual_labels || []).join(' '), source.file_path, source.category, source.subcategory, source.category_path].filter(Boolean).join(' ');
|
||||
var score = 0;
|
||||
|
|
@ -1124,104 +1067,15 @@ function looksLikeTextOnlyPage(source) {
|
|||
return text.length > 900 && !/\b(figure|image|radiograph|x-?ray|cxr|ultrasound|ct|mri|scan|film|diagram|illustration)\b/i.test(text);
|
||||
}
|
||||
|
||||
function cosineSimilarity(a, b) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return 0;
|
||||
var dot = 0;
|
||||
var aa = 0;
|
||||
var bb = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
var av = Number(a[i]) || 0;
|
||||
var bv = Number(b[i]) || 0;
|
||||
dot += av * bv;
|
||||
aa += av * av;
|
||||
bb += bv * bv;
|
||||
}
|
||||
if (!aa || !bb) return 0;
|
||||
return dot / (Math.sqrt(aa) * Math.sqrt(bb));
|
||||
}
|
||||
|
||||
function multimodalImageUrl(imagePath) {
|
||||
imagePath = String(imagePath || '').trim();
|
||||
if (!imagePath) return '';
|
||||
return '/api/clinical-assistant/source-image/' + base64UrlEncode(imagePath);
|
||||
}
|
||||
|
||||
function resolveMultimodalImagePath(token) {
|
||||
var raw = base64UrlDecode(token);
|
||||
return resolveRawMultimodalImagePath(raw);
|
||||
}
|
||||
|
||||
function resolveRawMultimodalImagePath(raw) {
|
||||
if (!raw || raw.indexOf('\0') !== -1) return '';
|
||||
var incoming = path.resolve(raw);
|
||||
var internalRoot = withTrailingSeparator(MCP_MULTIMODAL_INTERNAL_ROOT);
|
||||
var servedRoot = withTrailingSeparator(MCP_MULTIMODAL_IMAGE_ROOT);
|
||||
var servedPath = incoming;
|
||||
if (incoming === MCP_MULTIMODAL_INTERNAL_ROOT || incoming.indexOf(internalRoot) === 0) {
|
||||
servedPath = path.resolve(MCP_MULTIMODAL_IMAGE_ROOT, path.relative(MCP_MULTIMODAL_INTERNAL_ROOT, incoming));
|
||||
}
|
||||
if (servedPath !== MCP_MULTIMODAL_IMAGE_ROOT && servedPath.indexOf(servedRoot) !== 0) return '';
|
||||
if (path.extname(servedPath).toLowerCase() !== '.png') return '';
|
||||
if (!fs.existsSync(servedPath)) return '';
|
||||
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);
|
||||
delete out.image_path_internal;
|
||||
delete out.image_path;
|
||||
delete out.file_path;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function withTrailingSeparator(p) {
|
||||
p = path.resolve(p);
|
||||
return p.endsWith(path.sep) ? p : p + path.sep;
|
||||
}
|
||||
|
||||
function base64UrlEncode(value) {
|
||||
return Buffer.from(String(value), 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base64UrlDecode(value) {
|
||||
value = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (value.length % 4) value += '=';
|
||||
return Buffer.from(value, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function buildSavedChatPayload(body) {
|
||||
var messages = Array.isArray(body.messages) ? body.messages.slice(-80).map(function(m) {
|
||||
return {
|
||||
|
|
@ -1235,7 +1089,6 @@ function buildSavedChatPayload(body) {
|
|||
page: s.page || s.page_number || s.pageNumber || null,
|
||||
source_type: clip(s.source_type || '', 80),
|
||||
doc_type: clip(s.doc_type || s.type || '', 80),
|
||||
image_url: safeSourceImageUrl(s.image_url || s.imageUrl),
|
||||
excerpt: clip(s.excerpt || '', 1800),
|
||||
score: s.score == null ? null : Number(s.score)
|
||||
};
|
||||
|
|
@ -1250,7 +1103,6 @@ function buildSavedChatPayload(body) {
|
|||
page: s.page || s.page_number || s.pageNumber || null,
|
||||
source_type: clip(s.source_type || '', 80),
|
||||
doc_type: clip(s.doc_type || s.type || '', 80),
|
||||
image_url: safeSourceImageUrl(s.image_url || s.imageUrl),
|
||||
excerpt: clip(s.excerpt || '', 1800),
|
||||
score: s.score == null ? null : Number(s.score)
|
||||
};
|
||||
|
|
@ -1272,12 +1124,6 @@ function safeImageForSave(image) {
|
|||
return '';
|
||||
}
|
||||
|
||||
function safeSourceImageUrl(url) {
|
||||
url = String(url || '');
|
||||
if (url.indexOf('/api/clinical-assistant/source-image/') === 0) return url.substring(0, 2000);
|
||||
return '';
|
||||
}
|
||||
|
||||
function firstUserMessage(messages) {
|
||||
if (!Array.isArray(messages)) return '';
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
|
|
@ -1308,54 +1154,7 @@ 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);
|
||||
return [];
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
var examples = [];
|
||||
var candidates = rotateExamples(EXAMPLE_CANDIDATES, now);
|
||||
for (var i = 0; i < candidates.length && examples.length < 9; i++) {
|
||||
var item = candidates[i];
|
||||
try {
|
||||
var searchResponse = await semanticSearch(item.prompt, {
|
||||
limit: 2,
|
||||
includeContext: true,
|
||||
contextChars: 500
|
||||
});
|
||||
var source = dedupeSources(normalizeMcpSearchResponse(searchResponse))[0];
|
||||
if (!source) continue;
|
||||
examples.push({
|
||||
label: item.label,
|
||||
prompt: item.prompt,
|
||||
sourceTitle: source.title,
|
||||
page: source.page || null
|
||||
});
|
||||
} catch (e) {
|
||||
if (examples.length === 0) throw e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_exampleCache = {
|
||||
expiresAt: now + EXAMPLE_CACHE_MS,
|
||||
examples: examples
|
||||
};
|
||||
redisCache.setJson('clinical-assistant:examples:v1', { examples: examples }, 1800).catch(function() {});
|
||||
return examples;
|
||||
return promptPool.getAvailableExamples();
|
||||
}
|
||||
|
||||
async function getIndexedTopicExamples() {
|
||||
|
|
@ -1383,12 +1182,6 @@ async function getIndexedTopicExamples() {
|
|||
}).filter(function(item) { return item.prompt; });
|
||||
}
|
||||
|
||||
function rotateExamples(items, seed) {
|
||||
var copy = items.slice();
|
||||
var offset = Math.floor(seed / EXAMPLE_CACHE_MS) % copy.length;
|
||||
return copy.slice(offset).concat(copy.slice(0, offset));
|
||||
}
|
||||
|
||||
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' };
|
||||
|
|
@ -1477,15 +1270,6 @@ function getTopicSuggestions(message) {
|
|||
for (var i = 0; i < keys.length; i++) {
|
||||
if (normalized.indexOf(keys[i]) !== -1) return TOPIC_SUGGESTIONS[keys[i]];
|
||||
}
|
||||
if (normalized.split(/\s+/).length <= 2) {
|
||||
return [
|
||||
'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 [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -439,6 +439,7 @@ async function callAIStream(messages, options, onToken) {
|
|||
if (!client) throw new Error('Streaming is only configured for OpenAI-compatible providers');
|
||||
|
||||
var content = '';
|
||||
var finishReason = null;
|
||||
var stream = await client.chat.completions.create({
|
||||
model: model,
|
||||
messages: messages,
|
||||
|
|
@ -447,14 +448,16 @@ async function callAIStream(messages, options, onToken) {
|
|||
stream: true
|
||||
});
|
||||
for await (var part of stream) {
|
||||
var delta = part && part.choices && part.choices[0] && part.choices[0].delta ? part.choices[0].delta.content : '';
|
||||
var choice = part && part.choices && part.choices[0] ? part.choices[0] : null;
|
||||
if (choice && choice.finish_reason) finishReason = choice.finish_reason;
|
||||
var delta = choice && choice.delta ? choice.delta.content : '';
|
||||
if (!delta) continue;
|
||||
content += delta;
|
||||
if (typeof onToken === 'function') onToken(delta);
|
||||
}
|
||||
var duration = Date.now() - startTime;
|
||||
logger.apiCall(null, provider + '/' + model, { model: model, duration: duration, statusCode: 200 });
|
||||
return { success: true, content: content, model: model, provider: provider, duration: duration };
|
||||
return { success: true, content: content, model: model, provider: provider, duration: duration, finishReason: finishReason };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
208
src/utils/clinicalPromptPool.js
Normal file
208
src/utils/clinicalPromptPool.js
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
function positiveInt(value, fallback) {
|
||||
var n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
||||
}
|
||||
|
||||
function clip(text, max) {
|
||||
text = String(text == null ? '' : text);
|
||||
return text.length > max ? text.substring(0, max - 1).trimEnd() + '…' : text;
|
||||
}
|
||||
|
||||
function parseJsonObject(text) {
|
||||
text = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
||||
try { return JSON.parse(text); } catch (e) {}
|
||||
var start = text.indexOf('{');
|
||||
var end = text.lastIndexOf('}');
|
||||
if (start !== -1 && end > start) {
|
||||
try { return JSON.parse(text.slice(start, end + 1)); } catch (e2) {}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function createClinicalPromptPool(opts) {
|
||||
opts = opts || {};
|
||||
var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000);
|
||||
var refreshMs = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 24 * 60 * 60 * 1000);
|
||||
var ttlSeconds = Math.max(3600, Math.ceil(refreshMs / 1000) * 2);
|
||||
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 500);
|
||||
var redisKey = process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v1';
|
||||
var memoryCache = { expiresAt: 0, examples: [] };
|
||||
var refreshPromise = null;
|
||||
|
||||
async function getAvailableExamples() {
|
||||
var now = Date.now();
|
||||
if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples;
|
||||
var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
|
||||
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
|
||||
memoryCache = { expiresAt: now + cacheMs, examples: cached.examples };
|
||||
if (!cached.generatedAt || now - cached.generatedAt > refreshMs) refreshIfNeeded(false).catch(function() {});
|
||||
return cached.examples;
|
||||
}
|
||||
|
||||
refreshIfNeeded(false).catch(function() {});
|
||||
|
||||
var examples = [];
|
||||
rotateExamples(opts.exampleCandidates || [], now).slice(0, 9).forEach(function(item) {
|
||||
examples.push({ label: item.label, prompt: item.prompt });
|
||||
});
|
||||
|
||||
memoryCache = { expiresAt: now + cacheMs, examples: examples };
|
||||
return examples;
|
||||
}
|
||||
|
||||
async function refreshIfNeeded(force) {
|
||||
var now = Date.now();
|
||||
var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
|
||||
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && cached.generatedAt && now - cached.generatedAt < refreshMs) {
|
||||
return cached.examples;
|
||||
}
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = buildCorpusPromptPool().then(function(examples) {
|
||||
refreshPromise = null;
|
||||
if (!examples.length) return [];
|
||||
var payload = { generatedAt: Date.now(), examples: examples };
|
||||
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
|
||||
return opts.redisCache.setJson(redisKey, payload, ttlSeconds).then(function() { return examples; });
|
||||
}).catch(function(e) {
|
||||
refreshPromise = null;
|
||||
console.warn('[clinical-assistant] prompt pool refresh failed:', e.message);
|
||||
return [];
|
||||
});
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
async function buildCorpusPromptPool() {
|
||||
var snippets = await collectPromptSeedSnippets();
|
||||
if (!snippets.length) return fallbackIndexedExamples();
|
||||
var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', '');
|
||||
var generated = [];
|
||||
var batches = Math.max(1, Math.min(20, Math.ceil(target / 25)));
|
||||
for (var i = 0; i < batches && generated.length < target; i++) {
|
||||
var batchSnippets = rotateExamples(snippets, Date.now() + i * 997).slice(0, 24);
|
||||
var sourceText = batchSnippets.map(function(s, idx) {
|
||||
return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 650);
|
||||
}).join('\n\n');
|
||||
var ai = await opts.callAI([
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Generate realistic pediatric clinical assistant starter questions from indexed source snippets. Use only the provided titles/snippets as inspiration. Do not answer the questions. Do not mention source names. Prefer broad practical clinical questions involving diagnosis, red flags, severity, treatment, dosing, fluids, admission/discharge, counseling, or differential diagnosis. Return strict JSON only: {"questions":[{"label":"2-5 word label","prompt":"question","category":"emergency|infectious disease|neonates|respiratory|gi|neurology|cardiology|endocrine|hematology|development|toxicology|procedures|dosing/fluids|red flags|differential|admission/discharge|counseling|general","intent":"diagnosis|management|red_flags|differential|counseling|dosing|admission|review"}]}.'
|
||||
},
|
||||
{ role: 'user', content: 'Indexed pediatric source snippets:\n\n' + sourceText + '\n\nCreate 35 varied starter questions. Make this batch different from common asthma, croup, and UTI examples when possible. Include a mix of emergencies, outpatient questions, red flags, diagnostics, treatment, counseling, fluids, dosing, admission/discharge, and differentials.' }
|
||||
], {
|
||||
model: chatModel || undefined,
|
||||
temperature: 0.75,
|
||||
maxTokens: 2600
|
||||
});
|
||||
var parsed = parseJsonObject(String(ai.content || ''));
|
||||
generated = normalizeGeneratedExamples(generated.concat(parsed.questions || []));
|
||||
}
|
||||
if (generated.length < 8) return fallbackIndexedExamples();
|
||||
return generated;
|
||||
}
|
||||
|
||||
async function collectPromptSeedSnippets() {
|
||||
var seeds = promptSeedQueries();
|
||||
var seen = new Set();
|
||||
var snippets = [];
|
||||
for (var i = 0; i < seeds.length && snippets.length < 120; i++) {
|
||||
try {
|
||||
var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 700 });
|
||||
opts.dedupeSources(opts.normalizeMcpSearchResponse(searchResponse)).forEach(function(source) {
|
||||
var key = [source.title, source.page, source.chunk_index].join('|');
|
||||
if (seen.has(key) || snippets.length >= 120) return;
|
||||
seen.add(key);
|
||||
snippets.push(source);
|
||||
});
|
||||
} catch (e) {
|
||||
if (snippets.length === 0) throw e;
|
||||
}
|
||||
}
|
||||
return snippets;
|
||||
}
|
||||
|
||||
function promptSeedQueries() {
|
||||
var seeds = (opts.exampleCandidates || []).map(function(item) { return item.prompt; });
|
||||
Object.keys(opts.topicSuggestions || {}).forEach(function(key) {
|
||||
seeds = seeds.concat(opts.topicSuggestions[key]);
|
||||
});
|
||||
seeds = seeds.concat([
|
||||
'pediatric emergency red flags admission criteria treatment dosing',
|
||||
'neonatal fever jaundice respiratory distress vomiting dehydration',
|
||||
'childhood infectious diseases empiric antibiotics workup disposition',
|
||||
'pediatric respiratory illness asthma bronchiolitis pneumonia croup',
|
||||
'pediatric gastrointestinal dehydration abdominal pain bilious vomiting',
|
||||
'developmental milestones anemia endocrine neurologic pediatric review'
|
||||
]);
|
||||
return shuffle(seeds).slice(0, 30);
|
||||
}
|
||||
|
||||
function fallbackIndexedExamples() {
|
||||
return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : [];
|
||||
}
|
||||
|
||||
function normalizeGeneratedExamples(items) {
|
||||
var seen = new Set();
|
||||
var out = [];
|
||||
(Array.isArray(items) ? items : []).forEach(function(item) {
|
||||
var prompt = clip(String(item.prompt || item.question || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(), 220);
|
||||
if (!isUsefulQuestion(prompt)) return;
|
||||
var key = prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
label: cleanExampleLabel(item.label || prompt),
|
||||
prompt: prompt,
|
||||
category: clip(String(item.category || 'general'), 40),
|
||||
intent: clip(String(item.intent || 'review'), 30)
|
||||
});
|
||||
});
|
||||
return out.slice(0, target);
|
||||
}
|
||||
|
||||
function isUsefulQuestion(prompt) {
|
||||
if (!prompt || prompt.length < 25 || prompt.length > 220) return false;
|
||||
if (prompt.indexOf('?') === -1) return false;
|
||||
if (!/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return false;
|
||||
return !/\b(source|snippet|textbook|chapter|document|database)\b/i.test(prompt);
|
||||
}
|
||||
|
||||
function cleanExampleLabel(label) {
|
||||
label = String(label || '').replace(/[?!.:,;]+$/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (!label || label.length > 42) label = labelFromQuestion(label || 'Clinical question');
|
||||
return clip(label, 42);
|
||||
}
|
||||
|
||||
function labelFromQuestion(question) {
|
||||
return String(question || 'Clinical question')
|
||||
.replace(/^(how|what|when|which|why)\s+(should|do|does|can|are|is)\s+/i, '')
|
||||
.replace(/\?.*$/, '')
|
||||
.split(/\s+/)
|
||||
.slice(0, 4)
|
||||
.join(' ') || 'Clinical question';
|
||||
}
|
||||
|
||||
function rotateExamples(items, seed) {
|
||||
if (!items.length) return [];
|
||||
var copy = items.slice();
|
||||
var offset = Math.floor(seed / cacheMs) % copy.length;
|
||||
return copy.slice(offset).concat(copy.slice(0, offset));
|
||||
}
|
||||
|
||||
function shuffle(items) {
|
||||
var copy = items.slice();
|
||||
for (var i = copy.length - 1; i > 0; i--) {
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
var tmp = copy[i];
|
||||
copy[i] = copy[j];
|
||||
copy[j] = tmp;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
return {
|
||||
getAvailableExamples: getAvailableExamples,
|
||||
refreshIfNeeded: refreshIfNeeded
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createClinicalPromptPool };
|
||||
|
|
@ -109,7 +109,7 @@ test('normalizes old saved assistant answer formatting', async () => {
|
|||
const html = renderAssistantMarkdown('Comparison of Bronchiolitis and Asthma Management in Infants ### Bronchiolitis Management - Mainstay: Supportive care [1, 2]. - Medications: bronchodilators are not routine [2]. ### Key Differences | Aspect | Bronchiolitis | Asthma | |---|---|---| | Therapy | Supportive | SABA | --- References: [1] duplicate', sources);
|
||||
assert.match(html, /<h3>Bronchiolitis Management<\/h3>/);
|
||||
assert.match(html, /<li>Mainstay: Supportive care/);
|
||||
assert.match(html, /<table>/);
|
||||
assert.match(html, /Key Differences/);
|
||||
assert.doesNotMatch(html, /duplicate/);
|
||||
});
|
||||
|
||||
|
|
@ -145,13 +145,12 @@ test('preserves code block contents without creating citation links inside code'
|
|||
assert.doesNotMatch(html, /assistant-source-1/);
|
||||
});
|
||||
|
||||
test('repairs model-split markdown tables before rendering', async () => {
|
||||
test('does not repair model-split markdown tables', 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/);
|
||||
assert.doesNotMatch(html, /Max Dose Infusion Time/);
|
||||
assert.match(html, /Infusion Time/);
|
||||
assert.match(html, /assistant-cite/);
|
||||
});
|
||||
|
||||
test('breaks inline numbered clinical sections onto new lines', async () => {
|
||||
|
|
@ -171,6 +170,48 @@ test('strips orphan trailing markdown emphasis markers', async () => {
|
|||
assert.doesNotMatch(html, /\*\*/);
|
||||
});
|
||||
|
||||
test('strips orphan trailing markdown marker after tab-separated text', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
||||
const input = 'Summary Table\nStep\tRecommendation\nBlood culture\tObtain before antibiotics\nHospitalization\tYes, for all febrile neonates <=28 days old\n**';
|
||||
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
||||
assert.doesNotMatch(html, /<table>/);
|
||||
assert.match(html, /Hospitalization/);
|
||||
assert.doesNotMatch(html, /\*\*/);
|
||||
});
|
||||
|
||||
test('keeps summary paragraph outside tab-separated text', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
||||
const input = 'Summary Table\nStep/Indication\tRecommendation/Threshold\tCitation\nInitial assessment\tABCs and focused exam\t[1]\nActivated charcoal window\tWithin 1 hour\t[2]\nIn summary: rapidly assess ABCs and consider activated charcoal when appropriate [1, 2].';
|
||||
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
||||
assert.doesNotMatch(html, /<table>/);
|
||||
assert.match(html, /In summary:/);
|
||||
assert.doesNotMatch(html, /<td>In summary:/);
|
||||
});
|
||||
|
||||
test('strips trailing marker after notes followed by tab-separated text', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
||||
const input = 'Additional Notes\nListeria coverage: Ampicillin should be added for infants <3 months [1].\nVancomycin: Added for resistant pneumococcus [2].\nAge Group\tEmpiric Antibiotics\n<1 month\tAmpicillin + Cefotaxime\n1-23 months\tVancomycin + Cefotaxime or Ceftriaxone\n>=24 months\tVancomycin + Cefotaxime or Ceftriaxone\n**';
|
||||
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
||||
assert.match(html, /Additional Notes/);
|
||||
assert.doesNotMatch(html, /<table>/);
|
||||
assert.doesNotMatch(html, /\*\*/);
|
||||
});
|
||||
|
||||
test('links citation-only cells in markdown pipe tables', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
||||
const input = '| Step | Purpose | Source(s) |\n|---|---|---|\n| Surgical consult | Urgent assessment | [1, 2] |\n| X-ray | Assess obstruction | [3] |';
|
||||
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
||||
assert.match(html, /<table>/);
|
||||
assert.match(html, /data-source-number="1"/);
|
||||
assert.match(html, /data-source-number="2"/);
|
||||
assert.match(html, /data-source-number="3"/);
|
||||
assert.doesNotMatch(html, /<td>\[1, 2\]<\/td>/);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
|
@ -180,10 +221,10 @@ test('uses markdown-it stack for GFM-style tables when provided', async () => {
|
|||
assert.match(html, /assistant-cite/);
|
||||
});
|
||||
|
||||
test('renders tab-separated summary tables and removes trailing emphasis junk', async () => {
|
||||
test('renders pipe 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 input = 'Summary Table\n\n| Situation | Imaging Recommended | Timing/Notes |\n|---|---|---|\n| First febrile UTI | Renal/bladder ultrasound | After recovery |\n| Routine use of DMSA scan | Not recommended | |\n**';
|
||||
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
||||
assert.match(html, /<table>/);
|
||||
assert.match(html, /<th>Situation<\/th>/);
|
||||
|
|
@ -191,14 +232,12 @@ test('renders tab-separated summary tables and removes trailing emphasis junk',
|
|||
assert.doesNotMatch(html, /\*\*/);
|
||||
});
|
||||
|
||||
test('repairs split tab-separated clinical summary rows', async () => {
|
||||
test('does not repair malformed tab-separated clinical summary rows into a table', 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/);
|
||||
assert.doesNotMatch(html, /<table>/);
|
||||
assert.match(html, /Initial Imaging/);
|
||||
assert.match(html, /If you need details/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue