fix: citation chips that sit together read in ascending order
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 6s

[1] early in an answer and then "[2][1]" later showed "2 1". A run of
adjacent chips is now sorted by what it displays — a core rule after inline
parsing — so it reads "1 2". Text between two clusters keeps them apart,
every chip still points at its own source, and the numbering itself is
unchanged: only the order within a run moves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 14:42:51 +02:00
parent ed17d38229
commit 6e779e61ff
2 changed files with 45 additions and 3 deletions

View file

@ -78,13 +78,41 @@ function displayNumber(env, n) {
return i + 1;
}
// What a chip shows. A list ordered by citation already says what the reader
// should see, and the panel shows the same number; otherwise, first
// appearance in this text.
function displayFor(env, meta) {
var source = sourceByNumber((env && env.assistantSources) || [], meta.source);
return source && source.sourceNumber != null ? source.number : meta.display;
}
// Chips that sit together read in order. "[2][1]" written by the model after
// [1] appeared earlier showed "2 1"; the run is sorted by what it displays, so
// it reads "1 2". Only runs of adjacent chips are touched — text between two
// clusters keeps them apart — and every chip still points at its own source,
// so nothing about accuracy changes.
function sortCitationRuns(state) {
state.tokens.forEach(function (block) {
if (block.type !== 'inline' || !block.children) return;
var kids = block.children, i = 0;
while (i < kids.length) {
if (kids[i].type !== 'assistant_cite') { i++; continue; }
var j = i;
while (j < kids.length && kids[j].type === 'assistant_cite') j++;
if (j - i > 1) {
var run = kids.slice(i, j).sort(function (a, b) { return displayFor(state.env, a.meta) - displayFor(state.env, b.meta); });
for (var k = 0; k < run.length; k++) kids[i + k] = run[k];
}
i = j;
}
});
}
function renderCitation(tokens, idx, o, env) {
var meta = tokens[idx].meta;
var opts = (env && env.assistantOpts) || {};
var source = sourceByNumber((env && env.assistantSources) || [], meta.source);
// A list ordered by citation already says what the reader should see, and
// the panel shows the same number. Otherwise, first appearance in this text.
var display = source && source.sourceNumber != null ? source.number : meta.display;
var display = displayFor(env, meta);
var title = source ? source.title || source.resource || 'Source' : 'Source';
var page = source && (source.page || source.page_number || source.pageNumber);
var label = 'Source ' + display + ': ' + title + (page ? ', page ' + page : '');
@ -112,6 +140,7 @@ export function installCitations(md) {
md.__assistantCitations = true;
// Before 'escape', or \[1\] is consumed as an escaped bracket first.
md.inline.ruler.before('escape', 'assistant_cite', citationRule);
md.core.ruler.push('assistant_cite_order', sortCitationRuns);
md.renderer.rules.assistant_cite = renderCitation;
var image = md.renderer.rules.image || function (t, i, o, e, self) { return self.renderToken(t, i, o); };
md.renderer.rules.image = function (tokens, idx, o, env, self) {

View file

@ -371,3 +371,16 @@ test('a markdown link or footnote whose text is a bare number stays a link, not
assert.equal((html.match(/assistant-cite/g) || []).length, 1);
assert.match(html, /data-source-number="2"/);
});
test('chips that sit together read in ascending order, and still point at their own sources', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const four = [1, 2, 3, 4].map(n => ({ number: n, title: 'S' + n }));
// [1] appears first; the model then writes [2][1] — which showed "2 1".
const html = renderAssistantMarkdown('First claim [1]. Second claim [2][1]. Third [3, 2].', four);
const chips = [...html.matchAll(/data-source-number="(\d+)"[^>]*data-display-number="(\d+)"/g)].map(m => m[1] + '→' + m[2]);
assert.deepEqual(chips, ['1→1', '1→1', '2→2', '2→2', '3→3'], 'each run ascends by display; identity is untouched');
// Clusters separated by text are not merged into one run.
const apart = renderAssistantMarkdown('A [2] then [1].', four);
const order = [...apart.matchAll(/data-display-number="(\d+)"/g)].map(m => m[1]);
assert.deepEqual(order, ['1', '2'], 'first appearance still numbers them; nothing is reordered across text');
});