diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js index 5502457..ffd963a 100644 --- a/public/js/assistant/citations.js +++ b/public/js/assistant/citations.js @@ -55,13 +55,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+\]\s*)+)\[((?:\d+\s*,\s*)*\d+)(?=$|[.,;:])/g, function(match, completeClusters, trailingCluster) { + var normalized = String(text || '').replace(/((?:\[(?:\d+\s*,\s*)*\d+\][ \t]*)+)\[((?:\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+\]\s*){2,}/g, function(match) { + return normalized.replace(/(?:\[(?:\d+\s*,\s*)*\d+\][ \t]*){2,}/g, function(match) { var nums = citationNumbers(match); if (!allCitationsAvailable(nums, available)) return match; return formatCitationCluster(nums); @@ -90,21 +90,43 @@ function formatCitationCluster(nums) { return '[' + nums.join(', ') + ']'; } -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') +// 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') .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') - .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(/([.!?:;)\]])\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())); + // 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()); } export function normalizeTableSourceCitationCells(text) { diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js index 989e307..15b15fc 100644 --- a/test/assistant-citations.test.js +++ b/test/assistant-citations.test.js @@ -291,3 +291,48 @@ test('dollar amounts are not rendered as mathematics', async () => { 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.' + ); +});