Improve flight-finder report links and cron workflow
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user