mzidan000 commited on
Commit
002831b
·
verified ·
1 Parent(s): 95bd1fc

Upload folder using huggingface_hub

Browse files
app.py CHANGED
@@ -1,16 +1,183 @@
1
- """HF Space entry point delegates to the MatchDay Gradio app.
2
 
3
- Hugging Face Spaces run this file and serve the module-level ``demo`` (a
4
- ``gr.Blocks``). The real app lives in ``matchday/app.py``; this thin wrapper
5
- just makes the repo root Space-compatible.
 
 
 
 
 
 
 
 
6
  """
 
 
 
 
7
  import os
8
  import sys
 
 
9
 
10
- # Ensure the repo root is importable when the Space runs this file.
11
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
 
13
- from matchday.app import demo # noqa: E402,F401 (re-exported for the Space)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  if __name__ == "__main__":
16
- demo.launch()
 
1
+ """MatchDay — HF Space entry point (gradio.Server mode, N1 / Off-Brand).
2
 
3
+ The Space runs THIS file. It is a `gradio.Server` app: a fully custom
4
+ ``index.html`` frontend is served at ``/`` while ``@app.api("plan_trip")`` is an
5
+ async generator that streams N12-typed JSON events through Gradio's queue (SSE),
6
+ so the frontend updates live as Nemotron decides → Python scores → Nemotron
7
+ explains. This is the Off-Brand path: a bespoke UI powered by Gradio's backend
8
+ (queuing, concurrency, Spaces hosting) — not stock Gradio components.
9
+
10
+ Brain + Hands: Nemotron (on Modal) never calls an API or names a price; Python
11
+ executes every call and scores every value. Every figure carries provenance.
12
+
13
+ The gr.Blocks streaming version is retained at ``matchday/app.py`` as a fallback.
14
  """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
  import os
20
  import sys
21
+ from datetime import date
22
+ from pathlib import Path
23
 
24
+ # Repo-root importability when the Space runs this file directly.
25
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
 
27
+ from fastapi.responses import HTMLResponse # noqa: E402
28
+ from gradio import Server # noqa: E402
29
+
30
+ from matchday.agent import MatchDayAgent # noqa: E402
31
+ from matchday.agent_loop import BuildTripPackagesArgs # noqa: E402
32
+ from matchday.intent import parse_intent # noqa: E402
33
+ from matchday.models import TripRequest # noqa: E402
34
+ from matchday.prompts import EXPLANATION_HINT # noqa: E402
35
+ from matchday.render import render_full # noqa: E402
36
+ from matchday.trip_tool import build_trip_packages, format_for_nemotron # noqa: E402
37
+
38
+ logger = logging.getLogger(__name__)
39
+ logging.basicConfig(level=logging.INFO)
40
+
41
+ # Nemotron primary, deterministic fallback. Flip to False to force the
42
+ # deterministic path (fast demo / Modal-down insurance).
43
+ USE_AGENT = True
44
+
45
+ HERE = Path(__file__).parent
46
+ INDEX_HTML = HERE / "index.html"
47
+
48
+ app = Server()
49
+
50
+
51
+ def _ev(**payload) -> str:
52
+ """Serialize a typed stream event (N12) as a JSON string for the SSE stream."""
53
+ return json.dumps(payload, ensure_ascii=False)
54
+
55
+
56
+ def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
57
+ return TripRequest(
58
+ origin_airport=a.origin_airport,
59
+ match_name=a.match_name or "the match",
60
+ match_date=date.fromisoformat(a.match_date),
61
+ check_in=date.fromisoformat(a.check_in),
62
+ check_out=date.fromisoformat(a.check_out),
63
+ travelers=a.travelers,
64
+ budget_tier=a.budget_tier,
65
+ )
66
+
67
+
68
+ async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> str:
69
+ """Round 2 — Nemotron compares the packages. Best-effort ('' on failure)."""
70
+ args_json = json.dumps(trip.model_dump(mode="json"))
71
+ convo = [
72
+ {"role": "user", "content": user_text},
73
+ {
74
+ "role": "assistant",
75
+ "content": "",
76
+ "tool_calls": [{
77
+ "id": "call_build",
78
+ "type": "function",
79
+ "function": {"name": "build_trip_packages", "arguments": args_json},
80
+ }],
81
+ },
82
+ {
83
+ "role": "tool",
84
+ "tool_call_id": "call_build",
85
+ "name": "build_trip_packages",
86
+ "content": format_for_nemotron(result),
87
+ },
88
+ {"role": "user", "content": EXPLANATION_HINT},
89
+ ]
90
+ try:
91
+ r2 = await agent.run(convo, tools=[]) # no tools → Nemotron must write text
92
+ return (r2.get("text") or "").strip()
93
+ except Exception as exc:
94
+ logger.warning("explanation round failed: %s", exc)
95
+ return ""
96
+
97
+
98
+ @app.api(name="plan_trip", concurrency_limit=4, stream_every=0.5)
99
+ async def plan_trip(user_text: str) -> str:
100
+ """Stream the agentic trip build as typed events (N12 + N10).
101
+
102
+ Yields: commentary (progress beats, sent immediately) → greenlight
103
+ (parsed trip) | clarify | error → result (full cards+map+timeline render
104
+ + Nemotron's explanation). Falls back to the deterministic parser if the
105
+ agent is unavailable or hedges.
106
+ """
107
+ yield _ev(type="commentary", text="Reading your trip request…")
108
+
109
+ agent = None
110
+ if USE_AGENT:
111
+ try:
112
+ agent = MatchDayAgent()
113
+ except Exception as exc:
114
+ logger.warning("agent init failed (%s); deterministic path.", exc)
115
+
116
+ # Round 1 — Nemotron decides which tool to call.
117
+ r1: dict = {"tool_calls": []}
118
+ if agent is not None:
119
+ yield _ev(type="commentary", text="🤖 Nemotron is choosing your best options…")
120
+ try:
121
+ r1 = await agent.run([{"role": "user", "content": user_text}])
122
+ except Exception as exc:
123
+ logger.warning("agent round 1 failed (%s).", exc)
124
+ r1 = {"tool_calls": []}
125
+
126
+ # Resolve a validated trip: Nemotron's args first, then deterministic parse.
127
+ tool_calls = r1.get("tool_calls") or []
128
+ trip: TripRequest | None = None
129
+ if tool_calls and tool_calls[0].get("name") == "build_trip_packages":
130
+ try:
131
+ trip = _args_to_trip(
132
+ BuildTripPackagesArgs.model_validate(tool_calls[0].get("arguments", {}))
133
+ )
134
+ except Exception:
135
+ trip = None
136
+ if trip is None:
137
+ parsed = parse_intent(user_text)
138
+ if parsed.trip_request is not None:
139
+ trip = parsed.trip_request
140
+
141
+ if trip is None:
142
+ clarify_q = ""
143
+ if tool_calls and tool_calls[0].get("name") == "clarify":
144
+ clarify_q = tool_calls[0].get("arguments", {}).get("question", "")
145
+ if not clarify_q:
146
+ clarify_q = parse_intent(user_text).question
147
+ yield _ev(
148
+ type="clarify",
149
+ text=clarify_q
150
+ or "Tell me where you're flying from and which match you want to see.",
151
+ )
152
+ return
153
+
154
+ yield _ev(type="greenlight", text=trip.summary())
155
+ yield _ev(
156
+ type="commentary",
157
+ text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…",
158
+ )
159
+ try:
160
+ result = await build_trip_packages(trip)
161
+ except Exception as exc:
162
+ yield _ev(type="error", text=f"⚠️ {exc}")
163
+ return
164
+
165
+ yield _ev(type="commentary", text="🗺️ Scoring 3 packages — Nemotron is writing your comparison…")
166
+ explanation = ""
167
+ if agent is not None:
168
+ explanation = await _agent_explain(agent, user_text, trip, result)
169
+
170
+ # Final: the full Layla-competitive render (status + cards + map + timeline).
171
+ # leaflet_preloaded=True → the frontend already loaded Leaflet in <head>; the
172
+ # map's inline init script is re-run after injection (see index.html).
173
+ yield _ev(type="result", html=render_full(result, trip, leaflet_preloaded=True), explanation=explanation)
174
+
175
+
176
+ @app.get("/", response_class=HTMLResponse)
177
+ async def homepage():
178
+ with open(INDEX_HTML, "r", encoding="utf-8") as fh:
179
+ return fh.read()
180
+
181
 
182
  if __name__ == "__main__":
183
+ app.launch(server_port=int(os.environ.get("PORT", "7860")), show_error=True)
index.html ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>MatchDay — AI trip planner for the 2026 FIFA World Cup, Vancouver</title>
7
+
8
+ <!-- Leaflet preloaded in <head> so the injected map's inline init runs instantly (N1). -->
9
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
10
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
11
+
12
+ <style>
13
+ :root{
14
+ --violet:#7c3aed; --violet-d:#6d28d9; --emerald:#10b981; --amber:#f59e0b;
15
+ --ink:#111827; --ink2:#374151; --mut:#6b7280; --mut2:#9ca3af;
16
+ --line:#e5e7eb; --panel:#f9fafb; --gray:#f3f4f6;
17
+ }
18
+ *{box-sizing:border-box;}
19
+ html,body{margin:0;height:100%;}
20
+ body{
21
+ font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
22
+ color:var(--ink); background:#fff; -webkit-font-smoothing:antialiased;
23
+ }
24
+ ::-webkit-scrollbar{width:9px;height:9px;}
25
+ ::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:6px;}
26
+
27
+ /* ── Top bar ─────────────────────────────────────────── */
28
+ .topbar{
29
+ display:flex;align-items:center;gap:14px;padding:12px 20px;
30
+ border-bottom:1px solid var(--line);
31
+ background:linear-gradient(100deg,#0f172a,#1e3a8a);color:#fff;position:relative;z-index:5;
32
+ }
33
+ .topbar .logo{font-size:21px;font-weight:800;letter-spacing:-.02em;display:flex;align-items:center;gap:8px;}
34
+ .topbar .tag{font-size:13px;opacity:.85;font-weight:500;}
35
+ .topbar .sp{flex:1;}
36
+ .topbar .pill{font-size:11px;font-weight:600;background:rgba(255,255,255,.14);
37
+ padding:4px 10px;border-radius:999px;border:1px solid rgba(255,255,255,.22);}
38
+ .topbar .pill b{color:#c4b5fd;}
39
+
40
+ /* ── 3-column layout ─────────────────────────────────── */
41
+ .app{display:grid;grid-template-columns:minmax(320px,35%) minmax(0,45%) minmax(180px,20%);
42
+ height:calc(100vh - 53px);}
43
+ .col{display:flex;flex-direction:column;min-height:0;min-width:0;}
44
+ .chat{border-right:1px solid var(--line);}
45
+ .sidebar{border-left:1px solid var(--line);background:var(--panel);overflow:auto;}
46
+
47
+ /* ── Chat column ─────────────────────────────────────── */
48
+ #chat{flex:1;overflow-y:auto;padding:18px 18px 8px;display:flex;flex-direction:column;gap:14px;}
49
+ .hero{background:var(--gray);border:1px solid var(--line);border-radius:14px;padding:16px;}
50
+ .hero h2{margin:0 0 6px;font-size:15px;}
51
+ .hero p{margin:0;font-size:13px;color:var(--mut);line-height:1.5;}
52
+ .msg{display:flex;gap:10px;max-width:92%;animation:fade .25s ease;}
53
+ .msg.user{align-self:flex-end;flex-direction:row-reverse;}
54
+ .a-avatar{width:30px;height:30px;border-radius:50%;background:#1e3a8a;color:#fff;
55
+ display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0 30px;}
56
+ .a-body{background:var(--gray);border:1px solid var(--line);border-radius:14px;
57
+ padding:11px 14px;min-width:0;}
58
+ .msg.user .a-body{background:var(--violet);color:#fff;border-color:var(--violet-d);}
59
+ .a-content{font-size:14px;line-height:1.5;}
60
+ .a-beat{display:flex;align-items:center;gap:9px;font-size:13.5px;color:var(--ink2);
61
+ font-weight:500;padding-top:2px;}
62
+ .spin{display:inline-block;animation:spin 1s linear infinite;color:var(--violet);}
63
+ @keyframes spin{to{transform:rotate(360deg);}}
64
+ @keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
65
+
66
+ .green{background:#f0fdf4;border:1px solid #bbf7d0;color:#166534;border-radius:12px;
67
+ padding:10px 13px;font-size:14px;font-weight:600;}
68
+ .clarify{background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;border-radius:12px;padding:12px 14px;font-size:14.5px;}
69
+ .err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b;border-radius:12px;padding:11px 13px;font-size:14px;}
70
+ .nemotron-note{font-size:11px;color:var(--mut2);margin-top:7px;font-weight:600;letter-spacing:.02em;}
71
+ .msg.user .nemotron-note,.msg.user .a-beat{color:rgba(255,255,255,.92);}
72
+
73
+ /* input row */
74
+ .composer{border-top:1px solid var(--line);padding:12px 14px;background:#fff;}
75
+ .composer textarea{width:100%;border:1px solid #d1d5db;border-radius:12px;padding:11px 13px;
76
+ font:inherit;font-size:14px;resize:none;outline:none;min-height:48px;}
77
+ .composer textarea:focus{border-color:var(--violet);box-shadow:0 0 0 3px rgba(124,58,237,.15);}
78
+ .composer .row{display:flex;align-items:center;gap:8px;margin-top:8px;}
79
+ .examples{display:flex;flex-wrap:wrap;gap:6px;padding:0 18px 10px;}
80
+ .chip{font-size:12px;background:#fff;border:1px solid var(--line);border-radius:999px;
81
+ padding:5px 11px;color:var(--ink2);cursor:pointer;transition:.15s;}
82
+ .chip:hover{border-color:var(--violet);color:var(--violet);}
83
+ button.primary{background:var(--violet);color:#fff;border:0;border-radius:10px;padding:9px 16px;
84
+ font:inherit;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;}
85
+ button.primary:hover{background:var(--violet-d);}
86
+ button.primary:disabled{opacity:.55;cursor:not-allowed;}
87
+ .hint{font-size:12px;color:var(--mut);}
88
+
89
+ /* ── Result column ───────────────────────────────────── */
90
+ .result{overflow-y:auto;}
91
+ #result{padding:16px 18px 24px;}
92
+ .empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
93
+ height:100%;text-align:center;color:var(--mut2);padding:30px;}
94
+ .empty .big{font-size:46px;margin-bottom:10px;}
95
+ .empty h3{margin:0 0 6px;font-size:16px;color:var(--ink2);}
96
+ .empty p{margin:0;font-size:13px;max-width:320px;line-height:1.5;}
97
+
98
+ /* ── Sidebar ─────────────────────────────────────────── */
99
+ .sidebar .sec{padding:16px 16px 14px;border-bottom:1px solid var(--line);}
100
+ .sidebar h4{margin:0 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);}
101
+ .brain{display:flex;gap:10px;align-items:flex-start;}
102
+ .brain .b{font-size:20px;}
103
+ .brain .t{font-size:12.5px;line-height:1.45;color:var(--ink2);}
104
+ .brain .t b{color:var(--violet);}
105
+ .legend-row{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--ink2);margin:5px 0;}
106
+ .dot{width:9px;height:9px;border-radius:50%;display:inline-block;}
107
+ .dot.live{background:var(--emerald);}
108
+ .dot.fallback{background:var(--amber);}
109
+ .badges{display:flex;flex-wrap:wrap;gap:5px;}
110
+ .badge{font-size:10.5px;font-weight:600;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;
111
+ border-radius:6px;padding:3px 7px;}
112
+ .fact{font-size:12.5px;color:var(--ink2);line-height:1.5;margin:4px 0;}
113
+ .fact b{color:var(--ink);}
114
+
115
+ /* full-screen map */
116
+ #matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;
117
+ height:100vh!important;width:100vw!important;border-radius:0!important;}
118
+ #md-fs-close{position:fixed;top:16px;right:18px;z-index:99999;background:#fff;color:var(--ink);
119
+ font-weight:700;border:0;border-radius:10px;padding:9px 15px;cursor:pointer;font-size:14px;
120
+ box-shadow:0 4px 14px rgba(0,0,0,.25);display:none;}
121
+ #md-fs-close.show{display:block;}
122
+
123
+ @media (max-width:980px){
124
+ .app{grid-template-columns:1fr;height:auto;}
125
+ .chat{border-right:0;border-bottom:1px solid var(--line);}
126
+ .sidebar{border-left:0;border-top:1px solid var(--line);}
127
+ .result{min-height:60vh;}
128
+ }
129
+ </style>
130
+ </head>
131
+ <body>
132
+ <header class="topbar">
133
+ <div class="logo">⚽ MatchDay</div>
134
+ <div class="tag">Your AI trip planner for the <b>2026 FIFA World Cup · Vancouver</b></div>
135
+ <div class="sp"></div>
136
+ <span class="pill">Brain <b>Nemotron-3-Nano-30B</b> · Hands <b>Python</b> · <b>gradio.Server</b></span>
137
+ </header>
138
+
139
+ <div class="app">
140
+ <!-- LEFT: conversation -->
141
+ <section class="col chat">
142
+ <div id="chat">
143
+ <div class="hero">
144
+ <h2>👋 Plan your World Cup trip</h2>
145
+ <p>Tell me where you're flying from, the match you want, dates and budget. I'll build 3 ranked packages — cheapest flight, safest arrival, closest hotel to BC Place — with live prices and honest provenance.</p>
146
+ </div>
147
+ </div>
148
+
149
+ <div class="examples" id="examples">
150
+ <span class="chip">Flying from Montreal, Canada vs Qatar, mid-range, June 26-29, just me</span>
151
+ <span class="chip">Toronto to see Brazil vs Germany, premium, July 12, 2 adults</span>
152
+ <span class="chip">From Halifax, Canada vs Morocco June 18, couple, luxury</span>
153
+ </div>
154
+
155
+ <div class="composer">
156
+ <textarea id="prompt" rows="2" placeholder="e.g. Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me"
157
+ aria-label="Describe your trip"></textarea>
158
+ <div class="row">
159
+ <button class="primary" id="send" onclick="runTrip()">🏈 Plan my trip</button>
160
+ <span class="hint">Enter to send · Enter+Shift for newline</span>
161
+ </div>
162
+ </div>
163
+ </section>
164
+
165
+ <!-- MIDDLE: result -->
166
+ <section class="col result">
167
+ <div id="result">
168
+ <div class="empty">
169
+ <div class="big">🏟️</div>
170
+ <h3>Your trip packages will appear here</h3>
171
+ <p>Once you send your request, Nemotron picks the tools and Python scores real flights, hotels, weather and nearby spots — ranked on an interactive map.</p>
172
+ </div>
173
+ </div>
174
+ </section>
175
+
176
+ <!-- RIGHT: sidebar -->
177
+ <aside class="col sidebar">
178
+ <div class="sec">
179
+ <h4>How it works</h4>
180
+ <div class="brain">
181
+ <div class="b">🧠</div>
182
+ <div class="t"><b>Brain:</b> Nemotron-3-Nano-30B (3B-active MoE) on Modal A100. It chooses the tools and writes the comparisons. It <b>never</b> calls an API or names a price.</div>
183
+ </div>
184
+ <div class="brain" style="margin-top:10px;">
185
+ <div class="b">🤲</div>
186
+ <div class="t"><b>Hands:</b> deterministic Python calls SerpApi / Open-Meteo / OpenStreetMap, scores packages with a fixed formula, and tags every value with provenance.</div>
187
+ </div>
188
+ </div>
189
+ <div class="sec">
190
+ <h4>Provenance — no hallucinated prices</h4>
191
+ <div class="legend-row"><span class="dot live"></span> <b>● live</b> — real SerpApi / Open-Meteo / OSM reading</div>
192
+ <div class="legend-row"><span class="dot fallback"></span> <b>example</b> — fixture (demo fallback)</div>
193
+ </div>
194
+ <div class="sec">
195
+ <h4>Built for Build Small</h4>
196
+ <div class="badges">
197
+ <span class="badge">🎨 Off-Brand</span>
198
+ <span class="badge">🤖 Best Agent</span>
199
+ <span class="badge">🟩 Nemotron</span>
200
+ <span class="badge">🟢 Modal</span>
201
+ <span class="badge">📡 Trace</span>
202
+ </div>
203
+ </div>
204
+ <div class="sec">
205
+ <h4>The match</h4>
206
+ <div class="fact">🏟️ <b>BC Place Stadium</b>, Vancouver</div>
207
+ <div class="fact">⏰ ~7:00 PM PT kickoff</div>
208
+ <div class="fact">📍 Downtown — walkable from central hotels</div>
209
+ </div>
210
+ </aside>
211
+ </div>
212
+
213
+ <button id="md-fs-close" onclick="matchdayFullscreen(false)" aria-label="Exit full screen">✕ Close</button>
214
+
215
+ <script>
216
+ const EXAMPLES = [
217
+ "Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me",
218
+ "I want to fly from Toronto to see Brazil vs Germany, premium, July 12, 2 adults",
219
+ "Take me from Ottawa to Vancouver for Canada vs Qatar on 2026-06-26, budget",
220
+ "From Halifax, Canada vs Morocco June 18, couple, luxury",
221
+ ];
222
+ // Pre-fill the first example for instant demo.
223
+ document.getElementById("prompt").value = EXAMPLES[0];
224
+
225
+ let running = false;
226
+ let resultHandled = false; // the result event repeats on `complete` — render it once
227
+ const chatEl = document.getElementById("chat");
228
+ const resultEl = document.getElementById("result");
229
+ const promptEl = document.getElementById("prompt");
230
+ const sendBtn = document.getElementById("send");
231
+
232
+ // ── DOM helpers ────────────────────────────────────────
233
+ function addUserBubble(text){
234
+ const m = document.createElement("div");
235
+ m.className = "msg user";
236
+ m.innerHTML = `<div class="a-avatar">🧑</div><div class="a-body"><div class="a-content"></div></div>`;
237
+ m.querySelector(".a-content").textContent = text;
238
+ chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight;
239
+ return m;
240
+ }
241
+ function addAssistantBubble(){
242
+ const m = document.createElement("div");
243
+ m.className = "msg assistant";
244
+ m.innerHTML =
245
+ `<div class="a-avatar">🤖</div>
246
+ <div class="a-body">
247
+ <div class="a-content"></div>
248
+ <div class="a-beat"><span class="spin">◐</span><span class="beat-text">Reading your trip request…</span></div>
249
+ </div>`;
250
+ chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight;
251
+ return m;
252
+ }
253
+ function setBeat(bubble, text){
254
+ const beat = bubble.querySelector(".a-beat");
255
+ if (!beat) return;
256
+ beat.style.display = "flex";
257
+ beat.querySelector(".beat-text").textContent = text;
258
+ }
259
+ function hideBeat(bubble){
260
+ const beat = bubble.querySelector(".a-beat");
261
+ if (beat) beat.style.display = "none";
262
+ }
263
+ function esc(s){ const d=document.createElement("div"); d.textContent=s==null?"":String(s); return d.innerHTML; }
264
+
265
+ // ── Streaming via raw SSE (verified wire format) ───────
266
+ function handleEvent(ev, bubble){
267
+ if (!ev) return;
268
+ if (ev.type === "commentary"){
269
+ setBeat(bubble, ev.text);
270
+ } else if (ev.type === "greenlight"){
271
+ bubble.querySelector(".a-content").innerHTML =
272
+ `<div class="green">✅ Planning your trip: ${esc(ev.text)}</div>`;
273
+ } else if (ev.type === "clarify"){
274
+ bubble.querySelector(".a-content").innerHTML = `<div class="clarify">💬 ${esc(ev.text)}</div>`;
275
+ hideBeat(bubble);
276
+ } else if (ev.type === "error"){
277
+ bubble.querySelector(".a-content").innerHTML = `<div class="err">${esc(ev.text)}</div>`;
278
+ hideBeat(bubble);
279
+ } else if (ev.type === "result"){
280
+ if (resultHandled) return; // `complete` re-emits the final yield — render once
281
+ resultHandled = true;
282
+ hideBeat(bubble);
283
+ bubble.querySelector(".a-content").innerHTML =
284
+ `<div class="green">✅ Built 3 ranked packages → see them on the map</div>`;
285
+ resultEl.innerHTML = ev.html;
286
+ activateScripts(resultEl); // re-init the injected Leaflet map
287
+ if (ev.explanation && ev.explanation.trim()){
288
+ const nb = document.createElement("div");
289
+ nb.className = "msg assistant";
290
+ const safe = esc(ev.explanation).replace(/\n/g,"<br>");
291
+ nb.innerHTML =
292
+ `<div class="a-avatar">🤖</div><div class="a-body">
293
+ <div class="a-content"><b style="color:#1e3a8a">🤖 Nemotron compares your options</b><br>${safe}
294
+ <div class="nemotron-note">written by Nemotron-3-Nano-30B · grounded in the scored data</div>
295
+ </div></div>`;
296
+ chatEl.appendChild(nb);
297
+ }
298
+ chatEl.scrollTop = chatEl.scrollHeight;
299
+ }
300
+ }
301
+
302
+ function activateScripts(root){
303
+ // innerHTML-inserted <script> tags don't auto-run — re-create them so the
304
+ // Leaflet init (and any other inline script in the result fragment) executes.
305
+ root.querySelectorAll("script").forEach(old=>{
306
+ const s = document.createElement("script");
307
+ if (old.src){ s.src = old.src; } else { s.textContent = old.textContent; }
308
+ old.replaceWith(s);
309
+ });
310
+ }
311
+
312
+ async function runTrip(forcedText){
313
+ if (running) return;
314
+ const text = (forcedText == null ? promptEl.value : forcedText).trim();
315
+ if (!text) return;
316
+ running = true; sendBtn.disabled = true;
317
+ addUserBubble(text);
318
+ const bubble = addAssistantBubble();
319
+
320
+ try{
321
+ const resp = await fetch("/gradio_api/call/plan_trip", {
322
+ method: "POST", headers: {"Content-Type":"application/json"},
323
+ body: JSON.stringify({ data: [text] }),
324
+ });
325
+ const j = await resp.json();
326
+ const eventId = j && j.event_id;
327
+ if (!eventId){ bubble.querySelector(".a-content").innerHTML = `<div class="err">No event id returned from the queue.</div>`; hideBeat(bubble); return; }
328
+
329
+ const es = new EventSource("/gradio_api/call/plan_trip/" + eventId);
330
+ es.addEventListener("generating", (e)=>{
331
+ try{ const arr = JSON.parse(e.data); handleEvent(JSON.parse(arr[0]), bubble); }catch(_){}
332
+ });
333
+ es.addEventListener("complete", (e)=>{
334
+ try{ const arr = JSON.parse(e.data); handleEvent(arr && arr[0] ? JSON.parse(arr[0]) : null, bubble); }catch(_){}
335
+ es.close(); finish();
336
+ });
337
+ es.addEventListener("error", ()=>{ es.close(); finish(); });
338
+ }catch(err){
339
+ bubble.querySelector(".a-content").innerHTML = `<div class="err">⚠️ ${esc(err.message)}</div>`;
340
+ hideBeat(bubble); finish();
341
+ }
342
+ }
343
+ function finish(){ running = false; sendBtn.disabled = false; resultHandled = false; }
344
+
345
+ // ── Full-screen map (Layla frame 24) ───────────────────
346
+ function matchdayFullscreen(on){
347
+ const mapEl = document.getElementById("matchday-map");
348
+ const closeBtn = document.getElementById("md-fs-close");
349
+ if (!mapEl) return;
350
+ if (on === undefined) on = !mapEl.classList.contains("fs");
351
+ mapEl.classList.toggle("fs", on);
352
+ closeBtn.classList.toggle("show", on);
353
+ setTimeout(()=>{ if (window._matchdayMap){ window._matchdayMap.invalidateSize(); } }, 120);
354
+ }
355
+ window.matchdayFullscreen = matchdayFullscreen;
356
+
357
+ // ── Examples + keyboard ────────────────────────────────
358
+ document.getElementById("examples").addEventListener("click", (e)=>{
359
+ const chip = e.target.closest(".chip");
360
+ if (!chip) return;
361
+ promptEl.value = chip.textContent;
362
+ runTrip(chip.textContent);
363
+ });
364
+ promptEl.addEventListener("keydown", (e)=>{
365
+ if (e.key === "Enter" && !e.shiftKey){ e.preventDefault(); runTrip(); }
366
+ });
367
+ </script>
368
+ </body>
369
+ </html>
matchday/agent_loop.py CHANGED
@@ -31,6 +31,7 @@ from typing import Any, Literal
31
  from pydantic import BaseModel, Field, field_validator
32
 
33
  from matchday.api_registry import registry
 
34
  from matchday.models import ScoredPackage
35
 
36
  logger = logging.getLogger(__name__)
@@ -948,10 +949,7 @@ class AgentLoop:
948
  try:
949
  validated_args = self._allow_list.validate_args(tool_name, raw_args)
950
  except Exception as exc:
951
- error_msg = (
952
- f"Argument validation failed for {tool_name!r}: {exc}. "
953
- "Please correct the arguments and call the tool again."
954
- )
955
  logger.warning("Argument validation error: %s", exc)
956
 
957
  self._inject_error_message(messages, tool_name, error_msg)
 
31
  from pydantic import BaseModel, Field, field_validator
32
 
33
  from matchday.api_registry import registry
34
+ from matchday.errors import format_validation_error
35
  from matchday.models import ScoredPackage
36
 
37
  logger = logging.getLogger(__name__)
 
949
  try:
950
  validated_args = self._allow_list.validate_args(tool_name, raw_args)
951
  except Exception as exc:
952
+ error_msg = format_validation_error(tool_name, exc)
 
 
 
953
  logger.warning("Argument validation error: %s", exc)
954
 
955
  self._inject_error_message(messages, tool_name, error_msg)
matchday/app.py CHANGED
@@ -1,17 +1,17 @@
1
  """MatchDay — Gradio app for the 2026 FIFA World Cup in Vancouver.
2
 
3
- Agentic flow (primary): Nemotron (Brain, on Modal) decides which tool to call
4
  and extracts the trip intent -> Python (Hands) executes build_trip_packages and
5
- scores every price deterministically -> Nemotron writes the comparison. Falls
6
- back to the deterministic intent parser if the agent is unavailable or declines
7
- to call a tool, so the demo never breaks.
 
8
 
9
  Brain + Hands: Nemotron never calls an API or names a price; Python does. Every
10
  figure carries a provenance badge (live vs example).
11
  """
12
  from __future__ import annotations
13
 
14
- import asyncio
15
  import json
16
  import logging
17
  from datetime import date
@@ -23,7 +23,7 @@ from matchday.agent_loop import BuildTripPackagesArgs
23
  from matchday.intent import parse_intent
24
  from matchday.models import TripRequest
25
  from matchday.prompts import EXPLANATION_HINT
26
- from matchday.render import render_full
27
  from matchday.trip_tool import build_trip_packages, format_for_nemotron
28
 
29
  logger = logging.getLogger(__name__)
@@ -82,10 +82,11 @@ def _explain_html(text: str) -> str:
82
  )
83
 
84
 
85
- def _fallback_note_html() -> str:
86
- return (
87
- '<div style="font-size:11px;color:#64748b;padding:4px 2px">'
88
- "⚙️ agent offline — showing deterministic result.</div>"
 
89
  )
90
 
91
 
@@ -102,12 +103,7 @@ def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
102
 
103
 
104
  async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> str:
105
- """Round 2: Nemotron compares the packages. Best-effort ('' on failure).
106
-
107
- Builds a tool_call/tool_result pair from the executed trip so Nemotron sees
108
- the packages regardless of whether it or the deterministic parser drove the
109
- build.
110
- """
111
  args_json = json.dumps(trip.model_dump(mode="json"))
112
  convo = [
113
  {"role": "user", "content": user_text},
@@ -136,92 +132,72 @@ async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> st
136
  return ""
137
 
138
 
139
- async def _agentic(user_text: str) -> str | None:
140
- """Nemotron decides + explains, with a deterministic build safety net.
141
-
142
- Nemotron (round 1) picks a tool. If it calls build_trip_packages we use its
143
- args; if it clarifies/declines but the deterministic parser can still extract
144
- a complete trip, we build anyway (the 30B model is sometimes over-cautious).
145
- Only a genuinely incomplete request yields a clarifying question. Nemotron
146
- always writes the final comparison (round 2).
147
  """
148
- try:
149
- agent = MatchDayAgent()
150
- except Exception as exc:
151
- logger.warning("agent init failed (%s); deterministic fallback.", exc)
152
- return None
153
 
154
- base = [{"role": "user", "content": user_text}]
155
- try:
156
- r1 = await agent.run(base)
157
- except Exception as exc:
158
- logger.warning("agent round 1 failed (%s); deterministic fallback.", exc)
159
- return None
 
 
 
 
 
 
 
 
 
 
160
 
 
161
  tool_calls = r1.get("tool_calls") or []
162
  trip: TripRequest | None = None
163
-
164
  if tool_calls and tool_calls[0].get("name") == "build_trip_packages":
165
  try:
166
  trip = _args_to_trip(
167
  BuildTripPackagesArgs.model_validate(tool_calls[0].get("arguments", {}))
168
  )
169
- except Exception as exc:
170
- logger.warning("agent build_trip_packages args invalid (%s).", exc)
171
-
172
- # Safety net: if Nemotron clarified/declined/gave bad args, try deterministic.
173
  if trip is None:
174
  parsed = parse_intent(user_text)
175
  if parsed.trip_request is not None:
176
  trip = parsed.trip_request
177
 
178
  if trip is None:
179
- # Genuinely incomplete: surface a clarifying question (Nemotron's, if any).
180
  clarify_q = ""
181
  if tool_calls and tool_calls[0].get("name") == "clarify":
182
  clarify_q = tool_calls[0].get("arguments", {}).get("question", "")
183
  if not clarify_q:
184
  clarify_q = parse_intent(user_text).question
185
- return _clarify_html(
186
  clarify_q or "Tell me where you're flying from and which match you want to see."
187
  )
 
188
 
189
- try:
190
- result = await build_trip_packages(trip)
191
- except Exception as exc:
192
- return _greenlight_html(trip.summary()) + f'<div style="color:#991b1b;padding:12px">⚠️ {exc}</div>'
193
-
194
- explanation = await _agent_explain(agent, user_text, trip, result)
195
- return (
196
- _greenlight_html(trip.summary())
197
- + render_full(result)
198
- + (_explain_html(explanation) if explanation else "")
199
  )
200
-
201
-
202
- async def _deterministic(user_text: str) -> str:
203
- parsed = parse_intent(user_text)
204
- if parsed.trip_request is None:
205
- return _clarify_html(parsed.question)
206
- greenlight = _greenlight_html(parsed.trip_request.summary())
207
  try:
208
- result = await build_trip_packages(parsed.trip_request)
209
  except Exception as exc:
210
- return greenlight + f'<div style="color:#991b1b;padding:12px">⚠️ {exc}</div>'
211
- return greenlight + _fallback_note_html() + render_full(result)
212
-
213
-
214
- async def _plan(user_text: str) -> str:
215
- if USE_AGENT:
216
- html = await _agentic(user_text)
217
- if html is not None:
218
- return html
219
- return await _deterministic(user_text)
220
 
 
 
 
 
221
 
222
- def plan_trip(user_text: str) -> str:
223
- """Gradio handler (sync wrapper around the async agentic pipeline)."""
224
- return asyncio.run(_plan(user_text))
225
 
226
 
227
  with gr.Blocks(title="MatchDay — FIFA World Cup Vancouver") as demo:
 
1
  """MatchDay — Gradio app for the 2026 FIFA World Cup in Vancouver.
2
 
3
+ Streaming agentic flow: Nemotron (Brain, on Modal) decides which tool to call
4
  and extracts the trip intent -> Python (Hands) executes build_trip_packages and
5
+ scores every price deterministically -> Nemotron writes the comparison. The
6
+ handler is an async generator that yields staged status between the real phases
7
+ (U4 progressive rendering + G1 staged narration), so the UI updates live instead
8
+ of freezing. Falls back to the deterministic parser if the agent is unavailable.
9
 
10
  Brain + Hands: Nemotron never calls an API or names a price; Python does. Every
11
  figure carries a provenance badge (live vs example).
12
  """
13
  from __future__ import annotations
14
 
 
15
  import json
16
  import logging
17
  from datetime import date
 
23
  from matchday.intent import parse_intent
24
  from matchday.models import TripRequest
25
  from matchday.prompts import EXPLANATION_HINT
26
+ from matchday.render import _CSS, render_full
27
  from matchday.trip_tool import build_trip_packages, format_for_nemotron
28
 
29
  logger = logging.getLogger(__name__)
 
82
  )
83
 
84
 
85
+ def _progress_html(msg: str) -> str:
86
+ """Staged status card between phases (U4 + G1 — narration tied to real progress)."""
87
+ return _CSS + (
88
+ '<div class="md-wrap"><div class="md-progress">'
89
+ f'<span class="spin">⏳</span> <span>{msg}</span></div></div>'
90
  )
91
 
92
 
 
103
 
104
 
105
  async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> str:
106
+ """Round 2: Nemotron compares the packages. Best-effort ('' on failure)."""
 
 
 
 
 
107
  args_json = json.dumps(trip.model_dump(mode="json"))
108
  convo = [
109
  {"role": "user", "content": user_text},
 
132
  return ""
133
 
134
 
135
+ async def plan_trip(user_text: str):
136
+ """Streaming handler: yields staged status during the real agent phases
137
+ (deciding -> searching -> scoring -> final cards+map+timeline). Falls back
138
+ to the deterministic parser if Nemotron is unavailable or hedges.
 
 
 
 
139
  """
140
+ yield _progress_html("Reading your trip request…")
 
 
 
 
141
 
142
+ agent = None
143
+ if USE_AGENT:
144
+ try:
145
+ agent = MatchDayAgent()
146
+ except Exception as exc:
147
+ logger.warning("agent init failed (%s); deterministic path.", exc)
148
+
149
+ # Round 1 — Nemotron decides which tool to call.
150
+ r1: dict = {"tool_calls": []}
151
+ if agent is not None:
152
+ yield _progress_html("🤖 Nemotron is choosing your best options…")
153
+ try:
154
+ r1 = await agent.run([{"role": "user", "content": user_text}])
155
+ except Exception as exc:
156
+ logger.warning("agent round 1 failed (%s).", exc)
157
+ r1 = {"tool_calls": []}
158
 
159
+ # Resolve a validated trip: Nemotron's args first, then deterministic parse.
160
  tool_calls = r1.get("tool_calls") or []
161
  trip: TripRequest | None = None
 
162
  if tool_calls and tool_calls[0].get("name") == "build_trip_packages":
163
  try:
164
  trip = _args_to_trip(
165
  BuildTripPackagesArgs.model_validate(tool_calls[0].get("arguments", {}))
166
  )
167
+ except Exception:
168
+ trip = None
 
 
169
  if trip is None:
170
  parsed = parse_intent(user_text)
171
  if parsed.trip_request is not None:
172
  trip = parsed.trip_request
173
 
174
  if trip is None:
 
175
  clarify_q = ""
176
  if tool_calls and tool_calls[0].get("name") == "clarify":
177
  clarify_q = tool_calls[0].get("arguments", {}).get("question", "")
178
  if not clarify_q:
179
  clarify_q = parse_intent(user_text).question
180
+ yield _clarify_html(
181
  clarify_q or "Tell me where you're flying from and which match you want to see."
182
  )
183
+ return
184
 
185
+ green = _greenlight_html(trip.summary())
186
+ yield green + _progress_html(
187
+ "✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…"
 
 
 
 
 
 
 
188
  )
 
 
 
 
 
 
 
189
  try:
190
+ result = await build_trip_packages(trip)
191
  except Exception as exc:
192
+ yield green + f'<div style="color:#991b1b;padding:12px">⚠️ {exc}</div>'
193
+ return
 
 
 
 
 
 
 
 
194
 
195
+ yield green + _progress_html("🗺️ Scoring 3 packages · Nemotron is writing your comparison…")
196
+ explanation = ""
197
+ if agent is not None:
198
+ explanation = await _agent_explain(agent, user_text, trip, result)
199
 
200
+ yield green + render_full(result, trip) + (_explain_html(explanation) if explanation else "")
 
 
201
 
202
 
203
  with gr.Blocks(title="MatchDay — FIFA World Cup Vancouver") as demo:
matchday/errors.py CHANGED
@@ -442,6 +442,68 @@ def _classify_by_exception(
442
  import json # noqa: E402 (imported here to keep it close to usage)
443
 
444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  # ---------------------------------------------------------------------------
446
  # jittered_backoff — exponential backoff with random jitter
447
  # ---------------------------------------------------------------------------
 
442
  import json # noqa: E402 (imported here to keep it close to usage)
443
 
444
 
445
+ # ---------------------------------------------------------------------------
446
+ # N7 — LLM-friendly Pydantic validation-error formatter
447
+ # ---------------------------------------------------------------------------
448
+ # Source pattern: Claude Code/utils/toolErrors.ts (categorize into missing /
449
+ # unexpected / type-mismatch, each with the field name). Adapted for Pydantic v2
450
+ # ValidationError so Nemotron's ONE correction attempt (A4) gets a concise,
451
+ # model-readable reason instead of a raw Pydantic dump — raising the chance the
452
+ # retry succeeds instead of falling back to the deterministic parser.
453
+
454
+ # Per-field hints keyed by the tool-argument field name (shared across tools).
455
+ _FIELD_HINTS: dict[str, str] = {
456
+ "match_date": "Expected format: YYYY-MM-DD.",
457
+ "check_in": "Expected format: YYYY-MM-DD.",
458
+ "check_out": "Expected format: YYYY-MM-DD.",
459
+ "origin_airport": "Must be a 3-letter IATA airport code (e.g. YUL, YYZ, YVR).",
460
+ "travelers": "Must be a whole number from 1 to 6.",
461
+ "budget_tier": "Must be one of: budget, mid_range, premium.",
462
+ "query": "Must be a non-empty search string.",
463
+ "max_results": "Must be a whole number from 1 to 10.",
464
+ "question": "Must be a non-empty clarifying question.",
465
+ }
466
+
467
+
468
+ def format_validation_error(tool_name: str, exc: BaseException, max_lines: int = 4) -> str:
469
+ """Turn a tool-argument validation failure into a concise, model-readable
470
+ message (N7). Categorizes each error as missing-required / wrong-type /
471
+ invalid-value with the field name + a hint, head-truncated to ``max_lines``.
472
+
473
+ Falls back to a short ``str(exc)`` when ``exc`` is not a Pydantic
474
+ ``ValidationError`` (e.g. a plain ``ValueError`` from a custom validator).
475
+ """
476
+ lines: list[str] = []
477
+ errors_fn = getattr(exc, "errors", None)
478
+ if callable(errors_fn):
479
+ try:
480
+ for err in errors_fn()[:max_lines]:
481
+ loc = err.get("loc") or ()
482
+ field = str(loc[-1]) if loc else "argument"
483
+ etype = (err.get("type") or "").lower()
484
+ msg = (err.get("msg") or "invalid value").rstrip(".")
485
+ inp = err.get("input")
486
+ hint = _FIELD_HINTS.get(field, "")
487
+ if etype == "missing" or "required" in msg.lower():
488
+ line = f"Required parameter '{field}' is missing."
489
+ elif "literal" in etype or "enum" in etype or "pattern" in etype:
490
+ line = f"Parameter '{field}' has an invalid value {inp!r}."
491
+ elif etype and ("int" in etype or "float" in etype or etype.endswith("_type")):
492
+ line = f"Parameter '{field}' has the wrong type (got {inp!r})."
493
+ else:
494
+ line = f"Parameter '{field}': {msg} (got {inp!r})."
495
+ lines.append(f"{line} {hint}".strip())
496
+ except Exception: # never let the formatter itself throw
497
+ lines = []
498
+ if not lines:
499
+ lines = [str(exc)[:200]]
500
+ body = " ".join(lines[:max_lines])
501
+ return (
502
+ f"The tool call to {tool_name!r} was rejected because: {body} "
503
+ f"Fix these arguments and call {tool_name!r} again."
504
+ )
505
+
506
+
507
  # ---------------------------------------------------------------------------
508
  # jittered_backoff — exponential backoff with random jitter
509
  # ---------------------------------------------------------------------------
matchday/render.py CHANGED
@@ -51,7 +51,25 @@ _CSS = """
51
  .md-live{background:#dcfce7;color:#166534;}
52
  .md-example{background:#fef3c7;color:#92400e;}
53
  .md-sub{color:#64748b;font-size:11px;}
54
- #matchday-map{height:380px;border-radius:12px;border:1px solid #e2e8f0;margin-top:10px;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  </style>
56
  """
57
 
@@ -167,13 +185,21 @@ def _js_markers(result: TripPackageResult) -> str:
167
  return "\n".join(lines)
168
 
169
 
170
- def render_map(result: TripPackageResult) -> str:
 
 
 
 
 
 
171
  return f"""
172
  {_CSS}
173
  <div class="md-wrap">
174
- <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
 
 
 
175
  <div id="matchday-map"></div>
176
- <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
177
  <script>
178
  (function(){{
179
  var el=document.getElementById('matchday-map');
@@ -181,6 +207,7 @@ def render_map(result: TripPackageResult) -> str:
181
  if(el._lmap){{el._lmap.remove();}}
182
  var map=L.map('matchday-map').setView([{BC_PLACE_LAT},{BC_PLACE_LON}],15);
183
  el._lmap=map;
 
184
  L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{maxZoom:19,attribution:'© OpenStreetMap'}}).addTo(map);
185
  var stadiumIcon=L.divIcon({{html:'🏟️',iconSize:[26,26],iconAnchor:[13,13]}});
186
  var hotelIcon=L.divIcon({{html:'🏨',iconSize:[22,22],iconAnchor:[11,11]}});
@@ -192,5 +219,62 @@ def render_map(result: TripPackageResult) -> str:
192
  """
193
 
194
 
195
- def render_full(result: TripPackageResult) -> str:
196
- return render_status_bar(result) + render_cards(result) + render_map(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  .md-live{background:#dcfce7;color:#166534;}
52
  .md-example{background:#fef3c7;color:#92400e;}
53
  .md-sub{color:#64748b;font-size:11px;}
54
+ #matchday-map{height:460px;border-radius:12px;border:1px solid #e2e8f0;margin-top:10px;}
55
+ .md-map-bar{display:flex;align-items:center;justify-content:space-between;margin-top:6px;}
56
+ .md-fs-btn{font-size:12px;font-weight:600;color:#1e3a8a;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:5px 12px;cursor:pointer;}
57
+ .md-fs-overlay{position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:9999;display:none;}
58
+ .md-fs-overlay.on{display:block;}
59
+ .md-fs-map{position:absolute;inset:18px;border-radius:12px;overflow:hidden;}
60
+ .md-fs-close{position:absolute;top:24px;right:28px;z-index:10000;background:#fff;color:#0f172a;font-weight:700;border:none;border-radius:8px;padding:8px 14px;cursor:pointer;font-size:14px;}
61
+ .md-progress{font-family:-apple-system,Roboto,sans-serif;padding:18px;background:linear-gradient(135deg,#0f172a,#1e3a8a);color:#fff;border-radius:14px;font-size:16px;font-weight:600;margin:10px 0;min-height:60px;display:flex;align-items:center;gap:12px;}
62
+ .md-progress .spin{display:inline-block;animation:mdspin 1s linear infinite;}
63
+ @keyframes mdspin{to{transform:rotate(360deg);}}
64
+ .md-timeline{margin:14px 0;padding:0 4px;}
65
+ .md-tl-row{display:flex;gap:14px;padding:10px 0;border-left:3px solid #e2e8f0;margin-left:14px;padding-left:18px;position:relative;}
66
+ .md-tl-row.match{border-left-color:#16a34a;}
67
+ .md-tl-dot{position:absolute;left:-15px;top:10px;width:28px;height:28px;border-radius:50%;background:#fff;border:2px solid #1e3a8a;display:flex;align-items:center;justify-content:center;font-size:15px;}
68
+ .md-tl-row.match .md-tl-dot{border-color:#16a34a;background:#dcfce7;}
69
+ .md-tl-title{font-weight:700;color:#0f172a;font-size:14px;}
70
+ .md-tl-row.match .md-tl-title{color:#166534;}
71
+ .md-tl-text{color:#475569;font-size:13px;margin-top:2px;line-height:1.4;}
72
+ .md-tl-h{font-size:14px;font-weight:700;color:#1e3a8a;margin:14px 0 4px;}
73
  </style>
74
  """
75
 
 
185
  return "\n".join(lines)
186
 
187
 
188
+ def render_map(result: TripPackageResult, leaflet_preloaded: bool = False) -> str:
189
+ # When the frontend preloaded Leaflet in <head> (gradio.Server path), skip the
190
+ # CDN tags so re-running the inline init script after injection is dependency-free.
191
+ _cdn = "" if leaflet_preloaded else (
192
+ ' <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>\n'
193
+ ' <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>\n'
194
+ )
195
  return f"""
196
  {_CSS}
197
  <div class="md-wrap">
198
+ {_cdn} <div class="md-map-bar">
199
+ <span class="md-sub">🏟️ BC Place · 🏨 hotels · 📍 nearby spots — drag &amp; zoom to explore</span>
200
+ <button class="md-fs-btn" type="button" onclick="window.matchdayFullscreen&amp;&amp;matchdayFullscreen()">⛶ Full screen</button>
201
+ </div>
202
  <div id="matchday-map"></div>
 
203
  <script>
204
  (function(){{
205
  var el=document.getElementById('matchday-map');
 
207
  if(el._lmap){{el._lmap.remove();}}
208
  var map=L.map('matchday-map').setView([{BC_PLACE_LAT},{BC_PLACE_LON}],15);
209
  el._lmap=map;
210
+ window._matchdayMap=map;
211
  L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{maxZoom:19,attribution:'© OpenStreetMap'}}).addTo(map);
212
  var stadiumIcon=L.divIcon({{html:'🏟️',iconSize:[26,26],iconAnchor:[13,13]}});
213
  var hotelIcon=L.divIcon({{html:'🏨',iconSize:[22,22],iconAnchor:[11,11]}});
 
219
  """
220
 
221
 
222
+ def render_timeline(trip, result: TripPackageResult) -> str:
223
+ """Day-by-day vertical itinerary (A2): arrival → match day → explore.
224
+
225
+ Uses the top package's flight/hotel to anchor Day 1; the match day is the
226
+ highlighted centerpiece.
227
+ """
228
+ from datetime import timedelta
229
+
230
+ top = result.packages[0] if result.packages else None
231
+ items: list[str] = []
232
+ d = trip.check_in
233
+ idx = 0
234
+ while d <= trip.check_out:
235
+ idx += 1
236
+ if d == trip.check_in and top:
237
+ hotel = top.hotel.name if top.hotel else "your hotel"
238
+ icon, title, body, cls = (
239
+ "✈️", f"Day {idx} · {d:%a %b %d}",
240
+ f"Fly <b>{_e(top.flight.airline)} {_e(top.flight.flight_number)}</b> "
241
+ f"({_e(top.flight.origin)}→YVR, lands {top.flight.arrival_time:%H:%M}); "
242
+ f"check in to {_e(hotel)}.",
243
+ "",
244
+ )
245
+ elif d == trip.match_date:
246
+ icon, title, body, cls = (
247
+ "🏟️", f"Day {idx} · {d:%a %b %d} — MATCH DAY",
248
+ f"<b>{_e(trip.match_name)}</b> at BC Place Stadium, 7:00 PM PT kickoff. "
249
+ "Soak up the fan zone, then head in early.",
250
+ "match",
251
+ )
252
+ elif d < trip.match_date:
253
+ icon, title, body, cls = (
254
+ "🏙️", f"Day {idx} · {d:%a %b %d}",
255
+ "Arrive & settle — Stanley Park, Granville Island, the seawall.",
256
+ "",
257
+ )
258
+ else:
259
+ icon, title, body, cls = (
260
+ "🗺️", f"Day {idx} · {d:%a %b %d}",
261
+ "Day after the match: brunch, last sights, fly home.",
262
+ "",
263
+ )
264
+ items.append(
265
+ f'<div class="md-tl-row {cls}"><div class="md-tl-dot">{icon}</div>'
266
+ f'<div><div class="md-tl-title">{title}</div>'
267
+ f'<div class="md-tl-text">{body}</div></div></div>'
268
+ )
269
+ d += timedelta(days=1)
270
+ return (
271
+ f'{_CSS}<div class="md-wrap"><div class="md-tl-h">📅 Day-by-day itinerary</div>'
272
+ f'<div class="md-timeline">{"".join(items)}</div></div>'
273
+ )
274
+
275
+
276
+ def render_full(result: TripPackageResult, trip=None, leaflet_preloaded: bool = False) -> str:
277
+ out = render_status_bar(result) + render_cards(result) + render_map(result, leaflet_preloaded=leaflet_preloaded)
278
+ if trip is not None:
279
+ out += render_timeline(trip, result)
280
+ return out
matchday/traces/session_001.jsonl CHANGED
@@ -1,4 +1,4 @@
1
- {"ts": "2026-06-13T21:09:55.888255+00:00", "event": "user", "content": "Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me"}
2
- {"ts": "2026-06-13T21:09:58.239967+00:00", "event": "nemotron_decision", "tool_calls": [{"name": "build_trip_packages", "arguments": {"check_out": "2026-06-29", "budget_tier": "mid_range", "origin_airport": "YUL", "match_name": "Canada vs Qatar", "check_in": "2026-06-26", "match_date": "2026-06-26", "travelers": 1}, "id": "call_c70b566e1c79417b8ae924eb"}], "text": "\n"}
3
- {"ts": "2026-06-13T21:09:59.343542+00:00", "event": "tool_result", "tool": "build_trip_packages", "trip": "YUL -> Vancouver, 2026-06-26 to 2026-06-29, mid_range budget, 1 traveler, watching Canada vs Qatar on 2026-06-26.", "status": "complete", "degradation": ["Photos unavailable"], "packages": [{"label": "Cheapest", "total_cost_cad": 890.0, "flight": "Flair Airlines F8 107 (lands 11:00)", "hotel": "Hotel At The Waldorf", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}, {"label": "Safest Arrival", "total_cost_cad": 970.0, "flight": "Air Canada AC 303 (lands 10:45)", "hotel": "Hotel At The Waldorf", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}, {"label": "Closest to Stadium", "total_cost_cad": 1353.0, "flight": "Flair Airlines F8 107 (lands 11:00)", "hotel": "Best Western Premier Chateau Granville Hotel & Suites & Conf. Centre", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}], "compact": "STATUS: complete | 1 degradation(s) | 3 package(s) scored\n DEGRADATION: Photos unavailable\n#1 Cheapest | $890.00 CAD | Flight Flair Airlines F8 107 lands 11:00 from YUL (source=serpapi) | Hotel Hotel At The Waldorf 3.367km to BC Place 3.6\u2605 (source=serpapi) | +8.0h buffer | 40 min walk | rain risk 35%\n#2 Safest Arrival | $970.00 CAD | Flight Air Canada AC 303 lands 10:45 from YUL (source=serpapi) | Hotel Hotel At The Waldorf 3.367km to BC Place 3.6\u2605 (source=serpapi) | +8.2h buffer | 40 min walk | rain risk 35%\n#3 Closest to Stadium | $1,353.00 CAD | Flight Flair Airlines F8 107 lands 11:00 from YUL (source=serpapi) | Hotel Best Western Premier Chateau Granville Hotel & Suites & Conf. Centre 0.615km to BC Place 4.2\u2605 (source=serpapi) | +8.0h buffer | 7 min walk | rain risk 35%"}
4
- {"ts": "2026-06-13T21:10:01.869612+00:00", "event": "nemotron_explanation", "text": "**#1 Cheapest** \u2013 $890.00 CAD | Airline: Flair Airlines | Arrival: 11:00 | Hotel: Hotel At The Waldorf | Distance: 3.367km to BC Place | Weather: rain risk 35% (example) \n**#2 Safest Arrival** \u2013 $970.00 CAD | Airline: Air Canada | Arrival: 10:45 | Hotel: Hotel At The Waldorf | Distance: 3.367km to BC Place | Weather: rain risk 35% (example) \n**#3 Closest to Stadium** \u2013 $1,353.00 CAD | Airline: Flair Airlines | Arrival: 11:00 | Hotel: Best Western Premier Chateau Granville Hotel & Suites & Conf. Centre | Distance: 0.615km to BC Place | Weather: rain risk 35% (example) \n\n**Recommendation:** Closest to Stadium offers the shortest walk but at a premium price."}
 
1
+ {"ts": "2026-06-14T06:00:58.930615+00:00", "event": "user", "content": "Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me"}
2
+ {"ts": "2026-06-14T06:04:55.078756+00:00", "event": "nemotron_decision", "tool_calls": [{"name": "build_trip_packages", "arguments": {"origin_airport": "YUL", "check_out": "2026-06-29", "check_in": "2026-06-26", "match_name": "Canada vs Qatar", "match_date": "2026-06-26", "budget_tier": "mid_range", "travelers": 1}, "id": "call_20bef97cf4a04a64a0865288"}], "text": "\n"}
3
+ {"ts": "2026-06-14T06:05:07.290104+00:00", "event": "tool_result", "tool": "build_trip_packages", "trip": "YUL -> Vancouver, 2026-06-26 to 2026-06-29, mid_range budget, 1 traveler, watching Canada vs Qatar on 2026-06-26.", "status": "complete", "degradation": ["Photos unavailable"], "packages": [{"label": "Closest to Stadium", "total_cost_cad": 1122.0, "flight": "Flair Airlines F8 107 (lands 11:00)", "hotel": "Moda Hotel", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}, {"label": "Cheapest", "total_cost_cad": 901.0, "flight": "Flair Airlines F8 107 (lands 11:00)", "hotel": "Hotel At The Waldorf", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}, {"label": "Safest Arrival", "total_cost_cad": 1202.0, "flight": "Air Canada AC 303 (lands 10:45)", "hotel": "Moda Hotel", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}], "compact": "STATUS: complete | 1 degradation(s) | 3 package(s) scored\n DEGRADATION: Photos unavailable\n#1 Closest to Stadium | $1,122.00 CAD | Flight Flair Airlines F8 107 lands 11:00 from YUL (source=serpapi) | Hotel Moda Hotel 0.354km to BC Place 3.8\u2605 (source=serpapi) | +8.0h buffer | 4 min walk | rain risk 39%\n#2 Cheapest | $901.00 CAD | Flight Flair Airlines F8 107 lands 11:00 from YUL (source=serpapi) | Hotel Hotel At The Waldorf 3.367km to BC Place 3.6\u2605 (source=serpapi) | +8.0h buffer | 40 min walk | rain risk 39%\n#3 Safest Arrival | $1,202.00 CAD | Flight Air Canada AC 303 lands 10:45 from YUL (source=serpapi) | Hotel Moda Hotel 0.354km to BC Place 3.8\u2605 (source=serpapi) | +8.2h buffer | 4 min walk | rain risk 39%"}
4
+ {"ts": "2026-06-14T06:05:10.956201+00:00", "event": "nemotron_explanation", "text": "#1 Closest to Stadium | $1,122.00 CAD | Airline: Flair Airlines | Arrival: 11:00 | Hotel: Moda Hotel | Distance: 0.354km | Weather: rain risk 39% (example: \"rain risk 39%\") \n#2 Cheapest | $901.00 CAD | Airline: Flair Airlines | Arrival: 11:00 | Hotel: Hotel At The Waldorf | Distance: 3.367km | Weather: rain risk 39% (example: \"rain risk 39%\") \n#3 Safest Arrival | $1,202.00 CAD | Airline: Air Canada | Arrival: 10:45 | Hotel: Moda Hotel | Distance: 0.354km | Weather: rain risk 39% (example: \"rain risk 39%\") \n**Recommendation:** Choose #2 Cheapest for lowest cost with reliable arrival timing."}