Only number inline citations that resolved to a source card
Browse filesThe frontend numbered every http(s) markdown link in the answer text,
while source cards only exist for links the server resolved against
current-turn evidence and the KB manifest. A link the model added from
memory (e.g. a Discord invite) became a numbered chip with no matching
card, and short link labels were swallowed into the bare number.
buildCitationNumbers now only assigns numbers to links whose normalized
URL matches a data-source part; other links render as ordinary
hyperlinks with their text. Chips consequently appear when the turn
completes (sources are emitted after the answer), matching the existing
gating of the Cited sources row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frontend/components/chat-message.test.tsx
CHANGED
|
@@ -252,6 +252,7 @@ describe("ChatMessage citations", () => {
|
|
| 252 |
text: [
|
| 253 |
"[Trusted](https://example.com/docs#section)",
|
| 254 |
"[Again](https://example.com/docs/)",
|
|
|
|
| 255 |
"[Unsafe](javascript:alert(1))",
|
| 256 |
"[Missing KB file](raw/docs/missing.md)",
|
| 257 |
].join(" "),
|
|
@@ -284,6 +285,12 @@ describe("ChatMessage citations", () => {
|
|
| 284 |
expect(sourceCard.textContent).toContain("1");
|
| 285 |
expect(sourceCard.textContent).toContain("Docs title");
|
| 286 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
expect(screen.getByText("Unsafe").closest("a")).toBeNull();
|
| 288 |
expect(screen.getByText("Missing KB file").closest("a")).toBeNull();
|
| 289 |
});
|
|
|
|
| 252 |
text: [
|
| 253 |
"[Trusted](https://example.com/docs#section)",
|
| 254 |
"[Again](https://example.com/docs/)",
|
| 255 |
+
"[Uncited](https://chat.example.com/invite)",
|
| 256 |
"[Unsafe](javascript:alert(1))",
|
| 257 |
"[Missing KB file](raw/docs/missing.md)",
|
| 258 |
].join(" "),
|
|
|
|
| 285 |
expect(sourceCard.textContent).toContain("1");
|
| 286 |
expect(sourceCard.textContent).toContain("Docs title");
|
| 287 |
|
| 288 |
+
// A link with no resolved source card keeps its text and stays a plain
|
| 289 |
+
// hyperlink instead of becoming a numbered chip with no matching card.
|
| 290 |
+
const uncited = screen.getByText("Uncited").closest("a");
|
| 291 |
+
expect(uncited?.getAttribute("href")).toBe("https://chat.example.com/invite");
|
| 292 |
+
expect(uncited?.classList.contains("citation-chip")).toBe(false);
|
| 293 |
+
|
| 294 |
expect(screen.getByText("Unsafe").closest("a")).toBeNull();
|
| 295 |
expect(screen.getByText("Missing KB file").closest("a")).toBeNull();
|
| 296 |
});
|
frontend/lib/chat-ui.test.mts
CHANGED
|
@@ -58,6 +58,15 @@ test("citation numbers follow rendered-link order and ignore non-rendered links"
|
|
| 58 |
"[Relative](raw/docs/example.md)",
|
| 59 |
].join("\n"),
|
| 60 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
]);
|
| 62 |
|
| 63 |
const numbers = buildCitationNumbers(message);
|
|
@@ -72,6 +81,35 @@ test("citation numbers follow rendered-link order and ignore non-rendered links"
|
|
| 72 |
assert.equal(citationNumberFor(numbers, "https://ignored.example.test/inline"), undefined);
|
| 73 |
});
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
test("KB references resolve to one navigable citation number", () => {
|
| 76 |
const canonicalUrl = "https://academy.example.test/lessons/lora";
|
| 77 |
const kbSource = source({
|
|
|
|
| 58 |
"[Relative](raw/docs/example.md)",
|
| 59 |
].join("\n"),
|
| 60 |
},
|
| 61 |
+
// Cards arrive with URL variants (fragment, trailing slash, raw unicode);
|
| 62 |
+
// normalization must still line them up with the answer-text links.
|
| 63 |
+
{
|
| 64 |
+
type: "data-source",
|
| 65 |
+
data: source({ url: "https://docs.example.test/Spring_(framework)#anchor" }),
|
| 66 |
+
},
|
| 67 |
+
{ type: "data-source", data: source({ url: "https://docs.example.test/café" }) },
|
| 68 |
+
{ type: "data-source", data: source({ url: "https://other.example.test/a(b)" }) },
|
| 69 |
+
{ type: "data-source", data: source({ url: "https://third.example.test/path/" }) },
|
| 70 |
]);
|
| 71 |
|
| 72 |
const numbers = buildCitationNumbers(message);
|
|
|
|
| 81 |
assert.equal(citationNumberFor(numbers, "https://ignored.example.test/inline"), undefined);
|
| 82 |
});
|
| 83 |
|
| 84 |
+
test("links without a resolved source card get no citation number", () => {
|
| 85 |
+
// Regression: the model cited one corpus page (resolved into a card) and
|
| 86 |
+
// also linked a Discord invite from memory. The invite must stay a plain
|
| 87 |
+
// hyperlink, not become chip "2" with no matching card.
|
| 88 |
+
const message = assistantMessage([
|
| 89 |
+
{
|
| 90 |
+
type: "text",
|
| 91 |
+
text: [
|
| 92 |
+
"See the [setup instructions](https://academy.example.test/lessons/setup#uv).",
|
| 93 |
+
"Ask in the [community Discord](https://discord.example.test/invite).",
|
| 94 |
+
].join("\n"),
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
type: "data-source",
|
| 98 |
+
data: source({ url: "https://academy.example.test/lessons/setup" }),
|
| 99 |
+
},
|
| 100 |
+
]);
|
| 101 |
+
|
| 102 |
+
const numbers = buildCitationNumbers(message);
|
| 103 |
+
|
| 104 |
+
assert.deepEqual([...numbers.entries()], [
|
| 105 |
+
["https://academy.example.test/lessons/setup", 1],
|
| 106 |
+
]);
|
| 107 |
+
assert.equal(
|
| 108 |
+
citationNumberFor(numbers, "https://discord.example.test/invite"),
|
| 109 |
+
undefined,
|
| 110 |
+
);
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
test("KB references resolve to one navigable citation number", () => {
|
| 114 |
const canonicalUrl = "https://academy.example.test/lessons/lora";
|
| 115 |
const kbSource = source({
|
frontend/lib/chat-ui.ts
CHANGED
|
@@ -343,15 +343,30 @@ export function resolveCitationHref(
|
|
| 343 |
}
|
| 344 |
|
| 345 |
// Number the citation links in a message's answer text by order of first
|
| 346 |
-
// appearance, deduped by normalized URL.
|
| 347 |
-
//
|
| 348 |
-
//
|
| 349 |
-
//
|
| 350 |
-
//
|
|
|
|
|
|
|
|
|
|
| 351 |
export function buildCitationNumbers(
|
| 352 |
message: TutorMessage,
|
| 353 |
resolutions?: Map<string, string>,
|
| 354 |
): Map<string, number> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
const numbers = new Map<string, number>();
|
| 356 |
for (const part of message.parts) {
|
| 357 |
if (!("type" in part) || part.type !== "text") {
|
|
@@ -364,7 +379,7 @@ export function buildCitationNumbers(
|
|
| 364 |
continue;
|
| 365 |
}
|
| 366 |
const key = normalizeCitationUrl(resolved);
|
| 367 |
-
if (key && !numbers.has(key)) {
|
| 368 |
numbers.set(key, numbers.size + 1);
|
| 369 |
}
|
| 370 |
}
|
|
|
|
| 343 |
}
|
| 344 |
|
| 345 |
// Number the citation links in a message's answer text by order of first
|
| 346 |
+
// appearance, deduped by normalized URL. Only links backed by a server-resolved
|
| 347 |
+
// source card (a data-source part) get a number, so every inline chip has a
|
| 348 |
+
// matching card on the sources row; a link the server could not resolve (e.g.
|
| 349 |
+
// a Discord invite the model added from memory) stays an ordinary hyperlink
|
| 350 |
+
// instead of becoming a numbered chip with no card. KB references are keyed
|
| 351 |
+
// under their resolved https URL so the inline chip and the source card share
|
| 352 |
+
// a number. The same map drives the inline citation chips and the numbers on
|
| 353 |
+
// the sources row.
|
| 354 |
export function buildCitationNumbers(
|
| 355 |
message: TutorMessage,
|
| 356 |
resolutions?: Map<string, string>,
|
| 357 |
): Map<string, number> {
|
| 358 |
+
const cardUrls = new Set<string>();
|
| 359 |
+
for (const part of message.parts) {
|
| 360 |
+
if (!("type" in part) || part.type !== "data-source") {
|
| 361 |
+
continue;
|
| 362 |
+
}
|
| 363 |
+
const data = (part as TutorMessagePart).data as SourcePartData | undefined;
|
| 364 |
+
const url = data?.url?.trim();
|
| 365 |
+
if (url && isHttpUrl(url)) {
|
| 366 |
+
cardUrls.add(normalizeCitationUrl(url));
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
const numbers = new Map<string, number>();
|
| 371 |
for (const part of message.parts) {
|
| 372 |
if (!("type" in part) || part.type !== "text") {
|
|
|
|
| 379 |
continue;
|
| 380 |
}
|
| 381 |
const key = normalizeCitationUrl(resolved);
|
| 382 |
+
if (key && cardUrls.has(key) && !numbers.has(key)) {
|
| 383 |
numbers.set(key, numbers.size + 1);
|
| 384 |
}
|
| 385 |
}
|