pediatric-ai-scribe-v3/public/js/assistant/citations.js
Daniel 2d292d12af
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
fix: make the markdown repairs line aware so tables and ranges survive
The earlier fix corrected two of the repair rules and left the rest with the same
flaw, and the punctuation boundary it introduced let the table case back in. A
review found the remainder.

Every repair rule inserted newlines with no idea whether it was inside a markdown
table row. A dose row such as "| Dexamethasone | 0.6 mg/kg PO (max 16 mg) - single
dose | 1 |" was split mid-row, which drops that row's citation and every row
below it out of the table. A "#" column destroyed the table outright. The rules
now skip table rows, dividers and headers entirely.

Ranges were still split in three other places: in a heading, so "### Dexamethasone
0.15 - 0.6 mg/kg" rendered as a heading reading "Dexamethasone 0.15"; after a
citation; and beside a hash, where "Room # 4" became a heading. A hyphen now
starts a list item only when a number does not follow, and a run-together heading
is recognised by the capital letter after the hashes rather than by position.

Adjacent citation merging could also cross a paragraph break, turning "[1]\n\n[2]
Vancomycin only if MRSA" into "[1, 2]Vancomycin only if MRSA" -- joining two
paragraphs and moving a citation onto a claim it never supported. Merging is now
limited to citations on the same line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:49:01 +02:00

279 lines
12 KiB
JavaScript

export function renderAssistantMarkdown(md, sources, options) {
var opts = options || {};
var codeBlocks = [];
var text = String(md || '').replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var idx = codeBlocks.length;
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
return '\n@@CODEBLOCK_' + idx + '@@\n';
});
text = stripOrphanMarkdownMarkers(normalizeMarkdownText(text));
text = renderLatexText(text, opts.katex);
text = normalizeAdjacentCitationClusters(text, sources || []);
var html;
if (opts.marked && typeof opts.marked.parse === 'function') {
html = opts.marked.parse(text, { breaks: true, gfm: true });
} else if (opts.markdownIt && typeof opts.markdownIt.render === 'function') {
html = opts.markdownIt.render(text);
} else {
html = fallbackMarkdown(text);
}
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 '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
});
html = wrapTables(html);
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
}
function wrapTables(html) {
return String(html || '')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll"><table$1>')
.replace(/<\/table>/g, '</table></div>');
}
export function renderCitationLinks(html, sources, options) {
var opts = options || {};
return String(html || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) {
var nums = cluster.split(',').map(function (n) { return Number(n.trim()); }).filter(function (n) { return Number.isInteger(n) && n > 0; });
if (!nums.length || nums.some(function (n) { return !sources[n - 1]; })) return match;
return nums.map(function (n) {
var source = sources[n - 1];
var title = source ? source.title || source.resource || 'Source' : 'Source';
var page = source && (source.page || source.page_number || source.pageNumber);
var label = 'Source ' + n + ': ' + title + (page ? ', page ' + page : '');
var text = opts.citationLabel === 'number' ? String(n) : 'src';
return '<a class="assistant-cite" href="#assistant-source-' + n + '" data-source-number="' + n + '" title="' + escapeHtml(label) + '" aria-label="' + escapeAttr(label) + '">' + text + '</a>';
}).join(' ');
});
}
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 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) {
var nums = citationNumbers(match);
if (!allCitationsAvailable(nums, available)) return match;
return formatCitationCluster(nums);
});
}
function citationNumbers(text) {
var clusters = String(text || '').match(/\[((?:\d+\s*,\s*)*\d+)\]/g) || [];
var nums = [];
clusters.forEach(function(cluster) {
nums = nums.concat(parseCitationCluster(cluster.slice(1, -1)));
});
return nums;
}
function parseCitationCluster(cluster) {
return String(cluster || '').split(',').map(function(n) { return Number(n.trim()); }).filter(function(n) { return Number.isInteger(n) && n > 0; });
}
function allCitationsAvailable(nums, sources) {
return nums.length > 0 && nums.every(function(n) { return sources[n - 1]; });
}
function formatCitationCluster(nums) {
nums = Array.from(new Set(nums)).sort(function(a, b) { return a - b; });
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')
.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());
}
export function normalizeTableSourceCitationCells(text) {
var lines = String(text || '').split('\n');
for (var i = 0; i < lines.length - 1; i++) {
if (!/^\s*\|.*\|\s*$/.test(lines[i]) || !/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[i + 1])) continue;
var header = tableCells(lines[i]);
var sourceCols = [];
header.forEach(function(cell, idx) {
if (/^(?:source|sources|source\(s\)|citation|citations|citation\(s\)|reference|references|ref|refs)$/i.test(cell.trim())) sourceCols.push(idx);
});
if (!sourceCols.length) continue;
var j = i + 2;
while (j < lines.length && /^\s*\|.*\|\s*$/.test(lines[j])) {
lines[j] = rewriteTableCells(lines[j], sourceCols, function(cell) {
return normalizeBareCitationCell(cell);
});
j++;
}
i = j - 1;
}
return lines.join('\n');
}
function rewriteTableCells(line, indexes, fn) {
var trimmed = String(line || '').trim();
var leading = /^\|/.test(trimmed);
var trailing = /\|$/.test(trimmed);
var cells = tableCells(line);
indexes.forEach(function(idx) {
if (idx < cells.length) cells[idx] = fn(cells[idx]);
});
return (leading ? '| ' : '') + cells.join(' | ') + (trailing ? ' |' : '');
}
function normalizeBareCitationCell(cell) {
var text = String(cell || '').trim();
if (/^\[(?:\d+\s*,\s*)*\d+\]$/.test(text)) return text;
if (/^\d+(?:\s*,\s*\d+)*$/.test(text)) return '[' + text.replace(/\s*,\s*/g, ', ') + ']';
return text.replace(/(^|\s)(\d+(?:\s*,\s*\d+)+)(?=$|\s)/g, function(match, prefix, nums) {
return prefix + '[' + nums.replace(/\s*,\s*/g, ', ') + ']';
});
}
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();
}
export function fallbackMarkdown(text) {
var html = escapeHtml(text)
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.split(/\n{2,}/).map(function (block) {
if (/^\s*<h\d/.test(block) && block.indexOf('\n') !== -1) {
var parts = block.split('\n');
return parts.shift() + fallbackMarkdown(parts.join('\n'));
}
if (/^\s*<(h\d|ul|ol|pre|div)/.test(block)) return block;
var lines = block.split('\n');
if (isMarkdownTable(lines)) return renderFallbackTable(lines);
if (lines.some(function (l) { return /^\s*[-*] /.test(l); })) return renderMixedList(lines);
if (lines.every(function (l) { return /^\s*[-*] /.test(l) || !l.trim(); })) {
return '<ul>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*[-*] /, '') + '</li>'; }).join('') + '</ul>';
}
if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) {
return '<ol>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*\d+\. /, '') + '</li>'; }).join('') + '</ol>';
}
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
}).join('');
return html;
}
function renderMixedList(lines) {
var html = '';
var list = [];
var paragraph = [];
lines.forEach(function(line) {
if (/^\s*[-*] /.test(line)) {
if (paragraph.length) {
html += '<p>' + paragraph.join('<br>') + '</p>';
paragraph = [];
}
list.push(line.replace(/^\s*[-*] /, ''));
return;
}
if (list.length) {
html += '<ul>' + list.map(function(item) { return '<li>' + item + '</li>'; }).join('') + '</ul>';
list = [];
}
if (line.trim()) paragraph.push(line);
});
if (paragraph.length) html += '<p>' + paragraph.join('<br>') + '</p>';
if (list.length) html += '<ul>' + list.map(function(item) { return '<li>' + item + '</li>'; }).join('') + '</ul>';
return html;
}
function isMarkdownTable(lines) {
return lines.length >= 2 && /^\s*\|.*\|\s*$/.test(lines[0]) && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[1]);
}
function renderFallbackTable(lines) {
var header = tableCells(lines[0]);
var rows = lines.slice(2).filter(function(line) { return /^\s*\|.*\|\s*$/.test(line); }).map(tableCells);
return '<table><thead><tr>' + header.map(function(cell) { return '<th>' + cell + '</th>'; }).join('') + '</tr></thead><tbody>' +
rows.map(function(row) { return '<tr>' + row.map(function(cell) { return '<td>' + cell + '</td>'; }).join('') + '</tr>'; }).join('') +
'</tbody></table>';
}
function tableCells(line) {
return String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function(cell) { return cell.trim(); });
}
export function renderLatexText(text, katex) {
if (!katex) return text;
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(/\\\((.+?)\\\)/g, function(_, expr) { return safeKatex(katex, expr, false); });
}
function safeKatex(katex, expr, displayMode) {
try { return katex.renderToString(expr, { displayMode: displayMode, throwOnError: false }); }
catch (e) { return escapeHtml(expr); }
}
export function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;');
}