feat: source citations with footnotes in the transcript #21
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
STREAMING_CURSOR,
|
||||
buildThreadMarkdown,
|
||||
quote,
|
||||
sessionToPlainText,
|
||||
} from "../lib/markdown";
|
||||
import { STREAMING_CURSOR, buildThreadMarkdown, quote, sessionToPlainText } from "../lib/markdown";
|
||||
import type { ChatTurn, ThreadMessage } from "../lib/types";
|
||||
|
||||
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", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({ role: "assistant", text: "`https://example.com`" }),
|
||||
]);
|
||||
const markdown = buildThreadMarkdown([message({ role: "assistant", text: "`https://example.com`" })]);
|
||||
|
||||
expect(markdown).not.toContain("```");
|
||||
expect(markdown).toContain("[https://example.com](https://example.com)");
|
||||
@@ -655,3 +648,73 @@ describe("attachments in the transcript", () => {
|
||||
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
@@ -1,5 +1,6 @@
|
||||
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 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. */
|
||||
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
|
||||
? // One pass, in one place: fences get their languages and links recovered,
|
||||
// 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
|
||||
? drawn.text
|
||||
: !message.isStreaming
|
||||
? "_No response._"
|
||||
: activityLine
|
||||
@@ -178,6 +188,14 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
|
||||
// something went wrong and should not be skimmed past.
|
||||
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
|
||||
* 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 answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
|
||||
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user