feat: source citations with footnotes in the transcript (#21)
This commit was merged in pull request #21.
This commit is contained in:
+47
-6
@@ -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] : [] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user