Compare commits
4 Commits
e2657f4850
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b9878e938c | |||
| b2e97a3036 | |||
| 809a3955e5 | |||
| fb868b9e5f |
@@ -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
|
||||
- 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
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
@@ -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>"
|
||||
```
|
||||
|
||||
Expected report-payload provenance fields:
|
||||
|
||||
- `searchExecution.freshSearch: true`
|
||||
- `searchExecution.startedAt`
|
||||
- `searchExecution.completedAt`
|
||||
- `searchExecution.artifactsRoot`
|
||||
|
||||
## Delivery
|
||||
|
||||
- sender identity: `luke@fiorinis.com`
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
Accept either:
|
||||
@@ -54,6 +66,8 @@ Completion rule:
|
||||
- A partial source 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.
|
||||
- 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
|
||||
|
||||
@@ -62,11 +76,29 @@ 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
|
||||
- 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
|
||||
|
||||
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
|
||||
- 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,8 +151,9 @@ 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,288 @@ 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 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(
|
||||
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 +307,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 completed: ${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", 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) => {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -4,7 +4,12 @@ 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,
|
||||
formatArtifactsRoot,
|
||||
renderFlightReportPdf,
|
||||
ReportValidationError
|
||||
} from "../src/report-pdf.js";
|
||||
import type { FlightReportPayload } from "../src/types.js";
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
@@ -14,6 +19,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",
|
||||
@@ -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 () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`);
|
||||
await renderFlightReportPdf(samplePayload(), outputPath);
|
||||
@@ -59,6 +75,122 @@ 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 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 () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
|
||||
await assert.rejects(
|
||||
@@ -74,3 +206,68 @@ 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
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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