pediatric-ai-scribe-v3/test/citation-ordering.test.js
Daniel e376f69502
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 21s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
fix: each TTS model offers only the voices it will accept
"These settings don't work" — picking a model and testing with a voice
returned 500 every time. The gateway said why, once asked directly:

    voice must be one of the following voices: [autumn diana hannah austin daniel troy]

The screen was listing twelve Orpheus voices and six Kokoro ones in a
single flat list with no indication of which model would accept which,
because LITELLM_TTS_VOICES — written for one model — was treated as a
list that applied to all of them, and the Orpheus lists were pushed in
beside it. Choosing Orpheus and testing it with a Kokoro voice is not a
configuration; it is an error, and it was the default outcome.

LiteLLM cannot supply this. /model/info reports mode audio_speech for
all four models and carries no voice field for any of them. So the
mapping lives here, keyed by family so the gateway alias and the
upstream id resolve to one list, and every list was taken from the
provider rather than from documentation:

  Groq Orpheus English   autumn diana hannah austin daniel troy   (stated by Groq)
  Groq Orpheus Arabic    abdullah fahad sultan lulwa noura aisha  (stated by Groq)
  Fish s2.1-pro          alloy                                    (alloy returns audio; the rest 400)
  Kokoro                 sherpa/kokoro:* from LITELLM_TTS_VOICES  (the gateway's own list)

The environment still wins for the model it was written for, so the
local gateway's voices can change without a code change — but it
answers for that model only. A model with no list at all is offered
nothing rather than another model's voices, and a voice known to belong
to a different family is refused.

There were two copies of this knowledge before: getLiteLLMTTSVoicesForModel
branched by family and fell through to the env list for any model it did
not recognise — which is how Fish came to be offered six Kokoro voices.
One table now.

Also in this commit: citation renumbering skips fenced code, inline code
and math, so arr[2][1] in a code block is never rewritten. Renumbering at
render time was tried and reverted — it also has to skip HTML attributes,
and every such region is another regex branch. It stays at the answer
boundary, and the saved-chat boundary is next.

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

142 lines
6.4 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].');
});
// ---- what must never be touched ---------------------------------------------
test('a citation-shaped index inside code is not a citation', () => {
// arr[2][1] is array indexing. Renumbering it would alter the code and
// nothing downstream could tell.
const { orderSourcesByCitation } = load();
const text = 'Use [3] here.\n\n```js\nconst x = arr[2][1];\n```\n\nAnd `m[1][2]` inline.';
const out = orderSourcesByCitation(text, four);
assert.match(out.text, /Use \[1\] here/);
assert.match(out.text, /arr\[2\]\[1\]/, 'the fenced code was rewritten');
assert.match(out.text, /`m\[1\]\[2\]`/, 'the inline code was rewritten');
});
test('a bracket inside math is not a citation', () => {
const { orderSourcesByCitation } = load();
const text = 'See [2].\n\n$$ f[1] = x $$ and $g[3]$.';
const out = orderSourcesByCitation(text, four);
assert.match(out.text, /See \[1\]/);
assert.match(out.text, /\$\$ f\[1\] = x \$\$/);
assert.match(out.text, /\$g\[3\]\$/);
});
test('a bracket inside code reserves no place in the order either', () => {
// If [1] inside a code block counted as a citation, the first real citation
// would come out numbered 2.
const { orderSourcesByCitation } = load();
const out = orderSourcesByCitation('```\nx[1]\n```\n\nReal [4].', four);
assert.equal(out.sources[0].title, 'Delta');
assert.match(out.text, /Real \[1\]/);
});