diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js index bf48070..b5f2331 100644 --- a/public/js/assistant/citations.js +++ b/public/js/assistant/citations.js @@ -84,7 +84,7 @@ function formatCitationCluster(nums) { } export function normalizeMarkdownText(text) { - return stripOrphanMarkdownMarkers(String(text || '') + return stripOrphanMarkdownMarkers(normalizeTableSourceCitationCells(String(text || '') .replace(/\r\n/g, '\n') .replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s*/g, '$1\n- ') .replace(/([.!?])\s*[-–—]\s+(\*\*)?/g, '$1\n- $2') @@ -97,7 +97,49 @@ export function normalizeMarkdownText(text) { .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+[^\n])/g, '$1\n$2') - .trim()); + .trim())); +} + +export function normalizeTableSourceCitationCells(text) { + var lines = String(text || '').split('\n'); + for (var i = 0; i < lines.length - 1; i++) { + if (!/^\s*\|.*\|\s*$/.test(lines[i]) || !/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[i + 1])) continue; + var header = tableCells(lines[i]); + var sourceCols = []; + header.forEach(function(cell, idx) { + if (/^(?:source|sources|source\(s\)|citation|citations|citation\(s\)|reference|references|ref|refs)$/i.test(cell.trim())) sourceCols.push(idx); + }); + if (!sourceCols.length) continue; + var j = i + 2; + while (j < lines.length && /^\s*\|.*\|\s*$/.test(lines[j])) { + lines[j] = rewriteTableCells(lines[j], sourceCols, function(cell) { + return normalizeBareCitationCell(cell); + }); + j++; + } + i = j - 1; + } + return lines.join('\n'); +} + +function rewriteTableCells(line, indexes, fn) { + var trimmed = String(line || '').trim(); + var leading = /^\|/.test(trimmed); + var trailing = /\|$/.test(trimmed); + var cells = tableCells(line); + indexes.forEach(function(idx) { + if (idx < cells.length) cells[idx] = fn(cells[idx]); + }); + return (leading ? '| ' : '') + cells.join(' | ') + (trailing ? ' |' : ''); +} + +function normalizeBareCitationCell(cell) { + var text = String(cell || '').trim(); + if (/^\[(?:\d+\s*,\s*)*\d+\]$/.test(text)) return text; + if (/^\d+(?:\s*,\s*\d+)*$/.test(text)) return '[' + text.replace(/\s*,\s*/g, ', ') + ']'; + return text.replace(/(^|\s)(\d+(?:\s*,\s*\d+)+)(?=$|\s)/g, function(match, prefix, nums) { + return prefix + '[' + nums.replace(/\s*,\s*/g, ', ') + ']'; + }); } export function stripOrphanMarkdownMarkers(text) { diff --git a/src/utils/clinicalAnswer.js b/src/utils/clinicalAnswer.js index 50b0432..9d72676 100644 --- a/src/utils/clinicalAnswer.js +++ b/src/utils/clinicalAnswer.js @@ -1,5 +1,5 @@ function buildSystemPrompt(behavior) { - return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when 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 the user names a specific source, textbook, guideline, or table, do not claim that another source is from the named source. If the named source is absent from the retrieved sources, say that explicitly before using other sources.\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.'; + return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when 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- If a table has a Source, Source(s), Citation, or Citation(s) column, every cell in that column must use bracketed citation tokens like [1] or [1, 3], never bare numbers like 1 or 1, 3.\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 the user names a specific source, textbook, guideline, or table, do not claim that another source is from the named source. If the named source is absent from the retrieved sources, say that explicitly before using other sources.\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) { diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js index f305fb9..1871c80 100644 --- a/test/assistant-citations.test.js +++ b/test/assistant-citations.test.js @@ -196,6 +196,18 @@ test('links citation-only cells in markdown pipe tables', async () => { assert.doesNotMatch(html, /\[1, 2\]<\/td>/); }); +test('repairs bare source numbers in markdown table source columns', async () => { + const { renderAssistantMarkdown } = await loadCitationModule(); + const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true }); + const input = '| Feature | Possible Surgical Cause(s) | Source(s) |\n|---|---|---|\n| Bilious vomiting | Malrotation with volvulus | 1, 2, 3 |\n| Projectile vomiting | Pyloric stenosis | 2 |'; + 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, 3<\/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 }); diff --git a/test/clinical-assistant-prompt-pool.test.js b/test/clinical-assistant-prompt-pool.test.js index 07ef6a3..ede8971 100644 --- a/test/clinical-assistant-prompt-pool.test.js +++ b/test/clinical-assistant-prompt-pool.test.js @@ -26,3 +26,10 @@ test('clinical assistant prompt forbids relabeling other sources as named source assert.match(answer, /do not claim that another source is from the named source/); assert.match(answer, /If the named source is absent from the retrieved sources, say that explicitly/); }); + +test('clinical assistant prompt requires bracketed citations in table source columns', () => { + const answer = read('src/utils/clinicalAnswer.js'); + assert.match(answer, /Source, Source\(s\), Citation, or Citation\(s\) column/); + assert.match(answer, /must use bracketed citation tokens like \[1\] or \[1, 3\]/); + assert.match(answer, /never bare numbers like 1 or 1, 3/); +});