mzidan000 commited on
Commit
4d052ec
·
verified ·
1 Parent(s): 342f056

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. README.md +169 -46
  2. app.py +54 -1
  3. matchday/render.py +7 -2
  4. matchday/trip_tool.py +87 -1
  5. matchday/wc2026.py +240 -0
README.md CHANGED
@@ -1,7 +1,7 @@
1
  ---
2
  title: MatchDay
3
  emoji: ⚽
4
- colorFrom: blue
5
  colorTo: green
6
  sdk: gradio
7
  app_file: app.py
@@ -12,70 +12,193 @@ tags:
12
  - backyard-ai
13
  - agents
14
  - react-agent
 
 
15
  - tool-use
 
 
16
  - nemotron
17
  - nvidia
 
 
18
  - modal
 
 
19
  - gradio
 
 
20
  - fifa-world-cup-2026
21
- - travel
 
 
 
22
  models:
23
  - nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16
24
  ---
25
 
26
- # MatchDay ⚽ — 2026 FIFA World Cup, Vancouver
27
 
28
- MatchDay is a Layla.ai-style **travel-intelligence agent** with one job: get you
29
- to a 2026 FIFA World Cup match in Vancouver with the **cheapest flight, the
30
- safest arrival, and a hotel closest to BC Place — explained, not just listed.**
31
 
32
- Say *"Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29"* and
33
- MatchDay's agent builds **3 ranked, scored packages** (flights · hotels ·
34
- weather · what's near the stadium) on an interactive Leaflet map, each price
35
- tagged with **honest provenance** `● live` vs `example` so nothing is
36
- hallucinated.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  ## How it works — Brain + Hands
39
- - **Brain:** **NVIDIA Nemotron-3-Nano-30B-A3B-BF16** — a 30B-total / **3B-active**
40
- Mixture-of-Experts model, served on **Modal A100** via SGLang. It selects tools,
41
- reasons, and writes the explanations. **It never calls an API or names a price.**
42
- - **Hands:** deterministic Python calls the APIs (flights, hotels, weather, POIs),
43
- scores packages with a fixed cost / arrival-buffer / stadium-proximity formula,
44
- and attaches provenance to every value.
45
- - **Loop:** a bounded ReAct agent (≤5 tool rounds). Nemotron decides the sequence,
46
- the hands execute, results return Nemotron-3-Nano emits structured tool calls
47
- via SGLang's `qwen3_coder` + `nemotron_3` parsers.
48
-
49
- ## Why this is "small"
50
- Nemotron-3-Nano-30B is a MoE — only **~3B parameters activate per token**, so the
51
- reasoning path is genuinely lean. Heavy 30B inference runs **remotely on Modal**
52
- (sanctioned hackathon compute); the Gradio Space itself stays lightweight.
53
-
54
- ## Tech
55
- Nemotron-3-Nano-30B-A3B (3B-active MoE) · Modal A100-80GB (SGLang v0.5.12,
56
- `qwen3_coder`+`nemotron_3`) · **gradio.Server** custom frontend (streaming chat +
57
- Leaflet map + day-by-day timeline — not stock Gradio) · SerpApi (Google
58
- Flights/Hotels/Search) · Open-Meteo · OpenStreetMap · httpx/Pydantic v2.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  ## Try it
61
- - **Live Space:** https://huggingface.co/spaces/build-small-hackathon/matchday
62
- - **Agent trace (Sharing-is-Caring):** https://huggingface.co/datasets/build-small-hackathon/matchday-agent-traces
 
63
  - **Field Notes (architecture story):** `matchday/FIELD_NOTES.md`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  ## Built for Build Small
66
- **Track: Backyard AI.** Sponsor tools used: **Nemotron-3-Nano-30B (NVIDIA)** +
67
- **Modal** (noted here per the Modal-prize requirement; Modal A100 is the runtime).
68
-
69
- Targeting:
70
- - 🟩 **NVIDIA Nemotron** — Nemotron-3-Nano-30B-A3B is the Brain.
71
- - 🟢 **Modal** ($10k/7k/3k) — Modal A100 serves the model.
72
- - 🤖 **Best Agent** — bounded multi-step tool use, ≤32B.
73
- - 🎨 **Off-Brand** — `gradio.Server` custom UI well beyond stock Gradio.
74
- - 📡 **Sharing-is-Caring** — agent trace on the Hub (link above).
75
- - 📓 **Field Notes** — architecture blog (`matchday/FIELD_NOTES.md`).
76
- - 🏆 **Bonus Quest Champion** + 🎬 **Best Demo** + 🗳️ **Community Choice**.
77
 
78
  ## Social
79
- **Post:** <paste your social post URL here> (REQ-04 — link your post, then redeploy).
80
- A ready-to-post draft is in `matchday/SOCIAL_POST.md`.
81
 
 
 
 
1
  ---
2
  title: MatchDay
3
  emoji: ⚽
4
+ colorFrom: indigo
5
  colorTo: green
6
  sdk: gradio
7
  app_file: app.py
 
12
  - backyard-ai
13
  - agents
14
  - react-agent
15
+ - agentic
16
+ - agent-loop
17
  - tool-use
18
+ - tool-calling
19
+ - multi-step-planning
20
  - nemotron
21
  - nvidia
22
+ - nvidia-nemotron
23
+ - nemotron-3-nano
24
  - modal
25
+ - modal-labs
26
+ - sglang
27
  - gradio
28
+ - gradio-server
29
+ - off-brand
30
  - fifa-world-cup-2026
31
+ - vancouver
32
+ - travel-planning
33
+ - trip-planner
34
+ - leaflet
35
  models:
36
  - nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16
37
  ---
38
 
39
+ # MatchDay ⚽ — your 2026 FIFA World Cup trip, planned by a small-model agent
40
 
41
+ > **A Backyard AI app for a real Vancouver World Cup use case: helping a fan plan
42
+ > one match-day trip with small-model reasoning, Gradio polish, and safe manual
43
+ > booking links.**
44
 
45
+ Type one sentence — *"Flying from Montreal, want Canada vs Qatar, mid-range,
46
+ June 26-29, just me"* and MatchDay's agent **grounds your request in the real
47
+ schedule, searches live flights + hotels + weather, ranks 3 packages, and
48
+ explains why each one won.** Every price is tagged `● live` or `example` so
49
+ nothing is hallucinated, and every booking link is a safe **search** (never a
50
+ fake "confirmed booking").
51
+
52
+ ## The idea
53
+
54
+ The 2026 World Cup is in Vancouver. Fans have one chaotic question — *"how do I
55
+ actually get to one match?"* — and existing tools split the answer across five
56
+ tabs (flights here, hotels there, weather somewhere else). MatchDay collapses it
57
+ into a single agent turn: **understand intent → ground it against the real
58
+ fixture list → call live data tools → rank → explain.** It's a focused, backyard
59
+ trip planner that treats a small model as a genuine decision-maker, not a chatbot.
60
+
61
+ The standout agent behavior: **MatchDay corrects you when you're wrong and
62
+ refuses to plan when a match doesn't exist.** Ask for *"Canada vs Qatar, June
63
+ 26"* and it tells you the real match is **June 18 at BC Place, 12:00 PM PT** and
64
+ re-plans around it. Ask for *"Canada vs Morocco"* and it won't pretend — that
65
+ match doesn't exist, so it offers the real alternatives instead. That grounding
66
+ is the difference between an agent and a form.
67
 
68
  ## How it works — Brain + Hands
69
+
70
+ - **🧠 Brain (decides + explains):** **NVIDIA Nemotron-3-Nano-30B-A3B** a
71
+ 30B-total / **3B-active** Mixture-of-Experts model served on **Modal A100**
72
+ via **SGLang**. It reads the request, picks tools, reasons about results, and
73
+ writes the final comparison. **It never calls an API, fetches a URL, or states
74
+ a price itself.**
75
+ - ** Hands (execute + score):** deterministic **Python** calls every API
76
+ (flights, hotels, weather, nearby spots), fans them out concurrently, and
77
+ scores each package with a fixed formula (cost / arrival-buffer /
78
+ stadium-proximity). Every value gets a provenance badge.
79
+ - **🔁 Loop:** a bounded ReAct agent loop (**≤5 tool rounds**) with an allowlist,
80
+ Pydantic argument validation, duplicate-call detection, one self-correction
81
+ pass, per-tool timeouts, and an honest deterministic fallback. Nemotron emits
82
+ structured tool calls via SGLang's `qwen3_coder` + `nemotron_3` parsers.
83
+
84
+ ## 🤖 Best Agent — multi-step tool use & planning (under the 32B cap)
85
+
86
+ This is the category we care about most, so here's exactly what makes MatchDay
87
+ an agent and not a pipeline:
88
+
89
+ - **3 tools, picked autonomously:** `build_trip_packages` (the data/scoring tool),
90
+ `web_search` (factual grounding — kickoff times, venue policy), and `clarify`
91
+ (ask one question when origin/date is genuinely missing).
92
+ - **Genuine multi-step turns:** Nemotron can `web_search` to ground a fact, read
93
+ the result, *then* call `build_trip_packages` with corrected understanding —
94
+ results threaded back into the conversation between rounds. Happy path is 2-3
95
+ rounds; the ceiling is 5 (`matchday/agent_loop.py`).
96
+ - **Schedule grounding before planning** (`matchday/wc2026.py`): a verified
97
+ fixture table is the ground truth. The agent re-centers the trip on the *real*
98
+ match date (preserving the user's nights) and refuses nonexistent matchups
99
+ with honest alternatives — proven by `tests/test_wc2026_grounding.py`
100
+ (6/6 zero-network checks: Canada vs Qatar → Jun 18 / 12:00 PT / 3 nights;
101
+ Brazil vs Germany and Canada vs Morocco refused).
102
+ - **Guardrails that keep it honest:** tool allowlist, Pydantic arg validation,
103
+ duplicate suppression, one malformed-call correction, timeouts, and a
104
+ user-visible fallback to deterministic parsing when Modal is cold-starting.
105
+ - **Brain + Hands separation:** the model decides and explains; Python executes
106
+ every external call and scores every price — so the model can't hallucinate a
107
+ flight number or invent a rate.
108
+
109
+ Nemotron-3-Nano-30B is **30B total parameters < the 32B cap.**
110
+
111
+ ## 🎨 Off-Brand — a custom UI on `gradio.Server`, well past stock Gradio
112
+
113
+ MatchDay does **not** use stock Gradio components. It runs on **`gradio.Server`**
114
+ (`app.py`), which serves a fully bespoke `index.html` frontend at `/` while a
115
+ single `@app.api("plan_trip")` async generator streams typed JSON events through
116
+ Gradio's queue (SSE) — so the UI updates live as the agent decides → Python
117
+ scores → Nemotron explains. `gr.Server` gives us Gradio's backend (queuing,
118
+ concurrency, Spaces hosting) under a hand-built product UI:
119
+
120
+ - Layla-style **photo-header package cards** with overlaid price + "★ Best match".
121
+ - **Provenance pills** on every figure (`● live` vs `example`) — the
122
+ anti-hallucination differentiator, visible right in the card.
123
+ - An interactive **Leaflet map** (stadium + hotels + POIs, hotel→stadium lines,
124
+ full-screen toggle) built in `matchday/render.py`.
125
+ - A **day-by-day itinerary** with unique, date-aware roles (arrival / match day /
126
+ local explore / departure) and a live **agent progress panel**.
127
+ - Per-option **action buttons**: a real flight/hotel **search** and
128
+ trip-specific **transit directions** (always with explicit origins) — never an
129
+ over-claiming "Book" button.
130
+
131
+ ## 🟢 NVIDIA Nemotron Quest — Nemotron is the Brain
132
+
133
+ - **Model:** `nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16` (30B MoE, ~3B active/token).
134
+ - **Served with SGLang** (`matchday/modal_spike.py`) using the NVIDIA-card
135
+ recommended tool-calling config: `--tool-call-parser qwen3_coder` +
136
+ `--reasoning-parser nemotron_3` + `--attention-backend flashinfer`. Verified
137
+ live that SGLang returns *parsed* `tool_calls` (not raw text) — the whole
138
+ Brain+Hands design depends on it. See `matchday/NEMOTRON_SGLANG_VERIFICATION.md`.
139
+ - **Reasoning mode:** Nemotron-3-Nano's thinking toggle (`enable_thinking`) is
140
+ wired end-to-end (`modal_spike.generate` → `matchday.agent.MatchDayAgent` →
141
+ `app.py`) per the official Nemotron usage guide. Enable on the Space with
142
+ `MATCHDAY_THINKING=1` to run the decide/ground/explain turns with
143
+ chain-of-thought reasoning.
144
+ - **Sampling** follows the model card: `temperature=0.6 / top_p=0.95` for tool
145
+ routing, reasoning on for complex planning.
146
+
147
+ ## 🟣 Modal — the runtime & inference layer
148
+
149
+ - Nemotron runs **remotely on Modal** (`modal.App("matchday-spike")`) on an
150
+ **A100-80GB** via a containerized SGLang server (`matchday/modal_spike.py`).
151
+ - The Gradio Space calls it with `modal.Cls.from_name(...).generate.remote.aio`
152
+ — the Space stays lightweight while the heavy 30B inference happens on
153
+ sanctioned Modal GPU compute.
154
+ - **Cold-start engineering:** a 60GB-model HF cache **Volume** (warm reload
155
+ ~1-2 GB/s vs re-download), `startup_timeout=120 min` for first load, a
156
+ server-side `warmup()`, and a Space-boot `_warm_nemotron()` task so the first
157
+ user query isn't stuck behind a cold start.
158
+
159
+ ## Tech stack
160
+
161
+ Nemotron-3-Nano-30B-A3B (3B-active MoE) · Modal A100-80GB + SGLang v0.5.12
162
+ (`qwen3_coder` + `nemotron_3`) · **gradio.Server** bespoke frontend · SerpApi
163
+ (Google Flights / Hotels / Search) · Open-Meteo (weather) · OpenStreetMap/Overpass
164
+ (nearby spots) · Leaflet + CARTO map · httpx + Pydantic v2 · Python 3.11.
165
 
166
  ## Try it
167
+
168
+ - **Live app:** https://build-small-hackathon-matchday.hf.space
169
+ - **Space:** https://huggingface.co/spaces/build-small-hackathon/matchday
170
  - **Field Notes (architecture story):** `matchday/FIELD_NOTES.md`
171
+ - **Nemotron + SGLang verification:** `matchday/NEMOTRON_SGLANG_VERIFICATION.md`
172
+
173
+ **Example queries to try:**
174
+ 1. *Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me* → watch it correct the date to **June 18**.
175
+ 2. *Toronto to see Brazil vs Germany, premium, July 12, 2 adults* → watch it **refuse** a nonexistent match honestly.
176
+ 3. *From Halifax, Canada vs Morocco, June 18, couple, luxury* → refused with real Group B alternatives.
177
+
178
+ ## Prizes we're competing for
179
+
180
+ | Prize | Why MatchDay qualifies |
181
+ | --- | --- |
182
+ | 🤖 **Best Agent** | Bounded ReAct loop (≤5 rounds), 3 tools chosen autonomously, genuine multi-step turns (search → build), schedule grounding + honest refusal, guardrails. 30B < 32B. |
183
+ | 🎨 **Off-Brand** | Bespoke Layla-style UI on `gradio.Server` — custom HTML/CSS/JS, photo cards, Leaflet map, provenance pills. Not stock Gradio. |
184
+ | 🟢 **NVIDIA Nemotron Quest** | Nemotron-3-Nano-30B is the Brain; SGLang tool-calling verified live; reasoning mode wired. |
185
+ | 🟣 **Modal** | A100 inference runtime, documented above (`matchday/modal_spike.py`). |
186
+ | 🎬 **Best Demo** | App + demo script (`matchday/DEMO_VIDEO_SCRIPT.md`) + social post (`matchday/SOCIAL_POST.md`). |
187
+ | 🏆 **Bonus Quest Champion** | Nemotron + Modal + Gradio + agent + custom UI, all in one focused app. |
188
+ | 🗳️ **Judges' Wildcard** | A genuinely useful, honest, small-model trip planner that corrects its user. |
189
+
190
+ > **Honest note on Tiny Titan:** we are **not** claiming Tiny Titan. That prize
191
+ > requires a model of ≤4B parameters; Nemotron-3-Nano-30B is a 30B-total MoE
192
+ > (only ~3B *active* per token, but 30B total weights). We'd rather flag this
193
+ > than over-claim.
194
 
195
  ## Built for Build Small
196
+
197
+ **Track: Backyard AI** a focused, real-world Vancouver World Cup use case.
198
+ Sponsor tools used: **NVIDIA Nemotron-3-Nano-30B** (the Brain) + **Modal A100**
199
+ (the runtime) + **Gradio `gradio.Server`** (the Off-Brand UI).
 
 
 
 
 
 
 
200
 
201
  ## Social
 
 
202
 
203
+ **Post:** _<paste your social post URL here, then redeploy>_ — a ready-to-post
204
+ draft is in `matchday/SOCIAL_POST.md`.
app.py CHANGED
@@ -187,7 +187,14 @@ async def plan_trip(user_text: str) -> str:
187
  agent = None
188
  if USE_AGENT:
189
  try:
190
- agent = MatchDayAgent()
 
 
 
 
 
 
 
191
  except Exception as exc:
192
  logger.warning("agent init failed (%s); deterministic path.", exc)
193
 
@@ -222,6 +229,23 @@ async def plan_trip(user_text: str) -> str:
222
  if res.type == "tool_called" and res.tool == "build_trip_packages":
223
  result = res.result.get("full_result")
224
  trip = res.result.get("trip")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  break
226
 
227
  if res.type == "tool_called" and res.tool == "web_search":
@@ -263,6 +287,14 @@ async def plan_trip(user_text: str) -> str:
263
  )
264
  break
265
 
 
 
 
 
 
 
 
 
266
  # ── Deterministic fallback (K3): parse intent + build directly. Used when
267
  # the agent is unavailable, hedged to a non-build answer, or the loop failed.
268
  if result is None and not agent_text:
@@ -290,6 +322,27 @@ async def plan_trip(user_text: str) -> str:
290
  except Exception as exc:
291
  yield _ev(type="error", text=f"⚠️ {exc}")
292
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  else:
294
  yield _ev(type="progress", step="ready", status="fallback", text="Need a detail from you")
295
  yield _ev(
 
187
  agent = None
188
  if USE_AGENT:
189
  try:
190
+ # Nemotron reasoning toggle (NVIDIA Nemotron Quest + Best Agent): the
191
+ # official Nemotron-3-Nano usage guide serves complex planning turns
192
+ # with thinking ON (chain-of-thought before the tool call). Default
193
+ # OFF to preserve the verified fast tool-routing path; set
194
+ # MATCHDAY_THINKING=1 on the Space to turn on reasoning for the
195
+ # agent's decide/ground/explain turns.
196
+ thinking = os.environ.get("MATCHDAY_THINKING", "").lower() in ("1", "true", "yes")
197
+ agent = MatchDayAgent(thinking=thinking)
198
  except Exception as exc:
199
  logger.warning("agent init failed (%s); deterministic path.", exc)
200
 
 
229
  if res.type == "tool_called" and res.tool == "build_trip_packages":
230
  result = res.result.get("full_result")
231
  trip = res.result.get("trip")
232
+ # Sync the display trip to the GROUNDED dates + canonical match
233
+ # name (the match was re-centered on the real WC fixture inside
234
+ # the tool), and surface any correction note to the user.
235
+ if result is not None and getattr(result, "grounded_match_date", None) and trip is not None:
236
+ upd = {
237
+ "match_date": result.grounded_match_date,
238
+ "check_in": result.grounded_check_in,
239
+ "check_out": result.grounded_check_out,
240
+ }
241
+ if getattr(result, "grounded_match_name", ""):
242
+ upd["match_name"] = result.grounded_match_name
243
+ try:
244
+ trip = trip.model_copy(update=upd)
245
+ except Exception:
246
+ pass
247
+ if result is not None and getattr(result, "grounding_note", ""):
248
+ yield _ev(type="commentary", text="📅 " + result.grounding_note)
249
  break
250
 
251
  if res.type == "tool_called" and res.tool == "web_search":
 
287
  )
288
  break
289
 
290
+ # If the agent's build already flagged an unrecognized match, surface it as a
291
+ # clarification with real alternatives (Best-Agent honesty: never silently
292
+ # plan a trip around a nonexistent fixture like "Canada vs Morocco").
293
+ if result is not None and getattr(result, "match_unrecognized", ""):
294
+ yield _ev(type="progress", step="ready", status="fallback", text="Match not found")
295
+ yield _ev(type="clarify", text=result.match_unrecognized)
296
+ return
297
+
298
  # ── Deterministic fallback (K3): parse intent + build directly. Used when
299
  # the agent is unavailable, hedged to a non-build answer, or the loop failed.
300
  if result is None and not agent_text:
 
322
  except Exception as exc:
323
  yield _ev(type="error", text=f"⚠️ {exc}")
324
  return
325
+ # Sync the display trip to the GROUNDED dates so greenlight +
326
+ # itinerary match the packages (match was re-centered on the real
327
+ # WC fixture inside build_trip_packages). Honesty: show the note.
328
+ if getattr(result, "grounded_match_date", None):
329
+ upd = {
330
+ "match_date": result.grounded_match_date,
331
+ "check_in": result.grounded_check_in,
332
+ "check_out": result.grounded_check_out,
333
+ }
334
+ if getattr(result, "grounded_match_name", ""):
335
+ upd["match_name"] = result.grounded_match_name
336
+ try:
337
+ trip = trip.model_copy(update=upd)
338
+ except Exception:
339
+ pass
340
+ if getattr(result, "match_unrecognized", ""):
341
+ yield _ev(type="progress", step="ready", status="fallback", text="Match not found")
342
+ yield _ev(type="clarify", text=result.match_unrecognized)
343
+ return
344
+ if getattr(result, "grounding_note", ""):
345
+ yield _ev(type="commentary", text="📅 " + result.grounding_note)
346
  else:
347
  yield _ev(type="progress", step="ready", status="fallback", text="Need a detail from you")
348
  yield _ev(
matchday/render.py CHANGED
@@ -414,10 +414,13 @@ def render_status_bar(result: TripPackageResult) -> str:
414
  def _js_markers(result: TripPackageResult) -> str:
415
  """Build Leaflet JS: stadium + hotel + POI markers + hotel→stadium lines."""
416
  lines: list[str] = []
 
 
 
417
  lines.append(
418
  f"var bb=[[{BC_PLACE_LAT},{BC_PLACE_LON}]];"
419
  f"L.marker([{BC_PLACE_LAT},{BC_PLACE_LON}],{{icon:stadiumIcon}})"
420
- f".addTo(map).bindPopup('<b>🏟️ BC Place Stadium</b><br>Match venue · 7:00 PM PT');"
421
  )
422
  seen: set[tuple[float, float]] = {(BC_PLACE_LAT, BC_PLACE_LON)}
423
  for p in result.packages:
@@ -510,6 +513,8 @@ def render_timeline(trip, result: TripPackageResult) -> str:
510
  from datetime import timedelta
511
 
512
  top = result.packages[0] if result.packages else None
 
 
513
  wx_by_date = {}
514
  if top and top.weather:
515
  wx_by_date = {w.date: w for w in top.weather}
@@ -544,7 +549,7 @@ def render_timeline(trip, result: TripPackageResult) -> str:
544
  arrive = f"Land via {flight_bit}, drop bags at <b>{_e(hotel_name)}</b>, then "
545
  icon, lbl, title, body, cls = (
546
  "🏟️", "Match day", f"{head} — MATCH DAY",
547
- f"{arrive}<b>{_e(trip.match_name)}</b> at BC Place, ~7:00 PM PT. "
548
  "Soak up the FIFA fan zone first, then it's a short "
549
  f"{top.hotel_to_stadium_min if top else 'few'}-min walk from "
550
  f"<b>{_e(hotel_name)}</b>.{_wx_note(d)} Head back after full-time.",
 
414
  def _js_markers(result: TripPackageResult) -> str:
415
  """Build Leaflet JS: stadium + hotel + POI markers + hotel→stadium lines."""
416
  lines: list[str] = []
417
+ # Real kickoff from the grounded fixture (e.g. "12:00 PT"); never the old
418
+ # hard-coded "7:00 PM PT". Empty → honest "kickoff TBD".
419
+ kickoff = result.kickoff_local or "kickoff TBD"
420
  lines.append(
421
  f"var bb=[[{BC_PLACE_LAT},{BC_PLACE_LON}]];"
422
  f"L.marker([{BC_PLACE_LAT},{BC_PLACE_LON}],{{icon:stadiumIcon}})"
423
+ f".addTo(map).bindPopup('<b>🏟️ BC Place Stadium</b><br>Match venue · {kickoff}');"
424
  )
425
  seen: set[tuple[float, float]] = {(BC_PLACE_LAT, BC_PLACE_LON)}
426
  for p in result.packages:
 
513
  from datetime import timedelta
514
 
515
  top = result.packages[0] if result.packages else None
516
+ # Real kickoff (e.g. "12:00 PT") from the grounded fixture; "" → "kickoff TBD".
517
+ kickoff = (", " + result.kickoff_local) if getattr(result, "kickoff_local", "") else ""
518
  wx_by_date = {}
519
  if top and top.weather:
520
  wx_by_date = {w.date: w for w in top.weather}
 
549
  arrive = f"Land via {flight_bit}, drop bags at <b>{_e(hotel_name)}</b>, then "
550
  icon, lbl, title, body, cls = (
551
  "🏟️", "Match day", f"{head} — MATCH DAY",
552
+ f"{arrive}<b>{_e(trip.match_name)}</b> at BC Place{kickoff}. "
553
  "Soak up the FIFA fan zone first, then it's a short "
554
  f"{top.hotel_to_stadium_min if top else 'few'}-min walk from "
555
  f"<b>{_e(hotel_name)}</b>.{_wx_note(d)} Head back after full-time.",
matchday/trip_tool.py CHANGED
@@ -34,7 +34,7 @@ from __future__ import annotations
34
 
35
  import asyncio
36
  import logging
37
- from datetime import datetime, timezone
38
  from typing import Any, Literal
39
 
40
  from pydantic import BaseModel, ConfigDict
@@ -55,6 +55,7 @@ from matchday.models import (
55
  TripRequest,
56
  Weather,
57
  )
 
58
 
59
  # Side-effect import: registers every API normalizer (weather/pois/flights/
60
  # hotels) with the module-level ``registry`` so dispatch() below finds them.
@@ -111,6 +112,33 @@ class TripPackageResult(BaseModel):
111
  missing, all flights late).
112
  """
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  model_config = ConfigDict(frozen=True, extra="forbid")
115
 
116
 
@@ -360,6 +388,58 @@ async def build_trip_packages(trip_request: TripRequest) -> TripPackageResult:
360
  total_combinations_scored=0,
361
  )
362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  # ------------------------------------------------------------------
364
  # 2. Dispatch all API categories concurrently
365
  # ------------------------------------------------------------------
@@ -433,6 +513,12 @@ async def build_trip_packages(trip_request: TripRequest) -> TripPackageResult:
433
  degradation_notices=degradation_notices,
434
  generated_at=now,
435
  total_combinations_scored=total_combinations,
 
 
 
 
 
 
436
  )
437
 
438
 
 
34
 
35
  import asyncio
36
  import logging
37
+ from datetime import date, datetime, timezone
38
  from typing import Any, Literal
39
 
40
  from pydantic import BaseModel, ConfigDict
 
55
  TripRequest,
56
  Weather,
57
  )
58
+ from matchday.wc2026 import ground_match
59
 
60
  # Side-effect import: registers every API normalizer (weather/pois/flights/
61
  # hotels) with the module-level ``registry`` so dispatch() below finds them.
 
112
  missing, all flights late).
113
  """
114
 
115
+ grounding_note: str = ""
116
+ """Human-readable note when the match was grounded in the verified 2026 WC
117
+ schedule — e.g. a date correction, or a non-Vancouver-venue warning.
118
+ Empty when the user's match/date were already correct."""
119
+
120
+ match_unrecognized: str = ""
121
+ """When non-empty, the named match is NOT a real 2026 fixture; this string
122
+ holds an honest explanation + real alternatives. The pipeline halts (no
123
+ packages) rather than planning a trip around a nonexistent match."""
124
+
125
+ kickoff_local: str = ""
126
+ """The verified match kickoff in local time (e.g. ``"12:00 PT"``), or ``""``
127
+ when unconfirmed. Render replaces the old hard-coded "7:00 PM PT"."""
128
+
129
+ grounded_match_date: date | None = None
130
+ """The REAL match date after schedule grounding, when the trip was
131
+ re-centered on the verified fixture. Callers sync their display trip
132
+ to this so the itinerary + greenlight match the packages."""
133
+
134
+ grounded_check_in: date | None = None
135
+ grounded_check_out: date | None = None
136
+ """Re-bracketed check-in/out around ``grounded_match_date``."""
137
+
138
+ grounded_match_name: str = ""
139
+ """Canonical match name from the verified schedule (e.g. ``"Canada vs Qatar"``)
140
+ after grounding. Empty when the match was unrecognized."""
141
+
142
  model_config = ConfigDict(frozen=True, extra="forbid")
143
 
144
 
 
388
  total_combinations_scored=0,
389
  )
390
 
391
+ # ------------------------------------------------------------------
392
+ # 1b. GROUND the match in the verified 2026 WC schedule (Best-Agent
393
+ # crux: a real agent corrects "Canada vs Qatar, June 26" to the real
394
+ # June 18 fixture, and refuses to plan a trip around a nonexistent
395
+ # match like "Canada vs Morocco"). Every downstream call (flights,
396
+ # hotels, weather, scoring, itinerary) then uses the REAL date +
397
+ # kickoff, so packages are built around truth, not the user's typo.
398
+ # ------------------------------------------------------------------
399
+ grounding_note = ""
400
+ match_unrecognized = ""
401
+ kickoff_local = ""
402
+ try:
403
+ grounded = ground_match(
404
+ trip_request.match_name,
405
+ trip_request.match_date,
406
+ trip_request.check_in,
407
+ trip_request.check_out,
408
+ )
409
+ except Exception: # grounding must never break the build
410
+ grounded = None
411
+
412
+ if grounded is not None:
413
+ kickoff_local = grounded.kickoff
414
+ grounding_note = grounded.note
415
+ # Re-center the trip on the REAL date (keeps the user's trip length).
416
+ # TripRequest is frozen + validated, so rebuild via model_copy.
417
+ try:
418
+ trip_request = trip_request.model_copy(update={
419
+ "match_name": grounded.match_name,
420
+ "match_date": grounded.match_date,
421
+ "check_in": grounded.check_in,
422
+ "check_out": grounded.check_out,
423
+ })
424
+ except Exception:
425
+ pass # keep the user's original dates if re-bracketing is invalid
426
+ else:
427
+ # Matchup not in the verified schedule → resolve() built alternatives.
428
+ from matchday.wc2026 import resolve_match
429
+ res = resolve_match(trip_request.match_name)
430
+ match_unrecognized = res.note or (
431
+ f"“{trip_request.match_name}” isn't a recognized 2026 World Cup "
432
+ "fixture. Tell me which real match you'd like to plan around."
433
+ )
434
+ return TripPackageResult(
435
+ packages=[],
436
+ status="failed",
437
+ degradation_notices=[match_unrecognized],
438
+ generated_at=now,
439
+ total_combinations_scored=0,
440
+ match_unrecognized=match_unrecognized,
441
+ )
442
+
443
  # ------------------------------------------------------------------
444
  # 2. Dispatch all API categories concurrently
445
  # ------------------------------------------------------------------
 
513
  degradation_notices=degradation_notices,
514
  generated_at=now,
515
  total_combinations_scored=total_combinations,
516
+ grounding_note=grounding_note,
517
+ kickoff_local=kickoff_local,
518
+ grounded_match_date=trip_request.match_date,
519
+ grounded_check_in=trip_request.check_in,
520
+ grounded_check_out=trip_request.check_out,
521
+ grounded_match_name=trip_request.match_name,
522
  )
523
 
524
 
matchday/wc2026.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verified 2026 FIFA World Cup fixtures — MatchDay's ground truth for match
2
+ grounding (the "Best Agent" crux: intent → ground in reality → rank).
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ Before this module, MatchDay accepted whatever match name + date the user typed
7
+ and built a trip around it — so "Canada vs Qatar, June 26" produced a June 26
8
+ trip even though the real match is **June 18 at BC Place**, and "Canada vs
9
+ Morocco" silently planned a trip for a match that doesn't exist. A real agent
10
+ grounds intent in truth. This module is that ground truth.
11
+
12
+ DATA PROVENANCE
13
+ ---------------
14
+ Schedule facts below are the *published* fixtures from the official FIFA match
15
+ schedule released after the Final Draw (Dec 5, 2025), cross-checked across
16
+ FIFA.com, ESPN, Sky Sports and host-city sites. This is a STATIC, curated table
17
+ — NOT a live feed. For any match not listed here, callers should fall back to
18
+ the agent's live ``web_search`` grounding rather than guess.
19
+
20
+ Source of truth: https://www.figma.com → FIFA:
21
+ https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/scores-fixtures
22
+
23
+ ACCURACY POLICY
24
+ ---------------
25
+ Only fixtures confirmed across multiple sources are listed. Kickoff times are
26
+ included only when well-sourced; otherwise ``""`` (rendered as "kickoff TBD —
27
+ check FIFA") — we never fabricate a time.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ from datetime import date, timedelta
33
+
34
+ TOURNAMENT_START = date(2026, 6, 11)
35
+ TOURNAMENT_END = date(2026, 7, 19)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Fixture:
40
+ """One verified 2026 World Cup fixture."""
41
+
42
+ match: str # canonical "Team A vs Team B" (as scheduled)
43
+ match_date: date
44
+ venue: str # "BC Place"
45
+ city: str # "Vancouver"
46
+ kickoff: str # "12:00 PT" or "" when unconfirmed
47
+ group: str # "B"
48
+
49
+
50
+ # ── Verified fixtures (high confidence) ──────────────────────────────────────
51
+ # Canada's full Group B slate + the other BC Place group games + Brazil/Group C
52
+ # (enough to correct every one of the user's example queries honestly).
53
+ _FIXTURES: list[Fixture] = [
54
+ # Group B — Canada's group (Canada, Bosnia & Herzegovina, Qatar, Switzerland)
55
+ Fixture("Canada vs Bosnia and Herzegovina", date(2026, 6, 12), "BMO Field", "Toronto", "15:00 ET", "B"),
56
+ Fixture("Canada vs Qatar", date(2026, 6, 18), "BC Place", "Vancouver", "12:00 PT", "B"),
57
+ Fixture("Canada vs Switzerland", date(2026, 6, 24), "BC Place", "Vancouver", "12:00 PT", "B"),
58
+ Fixture("Qatar vs Switzerland", date(2026, 6, 13), "Levi's Stadium", "San Francisco Bay Area", "", "B"),
59
+ Fixture("Switzerland vs Bosnia and Herzegovina", date(2026, 6, 18), "SoFi Stadium", "Los Angeles", "", "B"),
60
+ Fixture("Bosnia and Herzegovina vs Qatar", date(2026, 6, 24), "Lumen Field", "Seattle", "", "B"),
61
+ # Group C — Brazil's group (Brazil, Morocco, Scotland, Haiti)
62
+ Fixture("Brazil vs Morocco", date(2026, 6, 13), "MetLife Stadium", "New York / New Jersey", "18:00 ET", "C"),
63
+ # Other BC Place (Vancouver) group-stage games
64
+ Fixture("Australia vs Türkiye", date(2026, 6, 13), "BC Place", "Vancouver", "21:00 PT", "D"),
65
+ Fixture("New Zealand vs Egypt", date(2026, 6, 22), "BC Place", "Vancouver", "", "G"),
66
+ Fixture("Argentina vs Austria", date(2026, 6, 22), "BC Place", "Vancouver", "", "G"),
67
+ ]
68
+
69
+ # Spoken team-name aliases → canonical token used for matching. Lets "Bosnia"
70
+ # match "Bosnia and Herzegovina", "USA" match "United States", etc.
71
+ _TEAM_ALIASES: dict[str, str] = {
72
+ "bosnia and herzegovina": "bosnia", "bosnia & herzegovina": "bosnia",
73
+ "bosnia-herzegovina": "bosnia",
74
+ "new zealand": "newzealand",
75
+ "united states": "usa", "united states of america": "usa", "america": "usa",
76
+ "south korea": "korea", "korea republic": "korea",
77
+ "ir iran": "iran", "iran ir": "iran",
78
+ "türkiye": "turkey", "turkiye": "turkey",
79
+ "czechia": "czechrepublic", "czech republic": "czechrepublic",
80
+ }
81
+
82
+
83
+ def _norm_team(raw: str) -> str:
84
+ """Normalize one team name to a canonical lowercase token."""
85
+ t = raw.lower().strip().replace("&", "and").replace(".", "").replace("-", " ")
86
+ t = " ".join(t.split())
87
+ return _TEAM_ALIASES.get(t, t)
88
+
89
+
90
+ def _split_match(match_name: str) -> list[str]:
91
+ """Split 'A vs B' into normalized team tokens (any order)."""
92
+ import re
93
+ parts = re.split(r"\b(?:vs?\.?|versus|[-–])\b", match_name, maxsplit=1, flags=re.IGNORECASE)
94
+ if len(parts) != 2:
95
+ return []
96
+ return [_norm_team(parts[0]), _norm_team(parts[1])]
97
+
98
+
99
+ def _teams_match(query_teams: list[str], fixture: Fixture) -> bool:
100
+ """Order-independent match: both query teams appear among the fixture teams."""
101
+ if len(query_teams) != 2:
102
+ return False
103
+ ft = {_norm_team(fixture.match.split(" vs ")[0]), _norm_team(fixture.match.split(" vs ")[1])}
104
+ # a query team matches if it equals a fixture team OR one is a substring of
105
+ # the other (handles "Bosnia" vs "bosnia", "Korea" vs "south korea").
106
+ def hit(q: str) -> str | None:
107
+ for f in ft:
108
+ if q == f or (len(q) >= 3 and (q in f or f in q)):
109
+ return f
110
+ return None
111
+ a, b = hit(query_teams[0]), hit(query_teams[1])
112
+ return bool(a and b and a != b)
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class ResolveResult:
117
+ """Outcome of resolving a user's match name against verified fixtures."""
118
+
119
+ fixture: Fixture | None # the verified fixture, or None if unrecognized
120
+ recognized: bool # True iff the matchup exists in 2026 (any venue)
121
+ in_vancouver: bool # True iff a recognized fixture is at BC Place
122
+ note: str # human-readable grounding note for the UI
123
+
124
+
125
+ def resolve_match(match_name: str) -> ResolveResult:
126
+ """Resolve a user-typed match to a verified 2026 fixture.
127
+
128
+ Returns the Fixture if the matchup is real, plus flags + a UI note. If the
129
+ two teams never play each other in 2026, ``fixture`` is None and ``note``
130
+ suggests the nearest real alternatives so the app can clarify honestly.
131
+ """
132
+ qt = _split_match(match_name or "")
133
+ if len(qt) != 2:
134
+ return ResolveResult(None, False, False, "")
135
+
136
+ # Does this exact matchup exist?
137
+ exact = next((f for f in _FIXTURES if _teams_match(qt, f)), None)
138
+ if exact:
139
+ van = exact.city.lower() == "vancouver"
140
+ return ResolveResult(exact, True, van, "")
141
+
142
+ # Matchup not found — are BOTH teams in the tournament (just not vs each
143
+ # other)? Build honest alternatives from each team's real fixtures.
144
+ a_games = [f for f in _FIXTURES if _teams_match([qt[0], "__probe__"], f) or _has_team(f, qt[0])]
145
+ b_games = [f for f in _FIXTURES if _has_team(f, qt[1])]
146
+ # Simpler: gather each team's fixtures directly.
147
+ a_games = [f for f in _FIXTURES if _has_team(f, qt[0])]
148
+ b_games = [f for f in _FIXTURES if _has_team(f, qt[1])]
149
+
150
+ def _summarize(games: list[Fixture], team: str) -> str:
151
+ if not games:
152
+ return ""
153
+ items = ", ".join(
154
+ f"{_other(f, team)} ({f.match_date:%b %-d}, {f.city})" for f in games
155
+ )
156
+ return f"{team.title()} plays: {items}."
157
+
158
+ note = f"“{match_name}” isn't a 2026 World Cup fixture. "
159
+ note += _summarize(a_games, qt[0]) + " " if a_games else ""
160
+ note += _summarize(b_games, qt[1]) + " " if b_games else ""
161
+ note += "Tell me which real match you'd like to plan around."
162
+ note = " ".join(note.split())
163
+ return ResolveResult(None, False, False, note)
164
+
165
+
166
+ def _has_team(fixture: Fixture, team_token: str) -> bool:
167
+ ft = {_norm_team(fixture.match.split(" vs ")[0]), _norm_team(fixture.match.split(" vs ")[1])}
168
+ return any(team_token == f or (len(team_token) >= 3 and (team_token in f or f in team_token)) for f in ft)
169
+
170
+
171
+ def _other(fixture: Fixture, team_token: str) -> str:
172
+ """Return the fixture's team that is NOT the given token (for summaries)."""
173
+ a, b = fixture.match.split(" vs ")
174
+ return a if _norm_team(b) == team_token or team_token in _norm_team(b) else b
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class GroundedTrip:
179
+ """Result of grounding a TripRequest against the real schedule."""
180
+
181
+ match_name: str # canonical match name (e.g. "Canada vs Qatar")
182
+ match_date: date # the REAL match date (corrected)
183
+ check_in: date # re-bracketed around the real date
184
+ check_out: date
185
+ venue: str # "BC Place"
186
+ city: str # "Vancouver"
187
+ kickoff: str # "12:00 PT" or ""
188
+ corrected: bool # was the user's date different from real?
189
+ user_match_date: date # what the user originally said
190
+ note: str # grounding note (correction / non-Vancouver / etc.)
191
+
192
+
193
+ def ground_match(match_name: str, user_match_date: date, check_in: date, check_out: date) -> GroundedTrip | None:
194
+ """Ground a trip's match against verified fixtures.
195
+
196
+ Returns a ``GroundedTrip`` with the REAL date + venue/kickoff and a UI note,
197
+ or ``None`` when the matchup isn't recognized (caller should clarify).
198
+ Keeps the trip's original length (nights) but re-centers it on the real date.
199
+ """
200
+ res = resolve_match(match_name)
201
+ if not res.fixture:
202
+ return None # unrecognized — caller uses res.note to clarify
203
+
204
+ fx = res.fixture
205
+ nights = max(1, (check_out - check_in).days)
206
+ # Preserve the user's trip LENGTH but make sure the real match is inside the
207
+ # window. If their stated dates already cover the real match, keep them; if
208
+ # not (e.g. "Canada vs Qatar, June 26-29" — the real match is June 18),
209
+ # re-bracket around the real date keeping the same number of nights and
210
+ # arriving the day before the match. Avoids silently stretching a 3-night
211
+ # trip into an 11-night one just to absorb a date correction.
212
+ if check_in <= fx.match_date <= check_out:
213
+ real_ci, real_co = check_in, check_out
214
+ else:
215
+ lead = min(nights - 1, 1) # arrive 1 day before the match (same-day if 1 night)
216
+ real_ci = fx.match_date - timedelta(days=lead)
217
+ real_co = real_ci + timedelta(days=nights)
218
+
219
+ corrected = fx.match_date != user_match_date
220
+ note = ""
221
+ if corrected:
222
+ note = (
223
+ f"{fx.match} is on {fx.match_date:%A %b %-d}, {fx.match_date.year} at "
224
+ f"{fx.venue} (you said {user_match_date:%b %-d}) — I've planned your trip around the real date."
225
+ )
226
+ if not res.in_vancouver:
227
+ note += (
228
+ f" Heads up: {fx.match} is in {fx.city} ({fx.venue}), not Vancouver. "
229
+ "MatchDay plans Vancouver / BC Place trips."
230
+ )
231
+ return GroundedTrip(
232
+ match_name=fx.match, match_date=fx.match_date, check_in=real_ci,
233
+ check_out=real_co, venue=fx.venue, city=fx.city, kickoff=fx.kickoff,
234
+ corrected=corrected, user_match_date=user_match_date, note=note.strip(),
235
+ )
236
+
237
+
238
+ def vancouver_fixtures() -> list[Fixture]:
239
+ """All verified fixtures at BC Place, Vancouver (the app's home venue)."""
240
+ return [f for f in _FIXTURES if f.city.lower() == "vancouver"]