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 '
' + 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) => '