refactor clinical assistant prompt handling

This commit is contained in:
Daniel 2026-05-07 22:46:28 +02:00
parent 6925250e75
commit baf05198a8
9 changed files with 368 additions and 476 deletions

View file

@ -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

View file

@ -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">

View file

@ -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) {

View file

@ -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) {

View file

@ -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')) {

View file

@ -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 [];
}

View file

@ -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 };
}
// ============================================================

View 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 };

View file

@ -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/);
});