elliot-stack 1.0.13 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ # Shuttle Schedules — Template & Format Reference
2
+
3
+ This skill optionally pairs flights with a ground shuttle service. Pairing is **off by default** — the skill only runs it if the user's config has a `shuttle_service` block set.
4
+
5
+ This file describes:
6
+ 1. How to configure your shuttle service in `~/.flight-planner/config.json`
7
+ 2. The JSON format the pairing script (`scripts/pair_shuttles.py`) expects
8
+ 3. How the skill builds that JSON from your shuttle company's website each run
9
+
10
+ ## 1. Config block
11
+
12
+ Add this to `~/.flight-planner/config.json` if you regularly use a shuttle:
13
+
14
+ ```json
15
+ "shuttle_service": {
16
+ "name": "Acme Airport Express",
17
+ "schedule_urls": [
18
+ "https://acmeairport.example.com/schedules/ord",
19
+ "https://acmeairport.example.com/schedules/mdw"
20
+ ],
21
+ "costs": {
22
+ "ORD": 60,
23
+ "MDW": 55
24
+ },
25
+ "home_timezone": "America/New_York",
26
+ "airport_timezones": {
27
+ "ORD": "America/Chicago",
28
+ "MDW": "America/Chicago"
29
+ }
30
+ }
31
+ ```
32
+
33
+ **Fields:**
34
+
35
+ - `name` — display name for the company
36
+ - `schedule_urls` — one or more URLs the skill will fetch with WebFetch each run. Should be public schedule pages.
37
+ - `costs` — one-way USD cost per destination airport (IATA code)
38
+ - `home_timezone` — IANA timezone string for the city/town the shuttle picks you up from
39
+ - `airport_timezones` — IANA timezone string per destination airport
40
+
41
+ Set `shuttle_service: null` (or omit the field entirely) if you don't use a shuttle.
42
+
43
+ ## 2. JSON the pairing script expects
44
+
45
+ After fetching schedule URLs, the skill assembles this JSON and saves it as `shuttles.json`:
46
+
47
+ ```json
48
+ {
49
+ "shuttles": [
50
+ {
51
+ "company": "Acme Airport Express",
52
+ "from": "Home",
53
+ "to": "ORD",
54
+ "pickup_location": "Downtown station",
55
+ "departs_local": "06:00",
56
+ "arrives_local": "08:30"
57
+ },
58
+ {
59
+ "company": "Acme Airport Express",
60
+ "from": "Home",
61
+ "to": "ORD",
62
+ "pickup_location": "Downtown station",
63
+ "departs_local": "10:00",
64
+ "arrives_local": "12:30"
65
+ }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ **Field rules:**
71
+
72
+ - `from` — descriptive label for the pickup region (not used for matching)
73
+ - `to` — destination airport IATA code (used to match flights)
74
+ - `departs_local` — pickup time in **home timezone**, HH:MM 24-hour
75
+ - `arrives_local` — arrival time in **destination airport's local timezone**, HH:MM 24-hour
76
+ - `pickup_location` — optional, shown in output
77
+
78
+ The pairing script uses `--tz-offsets` to translate `arrives_local` into the home timezone for buffer math. The skill computes those offsets from `home_timezone` and `airport_timezones` in your config.
79
+
80
+ ## 3. Extracting schedules from a website
81
+
82
+ Most shuttle companies post fixed weekly schedules on their site. To turn a schedule page into the JSON above:
83
+
84
+ 1. Use WebFetch on each URL in `schedule_urls`. Prompt: "extract every shuttle run with pickup time, destination airport, and arrival time."
85
+ 2. For each run, build one object with `from`, `to`, `departs_local`, `arrives_local`.
86
+ 3. Append all objects to a single `"shuttles"` array.
87
+
88
+ If the company has multiple runs to the same airport on different days, treat each as a separate entry. If schedules change on weekends, fetch the relevant day's schedule when the user's flight date is a weekend.
89
+
90
+ ## 4. Cost overrides for a single run
91
+
92
+ If the user wants to override shuttle costs for one run only (e.g., a friend is driving them for free), the skill passes `--shuttle-costs "ORD:0"` instead of the config value. This doesn't modify the saved config.
93
+
94
+ ## 5. Tightness tuning
95
+
96
+ `--min-buffer-min` (default 90) sets the floor for "viable" — flights with less buffer than this between shuttle arrival and flight departure are marked TOO_TIGHT. Increase to 120 if your airport has long security lines, decrease to 60 if you trust your shuttle and have TSA PreCheck.
97
+
98
+ `--max-wait-min` (default 240) caps how much pre-flight wait time is acceptable. Pairings beyond this get a LONG_WAIT label but still appear in the output.
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env bash
2
+ # Deterministic setup check. Run at skill load time via the ```! fence in SKILL.md.
3
+ # Outputs a human-readable report of:
4
+ # - whether ~/.flight-planner/config.json exists and its contents (key masked)
5
+ # - whether ~/.flight-planner/flight_history.json exists and its entry count
6
+ # - today's date (for resolving relative dates in Phase 1)
7
+ # - whether SERPAPI_KEY env var is set
8
+ # No side effects. Safe to run on every skill invocation.
9
+
10
+ set -u
11
+
12
+ CONFIG_DIR="$HOME/.flight-planner"
13
+ CONFIG_FILE="$CONFIG_DIR/config.json"
14
+ HISTORY_FILE="$CONFIG_DIR/flight_history.json"
15
+
16
+ echo "=== Flight Planner Setup ==="
17
+ echo "Today: $(date +%Y-%m-%d) (timezone: $(date +%Z))"
18
+ echo ""
19
+
20
+ # --- Config ---
21
+ if [ -f "$CONFIG_FILE" ]; then
22
+ echo "Config: $CONFIG_FILE (exists)"
23
+ echo ""
24
+ echo "--- Saved preferences ---"
25
+ # Mask serpapi_key value: show as "set" or "null" but never the actual key
26
+ python -c "
27
+ import json, sys
28
+ try:
29
+ with open(r'''$CONFIG_FILE''', encoding='utf-8') as f:
30
+ c = json.load(f)
31
+ except Exception as e:
32
+ print(f' ERROR reading config: {e}')
33
+ sys.exit(0)
34
+
35
+ key = c.get('serpapi_key')
36
+ print(' SerpAPI key: ' + ('set' if key else 'null (will use WebSearch fallback)'))
37
+ print(' Budget: \$' + str(c.get('budget_usd', '?')) + ' (' + str(c.get('budget_strength', '?')) + ')')
38
+ airlines = c.get('airline_preferences') or []
39
+ print(' Airlines: ' + (', '.join(airlines) if airlines else 'any') + ' (' + str(c.get('airline_preference_strength', '?')) + ')')
40
+ np = c.get('nonstop_preference', '?')
41
+ ns = c.get('nonstop_strength', '?')
42
+ print(' Nonstop: ' + str(np) + ' (' + str(ns) + ')')
43
+ bands = c.get('time_priority_bands') or []
44
+ print(' Time priority: ' + (', '.join(bands) if bands else 'none') + ' (' + str(c.get('time_priority_strength', '?')) + ')')
45
+ print(' Home airport: ' + str(c.get('home_airport') or 'not set'))
46
+ freq = c.get('frequent_destinations') or []
47
+ print(' Frequent destinations: ' + (', '.join(freq) if freq else 'not set'))
48
+ shuttle = c.get('shuttle_service')
49
+ if shuttle:
50
+ name = shuttle.get('name', '?')
51
+ costs = shuttle.get('costs', {}) or {}
52
+ cost_str = ', '.join(f'{k}: \${v}' for k, v in costs.items()) if costs else 'no costs configured'
53
+ print(' Shuttle service: ' + str(name) + ' (' + cost_str + ')')
54
+ else:
55
+ print(' Shuttle service: none (pairing step will be skipped)')
56
+ " 2>&1 || echo " (python not available; cannot parse config — read the file directly)"
57
+ else
58
+ echo "Config: $CONFIG_FILE (NOT FOUND)"
59
+ echo ""
60
+ echo "First-run setup is needed. The skill will walk the user through Phase 2"
61
+ echo "to create this file."
62
+ fi
63
+
64
+ echo ""
65
+
66
+ # --- Environment SERPAPI_KEY ---
67
+ if [ -n "${SERPAPI_KEY:-}" ]; then
68
+ echo "SERPAPI_KEY: set in environment (will be used if config key is null)"
69
+ else
70
+ echo "SERPAPI_KEY: not set in environment"
71
+ fi
72
+
73
+ echo ""
74
+
75
+ # --- Flight history ---
76
+ if [ -f "$HISTORY_FILE" ]; then
77
+ count=$(python -c "
78
+ import json
79
+ try:
80
+ with open(r'''$HISTORY_FILE''', encoding='utf-8') as f:
81
+ data = json.load(f)
82
+ print(len(data) if isinstance(data, list) else 0)
83
+ except Exception:
84
+ print(0)
85
+ " 2>/dev/null || echo "?")
86
+ echo "History: $HISTORY_FILE ($count entries)"
87
+ else
88
+ echo "History: $HISTORY_FILE (not created yet — first search will create it)"
89
+ fi
@@ -0,0 +1,99 @@
1
+ """
2
+ Fetch flight data from SerpAPI Google Flights.
3
+
4
+ Saves raw JSON responses to a platform-aware temp directory, one file per
5
+ route x date combination. Prints the temp directory path on stdout so the
6
+ caller can pipe it to filter_flights.py.
7
+
8
+ Usage:
9
+ python fetch_flights.py --dates 2026-05-09,2026-05-10 --routes IND-EWR,ORD-LGA
10
+ python fetch_flights.py --dates 2026-05-09 --routes IND-EWR --airlines UA,DL --stops 1
11
+ python fetch_flights.py --dates 2026-05-09 --routes IND-EWR --output-dir /tmp/x
12
+
13
+ Args:
14
+ --airlines Optional comma-separated IATA airline codes (e.g. UA,DL). Omit for any airline.
15
+ --stops Optional. 0=any (default), 1=nonstop only, 2=one-stop-max.
16
+
17
+ Auth:
18
+ Reads SERPAPI_KEY from environment. Override with --api-key.
19
+ """
20
+ import argparse
21
+ import os
22
+ import sys
23
+ import tempfile
24
+ import urllib.parse
25
+ import urllib.request
26
+ from pathlib import Path
27
+
28
+ ENDPOINT = "https://serpapi.com/search"
29
+ BASE_PARAMS = {
30
+ "engine": "google_flights",
31
+ "type": "2", # one-way
32
+ "sort_by": "2", # price
33
+ "currency": "USD",
34
+ }
35
+
36
+
37
+ def default_output_dir() -> Path:
38
+ base = Path(tempfile.gettempdir()) / "flight-planner"
39
+ base.mkdir(parents=True, exist_ok=True)
40
+ return base
41
+
42
+
43
+ def fetch_one(api_key: str, dep: str, arr: str, date: str, out_path: Path,
44
+ airlines: str = None, stops: str = None) -> int:
45
+ params = {**BASE_PARAMS, "departure_id": dep, "arrival_id": arr,
46
+ "outbound_date": date, "api_key": api_key}
47
+ if airlines:
48
+ params["include_airlines"] = airlines
49
+ if stops and stops != "0":
50
+ params["stops"] = stops
51
+ url = f"{ENDPOINT}?{urllib.parse.urlencode(params)}"
52
+ with urllib.request.urlopen(url, timeout=60) as resp:
53
+ data = resp.read()
54
+ out_path.write_bytes(data)
55
+ return len(data)
56
+
57
+
58
+ def main() -> int:
59
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
60
+ p.add_argument("--dates", required=True, help="Comma-separated YYYY-MM-DD")
61
+ p.add_argument("--routes", required=True, help="Comma-separated DEP-ARR (e.g. IND-EWR,ORD-LGA)")
62
+ p.add_argument("--airlines", default=None,
63
+ help="Optional comma-separated IATA codes (e.g. UA,DL). Omit for any airline.")
64
+ p.add_argument("--stops", default="0",
65
+ help="0=any (default), 1=nonstop only, 2=one-stop-max")
66
+ p.add_argument("--output-dir", default=None, help="Override temp dir")
67
+ p.add_argument("--api-key", default=None, help="SerpAPI key (else uses $SERPAPI_KEY)")
68
+ args = p.parse_args()
69
+
70
+ api_key = args.api_key or os.environ.get("SERPAPI_KEY")
71
+ if not api_key:
72
+ print("ERROR: provide --api-key or set SERPAPI_KEY env var", file=sys.stderr)
73
+ return 2
74
+
75
+ out_dir = Path(args.output_dir) if args.output_dir else default_output_dir()
76
+ out_dir.mkdir(parents=True, exist_ok=True)
77
+
78
+ dates = [d.strip() for d in args.dates.split(",") if d.strip()]
79
+ routes = [tuple(r.strip().split("-")) for r in args.routes.split(",") if r.strip()]
80
+
81
+ total = 0
82
+ for dep, arr in routes:
83
+ for date in dates:
84
+ fname = out_dir / f"{dep}_{arr}_{date}.json"
85
+ try:
86
+ size = fetch_one(api_key, dep, arr, date, fname,
87
+ airlines=args.airlines, stops=args.stops)
88
+ print(f" {dep}->{arr} {date}: {size} bytes", file=sys.stderr)
89
+ total += 1
90
+ except Exception as e:
91
+ print(f" {dep}->{arr} {date}: FAILED ({e})", file=sys.stderr)
92
+
93
+ print(f"Fetched {total} files to:", file=sys.stderr)
94
+ print(out_dir) # stdout = the path, for piping
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ sys.exit(main())
@@ -0,0 +1,265 @@
1
+ """
2
+ Filter and rank flights from saved SerpAPI JSON files.
3
+
4
+ Reads JSON files written by fetch_flights.py, applies filters (price, time
5
+ window, route, airlines, nonstop), ranks results, and outputs a JSON list to
6
+ stdout. Filters can be marked "soft" via --soft-filters — soft filters include
7
+ non-matching flights but flag them with `soft_filter_violations` and rank them
8
+ lower.
9
+
10
+ With --cluster-analysis, prints a constraint-impact report instead of
11
+ filtering: shows which constraint(s) eliminated which flight counts, plus a
12
+ price distribution. The skill uses this to propose specific relaxations when
13
+ hard filters return zero results.
14
+
15
+ Usage:
16
+ python filter_flights.py --json-dir /tmp/flight-planner/ --max-price 200 --from IND --to EWR
17
+ python filter_flights.py --json-dir /tmp/flight-planner/ --max-price 200 \\
18
+ --soft-filters max-price,time-priority
19
+ python filter_flights.py --json-dir /tmp/flight-planner/ --cluster-analysis
20
+
21
+ All filter args (--max-price, --time-priority, --from, --to, --airlines, --nonstop)
22
+ are required for normal filter mode (no defaults — caller passes config values).
23
+ Cluster-analysis mode echoes back impact of each provided filter.
24
+ """
25
+ import argparse
26
+ import glob
27
+ import json
28
+ import sys
29
+ import tempfile
30
+ from pathlib import Path
31
+
32
+
33
+ SOFT_FILTER_NAMES = {"max-price", "time-priority", "airlines", "nonstop", "route"}
34
+
35
+
36
+ def parse_band(b: str):
37
+ a, c = b.split("-")
38
+ return a.strip(), c.strip()
39
+
40
+
41
+ def in_band(t: str, lo: str, hi: str) -> bool:
42
+ return lo <= t <= hi
43
+
44
+
45
+ def parse_set(csv: str):
46
+ if not csv:
47
+ return set()
48
+ return {x.strip() for x in csv.split(",") if x.strip()}
49
+
50
+
51
+ def parse_bands(csv: str):
52
+ if not csv:
53
+ return []
54
+ return [parse_band(b) for b in csv.split(",") if b.strip()]
55
+
56
+
57
+ def load_flights(json_dir: Path):
58
+ flights = []
59
+ for fname in sorted(glob.glob(str(json_dir / "*.json"))):
60
+ parts = Path(fname).stem.split("_")
61
+ if len(parts) < 3:
62
+ continue
63
+ dep, arr = parts[0], parts[1]
64
+ date = "_".join(parts[2:])
65
+ try:
66
+ with open(fname, encoding="utf-8") as f:
67
+ data = json.load(f)
68
+ except Exception:
69
+ continue
70
+ for fg in data.get("best_flights", []) + data.get("other_flights", []):
71
+ legs = fg.get("flights", [])
72
+ if not legs:
73
+ continue
74
+ leg = legs[0]
75
+ last_leg = legs[-1]
76
+ dep_dt = leg.get("departure_airport", {}).get("time", "")
77
+ arr_dt = last_leg.get("arrival_airport", {}).get("time", "")
78
+ airline_code = leg.get("airline", "") or ""
79
+ airline_iata = (leg.get("airline_logo", "") or "")[-6:-4] if "airline_logo" in leg else ""
80
+ flights.append({
81
+ "date": date, "from": dep, "to": arr,
82
+ "flight": leg.get("flight_number", ""),
83
+ "airline": airline_code,
84
+ "airline_iata": (leg.get("flight_number", "") or "").split()[0] if leg.get("flight_number") else "",
85
+ "departs": dep_dt[-5:] if dep_dt else "",
86
+ "arrives": arr_dt[-5:] if arr_dt else "",
87
+ "departs_full": dep_dt, "arrives_full": arr_dt,
88
+ "price": fg.get("price"),
89
+ "duration_min": fg.get("total_duration", 0),
90
+ "aircraft": leg.get("airplane", ""),
91
+ "delayed": fg.get("often_delayed_by_over_30_min", False),
92
+ "stops": max(0, len(legs) - 1),
93
+ })
94
+ return flights
95
+
96
+
97
+ def violations_for(f, max_price, bands, origins, dests, airlines, nonstop_required):
98
+ """Return a list of filter names this flight violates, or [] if it passes all."""
99
+ v = []
100
+ if origins and f["from"] not in origins:
101
+ v.append("route")
102
+ if dests and f["to"] not in dests:
103
+ if "route" not in v:
104
+ v.append("route")
105
+ if max_price is not None and (f["price"] is None or f["price"] > max_price):
106
+ v.append("max-price")
107
+ if bands:
108
+ if not f["departs"]:
109
+ v.append("time-priority")
110
+ else:
111
+ in_any = any(in_band(f["departs"], lo, hi) for lo, hi in bands)
112
+ if not in_any:
113
+ v.append("time-priority")
114
+ if airlines and f.get("airline_iata") and f["airline_iata"] not in airlines:
115
+ v.append("airlines")
116
+ if nonstop_required and f.get("stops", 0) > 0:
117
+ v.append("nonstop")
118
+ return v
119
+
120
+
121
+ def priority_band(f, bands):
122
+ """Return index of first band the departure time falls into, or len(bands) if outside all."""
123
+ if not bands or not f["departs"]:
124
+ return 0
125
+ for i, (lo, hi) in enumerate(bands):
126
+ if in_band(f["departs"], lo, hi):
127
+ return i
128
+ return len(bands)
129
+
130
+
131
+ def cluster_analysis(flights, max_price, bands, origins, dests, airlines, nonstop_required):
132
+ """Report which constraint eliminated which flight counts + price distribution."""
133
+ prices = sorted({f["price"] for f in flights if f["price"] is not None})
134
+
135
+ # Per-constraint impact: how many would be eliminated by this filter alone
136
+ constraint_impact = {}
137
+ for name in ("route", "max-price", "time-priority", "airlines", "nonstop"):
138
+ eliminated = 0
139
+ for f in flights:
140
+ v = violations_for(f, max_price, bands, origins, dests, airlines, nonstop_required)
141
+ if name in v:
142
+ eliminated += 1
143
+ constraint_impact[name] = {
144
+ "eliminated": eliminated,
145
+ "of_total": len(flights),
146
+ }
147
+
148
+ # All-filters combined: how many pass everything
149
+ passing_all = sum(
150
+ 1 for f in flights
151
+ if not violations_for(f, max_price, bands, origins, dests, airlines, nonstop_required)
152
+ )
153
+
154
+ # Price distribution + natural gaps
155
+ gaps = []
156
+ if prices:
157
+ for i in range(1, len(prices)):
158
+ gap = prices[i] - prices[i - 1]
159
+ if gap >= 20:
160
+ gaps.append({"after": prices[i - 1], "before": prices[i], "gap": gap})
161
+
162
+ recommended_budgets = []
163
+ if prices:
164
+ if gaps:
165
+ recommended_budgets.append(gaps[0]["after"])
166
+ if len(gaps) > 1:
167
+ recommended_budgets.append(gaps[1]["after"])
168
+ else:
169
+ recommended_budgets.append(prices[len(prices) // 2])
170
+
171
+ return {
172
+ "total_flights": len(flights),
173
+ "passing_all_filters": passing_all,
174
+ "constraint_impact": constraint_impact,
175
+ "price_distribution": {
176
+ "min": prices[0] if prices else None,
177
+ "max": prices[-1] if prices else None,
178
+ "unique_prices": len(prices),
179
+ "natural_gaps": gaps[:5],
180
+ "recommended_budgets": recommended_budgets,
181
+ },
182
+ "applied_filters": {
183
+ "max_price": max_price,
184
+ "time_priority_bands": [f"{a}-{b}" for a, b in bands],
185
+ "origins": sorted(origins) if origins else None,
186
+ "destinations": sorted(dests) if dests else None,
187
+ "airlines": sorted(airlines) if airlines else None,
188
+ "nonstop_required": nonstop_required,
189
+ },
190
+ }
191
+
192
+
193
+ def main() -> int:
194
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
195
+ default_dir = Path(tempfile.gettempdir()) / "flight-planner"
196
+ p.add_argument("--json-dir", default=str(default_dir))
197
+ p.add_argument("--max-price", type=int, default=None,
198
+ help="Max price in USD. Omit for no price filter.")
199
+ p.add_argument("--time-priority", default="",
200
+ help="Comma-separated priority bands HH:MM-HH:MM (e.g. 11:00-14:00,14:00-22:00). Empty = no time filter.")
201
+ p.add_argument("--from", dest="origins", default="",
202
+ help="Comma-separated origin IATA codes. Empty = any origin.")
203
+ p.add_argument("--to", dest="dests", default="",
204
+ help="Comma-separated destination IATA codes. Empty = any destination.")
205
+ p.add_argument("--airlines", default="",
206
+ help="Comma-separated airline IATA codes (e.g. UA,DL). Empty = any airline.")
207
+ p.add_argument("--nonstop", action="store_true",
208
+ help="Require nonstop only (stops==0).")
209
+ p.add_argument("--soft-filters", default="",
210
+ help="Comma-separated filter names treated as soft (rank, don't exclude). "
211
+ "Valid: max-price, time-priority, airlines, nonstop, route.")
212
+ p.add_argument("--cluster-analysis", action="store_true",
213
+ help="Print constraint-impact + price distribution report instead of filtering.")
214
+ args = p.parse_args()
215
+
216
+ json_dir = Path(args.json_dir)
217
+ if not json_dir.exists():
218
+ print(f"ERROR: dir not found: {json_dir}", file=sys.stderr)
219
+ return 2
220
+
221
+ flights = load_flights(json_dir)
222
+ if not flights:
223
+ print(f"ERROR: no flights parsed from {json_dir}", file=sys.stderr)
224
+ return 2
225
+
226
+ origins = parse_set(args.origins)
227
+ dests = parse_set(args.dests)
228
+ airlines = parse_set(args.airlines)
229
+ bands = parse_bands(args.time_priority)
230
+ soft = parse_set(args.soft_filters)
231
+ bad_soft = soft - SOFT_FILTER_NAMES
232
+ if bad_soft:
233
+ print(f"ERROR: unknown --soft-filters values: {sorted(bad_soft)}. "
234
+ f"Valid: {sorted(SOFT_FILTER_NAMES)}", file=sys.stderr)
235
+ return 2
236
+
237
+ if args.cluster_analysis:
238
+ report = cluster_analysis(flights, args.max_price, bands, origins, dests,
239
+ airlines, args.nonstop)
240
+ print(json.dumps(report, indent=2))
241
+ return 0
242
+
243
+ out = []
244
+ for f in flights:
245
+ v = violations_for(f, args.max_price, bands, origins, dests, airlines, args.nonstop)
246
+ hard_v = [name for name in v if name not in soft]
247
+ if hard_v:
248
+ continue # excluded by a hard filter
249
+ f["priority_band"] = priority_band(f, bands)
250
+ f["soft_filter_violations"] = v # may be empty
251
+ out.append(f)
252
+
253
+ # Sort: # of soft violations asc, then priority band, then price, then departs
254
+ out.sort(key=lambda f: (
255
+ len(f.get("soft_filter_violations", [])),
256
+ f.get("priority_band", 0),
257
+ f.get("price") if f.get("price") is not None else 10**9,
258
+ f.get("departs", ""),
259
+ ))
260
+ print(json.dumps(out, indent=2))
261
+ return 0
262
+
263
+
264
+ if __name__ == "__main__":
265
+ sys.exit(main())