diff --git a/skills/flight-finder/src/report-pdf.ts b/skills/flight-finder/src/report-pdf.ts index 37186ed..c0833cc 100644 --- a/skills/flight-finder/src/report-pdf.ts +++ b/skills/flight-finder/src/report-pdf.ts @@ -48,7 +48,26 @@ function formatDateWindow(leg: FlightLegWindow): string { return "Not provided"; } +function ensurePageSpace(doc: PDFKit.PDFDocument, requiredHeight: number): void { + const bottomLimit = doc.page.height - doc.page.margins.bottom; + if (doc.y + requiredHeight > bottomLimit) { + doc.addPage(); + } +} + +const WORKSPACE_REPORTS_PREFIX = "/Users/stefano/.openclaw/workspace/reports/"; + +export function formatArtifactsRoot(value: string): string { + const trimmed = String(value || "").trim(); + if (!trimmed) return "Not provided"; + if (trimmed.startsWith(WORKSPACE_REPORTS_PREFIX)) { + return `reports/${trimmed.slice(WORKSPACE_REPORTS_PREFIX.length)}`; + } + return path.basename(trimmed) || trimmed; +} + function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void { + ensurePageSpace(doc, 28); const x = doc.page.margins.left; const y = doc.y; const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; @@ -68,6 +87,11 @@ function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void { function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void { const lines = bulletLines(items, fallback); for (const line of lines) { + const lineHeight = doc.heightOfString(line, { + width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14, + lineGap: 2 + }); + ensurePageSpace(doc, lineHeight + 12); const startY = doc.y; doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E"); doc @@ -108,6 +132,14 @@ export function describeQuoteBookingLinks(quote: FlightQuote): QuoteBookingLink[ function drawQuoteBookingLinks(doc: PDFKit.PDFDocument, links: QuoteBookingLink[], left: number, width: number): void { for (const linkItem of links) { + const lineHeight = Math.max( + doc.heightOfString(`${linkItem.label}:`, { width: 130 }), + doc.heightOfString(linkItem.text.replace(`${linkItem.label}: `, ""), { + width: width - 132, + lineGap: 1 + }) + ); + ensurePageSpace(doc, lineHeight + 8); const startY = doc.y; doc .fillColor("#1E2329") @@ -134,10 +166,11 @@ function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string] const valueWidth = totalWidth - keyWidth; for (const [key, value] of rows) { - const startY = doc.y; const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 }); const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 }); const rowHeight = Math.max(keyHeight, valueHeight) + 12; + ensurePageSpace(doc, rowHeight + 8); + const startY = doc.y; doc.save(); doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7"); @@ -197,8 +230,7 @@ function drawItineraryCard( ): void { const left = doc.page.margins.left; const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; - const top = doc.y; - const badge = option.backup ? "Backup" : "Primary"; + const badge = option.backup ? "Backup option" : "Primary recommendation"; const quoteBlocks = option.quoteIds.flatMap((quoteId) => { const quote = quotesById.get(quoteId); if (!quote) { @@ -227,79 +259,39 @@ function drawItineraryCard( } ]; }); - const estimateHeight = quoteBlocks.reduce((sum, block) => { - const headingHeight = doc.heightOfString(block.heading, { width: width - 20, lineGap: 2 }); - const detailsHeight = block.details.reduce( - (acc, line) => acc + doc.heightOfString(line, { width: width - 44, lineGap: 2 }) + 4, - 0 - ); - const linksHeight = block.links.reduce( - (acc, link) => - acc + - Math.max( - doc.heightOfString(`${link.label}:`, { width: 130 }), - doc.heightOfString(link.text.replace(`${link.label}: `, ""), { width: width - 152, lineGap: 1 }) - ) + - 4, - 0 - ); - return sum + headingHeight + detailsHeight + linksHeight + 18; - }, 0); - const rationaleHeight = doc.heightOfString(`Why it ranked here: ${option.rationale}`, { - width: width - 20, - lineGap: 2 - }); - const bodyHeight = rationaleHeight + estimateHeight + 24; - + ensurePageSpace(doc, 28); + const top = doc.y; doc.save(); - doc.roundedRect(left, top, width, 24, 4).fill(option.backup ? "#586E75" : "#1E6B52"); + doc.roundedRect(left, top, width, 22, 4).fill(option.backup ? "#586E75" : "#1E6B52"); doc .fillColor("white") .font("Helvetica-Bold") - .fontSize(11) - .text(`${badge}: ${option.title} (${formatUsd(option.totalPriceUsd)})`, left + 10, top + 7, { - width: width - 20 - }); - doc - .roundedRect(left, top + 24, width, bodyHeight, 4) - .fillAndStroke("#F8FAFC", "#C7D0D9"); - doc - .fillColor("#1E2329") - .font("Helvetica") - .fontSize(10) - .text(`Why it ranked here: ${option.rationale}`, left + 10, top + 34, { - width: width - 20, - lineGap: 2 - }); - doc.y = top + 34 + rationaleHeight + 10; + .fontSize(10.5) + .text(`${badge}: ${option.title}`, left + 10, top + 6.5, { width: width - 20 }); + doc.restore(); + doc.y = top + 28; + + drawKeyValueTable(doc, [ + ["Recommendation total", formatUsd(option.totalPriceUsd)], + ["Why it ranked here", option.rationale] + ]); + for (const block of quoteBlocks) { + ensurePageSpace(doc, 22); doc .fillColor("#10384E") .font("Helvetica-Bold") .fontSize(10) - .text(block.heading, left + 10, doc.y, { - width: width - 20, + .text(block.heading, left, doc.y, { + width, lineGap: 2 }); doc.moveDown(0.2); - for (const line of block.details) { - const startY = doc.y; - doc.circle(left + 16, startY + 6, 1.4).fill("#10384E"); - doc - .fillColor("#1E2329") - .font("Helvetica") - .fontSize(9.5) - .text(line, left + 24, startY, { - width: width - 44, - lineGap: 2 - }); - doc.moveDown(0.2); - } - drawQuoteBookingLinks(doc, block.links, left + 10, width - 20); - doc.moveDown(0.35); + drawBulletList(doc, block.details); + drawQuoteBookingLinks(doc, block.links, left, width); + doc.moveDown(0.5); } - doc.restore(); - doc.y = top + 24 + bodyHeight + 10; + doc.moveDown(0.2); } export async function renderFlightReportPdf( @@ -349,7 +341,7 @@ export async function renderFlightReportPdf( .font("Helvetica") .fontSize(10) .text( - `Market: ${payload.marketCountryUsed || "Default market"} | Fresh search: ${payload.searchExecution.completedAt}` + `Market: ${payload.marketCountryUsed || "Default market"} | Fresh search completed: ${payload.searchExecution.completedAt}` ); doc.moveDown(); @@ -363,7 +355,7 @@ export async function renderFlightReportPdf( request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open" ].join(" | ")], ["Market-country mode", request.preferences.marketCountry || "Off"], - ["Search artifacts", payload.searchExecution.artifactsRoot] + ["Search artifacts", formatArtifactsRoot(payload.searchExecution.artifactsRoot)] ]); drawSectionHeader(doc, "Legs And Travelers"); diff --git a/skills/flight-finder/tests/report-pdf.test.ts b/skills/flight-finder/tests/report-pdf.test.ts index ec8e8fb..3fd397a 100644 --- a/skills/flight-finder/tests/report-pdf.test.ts +++ b/skills/flight-finder/tests/report-pdf.test.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import { describeQuoteBookingLinks, + formatArtifactsRoot, renderFlightReportPdf, ReportValidationError } from "../src/report-pdf.js"; @@ -63,6 +64,10 @@ function samplePayload(): FlightReportPayload { }; } +function countPdfPages(outputPath: string): number { + return fs.readFileSync(outputPath, "latin1").split("/Type /Page").length - 1; +} + test("renderFlightReportPdf writes a non-empty PDF", async () => { const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`); await renderFlightReportPdf(samplePayload(), outputPath); @@ -111,6 +116,81 @@ test("renderFlightReportPdf embeds booking links and only includes direct-airlin assert.doesNotMatch(pdfContents, /Direct airline booking: not captured in this run[\s\S]*https:\/\/example\.com\/quote-1/); }); +test("renderFlightReportPdf avoids raw absolute artifact paths and keeps page count bounded for a long report", async () => { + const outputPath = path.join(os.tmpdir(), `flight-finder-long-${Date.now()}.pdf`); + const payload = samplePayload(); + payload.searchExecution.artifactsRoot = + "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742"; + payload.quotes = [ + { + ...payload.quotes[0], + id: "quote-1", + notes: [ + "Observed on fresh KAYAK sweep at $794 per traveler for 3 adults.", + "Skyscanner TH/USD corroborated the same Madrid pattern.", + "Expedia also found a nearby fare band." + ] + }, + { + id: "quote-2", + source: "kayak", + legId: "return-pair", + passengerGroupIds: ["pair"], + bookingLink: "https://example.com/quote-2", + itinerarySummary: "BLQ -> DFW via LHR", + totalPriceUsd: 1316, + displayPriceUsd: "$1,316 total ($658 pp x 2)", + notes: [ + "Cheapest valid paired return captured in the fresh KAYAK sweep.", + "Skyscanner did not reproduce the exact low Jun 8 return cleanly.", + "Expedia hit a bot challenge on the Jun 8 paired-return check." + ] + }, + { + id: "quote-3", + source: "kayak", + legId: "return-solo", + passengerGroupIds: ["solo"], + bookingLink: "https://example.com/quote-3", + itinerarySummary: "BLQ -> DFW via MAD", + totalPriceUsd: 722, + displayPriceUsd: "$722 total", + notes: [ + "Lowest valid solo-return fare in the fresh Jul window capture.", + "Skyscanner TH/USD showed the same Madrid connection at about $742.", + "Expedia surfaced a cheaper Turkish option, but it was excluded." + ] + } + ]; + payload.rankedOptions = [ + { + id: "primary", + title: "Best overall itinerary", + quoteIds: ["quote-1", "quote-2", "quote-3"], + totalPriceUsd: 4420, + rationale: "Best price-to-convenience tradeoff." + }, + { + id: "backup", + title: "Backup itinerary", + quoteIds: ["quote-1", "quote-2", "quote-3"], + totalPriceUsd: 4515, + rationale: "Slightly higher price but still viable." + } + ]; + payload.executiveSummary = [ + "Fresh Thailand-market sweeps shifted the outbound slightly earlier.", + "Lowest valid total observed was $4,420.", + "Cross-source corroboration is strongest for the outbound and solo return." + ]; + + await renderFlightReportPdf(payload, outputPath); + + const pdfContents = fs.readFileSync(outputPath, "latin1"); + assert.doesNotMatch(pdfContents, /\/Users\/stefano\/\.openclaw\/workspace\/reports\//); + assert.ok(countPdfPages(outputPath) <= 4); +}); + test("renderFlightReportPdf rejects incomplete report payloads", async () => { const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`); await assert.rejects( @@ -183,3 +263,11 @@ test("describeQuoteBookingLinks calls out missing direct-airline links explicitl } ]); }); + +test("formatArtifactsRoot only shortens known workspace report paths", () => { + assert.equal( + formatArtifactsRoot("/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742"), + "reports/dfw-blq-flight-report-2026-03-30-fresh-185742" + ); + assert.equal(formatArtifactsRoot("/tmp/run-123"), "run-123"); +});