omarsol Claude Fable 5 commited on
Commit
332195a
·
1 Parent(s): 9816fb5

Only number inline citations that resolved to a source card

Browse files

The 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. KB references are keyed under their
347
- // resolved https URL so the inline chip and the source card share a number;
348
- // references that resolve to nothing navigable get no number (the renderer
349
- // shows them as plain text instead of a broken chip). The same map drives the
350
- // inline citation chips and the numbers on the sources row.
 
 
 
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
  }