diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bcf0b573..9b5df96af4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +### Every chart says where its figures came from + +A chart a Bot draws now ends with a line naming what its figures were read from and when they are +from, such as "Sources: World Bank World Development Indicators · As of 2025". Figures a Bot charted +from its own knowledge, without looking them up, are marked "From the Bot's own knowledge. Not +checked against a source." Bots are told to look up figures that change over time before charting +them. In Slack and Teams the same line follows the chart in the reply. Charts already in a +conversation are drawn as before, with no such line. + ### Screens share one spacing, row and empty-state design Every configuration screen now takes its spacing from the page frame: 32px under the title, 48px diff --git a/app/src/components/gallery/charts.tsx b/app/src/components/gallery/charts.tsx index 6a1039819f..c1d815f9ca 100644 --- a/app/src/components/gallery/charts.tsx +++ b/app/src/components/gallery/charts.tsx @@ -10,6 +10,39 @@ const point = z.object({ value: z.number().describe("Its value"), }); +const source = z.object({ + title: z + .string() + .describe("The page, report or dataset, named the way a person would"), + url: z.string().optional().describe("Its address, when it has one"), +}); + +/** + * Where the figures came from, drawn under every chart. + * + * Required of the model, optional for the renderer. A chart is the most confident-looking thing a + * Bot can produce, and a number recalled from training looks exactly like one read this morning, so + * the Bot has to say which it is every time it draws one. Older conversations were drawn before the + * field existed, which is why the card still renders without it. + */ +const provenance = { + basis: z + .enum(["read", "given", "own-knowledge"]) + .describe( + "Where the figures come from. read: you read them with a tool in this conversation, and sources lists what you read. given: the person supplied them, in the conversation or a file. own-knowledge: you recalled them without looking them up; the chart then says they are unchecked. For figures that change over time, look them up before charting if you can", + ), + sources: z + .array(source) + .optional() + .describe("What you read the figures from. Required when basis is read"), + asOf: z + .string() + .optional() + .describe( + "When the figures are from, e.g. 'Q3 2026' or '24 Oct 2026'. Leave out for figures that do not date", + ), +}; + const common = { title: z.string().describe("A short title for the chart"), caption: z @@ -18,8 +51,86 @@ const common = { .describe( "One line under the title saying what the reader should take from it", ), + ...provenance, }; +type Provenance = Partial<{ + basis: "read" | "given" | "own-knowledge"; + sources: { title?: string; url?: string }[]; + asOf: string; +}>; + +/** Only web addresses become links. Anything else the model wrote is shown as text. */ +function isWebAddress(url: unknown): url is string { + return typeof url === "string" && /^https?:\/\//i.test(url); +} + +/** + * The sources worth naming, once each. The arguments are drawn as the model wrote them, unchecked, + * so a source that is not a titled object is skipped instead of breaking the card. + */ +function namedSources(sources: unknown): { title: string; url?: string }[] { + if (!Array.isArray(sources)) return []; + const seen = new Set(); + const named: { title: string; url?: string }[] = []; + for (const entry of sources) { + const title = typeof entry?.title === "string" ? entry.title.trim() : ""; + if (!title) continue; + const url = isWebAddress(entry.url) ? entry.url : undefined; + const key = `${title}|${url ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + named.push(url ? { title, url } : { title }); + } + return named; +} + +/** The line under a chart that says where its figures came from. Nothing when nobody said. */ +function Sources({ basis, sources, asOf: when }: Provenance) { + const named = namedSources(sources); + const unchecked = basis === "own-knowledge"; + const asOf = typeof when === "string" ? when.trim() : ""; + if (!unchecked && named.length === 0 && !asOf) return null; + return ( +

+ {unchecked ? ( + + From the Bot's own knowledge. Not checked against a source. + + ) : null} + {named.length > 0 ? ( + <> + {unchecked ? " · " : null} + {"Sources: "} + {named.map((entry, index) => ( + + {index > 0 ? ", " : null} + {entry.url ? ( + + {entry.title} + + ) : ( + entry.title + )} + + ))} + + ) : null} + {asOf ? ( + <> + {unchecked || named.length > 0 ? " · " : null} + {`As of ${asOf}`} + + ) : null} +

+ ); +} + /** Charts share a height so several in one answer do not stagger down the page. */ const HEIGHT = 180; @@ -66,6 +177,7 @@ export function BarChartCard({ title, caption, points: given, + ...origin }: Partial>) { const points = given ?? []; const max = Math.max(...points.map((entry) => entry.value ?? 0), 0); @@ -106,6 +218,7 @@ export function BarChartCard({ ))} )} + ); } @@ -121,6 +234,7 @@ export function PieChartCard({ title, caption, points: given, + ...origin }: Partial>) { const points = given ?? []; const size = 160; @@ -186,6 +300,7 @@ export function PieChartCard({ )} + ); } @@ -313,6 +428,7 @@ export function LineChartCard(args: Partial) { ) : ( )} + ); } @@ -332,6 +448,7 @@ export function AreaChartCard(args: Partial) { ) : ( )} + ); } @@ -356,6 +473,7 @@ export function ProgressChartCard({ title, caption, points: given, + ...origin }: Partial>) { const points = given ?? []; return ( @@ -391,6 +509,7 @@ export function ProgressChartCard({ })} )} + ); } @@ -406,6 +525,7 @@ export const GALLERY: GalleryComponent[] = [ parameters: BarChartProps, Component: BarChartCard as GalleryComponent["Component"], preview: { + basis: "given", title: "Revenue by team", caption: "Sales leads, and Engineering is closing the gap.", points: [ @@ -425,6 +545,7 @@ export const GALLERY: GalleryComponent[] = [ parameters: PieChartProps, Component: PieChartCard as GalleryComponent["Component"], preview: { + basis: "given", title: "Where the month went", points: [ { label: "Build", value: 48 }, @@ -443,6 +564,7 @@ export const GALLERY: GalleryComponent[] = [ parameters: LineChartProps, Component: LineChartCard as GalleryComponent["Component"], preview: { + basis: "given", title: "Signups", caption: "Six weeks, one release.", labels: ["W1", "W2", "W3", "W4", "W5", "W6"], @@ -459,6 +581,7 @@ export const GALLERY: GalleryComponent[] = [ parameters: AreaChartProps, Component: AreaChartCard as GalleryComponent["Component"], preview: { + basis: "given", title: "Storage used", caption: "Growing steadily since the migration.", labels: ["Jan", "Feb", "Mar", "Apr", "May"], @@ -475,6 +598,7 @@ export const GALLERY: GalleryComponent[] = [ parameters: ProgressChartProps, Component: ProgressChartCard as GalleryComponent["Component"], preview: { + basis: "given", title: "Migration to the new runtime", caption: "Two services left.", points: [ diff --git a/app/tests/chart-sources.test.tsx b/app/tests/chart-sources.test.tsx new file mode 100644 index 0000000000..f1675775d3 --- /dev/null +++ b/app/tests/chart-sources.test.tsx @@ -0,0 +1,123 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, render } from "@testing-library/react"; +import { + AreaChartProps, + BarChartCard, + BarChartProps, + GALLERY, + LineChartCard, + LineChartProps, + PieChartProps, + ProgressChartProps, +} from "@/components/gallery/charts"; +import { settleReactWork } from "./settle-react-work"; + +beforeAll(() => GlobalRegistrator.register({ url: "http://localhost/" })); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const POINTS = [ + { label: "France", value: 3.1 }, + { label: "Japan", value: 4.2 }, +]; + +test("a chart drawn from what the Bot read names and links its sources", () => { + const view = render( + , + ); + const link = view.getByRole("link", { name: "IMF World Economic Outlook" }); + expect(link.getAttribute("href")).toBe("https://imf.org/weo"); + expect(view.getByText(/Annual report/)).toBeTruthy(); + expect(view.getByText(/As of 2025/)).toBeTruthy(); +}); + +test("figures from the Bot's own knowledge are marked as unchecked", () => { + const view = render( + , + ); + expect(view.getByText(/Not checked against a source/)).toBeTruthy(); +}); + +test("the unchecked note and the sources read as separate parts", () => { + const view = render( + , + ); + expect(view.container.textContent).toContain( + "Not checked against a source. · Sources: Annual report", + ); +}); + +test("sources the model wrote in the wrong shape are skipped, not fatal", () => { + const view = render( + [0])} + />, + ); + expect(view.container.textContent).toContain("Sources: Notes"); + expect(view.container.textContent).not.toContain("Notes, Notes"); + expect(view.queryByText(/As of/)).toBeNull(); +}); + +test("only web addresses become links", () => { + const view = render( + , + ); + expect(view.queryByRole("link")).toBeNull(); + expect(view.getByText(/Notes/)).toBeTruthy(); +}); + +test("a chart from before sources existed still renders, with no footer", () => { + const view = render(); + expect(view.getByText("GDP")).toBeTruthy(); + expect(view.queryByText(/Sources:|As of|Not checked/)).toBeNull(); +}); + +test("the model has to say where a chart's figures came from", () => { + expect( + BarChartProps.safeParse({ title: "GDP", points: POINTS }).success, + ).toBe(false); + const schemas = [ + BarChartProps, + PieChartProps, + LineChartProps, + AreaChartProps, + ProgressChartProps, + ]; + GALLERY.forEach((chart, index) => { + expect(schemas[index].safeParse(chart.preview).success).toBe(true); + }); +}); diff --git a/server/src/components/headless.ts b/server/src/components/headless.ts index 7ec33d2396..a33c3e8727 100644 --- a/server/src/components/headless.ts +++ b/server/src/components/headless.ts @@ -1,8 +1,8 @@ import { NOT_SHOWN, NOT_SHOWN_TAIL } from "@openbot/shared/component-markers"; -import { z } from "zod"; -import type { AuditInitiator, AuditSink } from "../audit"; import type { HeadlessTool } from "@openbot/toolkit/headless-tools"; import type { ComponentCatalog } from "@openbot/toolkit/ports"; +import { z } from "zod"; +import type { AuditInitiator, AuditSink } from "../audit"; import type { ComponentStore } from "./store"; /** @@ -32,6 +32,34 @@ const point = z.object({ value: z.number().describe("Its value"), }); +/** Where the figures came from. The same fields, and the same reason, as the app's charts. */ +const provenance = { + basis: z + .enum(["read", "given", "own-knowledge"]) + .describe( + "Where the figures come from. read: you read them with a tool in this conversation, and sources lists what you read. given: the person supplied them, in the conversation or a file. own-knowledge: you recalled them without looking them up; the chart then says they are unchecked. For figures that change over time, look them up before charting if you can", + ), + sources: z + .array( + z.object({ + title: z + .string() + .describe( + "The page, report or dataset, named the way a person would", + ), + url: z.string().optional().describe("Its address, when it has one"), + }), + ) + .optional() + .describe("What you read the figures from. Required when basis is read"), + asOf: z + .string() + .optional() + .describe( + "When the figures are from, e.g. 'Q3 2026' or '24 Oct 2026'. Leave out for figures that do not date", + ), +}; + const common = { title: z.string().describe("A short title for the chart"), caption: z @@ -40,6 +68,7 @@ const common = { .describe( "One line under the title saying what the reader should take from it", ), + ...provenance, }; const BarChartProps = z.object({ diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 50ee2e7bc3..a4340c6f7a 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -7,18 +7,19 @@ * runs. */ import { randomUUID } from "node:crypto"; +import type { DrawnComponent } from "@openbot/toolkit/run-turn"; import { z } from "zod"; import { type ApprovalDecision, approvalPreview } from "../approvals/types"; import { ingestSlackEvent, type SlackTriggerEvent, } from "../responsibilities/slack"; -import type { DrawnComponent } from "@openbot/toolkit/run-turn"; import { agUiStream, approvalFields, COMPONENT_CALL_PREFIX, chartForOpenTag, + chartSourcesLine, componentCallEvents, confirmWriteInterrupt, isComponentFollowUp, @@ -208,6 +209,7 @@ export async function handleOpenTagRun( args, )) emit(event); + say(chartSourcesLine(component.args)); } if (outcome.interrupt) emit({ diff --git a/server/src/delivery/opentag.ts b/server/src/delivery/opentag.ts index 05a62d5fd0..7ca63ca031 100644 --- a/server/src/delivery/opentag.ts +++ b/server/src/delivery/opentag.ts @@ -555,6 +555,38 @@ export function chartForOpenTag(component: { return null; } +/** + * The line said under a chart drawn natively, saying where its figures came from, or "" when the + * Bot said nothing about that. OpenTag's chart has no caption, so without it a Slack or Teams chart + * would lose what the same chart says in OpenBot. Mirrors `Sources` in the app's gallery charts. + */ +export function chartSourcesLine(args: Record): string { + const seen = new Set(); + const named: string[] = []; + for (const entry of Array.isArray(args.sources) ? args.sources : []) { + const title = typeof entry?.title === "string" ? entry.title.trim() : ""; + if (!title) continue; + const url = + typeof entry.url === "string" && /^https?:\/\//i.test(entry.url) + ? entry.url + : undefined; + const key = `${title}|${url ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + named.push(url ? `[${title}](${url})` : title); + } + const asOf = typeof args.asOf === "string" ? args.asOf.trim() : ""; + return [ + args.basis === "own-knowledge" + ? "From the Bot's own knowledge. Not checked against a source." + : "", + named.length ? `Sources: ${named.join(", ")}` : "", + asOf ? `As of ${asOf}` : "", + ] + .filter(Boolean) + .join(" · "); +} + /** A component call for the Channel to draw, as AG-UI tool-call events. */ export function componentCallEvents( toolCallId: string, diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 83f13618c2..6b9b6ea636 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -716,14 +716,14 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/headless-components.integration.test.ts", - line: 349, + line: 350, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(bar?.hidden).toBeUndefined();", }, { file: "server/tests/headless-components.test.ts", - line: 197, + line: 202, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(bar?.hidden).toBeUndefined();", diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index 3602a981fe..758cc972cd 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -782,6 +782,71 @@ test("a chart the Bot drew is drawn natively by the Channel, and its follow-up r f.calls.filter((c) => (c as { converse?: unknown }).converse), ).toHaveLength(turns); }); +test("a chart drawn natively in Slack or Teams says where its figures came from", async () => { + const points = [{ label: "France", value: 3.1 }]; + const f = fixture({ + converse: { + kind: "replied", + binding: bound, + inboxId: "in1", + replyText: "Here is GDP.", + components: [ + { + name: "showBarChart", + args: { + title: "GDP", + points, + basis: "read", + sources: [ + { title: "World Bank", url: "https://data.worldbank.org" }, + { title: "World Bank", url: "https://data.worldbank.org" }, + { title: "Notes", url: "javascript:alert(1)" }, + null, + ], + asOf: "2025", + }, + }, + { + name: "showPieChart", + args: { title: "Unchecked", points, basis: "own-knowledge" }, + }, + { name: "showBarChart", args: { title: "Old", points } }, + ], + extras: { approvals: [], questions: [] }, + }, + }); + const result = await run(f, { + tools: [{ name: "render_chart", description: "", parameters: {} }], + context: [sender()], + messages: [{ id: "m1", role: "user", content: "chart GDP" }], + }); + expect( + result.events.filter((e) => e.type === "TOOL_CALL_START"), + ).toHaveLength(3); + expect( + result.events + .filter((e) => e.type === "TEXT_MESSAGE_CONTENT") + .map((e) => e.delta), + ).toEqual([ + "Here is GDP.", + "Sources: [World Bank](https://data.worldbank.org), Notes · As of 2025", + "From the Bot's own knowledge. Not checked against a source.", + ]); + // Each line follows its own chart. + const kinds = result.events + .filter( + (e) => e.type === "TOOL_CALL_START" || e.type === "TEXT_MESSAGE_CONTENT", + ) + .map((e) => e.type); + expect(kinds).toEqual([ + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_START", + ]); +}); test("a channel message from a colleague who never linked still reaches the owners' Slack triggers", async () => { // Whose triggers fire is decided by each owner's channel membership in the ingest, never by the // author's own link: the author here has no OpenBot binding at all. diff --git a/server/tests/headless-components.integration.test.ts b/server/tests/headless-components.integration.test.ts index 47917e80c9..c19af9a095 100644 --- a/server/tests/headless-components.integration.test.ts +++ b/server/tests/headless-components.integration.test.ts @@ -40,6 +40,7 @@ let addedComponents: string[] = []; const chartArgs = { title: "Stories on the front page by source", + basis: "read", caption: "Read from the page the routine opened.", points: [ { label: "github.com", value: 4 }, diff --git a/server/tests/headless-components.test.ts b/server/tests/headless-components.test.ts index 4173a247ef..61e6a870e3 100644 --- a/server/tests/headless-components.test.ts +++ b/server/tests/headless-components.test.ts @@ -149,6 +149,7 @@ describe("headless components mirror the compiled gallery", () => { ) as typeof import("../../app/node_modules/@types/react-dom/server"); const args = { title: "Open issues by team", + basis: "given", points: [ { label: "Platform", value: 12 }, { label: "Apps", value: 5 }, @@ -184,7 +185,11 @@ function fakeStore(decision: ComponentDecision): ComponentStore { describe("headless component tools", () => { const initiator = { kind: "routine" as const, id: "routine-1" }; - const valid = { title: "Revenue", points: [{ label: "Q1", value: 3 }] }; + const valid = { + title: "Revenue", + basis: "given", + points: [{ label: "Q1", value: 3 }], + }; test("a held chart is offered with its published description and records a confirmation", async () => { const tools = await createHeadlessComponentTools({ diff --git a/shared/bot-prompt.ts b/shared/bot-prompt.ts index 306e97566b..a2dfcd05de 100644 --- a/shared/bot-prompt.ts +++ b/shared/bot-prompt.ts @@ -131,6 +131,11 @@ const PROVENANCE_GUIDANCE_LINES = [ "person names. Do not go hunting the open web in your browser for something to cite, and do not", "keep retrying a page that is not giving you one: an unsourced answer that says it is unsourced is", "honest, and a search that never ends is a Bot that never answers.", + "", + "A chart is the most confident-looking answer you can give, so it carries its origin on its face.", + "Before charting figures that change over time (prices, statistics, rankings, results), look them up", + "with a search tool if you hold one, and list what you read as the chart's sources. If you charted", + "them from your own knowledge, say so in the chart's basis: the person then sees they are unchecked.", ]; export const PROVENANCE_GUIDANCE = PROVENANCE_GUIDANCE_LINES.reduce(