fix(flight-finder): require fresh search evidence and improve PDF layout

This commit is contained in:
2026-03-30 17:45:32 -05:00
parent e2657f4850
commit fb868b9e5f
6 changed files with 322 additions and 40 deletions

View File

@@ -12,10 +12,15 @@ Reusable flight-search report skill for OpenClaw. It replaces the brittle one-of
- normalize pricing to USD before ranking
- produce a report payload first, then render PDF/email only when the report is complete
- 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
- 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.
- 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.
@@ -36,6 +41,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>"
```
Expected report-payload provenance fields:
- `searchExecution.freshSearch: true`
- `searchExecution.startedAt`
- `searchExecution.completedAt`
- `searchExecution.artifactsRoot`
## Delivery
- sender identity: `luke@fiorinis.com`

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.
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
Accept either:
@@ -62,6 +68,7 @@ Follow the same operational style as `property-assessor`:
- missing required input should trigger a direct question, not an invented assumption
- `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
- 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
- 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
@@ -83,6 +90,7 @@ Rules:
- keep the bounded search phase observable with concise updates
- normalize compared prices to USD before ranking
- 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
@@ -118,6 +126,7 @@ Rules:
- `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
- `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
## Prompt migration rule

View File

@@ -73,6 +73,16 @@
"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": [
{
"source": "kayak",

View File

@@ -4,7 +4,13 @@ import PDFDocument from "pdfkit";
import { getFlightReportStatus } from "./report-status.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 {}
@@ -12,14 +18,194 @@ function bulletLines(value: string[] | undefined, fallback = "Not provided."): s
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 drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
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 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);
}
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 startY = doc.y;
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
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 top = doc.y;
const badge = option.backup ? "Backup" : "Primary";
const quoteLines = option.quoteIds.flatMap((quoteId) => {
const quote = quotesById.get(quoteId);
if (!quote) return [`Missing quote reference: ${quoteId}`];
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}`)
];
});
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;
doc.save();
doc.roundedRect(left, top, width, 24, 4).fill(option.backup ? "#586E75" : "#1E6B52");
doc
.fillColor("white")
.font("Helvetica-Bold")
.fontSize(11)
.text(`${badge}: ${option.title} (${formatUsd(option.totalPriceUsd)})`, left + 10, top + 7, {
width: width - 20
});
doc
.roundedRect(left, top + 24, width, bodyHeight, 4)
.fillAndStroke("#F8FAFC", "#C7D0D9");
doc
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(10)
.text(bodyText, left + 10, top + 34, {
width: width - 20,
lineGap: 2
});
doc.restore();
doc.y = top + 24 + bodyHeight + 10;
}
export async function renderFlightReportPdf(
payload: FlightReportPayload,
outputPath: string
): Promise<string> {
const status = getFlightReportStatus(
normalizeFlightReportRequest(payload.request),
payload
);
const normalizedRequest = normalizeFlightReportRequest(payload.request);
const status = getFlightReportStatus(normalizedRequest, payload);
if (!status.pdfReady) {
throw new ReportValidationError(
@@ -27,56 +213,89 @@ export async function renderFlightReportPdf(
);
}
ensureFreshSearch(payload);
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const doc = new PDFDocument({
size: "LETTER",
margin: 50
margin: 50,
info: {
Title: payload.request.tripName || "Flight report",
Author: "OpenClaw flight-finder"
}
});
const stream = fs.createWriteStream(outputPath);
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 {
doc.fontSize(20).text(payload.request.tripName || "Flight report", {
align: "left"
});
doc.moveDown(0.25);
doc.fontSize(10).fillColor("#555").text(`Generated: ${payload.generatedAt}`);
doc.fillColor("#000");
doc.moveDown();
doc.fontSize(14).text("Executive Summary");
bulletLines(payload.executiveSummary).forEach((line) => {
doc.fontSize(10).text(`${line}`);
});
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.fillColor("#10384E").font("Helvetica-Bold").fontSize(22).text(request.tripName || "Flight report");
doc.moveDown(0.2);
doc
.fillColor("#4F5B66")
.font("Helvetica")
.fontSize(10)
.text(`Generated: ${payload.generatedAt}`);
doc
.fillColor("#4F5B66")
.font("Helvetica")
.fontSize(10)
.text(
`Market: ${payload.marketCountryUsed || "Default market"} | Fresh search: ${payload.searchExecution.completedAt}`
);
doc.fontSize(10).text(option.rationale);
doc.moveDown(0.5);
});
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", 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) => {
doc.fontSize(11).text(`${finding.source}: ${finding.status}`);
bulletLines(finding.notes).forEach((line) => doc.fontSize(10).text(`${line}`));
doc.moveDown(0.25);
drawKeyValueTable(doc, [
["Source", finding.source],
["Status", finding.status],
["Checked", finding.checkedAt]
]);
drawBulletList(doc, finding.notes, "No source notes.");
});
doc.moveDown();
doc.fontSize(14).text("Warnings And Degraded Conditions");
bulletLines(
[...payload.reportWarnings, ...payload.degradedReasons],
"No additional warnings."
).forEach((line) => {
doc.fontSize(10).text(`${line}`);
});
drawSectionHeader(doc, "Warnings And Degraded Conditions");
drawBulletList(doc, [...payload.reportWarnings, ...payload.degradedReasons], "No additional warnings.");
doc.end();

View File

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

View File

@@ -14,6 +14,13 @@ import {
function samplePayload(): 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: "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
notes: ["Fresh bounded search executed for this report."]
},
sourceFindings: [
{
source: "kayak",
@@ -74,3 +81,21 @@ test("renderFlightReportPdf rejects incomplete report payloads", async () => {
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
);
});