4 Commits

7 changed files with 632 additions and 42 deletions

View File

@@ -11,17 +11,32 @@ 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
## Important rules ## Important rules
- Recipient email is a delivery gate, not a search gate. - Recipient email is a delivery gate, not a search gate.
- Cached flight-search data is forbidden as primary evidence for a new run.
- same-day workspace captures must not be reused
- previously rendered PDFs must not be treated as fresh search output
- if fresh search fails, the skill must degrade honestly instead of recycling earlier artifacts
- `marketCountry` is explicit-only in this implementation pass. - `marketCountry` is explicit-only in this implementation pass.
- It must be an ISO 3166-1 alpha-2 uppercase code such as `TH` or `DE`. - It must be an ISO 3166-1 alpha-2 uppercase code such as `TH` or `DE`.
- If present, it activates VPN only for the bounded search phase. - If present, it activates VPN only for the bounded search phase.
- 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
@@ -36,6 +51,13 @@ npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>" npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
``` ```
Expected report-payload provenance fields:
- `searchExecution.freshSearch: true`
- `searchExecution.startedAt`
- `searchExecution.completedAt`
- `searchExecution.artifactsRoot`
## Delivery ## Delivery
- sender identity: `luke@fiorinis.com` - sender identity: `luke@fiorinis.com`

View File

@@ -9,6 +9,18 @@ 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:
- 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.
- Every flight-finder run must execute a fresh bounded search before ranking, PDF render, or email delivery.
- If a fresh search cannot be completed, say so clearly and stop with a degraded/incomplete outcome instead of silently reusing old captures.
## Inputs ## Inputs
Accept either: Accept either:
@@ -54,6 +66,8 @@ Completion rule:
- A partial source result is not completion. - A partial source result is not completion.
- A partial helper result is not completion. - A partial helper result is not completion.
- The workflow is complete only when the final report payload exists, the PDF is rendered, the email was sent, and any VPN cleanup required by the run has been attempted. - The workflow is complete only when the final report payload exists, the PDF is rendered, the email was sent, and any VPN cleanup required by the run has been attempted.
- An interim commentary/status line is not completion.
- For isolated cron-style runs, do not leave the run with only a phase-update summary. If PDF render or email send did not happen, the terminal result must say so explicitly as a failed or incomplete outcome.
## WhatsApp-safe behavior ## WhatsApp-safe behavior
@@ -62,11 +76,29 @@ Follow the same operational style as `property-assessor`:
- missing required input should trigger a direct question, not an invented assumption - missing required input should trigger a direct question, not an invented assumption
- `update?`, `status?`, `and?`, and similar nudges mean “report status and keep going” - `update?`, `status?`, `and?`, and similar nudges mean “report status and keep going”
- a silent helper/source path is a failed path and should be abandoned quickly - a silent helper/source path is a failed path and should be abandoned quickly
- previously saved workspace captures are not a fallback; they are stale evidence and must not be reused for a new report
- keep progress state by phase so a dropped session can resume or at least report the last completed phase - keep progress state by phase so a dropped session can resume or at least report the last completed phase
- do not start email-tool exploration before the report payload is complete and the PDF is ready to render - do not start email-tool exploration before the report payload is complete and the PDF is ready to render
- if PDF render fails, return the completed report summary in chat and report delivery failure separately - if PDF render fails, return the completed report summary in chat and report delivery failure separately
- if email send fails after render, say so clearly and keep the rendered PDF path - if email send fails after render, say so clearly and keep the rendered PDF path
## Long-running command rule
- Do not fire a long-running local command and then abandon it.
- If an `exec` call returns a running session/process handle instead of a completed result, immediately follow it with the relevant process polling/log calls until the command finishes, fails, or times out.
- Treat VPN connect, fresh search sweeps, PDF render, and Gmail send as critical-path commands. They must be observed to completion; do not assume they succeeded just because the process started.
- If a critical-path command cannot be observed to completion, stop claiming progress and report the run as incomplete.
## Isolated cron rule
- In isolated cron runs, success means all of the following are true:
- fresh search artifacts exist for the current run
- final `report-payload.json` exists for the current run
- PDF render completed
- Gmail send completed to the requested recipient
- If the model/provider times out, the VPN step flakes, or the run ends before those artifacts exist, report a failure/incomplete outcome rather than an `ok`-sounding completion summary.
- Do not end an isolated cron run on the VPN/connect phase alone.
## Search-source contract ## Search-source contract
Use the proven travel-source order from the local viability findings: Use the proven travel-source order from the local viability findings:
@@ -83,6 +115,7 @@ Rules:
- keep the bounded search phase observable with concise updates - keep the bounded search phase observable with concise updates
- normalize compared prices to USD before ranking - normalize compared prices to USD before ranking
- do not claim market-localized pricing succeeded unless VPN connect and post-connect verification succeeded - do not claim market-localized pricing succeeded unless VPN connect and post-connect verification succeeded
- do not inspect prior `workspace/reports` flight artifacts as reusable search input for a new run
## VPN / market-country rules ## VPN / market-country rules
@@ -118,8 +151,9 @@ Rules:
- `normalize-request` should report missing search inputs separately from delivery-only email gaps - `normalize-request` should report missing search inputs separately from delivery-only email gaps
- `report-status` should expose whether the run is ready to search, ready for a chat summary, ready to render a PDF, or ready to email - `report-status` should expose whether the run is ready to search, ready for a chat summary, ready to render a PDF, or ready to email
- `render-report` must reject incomplete report payloads - `render-report` must reject incomplete report payloads
- `render-report` must reject payloads that are not explicitly marked as the output of a fresh search run
- `delivery-plan` must stay on the Luke sender path and must not silently fall back to another sender - `delivery-plan` must stay on the Luke sender path and must not silently fall back to another sender
## 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.

View File

@@ -73,6 +73,16 @@
"normalizeCurrencyTo": "USD" "normalizeCurrencyTo": "USD"
} }
}, },
"searchExecution": {
"freshSearch": true,
"startedAt": "2026-03-30T21:00:00Z",
"completedAt": "2026-03-30T21:20:00Z",
"artifactsRoot": "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
"notes": [
"Fresh bounded Thailand-market search executed for this report payload.",
"Prior workspace captures must not be reused across flight-finder runs."
]
},
"sourceFindings": [ "sourceFindings": [
{ {
"source": "kayak", "source": "kayak",

View File

@@ -4,7 +4,13 @@ import PDFDocument from "pdfkit";
import { getFlightReportStatus } from "./report-status.js"; import { getFlightReportStatus } from "./report-status.js";
import { normalizeFlightReportRequest } from "./request-normalizer.js"; import { normalizeFlightReportRequest } from "./request-normalizer.js";
import type { FlightReportPayload } from "./types.js"; import type {
FlightLegWindow,
FlightPassengerGroup,
FlightQuote,
FlightRecommendationOption,
FlightReportPayload
} from "./types.js";
export class ReportValidationError extends Error {} export class ReportValidationError extends Error {}
@@ -12,14 +18,288 @@ function bulletLines(value: string[] | undefined, fallback = "Not provided."): s
return value?.length ? value : [fallback]; return value?.length ? value : [fallback];
} }
function asLegLabelMap(legs: FlightLegWindow[]): Map<string, string> {
return new Map(legs.map((leg) => [leg.id, leg.label || `${leg.origin} -> ${leg.destination}`]));
}
function asPassengerGroupLabelMap(groups: FlightPassengerGroup[]): Map<string, string> {
return new Map(
groups.map((group) => [group.id, group.label || `${group.adults} adult${group.adults === 1 ? "" : "s"}`])
);
}
function formatUsd(value: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0
}).format(value);
}
function formatDateWindow(leg: FlightLegWindow): string {
if (leg.earliest && leg.latest) {
return leg.earliest === leg.latest ? leg.earliest : `${leg.earliest} to ${leg.latest}`;
}
if (leg.relativeToLegId) {
const min = leg.minDaysAfter ?? "?";
const max = leg.maxDaysAfter ?? "?";
return `${min}-${max} days after ${leg.relativeToLegId}`;
}
return "Not provided";
}
function ensurePageSpace(doc: PDFKit.PDFDocument, requiredHeight: number): void {
const bottomLimit = doc.page.height - doc.page.margins.bottom;
if (doc.y + requiredHeight > bottomLimit) {
doc.addPage();
}
}
const WORKSPACE_REPORTS_PREFIX = "/Users/stefano/.openclaw/workspace/reports/";
export function formatArtifactsRoot(value: string): string {
const trimmed = String(value || "").trim();
if (!trimmed) return "Not provided";
if (trimmed.startsWith(WORKSPACE_REPORTS_PREFIX)) {
return `reports/${trimmed.slice(WORKSPACE_REPORTS_PREFIX.length)}`;
}
return path.basename(trimmed) || trimmed;
}
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
ensurePageSpace(doc, 28);
const x = doc.page.margins.left;
const y = doc.y;
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const height = 18;
doc.save();
doc.roundedRect(x, y, width, height, 3).fill("#10384E");
doc
.fillColor("white")
.font("Helvetica-Bold")
.fontSize(12)
.text(title, x + 8, y + 4, { width: width - 16 });
doc.restore();
doc.moveDown(1.2);
}
function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void {
const lines = bulletLines(items, fallback);
for (const line of lines) {
const lineHeight = doc.heightOfString(line, {
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
lineGap: 2
});
ensurePageSpace(doc, lineHeight + 12);
const startY = doc.y;
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E");
doc
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(10.5)
.text(line, doc.page.margins.left + 14, startY, {
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
lineGap: 2
});
doc.moveDown(0.35);
}
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 lineHeight = Math.max(
doc.heightOfString(`${linkItem.label}:`, { width: 130 }),
doc.heightOfString(linkItem.text.replace(`${linkItem.label}: `, ""), {
width: width - 132,
lineGap: 1
})
);
ensurePageSpace(doc, lineHeight + 8);
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;
const keyWidth = 170;
const valueWidth = totalWidth - keyWidth;
for (const [key, value] of rows) {
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
ensurePageSpace(doc, rowHeight + 8);
const startY = doc.y;
doc.save();
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF");
doc
.lineWidth(0.5)
.strokeColor("#C7D0D9")
.rect(left, startY, totalWidth, rowHeight)
.stroke();
doc.moveTo(left + keyWidth, startY).lineTo(left + keyWidth, startY + rowHeight).stroke();
doc.restore();
doc
.fillColor("#1E2329")
.font("Helvetica-Bold")
.fontSize(9.5)
.text(key, left + 6, startY + 6, { width: keyWidth - 12 });
doc
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(9.5)
.text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 });
doc.y = startY + rowHeight;
}
doc.moveDown(0.8);
}
function ensureFreshSearch(payload: FlightReportPayload): void {
const searchExecution = payload.searchExecution;
if (!searchExecution?.freshSearch) {
throw new ReportValidationError(
"The flight report payload must come from a fresh search run. Cached or previously captured results are not allowed."
);
}
const startedAt = Date.parse(searchExecution.startedAt);
const completedAt = Date.parse(searchExecution.completedAt);
if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt) || completedAt < startedAt) {
throw new ReportValidationError(
"The flight report payload must include a valid fresh-search time window."
);
}
if (!String(searchExecution.artifactsRoot || "").trim()) {
throw new ReportValidationError(
"The flight report payload must include the artifacts root for the fresh search run."
);
}
}
function drawItineraryCard(
doc: PDFKit.PDFDocument,
option: FlightRecommendationOption,
quotesById: Map<string, FlightQuote>,
legLabels: Map<string, string>,
passengerLabels: Map<string, string>
): void {
const left = doc.page.margins.left;
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const badge = option.backup ? "Backup option" : "Primary recommendation";
const quoteBlocks = option.quoteIds.flatMap((quoteId) => {
const quote = quotesById.get(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 [
{
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)
}
];
});
ensurePageSpace(doc, 28);
const top = doc.y;
doc.save();
doc.roundedRect(left, top, width, 22, 4).fill(option.backup ? "#586E75" : "#1E6B52");
doc
.fillColor("white")
.font("Helvetica-Bold")
.fontSize(10.5)
.text(`${badge}: ${option.title}`, left + 10, top + 6.5, { width: width - 20 });
doc.restore();
doc.y = top + 28;
drawKeyValueTable(doc, [
["Recommendation total", formatUsd(option.totalPriceUsd)],
["Why it ranked here", option.rationale]
]);
for (const block of quoteBlocks) {
ensurePageSpace(doc, 22);
doc
.fillColor("#10384E")
.font("Helvetica-Bold")
.fontSize(10)
.text(block.heading, left, doc.y, {
width,
lineGap: 2
});
doc.moveDown(0.2);
drawBulletList(doc, block.details);
drawQuoteBookingLinks(doc, block.links, left, width);
doc.moveDown(0.5);
}
doc.moveDown(0.2);
}
export async function renderFlightReportPdf( export async function renderFlightReportPdf(
payload: FlightReportPayload, payload: FlightReportPayload,
outputPath: string outputPath: string
): Promise<string> { ): Promise<string> {
const status = getFlightReportStatus( const normalizedRequest = normalizeFlightReportRequest(payload.request);
normalizeFlightReportRequest(payload.request), const status = getFlightReportStatus(normalizedRequest, payload);
payload
);
if (!status.pdfReady) { if (!status.pdfReady) {
throw new ReportValidationError( throw new ReportValidationError(
@@ -27,56 +307,89 @@ export async function renderFlightReportPdf(
); );
} }
ensureFreshSearch(payload);
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const doc = new PDFDocument({ const doc = new PDFDocument({
size: "LETTER", size: "LETTER",
margin: 50 margin: 50,
info: {
Title: payload.request.tripName || "Flight report",
Author: "OpenClaw flight-finder"
}
}); });
const stream = fs.createWriteStream(outputPath); const stream = fs.createWriteStream(outputPath);
doc.pipe(stream); doc.pipe(stream);
const request = normalizedRequest.request;
const legLabels = asLegLabelMap(request.legs);
const passengerLabels = asPassengerGroupLabelMap(request.passengerGroups);
const quotesById = new Map(payload.quotes.map((quote) => [quote.id, quote]));
try { try {
doc.fontSize(20).text(payload.request.tripName || "Flight report", { doc.fillColor("#10384E").font("Helvetica-Bold").fontSize(22).text(request.tripName || "Flight report");
align: "left" doc.moveDown(0.2);
}); doc
doc.moveDown(0.25); .fillColor("#4F5B66")
doc.fontSize(10).fillColor("#555").text(`Generated: ${payload.generatedAt}`); .font("Helvetica")
doc.fillColor("#000"); .fontSize(10)
doc.moveDown(); .text(`Generated: ${payload.generatedAt}`);
doc
doc.fontSize(14).text("Executive Summary"); .fillColor("#4F5B66")
bulletLines(payload.executiveSummary).forEach((line) => { .font("Helvetica")
doc.fontSize(10).text(`${line}`); .fontSize(10)
}); .text(
`Market: ${payload.marketCountryUsed || "Default market"} | Fresh search completed: ${payload.searchExecution.completedAt}`
doc.moveDown();
doc.fontSize(14).text("Recommended Options");
payload.rankedOptions.forEach((option) => {
doc.fontSize(11).text(
`${option.backup ? "Backup" : "Primary"}: ${option.title}$${option.totalPriceUsd.toFixed(2)}`
); );
doc.fontSize(10).text(option.rationale);
doc.moveDown(0.5);
});
doc.moveDown(); doc.moveDown();
doc.fontSize(14).text("Source Findings");
drawSectionHeader(doc, "Trip Snapshot");
drawKeyValueTable(doc, [
["Recipient", request.recipientEmail || "Missing"],
["Passenger groups", request.passengerGroups.map((group) => group.label || `${group.adults} adults`).join(" | ")],
["Preferences", [
request.preferences.flexibleDates ? "Flexible dates" : "Fixed dates",
request.preferences.maxStops != null ? `Max stops ${request.preferences.maxStops}` : "Stops not capped",
request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open"
].join(" | ")],
["Market-country mode", request.preferences.marketCountry || "Off"],
["Search artifacts", formatArtifactsRoot(payload.searchExecution.artifactsRoot)]
]);
drawSectionHeader(doc, "Legs And Travelers");
for (const leg of request.legs) {
const assignedGroups = request.legAssignments
.find((assignment) => assignment.legId === leg.id)
?.passengerGroupIds.map((groupId) => passengerLabels.get(groupId) || groupId)
.join(", ") || "Not assigned";
drawKeyValueTable(doc, [
["Leg", leg.label || leg.id],
["Route", `${leg.origin} -> ${leg.destination}`],
["Window", formatDateWindow(leg)],
["Travelers", assignedGroups]
]);
}
drawSectionHeader(doc, "Executive Summary");
drawBulletList(doc, payload.executiveSummary);
drawSectionHeader(doc, "Recommended Options");
payload.rankedOptions.forEach((option) => drawItineraryCard(doc, option, quotesById, legLabels, passengerLabels));
drawSectionHeader(doc, "Source Findings");
payload.sourceFindings.forEach((finding) => { payload.sourceFindings.forEach((finding) => {
doc.fontSize(11).text(`${finding.source}: ${finding.status}`); drawKeyValueTable(doc, [
bulletLines(finding.notes).forEach((line) => doc.fontSize(10).text(`${line}`)); ["Source", finding.source],
doc.moveDown(0.25); ["Status", finding.status],
["Checked", finding.checkedAt]
]);
drawBulletList(doc, finding.notes, "No source notes.");
}); });
doc.moveDown(); drawSectionHeader(doc, "Warnings And Degraded Conditions");
doc.fontSize(14).text("Warnings And Degraded Conditions"); drawBulletList(doc, [...payload.reportWarnings, ...payload.degradedReasons], "No additional warnings.");
bulletLines(
[...payload.reportWarnings, ...payload.degradedReasons],
"No additional warnings."
).forEach((line) => {
doc.fontSize(10).text(`${line}`);
});
doc.end(); doc.end();

View File

@@ -149,6 +149,13 @@ export type NormalizedFlightReportRequest = {
export type FlightReportPayload = { export type FlightReportPayload = {
request: FlightReportRequest; request: FlightReportRequest;
searchExecution: {
freshSearch: boolean;
startedAt: string;
completedAt: string;
artifactsRoot: string;
notes?: string[];
};
sourceFindings: FlightSearchSourceFinding[]; sourceFindings: FlightSearchSourceFinding[];
quotes: FlightQuote[]; quotes: FlightQuote[];
rankedOptions: FlightRecommendationOption[]; rankedOptions: FlightRecommendationOption[];

View File

@@ -4,7 +4,12 @@ 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,
formatArtifactsRoot,
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,
@@ -14,6 +19,13 @@ import {
function samplePayload(): FlightReportPayload { function samplePayload(): 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: "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
notes: ["Fresh bounded search executed for this report."]
},
sourceFindings: [ sourceFindings: [
{ {
source: "kayak", source: "kayak",
@@ -52,6 +64,10 @@ function samplePayload(): FlightReportPayload {
}; };
} }
function countPdfPages(outputPath: string): number {
return fs.readFileSync(outputPath, "latin1").split("/Type /Page").length - 1;
}
test("renderFlightReportPdf writes a non-empty PDF", async () => { test("renderFlightReportPdf writes a non-empty PDF", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`); const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`);
await renderFlightReportPdf(samplePayload(), outputPath); await renderFlightReportPdf(samplePayload(), outputPath);
@@ -59,6 +75,122 @@ 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 avoids raw absolute artifact paths and keeps page count bounded for a long report", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-long-${Date.now()}.pdf`);
const payload = samplePayload();
payload.searchExecution.artifactsRoot =
"/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742";
payload.quotes = [
{
...payload.quotes[0],
id: "quote-1",
notes: [
"Observed on fresh KAYAK sweep at $794 per traveler for 3 adults.",
"Skyscanner TH/USD corroborated the same Madrid pattern.",
"Expedia also found a nearby fare band."
]
},
{
id: "quote-2",
source: "kayak",
legId: "return-pair",
passengerGroupIds: ["pair"],
bookingLink: "https://example.com/quote-2",
itinerarySummary: "BLQ -> DFW via LHR",
totalPriceUsd: 1316,
displayPriceUsd: "$1,316 total ($658 pp x 2)",
notes: [
"Cheapest valid paired return captured in the fresh KAYAK sweep.",
"Skyscanner did not reproduce the exact low Jun 8 return cleanly.",
"Expedia hit a bot challenge on the Jun 8 paired-return check."
]
},
{
id: "quote-3",
source: "kayak",
legId: "return-solo",
passengerGroupIds: ["solo"],
bookingLink: "https://example.com/quote-3",
itinerarySummary: "BLQ -> DFW via MAD",
totalPriceUsd: 722,
displayPriceUsd: "$722 total",
notes: [
"Lowest valid solo-return fare in the fresh Jul window capture.",
"Skyscanner TH/USD showed the same Madrid connection at about $742.",
"Expedia surfaced a cheaper Turkish option, but it was excluded."
]
}
];
payload.rankedOptions = [
{
id: "primary",
title: "Best overall itinerary",
quoteIds: ["quote-1", "quote-2", "quote-3"],
totalPriceUsd: 4420,
rationale: "Best price-to-convenience tradeoff."
},
{
id: "backup",
title: "Backup itinerary",
quoteIds: ["quote-1", "quote-2", "quote-3"],
totalPriceUsd: 4515,
rationale: "Slightly higher price but still viable."
}
];
payload.executiveSummary = [
"Fresh Thailand-market sweeps shifted the outbound slightly earlier.",
"Lowest valid total observed was $4,420.",
"Cross-source corroboration is strongest for the outbound and solo return."
];
await renderFlightReportPdf(payload, outputPath);
const pdfContents = fs.readFileSync(outputPath, "latin1");
assert.doesNotMatch(pdfContents, /\/Users\/stefano\/\.openclaw\/workspace\/reports\//);
assert.ok(countPdfPages(outputPath) <= 4);
});
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(
@@ -74,3 +206,68 @@ test("renderFlightReportPdf rejects incomplete report payloads", async () => {
ReportValidationError ReportValidationError
); );
}); });
test("renderFlightReportPdf rejects payloads that are not marked as fresh-search output", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-stale-${Date.now()}.pdf`);
await assert.rejects(
() =>
renderFlightReportPdf(
{
...samplePayload(),
searchExecution: {
...samplePayload().searchExecution,
freshSearch: false
}
},
outputPath
),
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
}
]);
});
test("formatArtifactsRoot only shortens known workspace report paths", () => {
assert.equal(
formatArtifactsRoot("/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742"),
"reports/dfw-blq-flight-report-2026-03-30-fresh-185742"
);
assert.equal(formatArtifactsRoot("/tmp/run-123"), "run-123");
});

View File

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