From 126d7928a2cdad85e7c1099627157841dff5ac8c Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 28 Aug 2026 19:20:39 +0200 Subject: [PATCH] fix: stop the markdown normaliser mangling doses, bold and dollar amounts Three rendering defects in the clinical assistant, all confirmed by running the code rather than reading it. A spaced hyphen anywhere in a sentence was rewritten as a list item, so every numeric range written that way was split in two: "Give dexamethasone 0.15 - 0.6 mg/kg orally." -> "Give dexamethasone 0.15\n- 0.6 mg/kg orally." which renders as a truncated sentence followed by a bullet, and a dose range therefore reads as a different dose. The same applied to SpO2 targets, pH ranges and age ranges. A hyphen now starts a list item only at the beginning of a line or after sentence punctuation, which still catches the case the rule was written for. An answer ending in a bold phrase lost its closing marker, because trailing emphasis was stripped unconditionally and the strip ran twice. Only an unpaired marker is removed now. Inline maths swallowed dollar amounts: "Costs $5 to $10 per dose" rendered the text between the signs as an equation and dropped both signs. A $...$ span is now treated as maths only when it contains something mathematical, so subscripts and fractions still render. Citation handling is unchanged and covered by the same tests: clusters still merge and sort, unknown source numbers stay literal, links still resolve by index, and bare numbers in a table's Source column still become bracketed tokens. The state before these fixes is tagged pre-citation-fixes-20260828. Co-Authored-By: Claude Opus 5 --- public/js/assistant/citations.js | 24 ++++++++++++++------ test/assistant-citations.test.js | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js index 117ec3d..5502457 100644 --- a/public/js/assistant/citations.js +++ b/public/js/assistant/citations.js @@ -102,8 +102,8 @@ export function normalizeMarkdownText(text) { .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') + .replace(/([.!?:;)\]])\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(/([.!?:;)\]])\s+(-\s+[^\n])/g, '$1\n$2') .trim())); } @@ -150,10 +150,18 @@ function normalizeBareCitationCell(cell) { } export function stripOrphanMarkdownMarkers(text) { - return String(text || '') - .replace(/\s*(?:\*\*|__|\*|_)\s*$/g, '') - .replace(/\s*(?:\*\*|__)?\s*(?:Figure|Fig\.)\s*(?:\*\*|__)?\s*$/i, '') - .trim(); + 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(); } export function fallbackMarkdown(text) { @@ -229,7 +237,9 @@ 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(_, expr) { return safeKatex(katex, expr, false); }) + .replace(/\$([^$\n]+?)\$/g, function(match, expr) { + return /[\\^_{}]|\\frac|\\times|\\le|\\ge/.test(expr) ? safeKatex(katex, expr, false) : match; + }) .replace(/\\\((.+?)\\\)/g, function(_, expr) { return safeKatex(katex, expr, false); }); } diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js index bb3d943..989e307 100644 --- a/test/assistant-citations.test.js +++ b/test/assistant-citations.test.js @@ -252,3 +252,42 @@ test('clinical assistant streams long table answers as lightweight text before f assert.match(source, /assistant-streaming-text/); 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'); +});