Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- README.md +169 -46
- app.py +54 -1
- matchday/render.py +7 -2
- matchday/trip_tool.py +87 -1
- matchday/wc2026.py +240 -0
README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
---
|
| 2 |
title: MatchDay
|
| 3 |
emoji: ⚽
|
| 4 |
-
colorFrom:
|
| 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 |
-
-
|
|
|
|
|
|
|
|
|
|
| 22 |
models:
|
| 23 |
- nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16
|
| 24 |
---
|
| 25 |
|
| 26 |
-
# MatchDay ⚽ — 2026 FIFA World Cup,
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
hallucinated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
## How it works — Brain + Hands
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
- **
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
##
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
## Try it
|
| 61 |
-
|
| 62 |
-
- **
|
|
|
|
| 63 |
- **Field Notes (architecture story):** `matchday/FIELD_NOTES.md`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
## Built for Build Small
|
| 66 |
-
|
| 67 |
-
**
|
| 68 |
-
|
| 69 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ·
|
| 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
|
| 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"]
|