feat: source citations with footnotes in the transcript #21

Merged
adrianbonpin merged 10 commits from feat/source-citations into prod 2026-10-07 08:12:52 +00:00
2 changed files with 105 additions and 15 deletions
Showing only changes of commit 7d7040353a - Show all commits
+72 -9
View File
@@ -1,10 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { STREAMING_CURSOR, buildThreadMarkdown, quote, sessionToPlainText } from "../lib/markdown";
STREAMING_CURSOR,
buildThreadMarkdown,
quote,
sessionToPlainText,
} from "../lib/markdown";
import type { ChatTurn, ThreadMessage } from "../lib/types"; import type { ChatTurn, ThreadMessage } from "../lib/types";
function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">): ThreadMessage { function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">): ThreadMessage {
@@ -235,9 +230,7 @@ describe("buildThreadMarkdown", () => {
}); });
it("links a lone URL the model put in inline code", () => { it("links a lone URL the model put in inline code", () => {
const markdown = buildThreadMarkdown([ const markdown = buildThreadMarkdown([message({ role: "assistant", text: "`https://example.com`" })]);
message({ role: "assistant", text: "`https://example.com`" }),
]);
expect(markdown).not.toContain("```"); expect(markdown).not.toContain("```");
expect(markdown).toContain("[https://example.com](https://example.com)"); expect(markdown).toContain("[https://example.com](https://example.com)");
@@ -655,3 +648,73 @@ describe("attachments in the transcript", () => {
expect(text).toContain("_(screenshot attached)_"); expect(text).toContain("_(screenshot attached)_");
}); });
}); });
describe("sources", () => {
const sources = [
{ index: 1, title: "First", url: "https://first.example" },
{ index: 2, title: "Second", url: "https://second.example" },
];
/** One search, and the answer that may or may not point at it. */
function answered(text: string, streaming = false): string {
return buildThreadMarkdown(
[
message({
id: "t1:assistant",
role: "assistant",
text,
isStreaming: streaming,
toolCalls: [{ name: "web_search", arguments: {}, result: "", isError: false, sources }],
}),
message({ id: "t1:user", role: "user", text: "When is the rain?" }),
],
{ showToolCalls: false, showReasoning: false },
);
}
it("links the markers and lists the sources the answer leaned on", () => {
const text = answered("Rain is likely [1].");
expect(text).toContain("Rain is likely [\\[1\\]](https://first.example).");
expect(text).toContain("_Sources_");
expect(text).toContain("- [\\[1\\]](https://first.example) First");
// The answer did not lean on the second one.
expect(text).not.toContain("Second");
});
it("lists everything the turn consulted when the answer cited nothing", () => {
const text = answered("Rain is likely.");
expect(text).toContain("- [\\[1\\]](https://first.example) First");
expect(text).toContain("- [\\[2\\]](https://second.example) Second");
});
it("waits for a citation before drawing anything", () => {
// Otherwise the list arrives in full with the first token and then shrinks as
// citations appear, which reads as a glitch rather than as settling.
expect(answered("Rain is", true)).not.toContain("_Sources_");
});
it("adds nothing to a turn that consulted nothing", () => {
expect(buildThreadMarkdown([message({ role: "assistant", text: "Plain." })])).toBe("Plain.");
});
it("carries the list into the copied conversation", () => {
const copied = sessionToPlainText([
{
id: "t1",
prompt: "When is the rain?",
response: "Rain [1].",
thinking: "",
model: "m",
createdAt: 0,
toolCalls: [{ name: "web_search", arguments: {}, result: "", isError: false, sources }],
attachments: [],
},
]);
expect(copied).toContain("**Lam**\n\nRain [1].");
expect(copied).toContain("_Sources_");
expect(copied).toContain("- [\\[1\\]](https://first.example) First");
});
});
+33 -6
View File
@@ -1,5 +1,6 @@
import { imageSource } from "./attachments"; import { imageSource } from "./attachments";
import { renderInlineContent, renderModelOutput } from "./render"; import { citedIndices, collectSources, renderSources } from "./citations";
import { renderInlineContent, renderModelOutput, type RenderedMarkdown } from "./render";
import { describeActivity } from "./thread"; import { describeActivity } from "./thread";
import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types"; import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types";
@@ -150,11 +151,20 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
/** The answer, or whatever stands in for it while there is none. */ /** The answer, or whatever stands in for it while there is none. */
const answered = message.text.trim(); const answered = message.text.trim();
/**
* The turn's sources, read back off its records rather than out of a tool result
* string, so a reopened conversation draws the same list with the same numbers.
*/
const sources = collectSources(message.toolCalls);
const drawn: RenderedMarkdown = answered
? // One pass, in one place: fences get their languages and links recovered, then
// TeX becomes something drawable, a citation marker becomes a link to the page
// it names, and the HTML around all of it is filtered to the subset the
// renderer acts on. See `render.ts` for the order and why.
renderModelOutput(answered, { sources })
: { text: "", cited: new Set<number>() };
const body = answered const body = answered
? // One pass, in one place: fences get their languages and links recovered, ? drawn.text
// then TeX becomes something drawable and the HTML around it is filtered to
// the subset the renderer acts on. See `render.ts` for the order and why.
renderModelOutput(answered).text
: !message.isStreaming : !message.isStreaming
? "_No response._" ? "_No response._"
: activityLine : activityLine
@@ -178,6 +188,14 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
// something went wrong and should not be skimmed past. // something went wrong and should not be skimmed past.
if (message.error) sections.push("", `**Error:** ${message.error}`); if (message.error) sections.push("", `**Error:** ${message.error}`);
/**
* Sources belong to the answer rather than to the tool list, so they are drawn
* whether or not the user asked to see tool calls. Hiding the activity line is
* about chrome; a citation list is the answer's own provenance.
*/
const list = renderSources(sources, drawn.cited, message.isStreaming);
if (list) sections.push("", list);
/** /**
* Tool calls are a display preference. When they are on, every tool is named * Tool calls are a display preference. When they are on, every tool is named
* once. When they are off, nothing is listed; a failure or a refused repeat * once. When they are off, nothing is listed; a failure or a refused repeat
@@ -276,7 +294,16 @@ export function sessionToPlainText(turns: ChatTurn[]): string {
const attached = turn.attachments.length > 0 ? "\n\n_(screenshot attached)_" : ""; const attached = turn.attachments.length > 0 ? "\n\n_(screenshot attached)_" : "";
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_"); const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : ""; const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}`; /**
* The same list the transcript draws, worked out from a plain scan rather than
* from the panel's own set. A copied conversation is markdown on its way
* somewhere else, where a `[1]` inside a snippet counting as a citation costs a
* reader nothing.
*/
const sources = collectSources(turn.toolCalls);
const list = renderSources(sources, citedIndices(turn.response, sources), false);
const cited = list ? `\n\n${list}` : "";
return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}${cited}`;
}) })
.join("\n\n---\n\n"); .join("\n\n---\n\n");
} }