feat: number, link and list the sources a turn consults
One number per page per turn, assigned where the tools run so the model and the reader agree on what [3] means. The module also owns the marker pass and the list under the answer, because Vicinae cannot draw a footnote: it parses markdown with CMARK_OPT_DEFAULT and attaches only the table and strikethrough extensions, so [^1] arrives as literal text. A marker stays text unless every number in it is a source with a destination this window should open, and a footnote definition for a source we already list is dropped while a markdown reference definition is left alone.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
citedIndices,
|
||||
collectSources,
|
||||
createSourceList,
|
||||
linkCitations,
|
||||
renderSources,
|
||||
} from "../lib/citations";
|
||||
import type { ToolCallRecord } from "../lib/types";
|
||||
|
||||
/** One record, with only the fields the source helpers read. */
|
||||
function call(sources?: ToolCallRecord["sources"]): ToolCallRecord {
|
||||
return { name: "web_search", arguments: {}, result: "", isError: false, sources };
|
||||
}
|
||||
|
||||
describe("createSourceList", () => {
|
||||
it("numbers sources from one in the order they were found", () => {
|
||||
const list = createSourceList();
|
||||
expect(list.add("First", "https://first.example").index).toBe(1);
|
||||
expect(list.add("Second", "https://second.example").index).toBe(2);
|
||||
});
|
||||
|
||||
it("gives a URL the number it already has, so a re-fetch cites the same page", () => {
|
||||
const list = createSourceList();
|
||||
list.add("First", "https://first.example");
|
||||
const again = list.add("First again", "https://first.example");
|
||||
|
||||
expect(again.index).toBe(1);
|
||||
// Nothing new was added, which is how the caller knows not to store it twice.
|
||||
expect(again.source).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the entry only the first time, so a turn stores it once", () => {
|
||||
const list = createSourceList();
|
||||
expect(list.add("First", "https://first.example").source).toEqual({
|
||||
index: 1,
|
||||
title: "First",
|
||||
url: "https://first.example",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores surrounding whitespace when matching a URL", () => {
|
||||
const list = createSourceList();
|
||||
list.add("First", "https://first.example");
|
||||
expect(list.add("First", " https://first.example ").index).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to the URL as the title when there is none", () => {
|
||||
const list = createSourceList();
|
||||
expect(list.add(" ", "https://first.example").source?.title).toBe("https://first.example");
|
||||
});
|
||||
|
||||
it("gives a result with no usable URL no number at all", () => {
|
||||
// Zero rather than the next number: there is nothing to link to, so the tool
|
||||
// prints no marker and the model cannot cite a page that has no address.
|
||||
const list = createSourceList();
|
||||
expect(list.add("Title", " ").index).toBe(0);
|
||||
expect(list.add("Next", "https://next.example").index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectSources", () => {
|
||||
it("flattens a turn's records into one list in number order", () => {
|
||||
const collected = collectSources([
|
||||
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
||||
call([{ index: 2, title: "Second", url: "https://second.example" }]),
|
||||
]);
|
||||
|
||||
expect(collected.map((source) => source.index)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("keeps the first record's version of a number", () => {
|
||||
// A number is assigned once, so two records carrying it describe the same
|
||||
// page, and the first one seen is the one that named it.
|
||||
const collected = collectSources([
|
||||
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
||||
call([{ index: 1, title: "Duplicate", url: "https://first.example" }]),
|
||||
]);
|
||||
|
||||
expect(collected).toEqual([{ index: 1, title: "First", url: "https://first.example" }]);
|
||||
});
|
||||
|
||||
it("sorts by number even when the records arrive out of order", () => {
|
||||
const collected = collectSources([
|
||||
call([{ index: 3, title: "Third", url: "https://third.example" }]),
|
||||
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
||||
]);
|
||||
|
||||
expect(collected.map((source) => source.index)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("is empty for a turn that consulted nothing", () => {
|
||||
expect(collectSources([])).toEqual([]);
|
||||
expect(collectSources([call()])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("citedIndices", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 2, title: "Second", url: "https://second.example" },
|
||||
];
|
||||
|
||||
it("finds the numbers an answer cites, in either marker shape", () => {
|
||||
expect([...citedIndices("See [1] and [^2].", sources)].sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("ignores a number that is not a source", () => {
|
||||
expect([...citedIndices("See [9].", sources)]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linkCitations", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 3, title: "Third", url: "https://third.example" },
|
||||
];
|
||||
|
||||
it("turns a marker into a link whose label is the marker", () => {
|
||||
const result = linkCitations("Rain is likely [1].", sources);
|
||||
|
||||
expect(result.text).toBe("Rain is likely [\\[1\\]](https://first.example).");
|
||||
expect([...result.cited]).toEqual([1]);
|
||||
});
|
||||
|
||||
it("reads the footnote shape as the same thing", () => {
|
||||
expect(linkCitations("Rain [^1].", sources).text).toBe("Rain [\\[1\\]](https://first.example).");
|
||||
});
|
||||
|
||||
it("links a list of markers and keeps the comma", () => {
|
||||
const result = linkCitations("Both [1, 3] agree.", sources);
|
||||
|
||||
expect(result.text).toBe(
|
||||
"Both [\\[1\\]](https://first.example), [\\[3\\]](https://third.example) agree.",
|
||||
);
|
||||
expect([...result.cited].sort()).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("leaves a marker alone when any number in it is not a source", () => {
|
||||
// Half a link reads as a citation that works, which is worse than the marker
|
||||
// the model wrote: the reader cannot tell which half is missing.
|
||||
const result = linkCitations("See [1, 9].", sources);
|
||||
|
||||
expect(result.text).toBe("See [1, 9].");
|
||||
expect([...result.cited]).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves a marker alone when the destination is not one this window should open", () => {
|
||||
const unsafe = [{ index: 1, title: "Nasty", url: "javascript:alert(1)" }];
|
||||
const result = linkCitations("See [1].", unsafe);
|
||||
|
||||
expect(result.text).toBe("See [1].");
|
||||
expect([...result.cited]).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves a destination containing whitespace as text", () => {
|
||||
const odd = [{ index: 1, title: "Odd", url: "https://first.example/a b" }];
|
||||
expect(linkCitations("See [1].", odd).text).toBe("See [1].");
|
||||
});
|
||||
|
||||
it("does not touch a link the model wrote", () => {
|
||||
const result = linkCitations("See [1](https://elsewhere.example).", sources);
|
||||
expect(result.text).toBe("See [1](https://elsewhere.example).");
|
||||
});
|
||||
|
||||
it("does not touch a reference definition line", () => {
|
||||
expect(linkCitations("[1]: https://elsewhere.example", sources).text).toBe(
|
||||
"[1]: https://elsewhere.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a footnote definition for a source it already lists", () => {
|
||||
const answer = "Rain [^1].\n\n[^1]: First, https://first.example";
|
||||
expect(linkCitations(answer, sources).text).toBe("Rain [\\[1\\]](https://first.example).\n");
|
||||
});
|
||||
|
||||
it("keeps a markdown reference definition, which backs a link the model wrote", () => {
|
||||
// `[1]:` with no caret is reference syntax, not a footnote. Only the footnote
|
||||
// shape is unambiguously a duplicate of an entry we render ourselves.
|
||||
const answer = "Rain [^1].\n\n[1]: https://elsewhere.example";
|
||||
expect(linkCitations(answer, sources).text).toBe(
|
||||
"Rain [\\[1\\]](https://first.example).\n\n[1]: https://elsewhere.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a definition for a number it knows nothing about", () => {
|
||||
const answer = "Rain [^1].\n\n[^7]: Something else";
|
||||
expect(linkCitations(answer, sources).text).toBe(
|
||||
"Rain [\\[1\\]](https://first.example).\n\n[^7]: Something else",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a marker inside a code span as the text it is", () => {
|
||||
expect(linkCitations("Type `curl [1]` now.", sources).text).toBe("Type `curl [1]` now.");
|
||||
});
|
||||
|
||||
it("changes nothing when there are no sources", () => {
|
||||
expect(linkCitations("See [1].", []).text).toBe("See [1].");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderSources", () => {
|
||||
const sources = [
|
||||
{ index: 1, title: "First", url: "https://first.example" },
|
||||
{ index: 3, title: "Third", url: "https://third.example" },
|
||||
];
|
||||
|
||||
it("lists the sources the answer cited, numbered as the model numbered them", () => {
|
||||
const list = renderSources(sources, new Set([3]), false);
|
||||
|
||||
expect(list).toBe("_Sources_\n\n- [\\[3\\]](https://third.example) Third");
|
||||
});
|
||||
|
||||
it("lists everything when the answer cited nothing, once the turn has ended", () => {
|
||||
// Nothing to filter on, so the only thing left to show is what was consulted.
|
||||
expect(renderSources(sources, new Set(), false)).toBe(
|
||||
"_Sources_\n\n- [\\[1\\]](https://first.example) First\n- [\\[3\\]](https://third.example) Third",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays away while an answer is still arriving", () => {
|
||||
// Otherwise the list appears in full at the first token and then shrinks as
|
||||
// citations arrive, which reads as a glitch rather than as settling.
|
||||
expect(renderSources(sources, new Set(), true)).toBe("");
|
||||
});
|
||||
|
||||
it("lists nothing when there was nothing to list", () => {
|
||||
expect(renderSources([], new Set(), false)).toBe("");
|
||||
});
|
||||
|
||||
it("keeps an entry whose destination cannot be linked", () => {
|
||||
const unsafe = [{ index: 1, title: "Nasty", url: "javascript:alert(1)" }];
|
||||
expect(renderSources(unsafe, new Set(), false)).toBe("_Sources_\n\n- [1] Nasty");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user