CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-travel

Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), traffic-aware drive planning for in-person meetings (auto drive blocks + leave-by traffic rechecks), travel-booking gap checks, and nightly TripIt sync. Per-chat overlay plugin.

Quality

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

High

Do not use without reviewing

Overview
Quality
Evals
Security
Files

chain_builder.pyskills/drive-engine/

"""Chain assembly — merged flights → ordered chains + per-pair context — pure.

Bridges `flight_identity.merge_flights` and `chain.plan_chain_legs`: groups the
merged flights into ordered per-trip chains and derives each consecutive pair's
`PairContext` (is there a lodging check-in between the two flights; did the operator
leave the terminal). Those two facts drive the §D / C2 connection classification.

Pure: the lodging fact is read from the itinerary schedule (already loaded); the
"left the terminal" fact is a geofence decision the I/O layer makes on the current
location fix and passes in as a predicate. No clock, no network here.
"""

from __future__ import annotations

from collections.abc import Callable
from datetime import datetime, timezone

from chain import PairContext
from flight_identity import MergedFlight


def _parse_iso(raw: object) -> datetime | None:
    if not isinstance(raw, str) or not raw:
        return None
    try:
        return datetime.fromisoformat(raw.replace("Z", "+00:00"))
    except ValueError:
        return None


def group_into_chains(flights: list[MergedFlight]) -> list[list[MergedFlight]]:
    """Group merged flights into operational chains by preferred `trip_id`.

    The preferred id preserves byAir's intentional split between outbound and
    return journeys around a long stay. Feeding a shared TripIt itinerary alias
    into connection classification would misread the stay as an airside layover
    when no lodging record exists. A flight with no preferred id forms its own
    singleton chain. Chains are ordered by their first scheduled departure.
    """
    by_trip: dict[int, list[MergedFlight]] = {}
    singletons: list[list[MergedFlight]] = []
    for flight in flights:
        if flight.trip_id is None:
            singletons.append([flight])
        else:
            by_trip.setdefault(flight.trip_id, []).append(flight)

    chains = [sorted(group, key=lambda flight: flight.scheduled_dep) for group in by_trip.values()]
    chains.extend(singletons)
    chains.sort(key=lambda chain: chain[0].scheduled_dep)
    return chains


def group_into_itineraries(flights: list[MergedFlight]) -> list[list[MergedFlight]]:
    """Group by connected source aliases for itinerary-level facts only.

    A shared TripIt alias can reconnect outbound and return chains that byAir gave
    different preferred ids. Consumers use these components to recognize the
    overall opening and closing airports; connection-leg planning continues to
    use `group_into_chains` so a multi-day stay keeps its ground endpoints.
    """
    parent = list(range(len(flights)))

    def find(index: int) -> int:
        while parent[index] != index:
            parent[index] = parent[parent[index]]
            index = parent[index]
        return index

    def union(left: int, right: int) -> None:
        left_root = find(left)
        right_root = find(right)
        if left_root != right_root:
            # Index order is deterministic and keeps the component root stable.
            low, high = sorted((left_root, right_root))
            parent[high] = low

    owner_by_alias: dict[int, int] = {}
    for index, flight in enumerate(flights):
        for alias in sorted(flight.all_trip_ids):
            owner = owner_by_alias.setdefault(alias, index)
            union(index, owner)

    by_component: dict[int, list[MergedFlight]] = {}
    for index, flight in enumerate(flights):
        by_component.setdefault(find(index), []).append(flight)

    itineraries = [
        sorted(group, key=lambda flight: flight.scheduled_dep) for group in by_component.values()
    ]
    itineraries.sort(key=lambda itinerary: itinerary[0].scheduled_dep)
    return itineraries


def _spans_overnight(start: datetime, end: datetime) -> bool:
    """Whether a connection gap crosses a calendar night (different UTC days).

    A real overnight break spans midnight; a same-day layover does not. TripIt
    records a hotel's check-in / check-out at a NOMINAL local time (a standard
    mid-afternoon check-in), so a DESTINATION hotel's check-in can land inside a
    long SAME-DAY layover window even though that hotel is reached only by a later
    flight — a Tel Aviv 12:00Z (15:00 local) check-in landing in a 06:40Z–14:25Z
    Paris (CDG) layover, misread as an overnight, drew a bogus `Drive: CDG → Tel
    Aviv` block. The same-day gate drops that false break; every genuine
    cross-night stay (the real Tel Aviv stay spans Aug 2 → Aug 6) still crosses a
    day boundary and stands.
    """
    return start.astimezone(timezone.utc).date() != end.astimezone(timezone.utc).date()


def has_lodging_between(schedule: list[dict] | None, start: datetime, end: datetime) -> bool:
    """Whether a lodging check-in falls strictly within `(start, end)`.

    Scans the itinerary schedule for a `Lodging` record whose check-in instant is
    after `start` and before `end`. Necessary but not sufficient for an overnight
    break — `build_pair_contexts` also requires the gap to span a night (see
    `_spans_overnight`) before classifying the pair as an overnight (§D).
    """
    if start >= end:
        return False
    for record in schedule or []:
        if not isinstance(record, dict) or record.get("type") != "Lodging":
            continue
        when = _parse_iso(record.get("start"))
        if when is not None and start < when < end:
            return True
    return False


def build_pair_contexts(
    chain: list[MergedFlight],
    *,
    schedule: list[dict] | None,
    left_terminal: Callable[[MergedFlight, MergedFlight], bool] | None = None,
) -> list[PairContext]:
    """Derive the `PairContext` for each consecutive pair in a chain.

    `left_terminal(earlier, later)` returns fresh geofence evidence the operator
    left the departure airport during the gap (only consulted for same-airport
    pairs by the classifier; default no evidence). Uses each flight's best-known
    times: the earlier flight's arrival and the later flight's departure bound the
    gap the lodging check is run over.

    `lodging_between` is set only when a lodging check-in falls in that gap AND the
    gap spans a night (`_spans_overnight`) — a same-day layover can't be an
    overnight however a nominal destination-hotel check-in time lands in it.
    """
    contexts: list[PairContext] = []
    for earlier, later in zip(chain, chain[1:], strict=False):
        arr = earlier.effective_arr
        dep = later.effective_dep
        lodging = bool(
            arr is not None
            and dep is not None
            and _spans_overnight(arr, dep)
            and has_lodging_between(schedule, arr, dep)
        )
        left = bool(left_terminal(earlier, later)) if left_terminal is not None else False
        contexts.append(PairContext(lodging_between=lodging, operator_left_terminal=left))
    return contexts

README.md

tile.json