feat: source citations with footnotes in the transcript #21
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user