From 3d4a95fea495601377ac631307ed7753ed0a53b1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 28 Aug 2026 19:55:03 +0200 Subject: [PATCH] Revert the clinical assistant markdown changes and fix mermaid rendering only Reverts 126d7928 and 2d292d12 in full. The markdown normaliser and the trailing emphasis and dollar handling return to exactly the state tagged pre-citation-fixes-20260828, which has been rendering acceptably in use. The one change kept is mermaid. DOMPurify 3.1.6 strips an attribute whose value contains "-->", and every mermaid flowchart contains one, so the sanitiser removed data-mermaid and querySelectorAll('[data-mermaid]') never matched: the diagram sat on "Rendering graph..." forever. Reproduced against the exact pinned build from cdnjs; sequence diagrams using "->>" were unaffected, which is why only flowcharts failed. The diagram source is now percent-encoded into the attribute and decoded when read, so no arrow ever appears in an attribute value. Nothing about the sanitiser configuration changes and no markup is newly allowed. The reader tolerates an unencoded value, so anything already in flight still renders. Co-Authored-By: Claude Opus 5 --- public/js/assistant/citations.js | 75 ++++++++------------------ public/js/clinicalAssistant.js | 10 ++-- test/assistant-citations.test.js | 92 ++++---------------------------- 3 files changed, 40 insertions(+), 137 deletions(-) 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); });