pediatric-ai-scribe-v3/test/citation-ordering.test.js
Daniel 0e1e74882e feat: sources are numbered in the order the answer cites them
Borrowed from the quiz app's AI Mode, where validating citations and
ordering them fall out of the same pass: it collects the sources an
answer actually used into an insertion-ordered map, so the list comes
back in first-citation order for free.

Ours listed sources in retrieval order — an order the reader never sees
and has no way to follow. An answer whose first citation was [7] opened
a list that began at [1], so matching a marker to a source meant
hunting. Reference lists in published writing are numbered by first
appearance for exactly this reason.

Cited sources now come first, renumbered by first appearance, and the
markers in the text are rewritten to match. Anything retrieved and not
cited keeps its place after them, labelled "not cited" — the panel is
also a view of what the search returned, which is worth keeping, but it
should not sit among the numbers the answer used.

The marker itself now shows its number instead of the word "src". Every
citation read identically, so the only way to tell one from another was
to hover it — which made the numbered list beneath useless to match
against. The export has shown numbers since the day "src" was
introduced, with no recorded reason for the difference.

Renumbering happens once the whole answer is known, never while
streaming: the order is the order of first citation, so a citation that
has not arrived yet cannot take its place, and numbers would shuffle
under the reader mid-sentence. The text is rewritten in a single pass —
number by number would turn 2 into 1 and then that 1 into whatever 1
maps to.

An invented citation reserves no position and is left exactly as it was.
It is still not turned into a link, and citation_audit still records it;
what matters here is that it cannot push a real source down the list.

Accuracy was already held: a marker with no matching source never
becomes a link. This changes what a reader can do with the ones that are
real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 03:26:17 +02:00

111 lines
5.1 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], so matching a marker to a source meant hunting for it.
//
// Borrowed from the quiz app, where validating citations and ordering them fall
// out of the same pass: it collects the sources the answer actually used into an
// insertion-ordered map, so the list comes back in first-citation order for
// free. Reference lists in published writing are numbered by first appearance
// for the same reason.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/assistant/citations.js'), 'utf8')
.replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '')
.replace(/^export /gm, '');
const ctx = { window: {}, document: undefined, console };
vm.createContext(ctx);
vm.runInContext(src + '\nthis.orderSourcesByCitation = orderSourcesByCitation;', ctx);
return ctx;
}
const four = [
{ number: 1, title: 'Alpha' }, { number: 2, title: 'Beta' },
{ number: 3, title: 'Gamma' }, { number: 4, title: 'Delta' }
];
test('sources come back in the order the answer cites them', () => {
const { orderSourcesByCitation } = load();
const out = orderSourcesByCitation('Third first [3]. Then the first [1].', four);
assert.deepEqual(Array.from(out.sources.slice(0, 2), s => s.title), ['Gamma', 'Alpha']);
assert.equal(out.text, 'Third first [1]. Then the first [2].');
});
test('a cluster is renumbered as a whole, in its own order', () => {
const { orderSourcesByCitation } = load();
const out = orderSourcesByCitation('Both of these [4, 2] agree.', four);
assert.equal(out.text, 'Both of these [1, 2] agree.');
assert.deepEqual(Array.from(out.sources.slice(0, 2), s => s.title), ['Delta', 'Beta']);
});
test('renumbering happens in one pass, so nothing is renumbered twice', () => {
// Rewriting number by number turns 2 into 1, then that 1 into whatever 1
// maps to. The whole text is rewritten once instead.
const { orderSourcesByCitation } = load();
const out = orderSourcesByCitation('[2] then [1] then [2] again.', four);
assert.equal(out.text, '[1] then [2] then [1] again.');
});
test('a source cited twice keeps its first position', () => {
const { orderSourcesByCitation } = load();
const out = orderSourcesByCitation('[3] ... [1] ... [3] again.', four);
assert.deepEqual(Array.from(out.sources.slice(0, 2), s => s.title), ['Gamma', 'Alpha']);
});
test('retrieved but uncited sources follow, marked and still numbered', () => {
// The panel is also a view of what the search returned, so they stay — just
// no longer mixed in among the numbers the answer used.
const { orderSourcesByCitation } = load();
const out = orderSourcesByCitation('Only this one [2].', four);
assert.equal(out.sources[0].title, 'Beta');
assert.equal(out.sources[0].uncited, undefined);
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 and is left alone', () => {
// It is not turned into a link either; the audit records it. What matters
// here is that it cannot push a real source down the list.
const { orderSourcesByCitation } = load();
const out = orderSourcesByCitation('Invented [9]. Real [2].', four);
assert.equal(out.sources[0].title, 'Beta');
assert.match(out.text, /Invented \[9\]/, 'the unresolved marker is untouched');
assert.match(out.text, /Real \[1\]/);
});
test('a cluster containing an invented number is left whole', () => {
// Renumbering half of it would silently change which source the good half
// points at.
const { orderSourcesByCitation } = load();
const out = orderSourcesByCitation('Mixed [2, 9].', four);
assert.equal(out.text, 'Mixed [2, 9].');
});
test('an answer that cites nothing is returned untouched', () => {
const { orderSourcesByCitation } = load();
const out = orderSourcesByCitation('No citations here.', four);
assert.equal(out.text, 'No citations here.');
assert.deepEqual(Array.from(out.sources), four);
});
test('the originals are not mutated, so a stored answer stays readable', () => {
// Its text still holds the original markers; renumbering in place would make
// the two disagree.
const { orderSourcesByCitation } = load();
const before = JSON.parse(JSON.stringify(four));
orderSourcesByCitation('[3] [1]', four);
assert.deepEqual(four, before);
});
test('sources with no number field fall back to position', () => {
const { orderSourcesByCitation } = load();
const bare = [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }];
const out = orderSourcesByCitation('Cite the third [3].', bare);
assert.equal(out.sources[0].title, 'Three');
assert.equal(out.text, 'Cite the third [1].');
});