diff --git a/docker-compose.yml b/docker-compose.yml
index 82da200..b740411 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:-minioadmin}
- CLINICAL_ASSISTANT_MULTIMODAL_S3_SECRET_KEY: ${MULTIMODAL_OBJECT_STORE_SECRET_KEY:-minioadmin}
REDIS_URL: redis://ped-ai-redis:6379
volumes:
- scribe-logs:/app/data/logs
diff --git a/public/index.html b/public/index.html
index 650c607..270a5c9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -15,9 +15,11 @@
-
+
+
-
+
+
diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js
index ff6c7f6..732cfea 100644
--- a/public/js/assistant/citations.js
+++ b/public/js/assistant/citations.js
@@ -1,6 +1,6 @@
export function renderAssistantMarkdown(md, sources, options) {
var opts = options || {};
- var text = 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) {
diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index 6da8a7e..926c402 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -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 }
'[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '' +
renderSourceBadges(s) +
'
' + escapeHtml(meta.join(' · ') || 'indexed source') + '
' +
- renderSourceImage(s, n) +
(s.excerpt ? '' + escapeHtml(cleanSourceExcerpt(s.excerpt).slice(0, 900)) + '
' : '') +
'';
}).join('');
@@ -378,18 +379,6 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
}).join('') + '';
}
- 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 '' +
- '
' +
- '
';
- }
-
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 ? 'Generated Image
 + ')
' : '';
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 '' +
'' + escapeHtml(heading) + '
' +
'Question: ' + escapeHtml(item.question || '') + '
' +
@@ -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) {
diff --git a/server.js b/server.js
index 5748df3..5b7a9c1 100644
--- a/server.js
+++ b/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')) {
diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js
index c23003f..dc2fb09 100644
--- a/src/routes/clinicalAssistant.js
+++ b/src/routes/clinicalAssistant.js
@@ -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 [];
}
diff --git a/src/utils/ai.js b/src/utils/ai.js
index 035b91c..d6bf8a4 100644
--- a/src/utils/ai.js
+++ b/src/utils/ai.js
@@ -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 };
}
// ============================================================
diff --git a/src/utils/clinicalPromptPool.js b/src/utils/clinicalPromptPool.js
new file mode 100644
index 0000000..0a6a694
--- /dev/null
+++ b/src/utils/clinicalPromptPool.js
@@ -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 };
diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js
index 2933a1f..6a10b40 100644
--- a/test/assistant-citations.test.js
+++ b/test/assistant-citations.test.js
@@ -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, /Bronchiolitis Management<\/h3>/);
assert.match(html, /
Mainstay: Supportive care/);
- assert.match(html, //);
+ 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, //);
- assert.match(html, /Max Dose Infusion Time/);
- assert.match(html, /2 g 20 min/);
- assert.doesNotMatch(html, /\| 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, /
/);
+ 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, //);
+ assert.match(html, /In summary:/);
+ assert.doesNotMatch(html, /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, //);
+ 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, //);
+ assert.match(html, /data-source-number="1"/);
+ assert.match(html, /data-source-number="2"/);
+ assert.match(html, /data-source-number="3"/);
+ assert.doesNotMatch(html, /\[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, //);
assert.match(html, /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, //);
- assert.match(html, /| Initial Imaging<\/td>/);
- assert.match(html, / | Diagnostic Gold Std<\/td>/);
- assert.match(html, / | Recurrence Rate<\/td>/);
- assert.doesNotMatch(html, /If you need details/);
+ assert.doesNotMatch(html, //);
+ assert.match(html, /Initial Imaging/);
+ assert.match(html, /If you need details/);
});
| | | |