Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions app/src/components/gallery/charts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string>();
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 (
<p className="mt-3 text-xs text-muted-foreground">
{unchecked ? (
<span className="text-amber-700 dark:text-amber-400">
From the Bot's own knowledge. Not checked against a source.
</span>
) : null}
{named.length > 0 ? (
<>
{unchecked ? " · " : null}
{"Sources: "}
{named.map((entry, index) => (
<span key={`${entry.title}|${entry.url ?? ""}`}>
{index > 0 ? ", " : null}
{entry.url ? (
<a
className="underline underline-offset-2 hover:text-foreground"
href={entry.url}
rel="noreferrer"
target="_blank"
>
{entry.title}
</a>
) : (
entry.title
)}
</span>
))}
</>
) : null}
{asOf ? (
<>
{unchecked || named.length > 0 ? " · " : null}
{`As of ${asOf}`}
</>
) : null}
</p>
);
}

/** Charts share a height so several in one answer do not stagger down the page. */
const HEIGHT = 180;

Expand Down Expand Up @@ -66,6 +177,7 @@ export function BarChartCard({
title,
caption,
points: given,
...origin
}: Partial<z.infer<typeof BarChartProps>>) {
const points = given ?? [];
const max = Math.max(...points.map((entry) => entry.value ?? 0), 0);
Expand Down Expand Up @@ -106,6 +218,7 @@ export function BarChartCard({
))}
</div>
)}
<Sources {...origin} />
</GalleryFrame>
);
}
Expand All @@ -121,6 +234,7 @@ export function PieChartCard({
title,
caption,
points: given,
...origin
}: Partial<z.infer<typeof PieChartProps>>) {
const points = given ?? [];
const size = 160;
Expand Down Expand Up @@ -186,6 +300,7 @@ export function PieChartCard({
</ul>
</div>
)}
<Sources {...origin} />
</GalleryFrame>
);
}
Expand Down Expand Up @@ -313,6 +428,7 @@ export function LineChartCard(args: Partial<LineArgs>) {
) : (
<Plot filled={false} labels={labels} series={series} />
)}
<Sources {...args} />
</GalleryFrame>
);
}
Expand All @@ -332,6 +448,7 @@ export function AreaChartCard(args: Partial<LineArgs>) {
) : (
<Plot filled labels={labels} series={series} />
)}
<Sources {...args} />
</GalleryFrame>
);
}
Expand All @@ -356,6 +473,7 @@ export function ProgressChartCard({
title,
caption,
points: given,
...origin
}: Partial<z.infer<typeof ProgressChartProps>>) {
const points = given ?? [];
return (
Expand Down Expand Up @@ -391,6 +509,7 @@ export function ProgressChartCard({
})}
</ul>
)}
<Sources {...origin} />
</GalleryFrame>
);
}
Expand All @@ -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: [
Expand All @@ -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 },
Expand All @@ -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"],
Expand All @@ -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"],
Expand All @@ -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: [
Expand Down
123 changes: 123 additions & 0 deletions app/tests/chart-sources.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<BarChartCard
asOf="2025"
basis="read"
points={POINTS}
sources={[
{ title: "IMF World Economic Outlook", url: "https://imf.org/weo" },
{ title: "Annual report" },
]}
title="GDP"
/>,
);
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(
<LineChartCard
basis="own-knowledge"
labels={["2023", "2024"]}
series={[{ name: "GDP", values: [1, 2] }]}
title="GDP"
/>,
);
expect(view.getByText(/Not checked against a source/)).toBeTruthy();
});

test("the unchecked note and the sources read as separate parts", () => {
const view = render(
<BarChartCard
basis="own-knowledge"
points={POINTS}
sources={[{ title: "Annual report" }]}
title="GDP"
/>,
);
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(
<BarChartCard
{...({
basis: "read",
points: POINTS,
sources: [null, { title: 3 }, { title: "Notes" }, { title: "Notes" }],
asOf: { year: 2025 },
title: "GDP",
} as unknown as Parameters<typeof BarChartCard>[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(
<BarChartCard
basis="read"
points={POINTS}
sources={[{ title: "Notes", url: "javascript:alert(1)" }]}
title="GDP"
/>,
);
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(<BarChartCard points={POINTS} title="GDP" />);
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);
});
});
Loading
Loading