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:
parent
3e06a78fcb
commit
51e80bfe1d
5 changed files with 55 additions and 50 deletions
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue