diff --git a/src/lib/agent/pdf-report.test.ts b/src/lib/agent/pdf-report.test.ts index b33c3f6..44b40a6 100644 --- a/src/lib/agent/pdf-report.test.ts +++ b/src/lib/agent/pdf-report.test.ts @@ -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 }); }); diff --git a/src/lib/agent/pdf-report.ts b/src/lib/agent/pdf-report.ts index d672462..475a7a6 100644 --- a/src/lib/agent/pdf-report.ts +++ b/src/lib/agent/pdf-report.ts @@ -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 { diff --git a/src/lib/agent/tools-actions.test.ts b/src/lib/agent/tools-actions.test.ts index fa141a2..b2a29ce 100644 --- a/src/lib/agent/tools-actions.test.ts +++ b/src/lib/agent/tools-actions.test.ts @@ -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 }); }); }); diff --git a/src/lib/agent/tools-actions.ts b/src/lib/agent/tools-actions.ts index 90f971e..480340a 100644 --- a/src/lib/agent/tools-actions.ts +++ b/src/lib/agent/tools-actions.ts @@ -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), }, ]; } diff --git a/src/lib/chat/system-prompt.ts b/src/lib/chat/system-prompt.ts index 69d48ed..2f1e110 100644 --- a/src/lib/chat/system-prompt.ts +++ b/src/lib/chat/system-prompt.ts @@ -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:]]\` 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.