diff --git a/docs/flight-finder.md b/docs/flight-finder.md index 65307b2..78158d9 100644 --- a/docs/flight-finder.md +++ b/docs/flight-finder.md @@ -11,6 +11,7 @@ Reusable flight-search report skill for OpenClaw. It replaces the brittle one-of - use bounded search phases across KAYAK, Skyscanner, Expedia, and a best-effort airline direct cross-check - normalize pricing to USD before ranking - produce a report payload first, then render PDF/email only when the report is complete +- render booking links in the PDF for each recommended fare, including airline-direct links when they were actually captured - 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 @@ -27,6 +28,15 @@ Reusable flight-search report skill for OpenClaw. It replaces the brittle one-of - If omitted, no VPN change happens. - If VPN connect or verification fails, the run falls back to the default market and records a degraded warning instead of hanging. - Direct-airline cross-checking is currently best-effort, not a hard blocker. +- The PDF should show: + - the captured booking/search link for every quoted fare + - the airline-direct link only when `directBookingUrl` was actually captured + - an explicit “not captured in this run” note when direct-airline booking was unavailable + +## DFW ↔ BLQ daily automation + +- The 6 AM DFW ↔ BLQ automation should invoke `flight-finder` directly with the structured JSON payload. +- The legacy file at `workspace/docs/prompts/dfw-blq-2026.md` should mirror that exact direct skill invocation, not old prose instructions. ## Helper package diff --git a/skills/flight-finder/SKILL.md b/skills/flight-finder/SKILL.md index b399b24..53490c0 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. +PDF output rule: + +- Render aggregator booking links for every quoted fare. +- Render direct-airline booking links when they were actually captured. +- If a direct-airline link was not captured for a quote, say so explicitly in the report instead of implying one exists. + 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. @@ -131,4 +137,4 @@ Rules: ## Prompt migration rule -The old DFW↔BLQ prompt is legacy input only. Do not treat it as the execution engine. Convert its hardcoded logic into the typed request model and run the skill workflow instead. +The old DFW↔BLQ prompt is legacy input only. Do not treat it as freeform execution prose. Its file should contain a literal `use flight finder skill with the following json payload` request so manual runs and scheduled runs stay aligned. diff --git a/skills/flight-finder/src/report-pdf.ts b/skills/flight-finder/src/report-pdf.ts index d25f1fb..37186ed 100644 --- a/skills/flight-finder/src/report-pdf.ts +++ b/skills/flight-finder/src/report-pdf.ts @@ -83,6 +83,50 @@ function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fa doc.moveDown(0.2); } +type QuoteBookingLink = { + label: string; + text: string; + url: string | null; +}; + +export function describeQuoteBookingLinks(quote: FlightQuote): QuoteBookingLink[] { + return [ + { + label: "Search / book this fare", + text: `Search / book this fare: ${quote.displayPriceUsd} via ${quote.source}`, + url: quote.bookingLink + }, + { + label: "Direct airline booking", + text: quote.directBookingUrl + ? `Direct airline booking: ${quote.airlineName || "Airline site"}` + : "Direct airline booking: not captured in this run", + url: quote.directBookingUrl || null + } + ]; +} + +function drawQuoteBookingLinks(doc: PDFKit.PDFDocument, links: QuoteBookingLink[], left: number, width: number): void { + for (const linkItem of links) { + const startY = doc.y; + doc + .fillColor("#1E2329") + .font("Helvetica-Bold") + .fontSize(9.5) + .text(`${linkItem.label}:`, left, startY, { width: 130 }); + doc + .fillColor(linkItem.url ? "#0B5EA8" : "#4F5B66") + .font("Helvetica") + .fontSize(9.5) + .text(linkItem.text.replace(`${linkItem.label}: `, ""), left + 132, startY, { + width: width - 132, + lineGap: 1, + ...(linkItem.url ? { link: linkItem.url, underline: true } : {}) + }); + doc.moveDown(0.25); + } +} + 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; @@ -155,26 +199,57 @@ function drawItineraryCard( 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 quoteBlocks = option.quoteIds.flatMap((quoteId) => { const quote = quotesById.get(quoteId); - if (!quote) return [`Missing quote reference: ${quoteId}`]; + if (!quote) { + return [ + { + heading: `Missing quote reference: ${quoteId}`, + details: [], + links: [] + } + ]; + } 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}`) + { + heading: `${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`, + details: [ + `Passengers: ${passengerLine}`, + `Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}`, + `Routing: ${quote.stopsText || "stops?"}; ${quote.layoverText || "layover?"}; ${quote.totalDurationText || "duration?"}`, + ...(quote.notes || []).map((note) => `Note: ${note}`) + ], + links: describeQuoteBookingLinks(quote) + } ]; }); - 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; + 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; doc.save(); doc.roundedRect(left, top, width, 24, 4).fill(option.backup ? "#586E75" : "#1E6B52"); @@ -192,10 +267,37 @@ function drawItineraryCard( .fillColor("#1E2329") .font("Helvetica") .fontSize(10) - .text(bodyText, left + 10, top + 34, { + .text(`Why it ranked here: ${option.rationale}`, left + 10, top + 34, { width: width - 20, lineGap: 2 }); + doc.y = top + 34 + rationaleHeight + 10; + for (const block of quoteBlocks) { + doc + .fillColor("#10384E") + .font("Helvetica-Bold") + .fontSize(10) + .text(block.heading, left + 10, doc.y, { + width: width - 20, + 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); + } doc.restore(); doc.y = top + 24 + bodyHeight + 10; } diff --git a/skills/flight-finder/tests/report-pdf.test.ts b/skills/flight-finder/tests/report-pdf.test.ts index 33abd23..ec8e8fb 100644 --- a/skills/flight-finder/tests/report-pdf.test.ts +++ b/skills/flight-finder/tests/report-pdf.test.ts @@ -4,7 +4,11 @@ import os from "node:os"; import path from "node:path"; import fs from "node:fs"; -import { renderFlightReportPdf, ReportValidationError } from "../src/report-pdf.js"; +import { + describeQuoteBookingLinks, + renderFlightReportPdf, + ReportValidationError +} from "../src/report-pdf.js"; import type { FlightReportPayload } from "../src/types.js"; import { DFW_BLQ_2026_PROMPT_DRAFT, @@ -66,6 +70,47 @@ test("renderFlightReportPdf writes a non-empty PDF", async () => { assert.ok(fs.statSync(outputPath).size > 0); }); +test("renderFlightReportPdf embeds booking links and only includes direct-airline URLs when captured", async () => { + const outputPath = path.join(os.tmpdir(), `flight-finder-links-${Date.now()}.pdf`); + const payload = samplePayload(); + payload.quotes = [ + { + ...payload.quotes[0], + bookingLink: "https://example.com/quote-1", + directBookingUrl: null + }, + { + id: "quote-2", + source: "airline-direct", + legId: "return-pair", + passengerGroupIds: ["pair"], + bookingLink: "https://example.com/quote-2", + directBookingUrl: "https://www.britishairways.com/", + airlineName: "British Airways", + itinerarySummary: "BLQ -> DFW via LHR", + totalPriceUsd: 1100, + displayPriceUsd: "$1,100" + } + ]; + payload.rankedOptions = [ + { + id: "primary", + title: "Best overall itinerary", + quoteIds: ["quote-1", "quote-2"], + totalPriceUsd: 3947, + rationale: "Best price-to-convenience tradeoff." + } + ]; + + await renderFlightReportPdf(payload, outputPath); + + const pdfContents = fs.readFileSync(outputPath, "latin1"); + assert.match(pdfContents, /https:\/\/example\.com\/quote-1/); + assert.match(pdfContents, /https:\/\/example\.com\/quote-2/); + assert.match(pdfContents, /https:\/\/www\.britishairways\.com\//); + assert.doesNotMatch(pdfContents, /Direct airline booking: not captured in this run[\s\S]*https:\/\/example\.com\/quote-1/); +}); + test("renderFlightReportPdf rejects incomplete report payloads", async () => { const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`); await assert.rejects( @@ -99,3 +144,42 @@ test("renderFlightReportPdf rejects payloads that are not marked as fresh-search ReportValidationError ); }); + +test("describeQuoteBookingLinks includes both aggregator and airline-direct links when captured", () => { + const payload = samplePayload(); + const quote = { + ...payload.quotes[0], + airlineName: "British Airways", + directBookingUrl: "https://www.britishairways.com/" + }; + + assert.deepEqual(describeQuoteBookingLinks(quote), [ + { + label: "Search / book this fare", + text: "Search / book this fare: $2,847 via kayak", + url: "https://example.com/quote-1" + }, + { + label: "Direct airline booking", + text: "Direct airline booking: British Airways", + url: "https://www.britishairways.com/" + } + ]); +}); + +test("describeQuoteBookingLinks calls out missing direct-airline links explicitly", () => { + const payload = samplePayload(); + + assert.deepEqual(describeQuoteBookingLinks(payload.quotes[0]), [ + { + label: "Search / book this fare", + text: "Search / book this fare: $2,847 via kayak", + url: "https://example.com/quote-1" + }, + { + label: "Direct airline booking", + text: "Direct airline booking: not captured in this run", + url: null + } + ]); +}); diff --git a/skills/flight-finder/tests/report-status.test.ts b/skills/flight-finder/tests/report-status.test.ts index 68918bd..c80c570 100644 --- a/skills/flight-finder/tests/report-status.test.ts +++ b/skills/flight-finder/tests/report-status.test.ts @@ -11,6 +11,13 @@ import type { FlightReportPayload } from "../src/types.js"; function buildPayload(): 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: "/tmp/flight-finder-report-status", + notes: ["Fresh bounded search executed for this report."] + }, sourceFindings: [ { source: "kayak",