feat(ask): make_pdf_report takes a structured document

make_pdf_report no longer just tags an answer with a title/subtitle —
it now takes { title, subtitle?, sections: [{ heading?, text?, kpis?,
chartId?, imageId?, table? }] }, matching src/lib/reports/pdf/document.ts's
ReportDocument. A section's table prefers { resultId } (pulled from the
cached query_records result server-side) over { columns, rows }, so the
model still never hand-copies a figure. The document is stored as-is
(no rendering here — that's the next commit's route) and streamed to
the browser via the existing `pdf` SSE event.

Updates the system prompt's PDF rule to describe the structured call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-09-27 09:16:28 +02:00
parent 3e06a78fcb
commit 51e80bfe1d
5 changed files with 55 additions and 50 deletions

View file

@ -1,27 +1,39 @@
import { describe, it, expect } from "vitest";
import { makePdfReport, extractPdfMeta } from "./pdf-report";
import { makePdfReport, extractPdfMeta, type PdfReportMeta } from "./pdf-report";
const doc: PdfReportMeta = {
title: "Fuel at The Hills",
subtitle: "September 2026",
sections: [{ heading: "Overview", text: "Fuel spend was steady." }],
};
describe("makePdfReport", () => {
it("returns the title/subtitle/filename it was given", () => {
expect(makePdfReport({ title: "Fuel at The Hills", subtitle: "September 2026" })).toEqual({
ok: true,
pdf: { title: "Fuel at The Hills", subtitle: "September 2026" },
});
it("passes the structured document straight through", () => {
expect(makePdfReport(doc)).toEqual({ ok: true, pdf: doc });
});
it("omits subtitle/filename when not given", () => {
expect(makePdfReport({ title: "Fuel report" })).toEqual({
ok: true,
pdf: { title: "Fuel report" },
});
it("keeps a section's kpis/chartId/imageId/table as given", () => {
const withEverything: PdfReportMeta = {
title: "Site spend report",
sections: [
{
heading: "Overview",
kpis: [{ label: "Total spend", value: "R 65 000" }],
chartId: "spend-by-site",
imageId: "img1",
table: { resultId: "q-1" },
},
],
};
expect(makePdfReport(withEverything)).toEqual({ ok: true, pdf: withEverything });
});
});
describe("extractPdfMeta", () => {
it("splits pdf meta out, leaving the rest for the model", () => {
const raw = JSON.stringify({ ok: true, pdf: { title: "Fuel report", subtitle: "Sept" } });
it("splits the structured document out, leaving the rest for the model", () => {
const raw = JSON.stringify({ ok: true, pdf: doc });
const { forModel, pdf } = extractPdfMeta(raw);
expect(pdf).toEqual({ title: "Fuel report", subtitle: "Sept" });
expect(pdf).toEqual(doc);
expect(JSON.parse(forModel)).toEqual({ ok: true });
});

View file

@ -1,29 +1,23 @@
/**
* `make_pdf_report` — the model's own signal that the answer it just
* built (headings, a short narrative, KPI tables quoted from tool
* display strings, charts via `make_chart`) is a finished report, not
* just a chat reply. No PDF is rendered here — that's still Task 8's
* `answerToPdf` (DOM + html-to-image, browser-only). This just carries
* the report's title/subtitle/filename down to the browser, the same
* built is a finished, structured report rather than a chat reply.
*
* No PDF is rendered here — this only carries the structured document
* (`src/lib/reports/pdf/document.ts`'s `ReportDocument`: title/subtitle
* plus sections of heading/text/kpis/chartId/imageId/table) down to the
* browser and, from there, onto the stored assistant message, the same
* way `extractCharts` carries chart specs and `extractActionCard`
* carries a plan: the model gets a minimal ack, the browser gets the
* full meta via `event: pdf`.
* full document via `event: pdf`. Rendering happens later, on demand, at
* `GET /api/ask/messages/[id]/pdf` (`src/lib/reports/pdf/build.ts`),
* which is also how a plain (non-report) answer gets its "Download PDF".
*/
export interface PdfReportMeta {
title: string;
subtitle?: string;
filename?: string;
}
import type { ReportDocument } from "@/lib/reports/pdf/document";
export type PdfReportMeta = ReportDocument;
export function makePdfReport(input: PdfReportMeta): { ok: true; pdf: PdfReportMeta } {
return {
ok: true,
pdf: {
title: input.title,
...(input.subtitle ? { subtitle: input.subtitle } : {}),
...(input.filename ? { filename: input.filename } : {}),
},
};
return { ok: true, pdf: input };
}
export interface WithPdfMeta {

View file

@ -443,14 +443,17 @@ describe("file_change_request", () => {
});
describe("make_pdf_report", () => {
it("returns the report meta for the route to stream as a pdf event", async () => {
it("returns the structured document for the route to stream as a pdf event", async () => {
const tools = actionTools(ctx);
const result = await tools
.find((t) => t.name === "make_pdf_report")!
.handler({ title: "Fuel at The Hills", subtitle: "September 2026" });
expect(result).toEqual({
ok: true,
pdf: { title: "Fuel at The Hills", subtitle: "September 2026" },
});
const doc = {
title: "Fuel at The Hills",
subtitle: "September 2026",
sections: [
{ heading: "Overview", text: "Fuel spend was steady.", table: { resultId: "q-1" } },
{ chartId: "fuel-by-week" },
],
};
const result = await tools.find((t) => t.name === "make_pdf_report")!.handler(doc);
expect(result).toEqual({ ok: true, pdf: doc });
});
});

View file

@ -22,6 +22,7 @@ import { QuerySpec, runQuery, getCachedResult } from "./query";
import type { ChartSpec, ChartKind, ValueFormat } from "@/lib/reports/chart-spec";
import { proposeChanges, type AgentOp } from "./gateway";
import { makePdfReport } from "./pdf-report";
import { ReportDocument } from "@/lib/reports/pdf/document";
import { matchPeople, type IncomingPerson, type EmployeeRecord } from "./people-match";
import { loadEmployees } from "./load";
import { generateImage, type AspectRatio } from "./images";
@ -472,14 +473,9 @@ export function actionTools(ctx: AgentContext): AgentTool[] {
{
name: "make_pdf_report",
description:
"Call this LAST, once, after you've written a complete report answer (headings, a short narrative, KPI tables quoted from tool display strings, and any charts via make_chart). Marks the answer as a finished report so the user gets a cover page and a Download PDF button — it does not render anything itself, and it never computes a number.",
schema: {
title: z.string().min(1),
subtitle: z.string().optional(),
filename: z.string().optional(),
},
handler: async (args) =>
makePdfReport(args as { title: string; subtitle?: string; filename?: string }),
"Call this LAST, once, after you've written a complete report answer. Pass a STRUCTURED document — title/subtitle plus sections of heading/text(markdown)/kpis/chartId/imageId/table — and the server renders it as a real PDF (real text, real tables, vector charts): it does not screenshot the chat. Prefer table: { resultId } (pulled from the query_records result on the server) over table: { columns, rows } so you never copy a number by hand. kpis values must be quoted from a tool's display strings. chartId/imageId reference ids from chartsAvailable/imagesAvailable. Never computes a number itself.",
schema: ReportDocument.shape,
handler: async (args) => makePdfReport(args as z.infer<typeof ReportDocument>),
},
];
}

View file

@ -82,7 +82,7 @@ When the user asks to change **the app itself** — a new page, column, button o
Use \`generate_image\` only for illustrations — a report cover, a mock-up, a diagram without figures. Never draw a chart, a number, or anything that looks like data with it; \`make_chart\` is the only source of a chart. Place it with \`[[image:<id>]]\` on its own line, the same way a chart is placed.
## PDF reports
When the user asks for a PDF, a report document, or "something I can print or send": build the report yourself first — headings, a short narrative, KPI tables quoting each tool's display strings verbatim, and charts via \`make_chart\` — then call \`make_pdf_report({ title, subtitle? })\` **once, last**, after the answer is complete. It doesn't render anything itself; it just marks the answer as a finished report. Never compute a number yourself, here or anywhere else.
When the user asks for a PDF, a report document, or "something I can print or send": write the answer as normal, then call \`make_pdf_report\` **once, last**, with a structured document — it builds a real PDF (real text, real tables, vector charts), it does not screenshot the chat. Pass \`{ title, subtitle?, sections: [...] }\`, where each section is any mix of \`heading\`, \`text\` (markdown), \`kpis\` (\`[{label, value}]\`, quoting each tool's display string verbatim), \`chartId\` (an id from \`chartsAvailable\`), \`imageId\` (an id from \`imagesAvailable\`), and \`table\`. For \`table\`, always prefer \`{ resultId }\` — the id \`query_records\` returned — over \`{ columns, rows }\`: the server pulls the figures straight from that cached result, so you never copy a number by hand. Never compute a number yourself, here or anywhere else.
## Files
When the user attaches an Excel/CSV/PDF/photo/text file, read it with \`read_attachment\` before answering about it. If a PDF or photo is a purchase order or delivery note, offer to send it to the pipeline with \`send_to_po_pipeline\` rather than trying to read its line items yourself.