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
|
- use bounded search phases across KAYAK, Skyscanner, Expedia, and a best-effort airline direct cross-check
|
||||||
- normalize pricing to USD before ranking
|
- normalize pricing to USD before ranking
|
||||||
- produce a report payload first, then render PDF/email only when the report is complete
|
- 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
|
- 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
|
- 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 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.
|
- 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.
|
- 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
|
## 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.
|
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:
|
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.
|
- 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
|
## 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);
|
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 {
|
function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void {
|
||||||
const left = doc.page.margins.left;
|
const left = doc.page.margins.left;
|
||||||
const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
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 width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
const top = doc.y;
|
const top = doc.y;
|
||||||
const badge = option.backup ? "Backup" : "Primary";
|
const badge = option.backup ? "Backup" : "Primary";
|
||||||
const quoteLines = option.quoteIds.flatMap((quoteId) => {
|
const quoteBlocks = option.quoteIds.flatMap((quoteId) => {
|
||||||
const quote = quotesById.get(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
|
const passengerLine = quote.passengerGroupIds
|
||||||
.map((groupId) => passengerLabels.get(groupId) || groupId)
|
.map((groupId) => passengerLabels.get(groupId) || groupId)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
const legLine = legLabels.get(quote.legId) || quote.legId;
|
const legLine = legLabels.get(quote.legId) || quote.legId;
|
||||||
return [
|
return [
|
||||||
`${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`,
|
{
|
||||||
`Passengers: ${passengerLine}`,
|
heading: `${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`,
|
||||||
`Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}; ${quote.stopsText || "stops?"}; ${quote.totalDurationText || "duration?"}`,
|
details: [
|
||||||
...(quote.notes || []).map((note) => `Note: ${note}`)
|
`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 = [
|
const estimateHeight = quoteBlocks.reduce((sum, block) => {
|
||||||
`Why it ranked here: ${option.rationale}`,
|
const headingHeight = doc.heightOfString(block.heading, { width: width - 20, lineGap: 2 });
|
||||||
...quoteLines
|
const detailsHeight = block.details.reduce(
|
||||||
];
|
(acc, line) => acc + doc.heightOfString(line, { width: width - 44, lineGap: 2 }) + 4,
|
||||||
const bodyText = bodyLines.join("\n");
|
0
|
||||||
const bodyHeight = doc.heightOfString(bodyText, { width: width - 20, lineGap: 2 }) + 24;
|
);
|
||||||
|
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.save();
|
||||||
doc.roundedRect(left, top, width, 24, 4).fill(option.backup ? "#586E75" : "#1E6B52");
|
doc.roundedRect(left, top, width, 24, 4).fill(option.backup ? "#586E75" : "#1E6B52");
|
||||||
@@ -192,10 +267,37 @@ function drawItineraryCard(
|
|||||||
.fillColor("#1E2329")
|
.fillColor("#1E2329")
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(10)
|
.fontSize(10)
|
||||||
.text(bodyText, left + 10, top + 34, {
|
.text(`Why it ranked here: ${option.rationale}`, left + 10, top + 34, {
|
||||||
width: width - 20,
|
width: width - 20,
|
||||||
lineGap: 2
|
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.restore();
|
||||||
doc.y = top + 24 + bodyHeight + 10;
|
doc.y = top + 24 + bodyHeight + 10;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs";
|
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 type { FlightReportPayload } from "../src/types.js";
|
||||||
import {
|
import {
|
||||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
@@ -66,6 +70,47 @@ test("renderFlightReportPdf writes a non-empty PDF", async () => {
|
|||||||
assert.ok(fs.statSync(outputPath).size > 0);
|
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 () => {
|
test("renderFlightReportPdf rejects incomplete report payloads", async () => {
|
||||||
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
|
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
@@ -99,3 +144,42 @@ test("renderFlightReportPdf rejects payloads that are not marked as fresh-search
|
|||||||
ReportValidationError
|
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 {
|
function buildPayload(): FlightReportPayload {
|
||||||
return {
|
return {
|
||||||
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
|
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: [
|
sourceFindings: [
|
||||||
{
|
{
|
||||||
source: "kayak",
|
source: "kayak",
|
||||||
|
|||||||
Reference in New Issue
Block a user