Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/flight-search/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ intent into the right invocation **on the first try**.

| Command | Purpose |
|---|---|
| `flight search ORIGIN DEST --dep YYYY-MM-DD [--return YYYY-MM-DD]` | Specific-date search. Auto-picks Google Flights for plain cash queries and ITA Matrix when a Matrix-only flag is set (routing/extension/multi-city slice/time-of-day/extra pax types/PP config). Force with `--backend matrix\|gflight`. |
| `flight search ORIGIN DEST --dep YYYY-MM-DD [--return YYYY-MM-DD]` | Specific-date search. Auto-picks Google Flights for plain cash queries and ITA Matrix when a Matrix-only flag is set (routing/extension/multi-city slice/time-of-day/extra pax types). Force with `--backend matrix\|gflight`. PointsPath award overlay runs on both backends when tokens are present. |
| `flight calendar ORIGIN DEST --start YYYY-MM-DD [--end ...] [-d 5-7]` | Lowest-fare grid across a date window. Matrix only. Default round-trip; `--one-way` flips. |
| `flight detail ORIGIN DEST --dep YYYY-MM-DD --start ... --end ...` | Phase-2 of the calendar flow: full itineraries for a date picked from the grid. Matrix only. |
| `flight airport QUERY` | IATA / partial-name autocomplete. |
Expand All @@ -26,7 +26,7 @@ Global flags (every search-printing command):
- `--json` — emit raw response JSON
- `--no-cache` — bypass the on-disk response cache
- `--matrix-url` / `--google-url` — toggle deep-link emission
- `--no-pp` (Matrix backend only) — skip the PointsPath award overlay even if tokens are logged in (PP runs implicitly when tokens are present)
- `--no-pp` — skip the PointsPath award overlay even if tokens are logged in (PP runs implicitly on both matrix and gflight backends when tokens are present)

## Intent → flag cheat sheet

Expand Down
4 changes: 4 additions & 0 deletions docs/memories/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ go into detail and are loaded on demand.
web search shows, this project is the only public wrapper of the Alkali
endpoint. Implications: our fixtures are the spec; forward-compat is
on us. What to do if Matrix changes shape.
- [pp_on_gflight.md](pp_on_gflight.md) — Why PointsPath overlay now
rides both backends. Decision: `enable_matching=False` + the existing
matcher keys, not the matched-Google-flight-id path. Empirical evidence
behind the choice and the upgrade path if it turns out worth it later.

## When to add a new memory file

Expand Down
61 changes: 61 additions & 0 deletions docs/memories/pp_on_gflight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# PointsPath overlay rides both backends (work-qmx1)

The PP augmenter is **backend-agnostic** at the matcher level. `pp/match.py`
joins by structural keys (flight#+date and route+time) that don't care
whether the cash side came from Matrix's Alkali response or fli's Google
Flights scrape. The adapter at `pp/gflight_adapter.py` wraps fli output
in a `SearchResult` shape so the existing matcher and renderer reuse
end-to-end — no duplication.

## Decision: enable_matching=False, not True

PointsPath's `airline-search` endpoint accepts a `CashFlightHint` payload
with `enableGoogleFlightMatching=true`, which is supposed to echo a
`matchedGoogleFlightId` per result so cash↔award join becomes trivial. We
considered using it but **didn't**, for three reasons:

1. **fli has no opaque Google Flights ID**. `fli.models.FlightResult`
only carries structured fields (airline, flight_number, datetimes,
route, price). The "real Google Flights ID" framing in the bd issue
prompt doesn't map to a field fli actually exposes.
2. **The previous probe** (`research/probe_pp_matching.py`,
gitignored capture at `research/capture/pp_matching_probe.json`)
showed that synthetic `flight_id` values cause PP to return an empty
`outboundFlights` list entirely — PP filters on the hint, doesn't
just echo it. Without a verified real-ID format the matching path is
a footgun.
3. **The existing (flight#, date) + (route+time) keys already work**
on the Matrix backend, including for codeshare bridging (work-22az).
The same keys carry over to fli output unchanged.

If a future spike confirms `matchedGoogleFlightId` populates for some
hint format that fli can produce, the upgrade path is small: add
`cash_hints=...` to `SearchSpec` in `pp/cli.py:_gather_pp` and a new
hit-merging branch in `match.py:join` that consumes the echoed match id.
Worth it only if codeshare-bridging on the gflight backend turns out to
be common — gflight is mostly used for plain ULCC/Frontier/Spirit kind
of queries, where matched-id robustness isn't load-bearing.

`research/probe_pp_real_hints.py` (gitignored) is the prepared probe
for the upgrade question. It runs a real fli search, builds CashFlightHints
from the structured output, and sends them to PP with enable_matching=True.
Attempting it during work-qmx1 hit a stale refresh token (Supabase rotates
single-use; a parallel Chrome session had consumed the chain), so the
empirical answer is deferred. Re-run after `flight auth pp login` to settle.

## Why: was forcing matrix on every PP query

Before this change, any presence of a `--pp-*` flag forced the matrix
backend. That was a 26s+ tax (vs gflight's ~0.7s) for queries that
didn't need Matrix's richer routing/extension semantics. Plain
`--pp-only` users — the canonical "is there an award for this trip?"
gut-check — paid that tax in full.

## How to apply

- New search modes should reuse the matcher unchanged; add to
`gflight_adapter` only when adapting a new source's output.
- `_should_run_pp` is now backend-agnostic — only token presence and
`--no-pp/--pp-only` flag coherence gate the overlay.
- `_pick_backend` no longer accepts PP-related parameters; PP-* flags
don't influence backend choice.
92 changes: 57 additions & 35 deletions src/flight_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,57 +218,42 @@ def _pick_backend(
youth: int,
inf_seat: int,
inf_lap: int,
pp_only: bool,
pp_airlines: str | None,
pp_cabin: str | None,
) -> str:
"""Resolve --backend to a concrete backend.

auto: matrix iff a Matrix-only flag is set, else gflight.
Matrix-only set: --routing/--extension/--slice/--depart-times/--return-times,
any pax type beyond adults+children, any --pp-* configuration flag.
PP is currently Matrix-only (provider abstraction is work-z6zi).
any pax type beyond adults+children. PP overlay rides both backends now —
plain `--pp-only` stays on gflight for speed + ULCC inventory.

Explicit --backend matrix: matrix. --backend gflight: gflight, unless a
Matrix-only flag is also set (error — the request is inexpressible on fli)."""
matrix_only = bool(
routing
or extension
or slice_specs
or depart_times
or return_times
or pp_airlines
or pp_cabin
) or (seniors > 0 or youth > 0 or inf_seat > 0 or inf_lap > 0 or pp_only)
matrix_only = bool(routing or extension or slice_specs or depart_times or return_times) or (
seniors > 0 or youth > 0 or inf_seat > 0 or inf_lap > 0
)
if backend == BACKEND_AUTO:
return BACKEND_MATRIX if matrix_only else BACKEND_GFLIGHT
if backend == BACKEND_GFLIGHT and matrix_only:
raise typer.BadParameter(
"--backend gflight is incompatible with Matrix-only flags "
"(--routing/--extension/--slice/--depart-times/--return-times/"
"extra pax types/--pp-*). Drop them, or use --backend matrix.",
"extra pax types). Drop them, or use --backend matrix.",
)
if backend not in _VALID_BACKENDS:
raise typer.BadParameter(f"--backend must be one of {_VALID_BACKENDS}; got {backend!r}")
return backend


def _should_run_pp(*, backend: str, no_pp: bool, pp_only: bool) -> bool:
def _should_run_pp(*, no_pp: bool, pp_only: bool) -> bool:
"""Decide whether PP augmentation runs.

Matrix backend + tokens present → True (unless --no-pp).
Tokens present → True (unless --no-pp). Both backends support PP overlay
now: matrix consumes its own SearchResult directly; gflight wraps fli
output via gflight_adapter into the same shape, so the matcher and
renderer reuse end-to-end.

--pp-only with missing tokens → hard error (user explicitly asked for PP).
gflight backend → False (PP needs Matrix's SearchResult shape; work-z6zi
introduces the AwardProvider abstraction that makes this provider-neutral).
"""
if backend != BACKEND_MATRIX:
if pp_only:
err.print(
"[red]--pp-only requires Matrix backend; "
"drop the Matrix-only flag or use --backend matrix.[/]",
)
raise typer.Exit(2)
return False
if no_pp:
if pp_only:
err.print("[red]--pp-only and --no-pp are mutually exclusive.[/]")
Expand Down Expand Up @@ -504,8 +489,17 @@ def _run_gflight_path(
opts: SearchOptions,
top_n: int,
json_out: bool,
run_pp: bool = False,
pp_only: bool = False,
pp_airlines: str | None = None,
pp_cabin: str | None = None,
) -> None:
"""Google Flights path: build fli filter → query → render. Single-leg or round-trip."""
"""Google Flights path: build fli filter → query → render. Single-leg or round-trip.

When run_pp=True, fli's results are adapted into a SearchResult shape so
the existing PP matcher + renderer reuse cleanly. PP runs on the same
(origin, dest, date) per leg as the matrix path.
"""
# fli is heavy (selenium/selectolax); lazy-import so the rest of flight_cli
# doesn't pay the startup cost when not used.
from .fli_bridge import run_gflight_search # noqa: PLC0415
Expand All @@ -528,7 +522,7 @@ def _run_gflight_path(
# fli/fast_flights have no type stubs; results are duck-typed pydantic
# models. Suppressing the noisy unknown-type chatter for this rendering
# block keeps the boundary localized.
if json_out:
if json_out and not run_pp:
out: list[Any] = []
for r in results:
if isinstance(r, tuple):
Expand All @@ -538,6 +532,27 @@ def _run_gflight_path(
sys.stdout.write(json.dumps(out, indent=2, default=str))
return

if not pp_only:
_render_gflight_table(results, legs=legs, top_n=top_n)

if run_pp:
from .pp.gflight_adapter import fli_results_to_search_result # noqa: PLC0415

sr = fli_results_to_search_result(results)
p = opts.pax
run_pp_for_search(
sr,
legs=_build_pp_legs(legs),
num_passengers=p.adults + p.children + p.seniors + p.youth,
airlines=pp_airlines,
cabins=pp_cabin,
pp_only=pp_only,
json_out=json_out,
)


def _render_gflight_table(results: list[Any], *, legs: tuple[Leg, ...], top_n: int) -> None:
"""Render fli results as a rich table. Duck-typed: fli has no type stubs."""
origin = legs[0].origins[0] if legs[0].origins else "?"
destination = legs[0].destinations[0] if legs[0].destinations else "?"
has_return = len(legs) >= _ROUND_TRIP_LEGS
Expand Down Expand Up @@ -702,9 +717,6 @@ def search(
youth=youth,
inf_seat=inf_seat,
inf_lap=inf_lap,
pp_only=pp_only,
pp_airlines=pp_airlines,
pp_cabin=pp_cabin,
)
if slice_specs:
legs = tuple(_parse_slice_spec(s) for s in slice_specs)
Expand Down Expand Up @@ -751,10 +763,20 @@ def search(
)

if resolved == BACKEND_GFLIGHT:
_run_gflight_path(legs=legs, opts=opts, top_n=page_size, json_out=json_out)
run_pp = _should_run_pp(no_pp=no_pp, pp_only=pp_only)
_run_gflight_path(
legs=legs,
opts=opts,
top_n=page_size,
json_out=json_out,
run_pp=run_pp,
pp_only=pp_only,
pp_airlines=pp_airlines,
pp_cabin=pp_cabin,
)
return

run_pp = _should_run_pp(backend=resolved, no_pp=no_pp, pp_only=pp_only)
run_pp = _should_run_pp(no_pp=no_pp, pp_only=pp_only)
_run_matrix_path(
legs=legs,
opts=opts,
Expand Down Expand Up @@ -914,7 +936,7 @@ def fare(
show_only_available=only_available,
page_size=page_size,
)
run_pp = _should_run_pp(backend=BACKEND_MATRIX, no_pp=no_pp, pp_only=pp_only)
run_pp = _should_run_pp(no_pp=no_pp, pp_only=pp_only)
_run_matrix_path(
legs=legs,
opts=opts,
Expand Down
93 changes: 93 additions & 0 deletions src/flight_cli/pp/gflight_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# pyright: reportCallIssue=false
# DIVERGE: pydantic Field(alias=...) on _Loose models trips basedpyright into
# treating alias names as required kwargs even though populate_by_name=True is
# set. Same posture as tests/pp/test_match.py.
"""Adapt fli's Google Flights output into the SearchResult shape match.py expects.

The matcher reads structural fields only — slices[i].flights[0], .departure,
.origin.code, .destination.code, Itinerary.price — so a thin wrap-and-translate
layer is enough; no matcher changes needed. PP runs with enable_matching=False
on this path; the same (flight#, date) + (route, time) keys that work for the
Matrix backend bridge the cash↔award join here too.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from ..models import (
Itinerary,
ItineraryDetails,
ItineraryExt,
SearchResult,
Slice,
SliceEndpoint,
)

if TYPE_CHECKING:
from collections.abc import Sequence


def _airport_code(a: Any) -> str:
name: str = getattr(a, "name", "") or ""
return name.removeprefix("_")


def _slice_from_flight_result(fr: Any) -> Slice:
legs: list[Any] = list(fr.legs)
first, last = legs[0], legs[-1]
return Slice(
flights=[leg.flight_number for leg in legs],
departure=first.departure_datetime.isoformat(),
arrival=last.arrival_datetime.isoformat(),
duration=fr.duration,
origin=SliceEndpoint(code=_airport_code(first.departure_airport)),
destination=SliceEndpoint(code=_airport_code(last.arrival_airport)),
)


def _price_string(fr: Any) -> str:
"""Match Matrix's price format ('USD877.00') so match._parse_cash works."""
currency = fr.currency or "USD"
return f"{currency}{fr.price:.2f}"


def fli_results_to_search_result(results: Sequence[Any]) -> SearchResult:
"""Wrap fli's heterogeneous return into a SearchResult.

fli returns ``list[FlightResult]`` for one-way and ``list[tuple[FlightResult, ...]]``
for round-trip/multi-city. Each top-level entry maps to one Itinerary; for
tuples, each FlightResult becomes one Slice in slice-index order. The
cheapest-cash price for the itinerary uses the outbound leg's price
(round-trip prices in fli are attached per-result; the outbound carries
the combined fare on round-trip queries).
"""
solutions: list[Itinerary] = []
cheapest_price: float | None = None
cheapest_currency: str = "USD"
for r in results:
items: list[Any] = list(r) if isinstance(r, tuple) else [r] # pyright: ignore[reportUnknownArgumentType]
if not items:
continue
slices = [_slice_from_flight_result(fr) for fr in items]
price_str = _price_string(items[0])
solutions.append(
Itinerary(
ext=ItineraryExt(price=price_str),
itinerary=ItineraryDetails(slices=slices, carriers=[]),
),
)
p: float = items[0].price
if cheapest_price is None or p < cheapest_price:
cheapest_price = p
cheapest_currency = items[0].currency or "USD"

sr = SearchResult(
solutionCount=len(solutions),
solutions=solutions,
)
if cheapest_price is not None:
sr.currency_notice.ext = ItineraryExt(
price=f"{cheapest_currency}{cheapest_price:.2f}",
)
return sr
Loading
Loading