CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-flight-assist

Flight notifications via byAir: delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics. NanoClaw per-chat overlay tile.

69

Quality

87%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

check-travel-bookings.pyskills/check-travel-bookings/scripts/

#!/usr/bin/env python3
"""
Travel booking gap checker — reads from travel-db.json.

travel-db.json is built nightly by build-travel-db.py inside
`nightly-external-sync` Step 5 ("Rebuild travel-db.json from the
schedule"). A missing, unreadable, or structurally invalid DB is a
hard error: that Step 5's failure branch — `mcp__nanoclaw__send_message`
notification + scheduled continuation per the skill's "Continuation
handling" — is the correct alerting surface for DB issues. A silent
live-ICS fallback here would only mask that signal. (The two-tier
freshness probe in Step 4, `references/two-tier-probe.md`, is for
`travel-schedule.json`, not the DB.)

Alerts on transport (Flight or Rail) + Lodging gaps; all item types are in the DB for future use.
"""

import json
import re
import sys
from datetime import date, datetime, timedelta, timezone

DB_PATH = "/workspace/group/travel-db.json"
STATE_PATH = "/workspace/group/travel-booking-state.json"

# Bump in lock-step with build-travel-db.py per
# `coding-policy: stateful-artifacts` + state-schema.md sibling file.
# Legacy data lacking schema_version is treated as implicit v1 (the
# field was introduced at v1; no prior version exists). Higher
# versions are forward-incompatible — return None / skip the entry.
SCHEMA_VERSION = 1


def _schema_compatible(value) -> bool:
    """Accept v1 explicitly OR legacy data with no schema_version."""
    if value is None:
        return True
    return isinstance(value, int) and not isinstance(value, bool) and value == SCHEMA_VERSION


# ---------------------------------------------------------------------------
# Core logic
# ---------------------------------------------------------------------------


def make_slug(summary: str, start: date) -> str:
    clean = re.sub(r"\s+\d{4}$", "", summary.strip())
    slug_base = re.sub(r"[^a-z0-9]+", "-", clean.lower()).strip("-")
    return f"{slug_base}-{start.year}-{start.month:02d}"


def build_lodging_ranges(lodging_items: list[dict]) -> list[tuple]:
    """
    Pair 'Check-in: Hotel' and 'Check-out: Hotel' events by hotel name.
    Returns list of (checkin_date, checkout_date) tuples.
    """
    checkins: dict[str, date] = {}
    checkouts: dict[str, date] = {}
    for item in lodging_items:
        summary = item.get("summary", "")
        dtstart = item.get("dtstart")
        if dtstart is None:
            continue
        if summary.startswith("Check-in:"):
            hotel = summary[len("Check-in:") :].strip()
            checkins[hotel] = dtstart
        elif summary.startswith("Check-out:"):
            hotel = summary[len("Check-out:") :].strip()
            checkouts[hotel] = dtstart
    ranges = []
    for hotel, ci in checkins.items():
        co = checkouts.get(hotel)
        if co and co > ci:
            ranges.append((ci, co))
        else:
            ranges.append((ci, ci + timedelta(days=1)))
    return ranges


def classify_trip(items: list[dict], trip_start: date, trip_end: date) -> dict:
    """Return classification flags and per-night gap list for a trip."""
    if not items:
        return {
            "is_empty": True,
            "has_transport": False,
            "has_lodging": False,
            "uncovered_nights": [],
        }

    types = [i.get("item_type", "Unknown") for i in items]
    has_flight = "Flight" in types
    has_rail = "Rail" in types
    has_lodging = "Lodging" in types
    has_transport = has_flight or has_rail

    lodging_items = [i for i in items if i.get("item_type") == "Lodging"]
    lodging_ranges = build_lodging_ranges(lodging_items)
    uncovered_nights = []

    if has_transport:
        # Only count transport dates strictly within [trip_start, trip_end).
        # This prevents the next trip's outbound flight (included via the date-
        # overlap query) from making tail-end home-nights look like gaps.
        trip_transport_dates: set[date] = set()
        for item in items:
            if item.get("item_type") in ("Flight", "Rail"):
                for d in [item.get("dtstart"), item.get("dtend")]:
                    if d and trip_start <= d < trip_end:
                        trip_transport_dates.add(d)

        night = trip_start
        while night < trip_end:
            covered = any(ci <= night < co for ci, co in lodging_ranges)
            is_travel_night = night in trip_transport_dates
            # No future transport = traveller is home; don't flag tail nights.
            has_future_transport = any(d > night for d in trip_transport_dates)
            if not covered and not is_travel_night and has_future_transport:
                uncovered_nights.append(night.isoformat())
            night += timedelta(days=1)

    return {
        "is_empty": False,
        "has_transport": has_transport,
        "has_lodging": has_lodging,
        "has_flight": has_flight,
        "has_rail": has_rail,
        "uncovered_nights": uncovered_nights,
    }


# ---------------------------------------------------------------------------
# Data loading: DB only
# ---------------------------------------------------------------------------


def load_trips_from_db(db_path: str) -> list[dict] | None:
    """
    Load trips from travel-db.json.
    Returns list of dicts with keys: summary, start (date), end (date), items.
    items is a list of dicts with: item_type, summary, dtstart (date), dtend (date).
    Returns None if the DB file is missing, unreadable, or structurally
    invalid — main() treats that as a hard error rather than falling
    back to a live fetch (see module docstring).
    """
    try:
        with open(db_path, encoding="utf-8") as f:
            db = json.load(f)
    except (OSError, UnicodeDecodeError, json.JSONDecodeError):
        # OSError covers FileNotFoundError, PermissionError, and other
        # IO errors. UnicodeDecodeError covers a non-UTF-8 file (e.g.
        # build-travel-db.py wrote binary garbage on a half-failed
        # run). JSONDecodeError covers a partially-written or corrupt
        # DB. All three are flavors of "unreadable" and land in the
        # hard-error JSON contract in main().
        return None

    # A parseable-but-structurally-invalid root payload (db is a
    # list, or db['trips'] is a list) would crash `.items()` below
    # with AttributeError. Treat root shape errors as "unreadable"
    # too so the contract in main() holds for the full set of bad-DB
    # shapes Step 5's failure branch is meant to alert on.
    if not isinstance(db, dict) or not isinstance(db.get("trips"), dict):
        return None

    # Schema-version gate per `coding-policy: stateful-artifacts` +
    # state-schema.md sibling file. Legacy data without `schema_version`
    # is implicit v1; higher versions are forward-incompatible (treat
    # as no-prior-state).
    if not _schema_compatible(db.get("schema_version")):
        return None

    trips = []
    for slug, t in db["trips"].items():
        # Per-trip shape errors (`t` not a dict, missing required keys,
        # `days` not a dict, bad date formats, non-iterable `day_events`)
        # are caught and the trip is skipped — same fail-soft pattern
        # this loop already used for malformed dates. Skipping per-trip
        # bad data instead of failing the whole DB is the right
        # trade-off: a single malformed row from upstream ICS noise
        # would otherwise block the brief on EVERY good trip too. But
        # silent skipping hides the malformation; emit a stderr
        # diagnostic so operators can see which slugs were dropped
        # without losing the rest of the brief, per
        # `coding-policy: error-handling` (Actionable Messages) +
        # `script-delegation` (stderr diagnostics). DB-level shape
        # errors still hard-fail at the isinstance guard above.
        try:
            # `[:10]` slice tolerates the ISO-datetime shape emitted
            # for timed VEVENTs by `refresh-travel-schedule.py` after
            # `nanoclaw-admin#289` — gap-classification is day-granular,
            # so the time component is intentionally discarded here.
            trip_start = date.fromisoformat(t["start"][:10])
            trip_end = date.fromisoformat(t["end"][:10])
            summary = t["summary"]

            items = []
            # Flatten days → items list, mapping DB field names to
            # what classify_trip expects
            for day_events in t.get("days", {}).values():
                try:
                    iterator = iter(day_events)
                except TypeError:
                    # `day_events` is non-iterable (e.g. None, scalar).
                    # Skip this day; the trip's other days still parse.
                    print(
                        f"check-travel-bookings: skipped non-iterable "
                        f"day-events under trip slug={slug!r}",
                        file=sys.stderr,
                    )
                    continue
                for ev in iterator:
                    try:
                        items.append(
                            {
                                "item_type": ev["type"],
                                "summary": ev["summary"],
                                "dtstart": date.fromisoformat(ev["start"][:10]),
                                "dtend": date.fromisoformat(ev["end"][:10]),
                                "uid": ev.get("uid", ""),
                            }
                        )
                    except (KeyError, TypeError, ValueError) as ev_err:
                        print(
                            f"check-travel-bookings: skipped malformed "
                            f"item under trip slug={slug!r}: {type(ev_err).__name__}",
                            file=sys.stderr,
                        )
                        continue
        except (KeyError, TypeError, AttributeError, ValueError) as trip_err:
            print(
                f"check-travel-bookings: skipped malformed trip "
                f"slug={slug!r}: {type(trip_err).__name__}",
                file=sys.stderr,
            )
            continue

        trips.append(
            {
                "summary": summary,
                "start": trip_start,
                "end": trip_end,
                "items": items,
                "slug": slug,
            }
        )

    return trips


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def _diagnose_db_failure(db_path: str) -> str:
    """Best-effort second read after `load_trips_from_db` returned None.
    Distinguishes a forward-incompatible schema_version (upgrade needed)
    from generic unreadable/missing/shape errors, so the operator
    diagnostic surfaces the actionable cause rather than a generic
    'unreadable' message that points at Step 5 in vain."""
    try:
        with open(db_path, encoding="utf-8") as f:
            db = json.load(f)
    except (OSError, UnicodeDecodeError, json.JSONDecodeError):
        return "missing, unreadable, or structurally invalid"
    if isinstance(db, dict):
        version = db.get("schema_version")
        if isinstance(version, int) and not isinstance(version, bool) and version > SCHEMA_VERSION:
            return (
                f"has forward-incompatible schema_version={version}; "
                f"this skill supports v{SCHEMA_VERSION} — upgrade the "
                "`tessl__check-travel-bookings` tile"
            )
    return "missing, unreadable, or structurally invalid"


def main():
    today = date.today()

    trips = load_trips_from_db(DB_PATH)
    if trips is None:
        detail = _diagnose_db_failure(DB_PATH)
        message = (
            f"travel-db.json {detail} at {DB_PATH} — "
            "tessl__nightly-external-sync Step 5 (Rebuild "
            "travel-db.json from the schedule) should have "
            "built it. Check that step's last run and any "
            "scheduled continuation in `scheduled_tasks` for "
            "the failure mode."
        )
        # Machine-readable JSON to stdout for the script-output
        # contract; human-readable diagnostic to stderr per
        # `coding-policy: script-delegation` (Self-error-handling)
        # and `coding-policy: file-hygiene` (stderr for diagnostics).
        print(json.dumps({"error": message}, ensure_ascii=False))
        print(f"check-travel-bookings: {message}", file=sys.stderr)
        sys.exit(1)

    # Load snooze state. The snooze file is purely advisory — a
    # missing or unreadable file means "no snoozes active", which is
    # the safe default (all gaps surface). Use the same broadened
    # except as the DB read so a permission glitch or non-UTF-8
    # write doesn't bring down the whole check.
    try:
        with open(STATE_PATH, encoding="utf-8") as f:
            snooze_state = json.load(f)
    except (OSError, UnicodeDecodeError, json.JSONDecodeError):
        snooze_state = {}
    # Valid JSON but wrong root shape (a list, a scalar, etc.) would
    # crash `.get(...)` below. Per the advisory-snooze contract, any
    # non-dict root means "no snoozes active".
    if not isinstance(snooze_state, dict):
        snooze_state = {}

    gaps = []
    complete_trips = 0

    for trip in trips:
        trip_start = trip["start"]
        trip_end = trip["end"]
        summary = trip["summary"]
        slug = trip["slug"]
        items = trip["items"]

        # Skip past trips
        if trip_end < today:
            continue

        classification = classify_trip(items, trip_start, trip_end)

        issue = None
        uncovered = classification.get("uncovered_nights", [])
        if classification["is_empty"]:
            issue = "ничего не забукано"
        elif classification["has_transport"] and not classification["has_lodging"]:
            issue = "рейсы есть, отеля нет"
        elif classification["has_transport"] and uncovered:
            issue = f"нет отеля на {len(uncovered)} ноч.: {uncovered[0]}…{uncovered[-1]}"

        if issue is None:
            complete_trips += 1
            continue

        # Check snooze. Per-entry schema_version gate per state-schema.md:
        # entries with a higher-than-current schema_version are treated as
        # forward-incompatible (no snooze active). Missing schema_version is
        # legacy data, accepted as implicit v1. Non-dict entries are
        # malformed → no snooze active.
        snooze_entry = snooze_state.get(slug, {})
        if not isinstance(snooze_entry, dict) or not _schema_compatible(
            snooze_entry.get("schema_version")
        ):
            snooze_entry = {}
        snooze_until_str = snooze_entry.get("snooze_until", "")
        if snooze_until_str:
            try:
                if date.fromisoformat(snooze_until_str) >= today:
                    complete_trips += 1
                    continue
            except ValueError:
                pass

        gaps.append(
            {
                "trip": summary,
                "start": trip_start.isoformat(),
                "end": trip_end.isoformat(),
                "issue": issue,
                "slug": slug,
                "uncovered_nights": uncovered if uncovered else [],
            }
        )

    output = {
        "gaps": gaps,
        "checked_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "total_trips": len(trips),
        "complete_trips": complete_trips,
    }
    print(json.dumps(output, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

README.md

tile.json