Improve flight-finder report links and cron workflow

This commit is contained in:
2026-03-30 18:26:09 -05:00
parent fb868b9e5f
commit 809a3955e5
5 changed files with 224 additions and 15 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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
}
]);
});

View File

@@ -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",