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

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