diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js index ffd963a5..b03305e6 100644 --- a/public/js/assistant/citations.js +++ b/public/js/assistant/citations.js @@ -22,7 +22,10 @@ export function renderAssistantMarkdown(md, sources, options) { html = renderCitationLinks(html, sources || [], opts); html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) { var block = codeBlocks[Number(idx)] || { lang: '', code: '' }; - if (block.lang === 'mermaid') return '
Rendering graph...
'; + // Percent-encoded: DOMPurify strips an attribute whose value contains "-->", + // which every mermaid flowchart has, leaving the diagram stuck on its + // placeholder. Encoding keeps the value intact; the reader decodes it. + if (block.lang === 'mermaid') return '
Rendering graph...
'; if (block.lang === 'chart' || block.lang === 'chartjs') return ''; return '
' + escapeHtml(block.code) + '
'; }); @@ -55,13 +58,13 @@ export function renderCitationLinks(html, sources, options) { export function normalizeAdjacentCitationClusters(text, sources) { var available = Array.isArray(sources) ? sources : []; - var normalized = String(text || '').replace(/((?:\[(?:\d+\s*,\s*)*\d+\][ \t]*)+)\[((?:\d+\s*,\s*)*\d+)(?=$|[.,;:])/g, function(match, completeClusters, trailingCluster) { + var normalized = String(text || '').replace(/((?:\[(?:\d+\s*,\s*)*\d+\]\s*)+)\[((?:\d+\s*,\s*)*\d+)(?=$|[.,;:])/g, function(match, completeClusters, trailingCluster) { var nums = citationNumbers(completeClusters).concat(parseCitationCluster(trailingCluster)); if (!allCitationsAvailable(nums, available)) return match; return formatCitationCluster(nums); }); - return normalized.replace(/(?:\[(?:\d+\s*,\s*)*\d+\][ \t]*){2,}/g, function(match) { + return normalized.replace(/(?:\[(?:\d+\s*,\s*)*\d+\]\s*){2,}/g, function(match) { var nums = citationNumbers(match); if (!allCitationsAvailable(nums, available)) return match; return formatCitationCluster(nums); @@ -90,43 +93,21 @@ function formatCitationCluster(nums) { return '[' + nums.join(', ') + ']'; } -// Repairs the loose formatting models emit: run-together bullets, inline -// headings, numbered items glued onto a sentence. -// -// Two rules make this safe. Table rows are never touched, because inserting a -// newline inside "| a | b |" destroys the row and every row after it. And a -// hyphen only starts a list item when what follows is not a number, because -// "0.15 - 0.6 mg/kg" is a dose range, not a bullet, and splitting it shows the -// reader a different dose. -var TABLE_ROW = /^\s*\|.*\|\s*$/; -var TABLE_DIVIDER = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/; -var LIST_BOUNDARY = /([.!?:;\]])\s+(-\s+(?!\d))/g; - -function repairLine(line) { - return line - // A citation followed by a hyphen starts the next item, unless a number follows. - .replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s+(?!\d)/g, '$1\n- ') - .replace(/([.!?])\s*[-–—]\s+(?!\d)(\*\*)?/g, '$1\n- $2') - .replace(/(:)\s*[-–—]\s+(?!\d)(\*\*)?/g, '$1\n- $2') +export function normalizeMarkdownText(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') + .replace(/(:)\s*[-–—]\s+(\*\*)?/g, '$1\n- $2') .replace(/(\[(?:\d+\s*,\s*)*\d+\]\.)\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2') .replace(/([.!?])\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2') .replace(/(\[(?:\d+\s*,\s*)*\d+\])(?=\s*(?:[A-Z][A-Za-z]+\s+){1,4}(?:deficits?|distress|apnoea|apnea|vomiting|seizures?|signs?|symptoms?|criteria|indications?|risk|oxygen|saturation|dehydration|lethargy|toxicity)\b)/g, '$1\n') - // A run-together heading is recognised by what follows the hashes: a capital - // letter means a heading, while "Item # 4" or a "#" table column does not. - .replace(/([^\n])\s+(#{1,4}\s+(?=[A-Z]))/g, '$1\n\n$2') - .replace(/(#{1,4}\s+[^\n]+?)\s+(-\s+(?!\d))/g, '$1\n\n$2') - .replace(LIST_BOUNDARY, '$1\n$2'); -} - -export function normalizeMarkdownText(text) { - var lines = String(text || '').replace(/\r\n/g, '\n').split('\n'); - var repaired = lines.map(function(line, index) { - if (TABLE_ROW.test(line) || TABLE_DIVIDER.test(line)) return line; - // A line directly above a divider is a table header even without pipes. - if (index + 1 < lines.length && TABLE_DIVIDER.test(lines[index + 1])) return line; - return repairLine(line); - }).join('\n'); - return stripOrphanMarkdownMarkers(normalizeTableSourceCitationCells(repaired).trim()); + .replace(/([^\n])\s+(#{1,4}\s+)/g, '$1\n\n$2') + .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+[^\n])/g, '$1\n$2') + .trim())); } export function normalizeTableSourceCitationCells(text) { @@ -172,18 +153,10 @@ function normalizeBareCitationCell(cell) { } export function stripOrphanMarkdownMarkers(text) { - var out = String(text || '') - .replace(/\s*(?:\*\*|__)?\s*(?:Figure|Fig\.)\s*(?:\*\*|__)?\s*$/i, ''); - // Remove a trailing marker only when it has no partner, so "…**Monitor - // closely**" keeps its closing pair while a dangling "**" is still cleaned up. - var trailing = out.match(/(\*\*|__|\*|_)\s*$/); - if (trailing) { - var marker = trailing[1]; - var body = out.slice(0, out.length - trailing[0].length); - var occurrences = body.split(marker).length - 1; - if (occurrences % 2 === 0) out = body; - } - return out.trim(); + return String(text || '') + .replace(/\s*(?:\*\*|__|\*|_)\s*$/g, '') + .replace(/\s*(?:\*\*|__)?\s*(?:Figure|Fig\.)\s*(?:\*\*|__)?\s*$/i, '') + .trim(); } export function fallbackMarkdown(text) { @@ -259,9 +232,7 @@ export function renderLatexText(text, katex) { return String(text || '') .replace(/\$\$([\s\S]+?)\$\$/g, function(_, expr) { return safeKatex(katex, expr, true); }) .replace(/\\\[([\s\S]+?)\\\]/g, function(_, expr) { return safeKatex(katex, expr, true); }) - .replace(/\$([^$\n]+?)\$/g, function(match, expr) { - return /[\\^_{}]|\\frac|\\times|\\le|\\ge/.test(expr) ? safeKatex(katex, expr, false) : match; - }) + .replace(/\$([^$\n]+?)\$/g, function(_, expr) { return safeKatex(katex, expr, false); }) .replace(/\\\((.+?)\\\)/g, function(_, expr) { return safeKatex(katex, expr, false); }); } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index c5964bcc..794057dd 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -432,13 +432,17 @@ import { } function renderEmbeddedBlocks(root) { + function mermaidSource(el) { + var raw = el.getAttribute('data-mermaid') || ''; + try { return decodeURIComponent(raw); } catch (e) { return raw; } + } root.querySelectorAll('[data-mermaid]').forEach(function (el) { ensureMermaid().then(function () { - if (!window.mermaid) { el.textContent = el.getAttribute('data-mermaid'); return; } + if (!window.mermaid) { el.textContent = mermaidSource(el); return; } var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2); - window.mermaid.render(id, el.getAttribute('data-mermaid') || '') + window.mermaid.render(id, mermaidSource(el)) .then(function (out) { el.innerHTML = out.svg || ''; }) - .catch(function () { el.textContent = el.getAttribute('data-mermaid') || ''; }); + .catch(function () { el.textContent = mermaidSource(el); }); }); }); root.querySelectorAll('canvas[data-chart]').forEach(function (canvas) { diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js index 15b15fc7..8a2a5c09 100644 --- a/test/assistant-citations.test.js +++ b/test/assistant-citations.test.js @@ -253,86 +253,14 @@ test('clinical assistant streams long table answers as lightweight text before f assert.match(source, /pipeRows >= 8/); }); -test('numeric ranges written with spaced hyphens are not turned into bullets', async () => { - const { normalizeMarkdownText } = await loadCitationModule(); - // A dose range split across a line break reads as a different dose. - for (const text of [ - 'Give dexamethasone 0.15 - 0.6 mg/kg orally.', - 'Target SpO2 92 - 96% on room air.', - 'Aim for pH 7.35 - 7.45.', - 'Ages 2 - 5 years.', - 'Use 5 - 10 mL/kg boluses.' - ]) { - assert.equal(normalizeMarkdownText(text), text, text); - } -}); - -test('a hyphen after sentence punctuation still starts a list item', async () => { - const { normalizeMarkdownText } = await loadCitationModule(); - assert.equal(normalizeMarkdownText('Treatment options. - Dexamethasone first.'), 'Treatment options.\n- Dexamethasone first.'); - assert.equal(normalizeMarkdownText('Options: - Dexamethasone'), 'Options:\n- Dexamethasone'); - assert.equal(normalizeMarkdownText('Works well [1] - Dexamethasone'), 'Works well [1]\n- Dexamethasone'); - assert.equal(normalizeMarkdownText('Intro\n- one\n- two'), 'Intro\n- one\n- two'); -}); - -test('a bold phrase at the end of an answer keeps its closing marker', async () => { - const { stripOrphanMarkdownMarkers } = await loadCitationModule(); - assert.equal(stripOrphanMarkdownMarkers('Give oxygen. **Monitor closely**'), 'Give oxygen. **Monitor closely**'); - assert.equal(stripOrphanMarkdownMarkers('Note the *caveat*'), 'Note the *caveat*'); - // A genuinely unpaired marker is still removed. - assert.equal(stripOrphanMarkdownMarkers('Ends with bold **'), 'Ends with bold'); -}); - -test('dollar amounts are not rendered as mathematics', async () => { - const { renderLatexText } = await loadCitationModule(); - const katex = { renderToString: (expr) => '' + expr + '' }; - assert.equal(renderLatexText('Costs $5 to $10 per dose', katex), 'Costs $5 to $10 per dose'); - // Real mathematics still renders. - assert.equal(renderLatexText('SpO$_2$ target', katex), 'SpO_2 target'); - assert.equal(renderLatexText('Use $\\frac{1}{2}$ dose', katex), 'Use \\frac{1}{2} dose'); -}); - -test('table rows are never rewritten by the bullet and heading repairs', async () => { - const { normalizeMarkdownText } = await loadCitationModule(); - // A newline inserted inside a row destroys that row and every row after it. - for (const text of [ - '| Dexamethasone | 0.6 mg/kg PO (max 16 mg) - single dose | 1 |', - '| Item # | Dose |', - '| Hypoxia | SpO2 <90 \\| escalate | 1 |', - '| Drug | Dose |\n|---|---|\n| Dex | 0.6 mg/kg (max 16 mg) - single dose |' - ]) { - assert.equal(normalizeMarkdownText(text), text, text); - } -}); - -test('ranges survive in headings, after citations, and beside a hash', async () => { - const { normalizeMarkdownText } = await loadCitationModule(); - for (const text of [ - '### Dexamethasone 0.15 - 0.6 mg/kg PO once', - '## Target SpO2 92 - 96%', - 'Magnesium 40 mg/kg IV over 20 min [1] - 50 mg/kg is an alternative.', - 'Room # 4 and bed # 2' - ]) { - assert.equal(normalizeMarkdownText(text), text, text); - } -}); - -test('the loose-formatting repairs still fire where they were meant to', async () => { - const { normalizeMarkdownText } = await loadCitationModule(); - assert.equal(normalizeMarkdownText('Treatment options. - Dexamethasone first.'), 'Treatment options.\n- Dexamethasone first.'); - assert.equal(normalizeMarkdownText('Options: - Dexamethasone'), 'Options:\n- Dexamethasone'); - assert.equal(normalizeMarkdownText('Works well [1] - Dexamethasone helps'), 'Works well [1]\n- Dexamethasone helps'); - assert.equal(normalizeMarkdownText('That is all. ## Management'), 'That is all.\n\n## Management'); - assert.equal(normalizeMarkdownText('## Management - Dexamethasone first'), '## Management\n\n- Dexamethasone first'); -}); - -test('adjacent citations merge only on the same line', async () => { - const { normalizeAdjacentCitationClusters } = await loadCitationModule(); - const sources = [{ title: 'A' }, { title: 'B' }, { title: 'C' }]; - assert.equal(normalizeAdjacentCitationClusters('Works [1] [2] [3].', sources), 'Works [1, 2, 3].'); - // Merging across a break moved a citation onto a claim it never supported. - assert.equal( - normalizeAdjacentCitationClusters('Give ceftriaxone 50 mg/kg [1]\n\n[2] Vancomycin only if MRSA.', sources), - 'Give ceftriaxone 50 mg/kg [1]\n\n[2] Vancomycin only if MRSA.' - ); +test('mermaid source survives the sanitiser and round-trips', async () => { + const { renderAssistantMarkdown } = await loadCitationModule(); + const flowchart = 'graph TD; A[Start]-->B[Give O2];'; + const html = renderAssistantMarkdown('```mermaid\n' + flowchart + '\n```', []); + const attr = html.match(/data-mermaid="([^"]*)"/); + assert.ok(attr, 'the placeholder must carry the diagram source'); + // Encoded, because DOMPurify strips an attribute whose value contains "-->" + // and every flowchart has one, which left diagrams stuck on their placeholder. + assert.ok(!attr[1].includes('-->'), 'the stored value must not contain a raw arrow'); + assert.equal(decodeURIComponent(attr[1].replace(/&/g, '&')).trim(), flowchart); });