elliot-stack 1.0.29 → 1.0.33
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.
- package/LICENSE +21 -21
- package/README.md +5 -0
- package/bin/install.cjs +981 -950
- package/hooks/repo-search-nudge.js +32 -32
- package/package.json +1 -1
- package/skills/estack-active-learning-tutor/SKILL.md +339 -339
- package/skills/estack-better-title/SKILL.md +64 -64
- package/skills/estack-better-title/scripts/rename.sh +55 -55
- package/skills/estack-chris-voss/SKILL.md +80 -80
- package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
- package/skills/estack-chris-voss/references/voss-principles.md +210 -210
- package/skills/estack-customer-discovery/SKILL.md +60 -60
- package/skills/estack-flight-planner/SKILL.md +332 -332
- package/skills/estack-flight-planner/references/config_schema.md +156 -156
- package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
- package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
- package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
- package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
- package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
- package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
- package/skills/estack-github-issue-tracker/SKILL.md +322 -322
- package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
- package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
- package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
- package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
- package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
- package/skills/estack-leadership-coach/SKILL.md +235 -0
- package/skills/estack-leadership-coach/adding-references.md +280 -0
- package/skills/estack-leadership-coach/frameworks/delegation/flows/post-mortem.md +120 -0
- package/skills/estack-leadership-coach/frameworks/delegation/flows/pre-delegation.md +138 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/1-intake.md +145 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/2-trm-assessment.md +119 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/3-enrollment.md +132 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/4-build-brief.md +171 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/5-monitoring.md +134 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/6-reverse-delegation.md +118 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/7-diagnose.md +200 -0
- package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__deci-olafsen-ryan-2017-self-determination-theory-in-work-organizations.md +1881 -0
- package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__gagne-deci-2005-self-determination-theory-and-work-motivation.md +2058 -0
- package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__selfdeterminationtheory-org-theory-overview-page.md +61 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-3-key-insights-into-the-global-workplace-2024.md +57 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-managers-account-for-70-percent-of-variance-in-employee-engagement-2015.md +40 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-global-data-summary.md +73 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-report-landing.md +42 -0
- package/skills/estack-leadership-coach/references/.source-files/hormozi-leila_4-stages__leila-hormozi-the-art-of-delegation-blog-post.md +91 -0
- package/skills/estack-leadership-coach/references/.source-files/oncken-wass_monkeys-hbr-1974__oncken-wass-management-time-whos-got-the-monkey-hbr-classic-1974.md +969 -0
- package/skills/estack-leadership-coach/references/.source-files/sanchez_main-street-millionaire__codie-sanchez-afford-anything-podcast-ep-565-show-notes.md +89 -0
- package/skills/estack-leadership-coach/references/.source-files/sullivan_who-not-how__dan-sullivan-impact-filter-tool-and-guide-booklet.md +565 -0
- package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-lewis-howes-school-of-greatness-ep-1231-show-notes.md +122 -0
- package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-roger-dooley-cues-interview.md +194 -0
- package/skills/estack-leadership-coach/references/deci-ryan_self-determination-theory.md +166 -0
- package/skills/estack-leadership-coach/references/doerr_measure-what-matters.md +154 -0
- package/skills/estack-leadership-coach/references/ferriss_4hww.md +189 -0
- package/skills/estack-leadership-coach/references/gallup_engagement-research.md +105 -0
- package/skills/estack-leadership-coach/references/gerber_e-myth-revisited.md +118 -0
- package/skills/estack-leadership-coach/references/grove_high-output-management.md +95 -0
- package/skills/estack-leadership-coach/references/hormozi-alex_followthrough.md +152 -0
- package/skills/estack-leadership-coach/references/hormozi-leila_4-stages.md +146 -0
- package/skills/estack-leadership-coach/references/oncken-wass_monkeys-hbr-1974.md +128 -0
- package/skills/estack-leadership-coach/references/sanchez_main-street-millionaire.md +196 -0
- package/skills/estack-leadership-coach/references/sullivan_who-not-how.md +137 -0
- package/skills/estack-leadership-coach/references/van-edwards_cues.md +189 -0
- package/skills/estack-migrate-claude-session-history/SKILL.md +226 -0
- package/skills/estack-migrate-claude-session-history/references/path-encoding.md +55 -0
- package/skills/estack-migrate-claude-session-history/references/troubleshooting.md +96 -0
- package/skills/estack-migrate-claude-session-history/scripts/migrate-claude-history.js +1123 -0
- package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +48 -0
- package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +326 -0
- package/skills/estack-migrate-claude-session-history/scripts/validate-migration.py +493 -0
- package/skills/estack-pdf-to-md/SKILL.md +180 -0
- package/skills/estack-pdf-to-md/scripts/pdf_to_md.py +596 -0
- package/skills/estack-productivity-prioritization-coach/SKILL.md +124 -0
- package/skills/estack-productivity-prioritization-coach/sources/01-tony-robbins-rpm.md +39 -0
- package/skills/estack-productivity-prioritization-coach/sources/02-justin-sung-task-prioritization.md +34 -0
- package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
- package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
- package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
- package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
- package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
- package/skills/estack-read-claude-session-history/SKILL.md +204 -204
- package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
- package/skills/estack-read-claude-session-history/references/modes.md +423 -423
- package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
- package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
- package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
- package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
- package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
- package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
- package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
- package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
- package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
- package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
- package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
- package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
- package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -80
- package/skills/estack-repo-search/SKILL.md +65 -65
- package/skills/estack-vscode-file-recovery/SKILL.md +188 -0
|
@@ -1,265 +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/estack-flight-planner/ --max-price 200 --from IND --to EWR
|
|
17
|
-
python filter_flights.py --json-dir /tmp/estack-flight-planner/ --max-price 200 \\
|
|
18
|
-
--soft-filters max-price,time-priority
|
|
19
|
-
python filter_flights.py --json-dir /tmp/estack-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()) / "estack-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())
|
|
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/estack-flight-planner/ --max-price 200 --from IND --to EWR
|
|
17
|
+
python filter_flights.py --json-dir /tmp/estack-flight-planner/ --max-price 200 \\
|
|
18
|
+
--soft-filters max-price,time-priority
|
|
19
|
+
python filter_flights.py --json-dir /tmp/estack-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()) / "estack-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())
|