diff --git a/docs/flight-finder.md b/docs/flight-finder.md index 80c5c99..65307b2 100644 --- a/docs/flight-finder.md +++ b/docs/flight-finder.md @@ -12,10 +12,15 @@ Reusable flight-search report skill for OpenClaw. It replaces the brittle one-of - normalize pricing to USD before ranking - produce a report payload first, then render PDF/email only when the report is complete - behave safely on WhatsApp-style chat surfaces by treating status nudges as updates, not resets +- require a fresh bounded search run for every report instead of reusing earlier captures ## Important rules - Recipient email is a delivery gate, not a search gate. +- Cached flight-search data is forbidden as primary evidence for a new run. + - same-day workspace captures must not be reused + - previously rendered PDFs must not be treated as fresh search output + - if fresh search fails, the skill must degrade honestly instead of recycling earlier artifacts - `marketCountry` is explicit-only in this implementation pass. - It must be an ISO 3166-1 alpha-2 uppercase code such as `TH` or `DE`. - If present, it activates VPN only for the bounded search phase. @@ -36,6 +41,13 @@ npm run render-report -- --input "" --output "" npm run delivery-plan -- --to "" --subject "" --body "" --attach "" ``` +Expected report-payload provenance fields: + +- `searchExecution.freshSearch: true` +- `searchExecution.startedAt` +- `searchExecution.completedAt` +- `searchExecution.artifactsRoot` + ## Delivery - sender identity: `luke@fiorinis.com` diff --git a/skills/flight-finder/SKILL.md b/skills/flight-finder/SKILL.md index 31b3a56..b399b24 100644 --- a/skills/flight-finder/SKILL.md +++ b/skills/flight-finder/SKILL.md @@ -9,6 +9,12 @@ Use this skill instead of the old `docs/prompts/dfw-blq-2026.md` prompt when the The deliverable target is always a PDF report plus email delivery when the report is complete. +Freshness rule: + +- Never reuse cached flight-search captures, prior same-day report payloads, earlier workspace artifacts, or previously rendered PDFs as the primary evidence for a new run. +- Every flight-finder run must execute a fresh bounded search before ranking, PDF render, or email delivery. +- If a fresh search cannot be completed, say so clearly and stop with a degraded/incomplete outcome instead of silently reusing old captures. + ## Inputs Accept either: @@ -62,6 +68,7 @@ Follow the same operational style as `property-assessor`: - missing required input should trigger a direct question, not an invented assumption - `update?`, `status?`, `and?`, and similar nudges mean “report status and keep going” - a silent helper/source path is a failed path and should be abandoned quickly +- previously saved workspace captures are not a fallback; they are stale evidence and must not be reused for a new report - keep progress state by phase so a dropped session can resume or at least report the last completed phase - do not start email-tool exploration before the report payload is complete and the PDF is ready to render - if PDF render fails, return the completed report summary in chat and report delivery failure separately @@ -83,6 +90,7 @@ Rules: - keep the bounded search phase observable with concise updates - normalize compared prices to USD before ranking - do not claim market-localized pricing succeeded unless VPN connect and post-connect verification succeeded +- do not inspect prior `workspace/reports` flight artifacts as reusable search input for a new run ## VPN / market-country rules @@ -118,6 +126,7 @@ Rules: - `normalize-request` should report missing search inputs separately from delivery-only email gaps - `report-status` should expose whether the run is ready to search, ready for a chat summary, ready to render a PDF, or ready to email - `render-report` must reject incomplete report payloads +- `render-report` must reject payloads that are not explicitly marked as the output of a fresh search run - `delivery-plan` must stay on the Luke sender path and must not silently fall back to another sender ## Prompt migration rule diff --git a/skills/flight-finder/examples/dfw-blq-2026-report-payload.json b/skills/flight-finder/examples/dfw-blq-2026-report-payload.json index 85cfbad..de2648e 100644 --- a/skills/flight-finder/examples/dfw-blq-2026-report-payload.json +++ b/skills/flight-finder/examples/dfw-blq-2026-report-payload.json @@ -73,6 +73,16 @@ "normalizeCurrencyTo": "USD" } }, + "searchExecution": { + "freshSearch": true, + "startedAt": "2026-03-30T21:00:00Z", + "completedAt": "2026-03-30T21:20:00Z", + "artifactsRoot": "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec", + "notes": [ + "Fresh bounded Thailand-market search executed for this report payload.", + "Prior workspace captures must not be reused across flight-finder runs." + ] + }, "sourceFindings": [ { "source": "kayak", diff --git a/skills/flight-finder/src/report-pdf.ts b/skills/flight-finder/src/report-pdf.ts index 45aae32..d25f1fb 100644 --- a/skills/flight-finder/src/report-pdf.ts +++ b/skills/flight-finder/src/report-pdf.ts @@ -4,7 +4,13 @@ import PDFDocument from "pdfkit"; import { getFlightReportStatus } from "./report-status.js"; import { normalizeFlightReportRequest } from "./request-normalizer.js"; -import type { FlightReportPayload } from "./types.js"; +import type { + FlightLegWindow, + FlightPassengerGroup, + FlightQuote, + FlightRecommendationOption, + FlightReportPayload +} from "./types.js"; export class ReportValidationError extends Error {} @@ -12,14 +18,194 @@ function bulletLines(value: string[] | undefined, fallback = "Not provided."): s return value?.length ? value : [fallback]; } +function asLegLabelMap(legs: FlightLegWindow[]): Map { + return new Map(legs.map((leg) => [leg.id, leg.label || `${leg.origin} -> ${leg.destination}`])); +} + +function asPassengerGroupLabelMap(groups: FlightPassengerGroup[]): Map { + return new Map( + groups.map((group) => [group.id, group.label || `${group.adults} adult${group.adults === 1 ? "" : "s"}`]) + ); +} + +function formatUsd(value: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0 + }).format(value); +} + +function formatDateWindow(leg: FlightLegWindow): string { + if (leg.earliest && leg.latest) { + return leg.earliest === leg.latest ? leg.earliest : `${leg.earliest} to ${leg.latest}`; + } + if (leg.relativeToLegId) { + const min = leg.minDaysAfter ?? "?"; + const max = leg.maxDaysAfter ?? "?"; + return `${min}-${max} days after ${leg.relativeToLegId}`; + } + return "Not provided"; +} + +function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void { + const x = doc.page.margins.left; + const y = doc.y; + const width = doc.page.width - doc.page.margins.left - doc.page.margins.right; + const height = 18; + + doc.save(); + doc.roundedRect(x, y, width, height, 3).fill("#10384E"); + doc + .fillColor("white") + .font("Helvetica-Bold") + .fontSize(12) + .text(title, x + 8, y + 4, { width: width - 16 }); + doc.restore(); + doc.moveDown(1.2); +} + +function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void { + const lines = bulletLines(items, fallback); + for (const line of lines) { + const startY = doc.y; + doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E"); + doc + .fillColor("#1E2329") + .font("Helvetica") + .fontSize(10.5) + .text(line, doc.page.margins.left + 14, startY, { + width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14, + lineGap: 2 + }); + doc.moveDown(0.35); + } + doc.moveDown(0.2); +} + +function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void { + const left = doc.page.margins.left; + const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + const keyWidth = 170; + 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; + + doc.save(); + doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7"); + doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF"); + doc + .lineWidth(0.5) + .strokeColor("#C7D0D9") + .rect(left, startY, totalWidth, rowHeight) + .stroke(); + doc.moveTo(left + keyWidth, startY).lineTo(left + keyWidth, startY + rowHeight).stroke(); + doc.restore(); + + doc + .fillColor("#1E2329") + .font("Helvetica-Bold") + .fontSize(9.5) + .text(key, left + 6, startY + 6, { width: keyWidth - 12 }); + doc + .fillColor("#1E2329") + .font("Helvetica") + .fontSize(9.5) + .text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 }); + doc.y = startY + rowHeight; + } + doc.moveDown(0.8); +} + +function ensureFreshSearch(payload: FlightReportPayload): void { + const searchExecution = payload.searchExecution; + if (!searchExecution?.freshSearch) { + throw new ReportValidationError( + "The flight report payload must come from a fresh search run. Cached or previously captured results are not allowed." + ); + } + + const startedAt = Date.parse(searchExecution.startedAt); + const completedAt = Date.parse(searchExecution.completedAt); + if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt) || completedAt < startedAt) { + throw new ReportValidationError( + "The flight report payload must include a valid fresh-search time window." + ); + } + + if (!String(searchExecution.artifactsRoot || "").trim()) { + throw new ReportValidationError( + "The flight report payload must include the artifacts root for the fresh search run." + ); + } +} + +function drawItineraryCard( + doc: PDFKit.PDFDocument, + option: FlightRecommendationOption, + quotesById: Map, + legLabels: Map, + passengerLabels: Map +): 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 quoteLines = option.quoteIds.flatMap((quoteId) => { + const quote = quotesById.get(quoteId); + if (!quote) return [`Missing quote reference: ${quoteId}`]; + const passengerLine = quote.passengerGroupIds + .map((groupId) => passengerLabels.get(groupId) || groupId) + .join(", "); + const legLine = legLabels.get(quote.legId) || quote.legId; + return [ + `${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`, + `Passengers: ${passengerLine}`, + `Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}; ${quote.stopsText || "stops?"}; ${quote.totalDurationText || "duration?"}`, + ...(quote.notes || []).map((note) => `Note: ${note}`) + ]; + }); + const bodyLines = [ + `Why it ranked here: ${option.rationale}`, + ...quoteLines + ]; + const bodyText = bodyLines.join("\n"); + const bodyHeight = doc.heightOfString(bodyText, { width: width - 20, lineGap: 2 }) + 24; + + doc.save(); + doc.roundedRect(left, top, width, 24, 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(bodyText, left + 10, top + 34, { + width: width - 20, + lineGap: 2 + }); + doc.restore(); + doc.y = top + 24 + bodyHeight + 10; +} + export async function renderFlightReportPdf( payload: FlightReportPayload, outputPath: string ): Promise { - const status = getFlightReportStatus( - normalizeFlightReportRequest(payload.request), - payload - ); + const normalizedRequest = normalizeFlightReportRequest(payload.request); + const status = getFlightReportStatus(normalizedRequest, payload); if (!status.pdfReady) { throw new ReportValidationError( @@ -27,56 +213,89 @@ export async function renderFlightReportPdf( ); } + ensureFreshSearch(payload); + await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); const doc = new PDFDocument({ size: "LETTER", - margin: 50 + margin: 50, + info: { + Title: payload.request.tripName || "Flight report", + Author: "OpenClaw flight-finder" + } }); const stream = fs.createWriteStream(outputPath); doc.pipe(stream); + const request = normalizedRequest.request; + const legLabels = asLegLabelMap(request.legs); + const passengerLabels = asPassengerGroupLabelMap(request.passengerGroups); + const quotesById = new Map(payload.quotes.map((quote) => [quote.id, quote])); + try { - doc.fontSize(20).text(payload.request.tripName || "Flight report", { - align: "left" - }); - doc.moveDown(0.25); - doc.fontSize(10).fillColor("#555").text(`Generated: ${payload.generatedAt}`); - doc.fillColor("#000"); - doc.moveDown(); - - doc.fontSize(14).text("Executive Summary"); - bulletLines(payload.executiveSummary).forEach((line) => { - doc.fontSize(10).text(`• ${line}`); - }); - - doc.moveDown(); - doc.fontSize(14).text("Recommended Options"); - payload.rankedOptions.forEach((option) => { - doc.fontSize(11).text( - `${option.backup ? "Backup" : "Primary"}: ${option.title} — $${option.totalPriceUsd.toFixed(2)}` + doc.fillColor("#10384E").font("Helvetica-Bold").fontSize(22).text(request.tripName || "Flight report"); + doc.moveDown(0.2); + doc + .fillColor("#4F5B66") + .font("Helvetica") + .fontSize(10) + .text(`Generated: ${payload.generatedAt}`); + doc + .fillColor("#4F5B66") + .font("Helvetica") + .fontSize(10) + .text( + `Market: ${payload.marketCountryUsed || "Default market"} | Fresh search: ${payload.searchExecution.completedAt}` ); - doc.fontSize(10).text(option.rationale); - doc.moveDown(0.5); - }); - doc.moveDown(); - doc.fontSize(14).text("Source Findings"); + + drawSectionHeader(doc, "Trip Snapshot"); + drawKeyValueTable(doc, [ + ["Recipient", request.recipientEmail || "Missing"], + ["Passenger groups", request.passengerGroups.map((group) => group.label || `${group.adults} adults`).join(" | ")], + ["Preferences", [ + request.preferences.flexibleDates ? "Flexible dates" : "Fixed dates", + request.preferences.maxStops != null ? `Max stops ${request.preferences.maxStops}` : "Stops not capped", + request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open" + ].join(" | ")], + ["Market-country mode", request.preferences.marketCountry || "Off"], + ["Search artifacts", payload.searchExecution.artifactsRoot] + ]); + + drawSectionHeader(doc, "Legs And Travelers"); + for (const leg of request.legs) { + const assignedGroups = request.legAssignments + .find((assignment) => assignment.legId === leg.id) + ?.passengerGroupIds.map((groupId) => passengerLabels.get(groupId) || groupId) + .join(", ") || "Not assigned"; + drawKeyValueTable(doc, [ + ["Leg", leg.label || leg.id], + ["Route", `${leg.origin} -> ${leg.destination}`], + ["Window", formatDateWindow(leg)], + ["Travelers", assignedGroups] + ]); + } + + drawSectionHeader(doc, "Executive Summary"); + drawBulletList(doc, payload.executiveSummary); + + drawSectionHeader(doc, "Recommended Options"); + payload.rankedOptions.forEach((option) => drawItineraryCard(doc, option, quotesById, legLabels, passengerLabels)); + + drawSectionHeader(doc, "Source Findings"); payload.sourceFindings.forEach((finding) => { - doc.fontSize(11).text(`${finding.source}: ${finding.status}`); - bulletLines(finding.notes).forEach((line) => doc.fontSize(10).text(`• ${line}`)); - doc.moveDown(0.25); + drawKeyValueTable(doc, [ + ["Source", finding.source], + ["Status", finding.status], + ["Checked", finding.checkedAt] + ]); + drawBulletList(doc, finding.notes, "No source notes."); }); - doc.moveDown(); - doc.fontSize(14).text("Warnings And Degraded Conditions"); - bulletLines( - [...payload.reportWarnings, ...payload.degradedReasons], - "No additional warnings." - ).forEach((line) => { - doc.fontSize(10).text(`• ${line}`); - }); + drawSectionHeader(doc, "Warnings And Degraded Conditions"); + drawBulletList(doc, [...payload.reportWarnings, ...payload.degradedReasons], "No additional warnings."); doc.end(); diff --git a/skills/flight-finder/src/types.ts b/skills/flight-finder/src/types.ts index 738119d..5cde5fe 100644 --- a/skills/flight-finder/src/types.ts +++ b/skills/flight-finder/src/types.ts @@ -149,6 +149,13 @@ export type NormalizedFlightReportRequest = { export type FlightReportPayload = { request: FlightReportRequest; + searchExecution: { + freshSearch: boolean; + startedAt: string; + completedAt: string; + artifactsRoot: string; + notes?: string[]; + }; sourceFindings: FlightSearchSourceFinding[]; quotes: FlightQuote[]; rankedOptions: FlightRecommendationOption[]; diff --git a/skills/flight-finder/tests/report-pdf.test.ts b/skills/flight-finder/tests/report-pdf.test.ts index 231f1cc..33abd23 100644 --- a/skills/flight-finder/tests/report-pdf.test.ts +++ b/skills/flight-finder/tests/report-pdf.test.ts @@ -14,6 +14,13 @@ import { function samplePayload(): FlightReportPayload { return { request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request, + searchExecution: { + freshSearch: true, + startedAt: "2026-03-30T20:45:00Z", + completedAt: "2026-03-30T21:00:00Z", + artifactsRoot: "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec", + notes: ["Fresh bounded search executed for this report."] + }, sourceFindings: [ { source: "kayak", @@ -74,3 +81,21 @@ test("renderFlightReportPdf rejects incomplete report payloads", async () => { ReportValidationError ); }); + +test("renderFlightReportPdf rejects payloads that are not marked as fresh-search output", async () => { + const outputPath = path.join(os.tmpdir(), `flight-finder-stale-${Date.now()}.pdf`); + await assert.rejects( + () => + renderFlightReportPdf( + { + ...samplePayload(), + searchExecution: { + ...samplePayload().searchExecution, + freshSearch: false + } + }, + outputPath + ), + ReportValidationError + ); +});