pediatric-ai-scribe-v3/test/citation-ordering.test.js
Daniel 8a6a4df121 refactor: citations are a markdown-it token, and the numbers you see are display order
The old renderer rewrote the text: it found "[n]" with regexes, renumbered
them, and swapped the result back in — which broke inside `arr[2][1]`, inside
HTML attributes, and whenever two turns disagreed about what "[3]" meant. It
also had a fallback markdown renderer of its own for when the rewrite
produced something markdown-it would not parse.

Now "[n]" is an inline rule registered on the same markdown-it instance that
renders everything else. The parser decides what is prose and what is code, a
link, or a URL, so the rule never sees "[1]" inside a code span, and it steps
aside for "[1](url)". Math is two more rules on the same parser instead of a
regex pre-pass, so "$" inside a URL is no longer math.

Identity vs display: the stored "[n]" and each card's id are the source's
identity (sourceNumber) and are never rewritten. The number a reader sees is
the order of first appearance, computed at render time from the token stream
(orderSourcesByCitation), so "one, then seven" cannot happen and a saved chat
re-opens pointing at the same cards it was saved with. Stored messages and
sources are untouched; export and the modal resolve by identity.

Translated HTML gets the same links through a TreeWalker over text nodes
(linkCitationsInHtml) rather than a regex over markup.

Deleted: renderCitationLinks, normalizeAdjacentCitationClusters, the
fallback renderer (fallbackMarkdown/renderMixedList/renderFallbackTable),
renderLatexText, CITATION_SCAN. Tests that asserted rewritten text now assert
token output; harnesses that render for real are given a parser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 04:41:40 +02:00

87 lines
4.3 KiB
JavaScript

// Sources arrived in retrieval order — an order the reader never sees and
// cannot follow: an answer whose first citation was [7] opened a list that
// began at [1]. Reference lists are numbered by first appearance for exactly
// this reason.
//
// Ordering is derived from the same token walk that renders the chips, so the
// list and the numbers on screen cannot disagree. The text is never rewritten:
// `[7]` stays `[7]`, because that number is the source's identity in the saved
// chat, the export and every chip. Two earlier versions rewrote the text and
// both were wrong — one hit array indexing in code, the other an HTML
// attribute — and a third would have hit the next literal context.
const test = require('node:test');
const assert = require('node:assert/strict');
const MarkdownIt = require('markdown-it');
globalThis.markdownit = MarkdownIt;
let mod;
async function load() {
if (!mod) {
const fs = require('node:fs'); const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/assistant/citations.js'), 'utf8');
mod = await import('data:text/javascript;charset=utf-8,' + encodeURIComponent(src));
}
return mod;
}
const four = [{ number: 1, title: 'Alpha' }, { number: 2, title: 'Beta' }, { number: 3, title: 'Gamma' }, { number: 4, title: 'Delta' }];
const titles = out => Array.from(out.sources, s => s.title);
test('sources come back in the order the answer cites them', async () => {
const { orderSourcesByCitation } = await load();
const out = orderSourcesByCitation('Third first [3]. Then the first [1].', four);
assert.deepEqual(titles(out).slice(0, 2), ['Gamma', 'Alpha']);
assert.deepEqual(Array.from(out.sources.slice(0, 2), s => [s.number, s.sourceNumber]), [[1, 3], [2, 1]]);
});
test('the text is returned exactly as given', async () => {
const { orderSourcesByCitation } = await load();
const text = 'Third first [3]. Then the first [1].';
assert.equal(orderSourcesByCitation(text, four).text, text);
});
test('a source cited twice keeps its first position', async () => {
const { orderSourcesByCitation } = await load();
assert.deepEqual(titles(orderSourcesByCitation('[3] ... [1] ... [3] again.', four)).slice(0, 2), ['Gamma', 'Alpha']);
});
test('retrieved but uncited sources follow, marked and still numbered', async () => {
const { orderSourcesByCitation } = await load();
const out = orderSourcesByCitation('Only this one [2].', four);
assert.equal(out.sources[0].title, 'Beta');
assert.equal(out.sources.length, 4, 'nothing is dropped');
assert.deepEqual(Array.from(out.sources.slice(1), s => s.uncited), [true, true, true]);
assert.deepEqual(Array.from(out.sources, s => s.number), [1, 2, 3, 4], 'numbering stays contiguous');
});
test('an invented citation reserves no place', async () => {
const { orderSourcesByCitation } = await load();
assert.equal(orderSourcesByCitation('Invented [9]. Real [2].', four).sources[0].title, 'Beta');
});
test('a bracket inside code, math or an HTML attribute is not a citation', async () => {
// By construction, not by pattern: the rule only runs where the parser runs
// inline rules, and those are places it never does.
const { orderSourcesByCitation } = await load();
const text = '```\nx = arr[3][1]\n```\n\n`m[4]` and $f[3]$ and <span title="see [3]">t</span>\n\nReal [2].';
const out = orderSourcesByCitation(text, four);
assert.equal(out.sources[0].title, 'Beta', 'the first real citation is first');
assert.equal(out.sources[0].sourceNumber, 2);
});
test('the rendered chips agree with the ordered list', async () => {
const { orderSourcesByCitation, renderAssistantMarkdown } = await load();
const text = 'See [4], then [2], then [4] again.';
const ordered = orderSourcesByCitation(text, four).sources;
const html = renderAssistantMarkdown(text, ordered);
// [4] is display 1, [2] is display 2 — in the chip text and in the list.
assert.match(html, /data-source-number="4"[^>]*data-display-number="1"[^>]*>1<\/a>/);
assert.match(html, /data-source-number="2"[^>]*data-display-number="2"[^>]*>2<\/a>/);
assert.equal(ordered[0].sourceNumber, 4); assert.equal(ordered[0].number, 1);
});
test('the originals are not mutated', async () => {
const { orderSourcesByCitation } = await load();
const before = JSON.parse(JSON.stringify(four));
orderSourcesByCitation('[3] [1]', four);
assert.deepEqual(four, before);
});