From baf05198a83cbea3cb53ffe6f1aff1b418c2ac06 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 7 May 2026 22:46:28 +0200 Subject: [PATCH] refactor clinical assistant prompt handling --- docker-compose.yml | 3 - public/index.html | 6 +- public/js/assistant/citations.js | 178 +---------------- public/js/clinicalAssistant.js | 42 ++-- server.js | 15 ++ src/routes/clinicalAssistant.js | 318 +++++-------------------------- src/utils/ai.js | 7 +- src/utils/clinicalPromptPool.js | 208 ++++++++++++++++++++ test/assistant-citations.test.js | 67 +++++-- 9 files changed, 368 insertions(+), 476 deletions(-) create mode 100644 src/utils/clinicalPromptPool.js diff --git a/docker-compose.yml b/docker-compose.yml index 6b6fb78..4888ffa 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:-} - CLINICAL_ASSISTANT_MULTIMODAL_S3_SECRET_KEY: ${MULTIMODAL_OBJECT_STORE_SECRET_KEY:-} 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

Generated clinical visual
' : ''; 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/); });