feat: source citations with footnotes in the transcript (#21)

This commit was merged in pull request #21.
This commit is contained in:
2026-10-07 08:12:48 +00:00
parent 72a63e69e6
commit fcc6d26c9d
16 changed files with 2716 additions and 205 deletions
+47 -6
View File
@@ -1,7 +1,8 @@
import { createSourceList, type SourceList } from "./citations";
import { formatNow, getLocationContext, weekdayName } from "./location";
import { webFetch, webSearch } from "./ollama";
import type { GeoLookup } from "./geo";
import type { ToolDefinition } from "./types";
import type { Source, ToolDefinition } from "./types";
import { errorMessage } from "./utils";
/**
@@ -35,11 +36,34 @@ export interface ToolContext {
* for a location on its own.
*/
ipPlace?: (options: { signal?: AbortSignal }) => Promise<GeoLookup>;
/**
* The turn's source list, when the caller keeps one.
*
* Optional, so a caller with no interest in citations (a unit test, or any
* future tool that returns no sources) still gets the same numbered output: a
* list is made on the spot when none is supplied.
*/
sources?: SourceList;
}
/**
* What a tool returned.
*
* `sources` is separate from `content` on purpose. The content is prose written
* for a model to read, while the sources are data the transcript renders, so a
* change to one format cannot quietly break the other.
*/
export interface ToolResult {
content: string;
isError: boolean;
/**
* The sources this call added, for the transcript to list under the answer.
*
* Only what this call found, never the turn's running total: that total is the
* flattening of the turn's records, so storing it here would write the same page
* once per later call.
*/
sources?: Source[];
}
/** Search snippets are truncated so a chatty result set cannot swallow the model's context window. */
@@ -272,8 +296,11 @@ async function runWebSearch(args: Record<string, unknown>, context: ToolContext)
return { content: `No web results for "${query}".`, isError: false };
}
const list = context.sources ?? createSourceList();
const added: Source[] = [];
const rendered = results
.map((result, index) => {
.map((result) => {
const collapsed = result.content.replace(/\s+/g, " ").trim();
// Mark the cut rather than silently dropping the tail: the model should
// know a snippet was trimmed, and this matches what web_fetch does.
@@ -281,11 +308,19 @@ async function runWebSearch(args: Record<string, unknown>, context: ToolContext)
collapsed.length > MAX_SNIPPET_CHARS
? `${collapsed.slice(0, MAX_SNIPPET_CHARS)} …[truncated]`
: collapsed;
return `${index + 1}. ${result.title}\n ${result.url}\n ${snippet}`;
const { index, source } = list.add(result.title, result.url);
if (source) added.push(source);
// The number is what the model cites, so it is printed rather than left to be
// inferred from position: three searches in one turn would otherwise each
// start again at 1, and a marker in the answer would point at three pages.
const marker = index > 0 ? `[${index}] ` : "";
return `${marker}${result.title}\n ${result.url}\n ${snippet}`;
})
.join("\n\n");
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false };
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false, sources: added };
}
async function runWebFetch(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
@@ -305,10 +340,16 @@ async function runWebFetch(args: Record<string, unknown>, context: ToolContext):
const truncated = page.content.length > MAX_FETCH_CHARS ? "\n\n[content truncated]" : "";
const links = page.links.slice(0, 20);
const sections = [`Title: ${page.title}`, `URL: ${url}`, "", content + truncated];
const { index, source } = (context.sources ?? createSourceList()).add(page.title, url);
const heading = index > 0 ? `[${index}] ${page.title}` : page.title;
// The address keeps its label here, unlike in a search result: the body after it
// can run for thousands of characters, and a reader scrolling that wants to be
// able to find the page's own URL again.
const sections = [heading, `URL: ${url}`, "", content + truncated];
if (links.length > 0) {
sections.push("", `Links on page:\n${links.map((link) => `- ${link}`).join("\n")}`);
}
return { content: sections.join("\n"), isError: false };
return { content: sections.join("\n"), isError: false, sources: source ? [source] : [] };
}