mzidan000 commited on
Commit
02b8de5
·
verified ·
1 Parent(s): 4d052ec

Upload folder using huggingface_hub

Browse files
app.py CHANGED
@@ -38,6 +38,13 @@ from gradio import Server # noqa: E402
38
 
39
  from matchday.agent import MatchDayAgent # noqa: E402
40
  from matchday.agent_loop import run_agent_loop # noqa: E402
 
 
 
 
 
 
 
41
  from matchday.intent import parse_intent # noqa: E402
42
  from matchday.models import TripRequest # noqa: E402
43
  from matchday.prompts import EXPLANATION_HINT # noqa: E402
@@ -53,6 +60,9 @@ USE_AGENT = True
53
 
54
  HERE = Path(__file__).parent
55
  INDEX_HTML = HERE / "index.html"
 
 
 
56
 
57
 
58
  async def _warm_nemotron() -> None:
@@ -115,6 +125,48 @@ def _notice_status(result, *keywords: str) -> str:
115
  return "fallback" if any(k in blob for k in keywords) else "done"
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  # Cached per-boot preflight (N35). Fail-fast ONLY on genuinely-doomed config
119
  # (missing SerpApi key — build_trip_packages cannot fetch live flights/hotels).
120
  # Modal cold-start is NOT a hard failure: it streams via _pulse heartbeats and
@@ -180,6 +232,12 @@ async def plan_trip(user_text: str) -> str:
180
  yield _ev(type="error", text=f"⚠️ {why}")
181
  return
182
 
 
 
 
 
 
 
183
  yield _ev(type="commentary", text="Reading your trip request…")
184
  yield _ev(type="progress", step="read", status="done", text="Read your request")
185
  yield _ev(type="progress", step="extract", status="running", text="Understanding your trip")
@@ -213,12 +271,17 @@ async def plan_trip(user_text: str) -> str:
213
  h = {}
214
  try:
215
  async for beat in _pulse(
216
- run_agent_loop(agent, messages),
217
  h,
218
  "🧠 Nemotron is understanding your request & choosing tools",
219
  ):
220
  yield beat
221
  res = h.get("result")
 
 
 
 
 
222
  except Exception as exc:
223
  logger.warning("agent loop attempt %d failed (%s).", attempt, exc)
224
  res = None
@@ -291,6 +354,8 @@ async def plan_trip(user_text: str) -> str:
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
@@ -299,6 +364,9 @@ async def plan_trip(user_text: str) -> str:
299
  # the agent is unavailable, hedged to a non-build answer, or the loop failed.
300
  if result is None and not agent_text:
301
  parsed = parse_intent(user_text)
 
 
 
302
  trip = parsed.trip_request
303
  if trip is not None:
304
  yield _ev(type="greenlight", text=trip.summary())
@@ -311,6 +379,7 @@ async def plan_trip(user_text: str) -> str:
311
  yield _ev(type="progress", step="weather", status="running")
312
  yield _ev(type="progress", step="nearby", status="running")
313
  hb = {}
 
314
  try:
315
  async for beat in _pulse(
316
  build_trip_packages(trip),
@@ -320,8 +389,25 @@ async def plan_trip(user_text: str) -> str:
320
  yield beat
321
  result = hb["result"]
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.
@@ -338,12 +424,18 @@ async def plan_trip(user_text: str) -> str:
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(
349
  type="clarify",
@@ -354,6 +446,10 @@ async def plan_trip(user_text: str) -> str:
354
 
355
  # Clarify / direct answer from the Brain (no packages to show).
356
  if result is None:
 
 
 
 
357
  yield _ev(type="progress", step="ready", status="fallback", text="Need a detail from you")
358
  yield _ev(type="clarify", text=agent_text)
359
  return
@@ -393,10 +489,12 @@ async def plan_trip(user_text: str) -> str:
393
  # Final: the full Layla-competitive render (status + cards + map + timeline).
394
  # leaflet_preloaded=True → the frontend already loaded Leaflet in <head>; the
395
  # map's inline init script is re-run after injection (see index.html).
 
 
396
  yield _ev(type="progress", step="itinerary", status="done")
397
  yield _ev(type="progress", step="links", status="done")
398
  yield _ev(type="progress", step="ready", status="done", text="Your packages are ready")
399
- yield _ev(type="result", html=render_full(result, trip, leaflet_preloaded=True), explanation=explanation)
400
 
401
 
402
  @app.get("/", response_class=HTMLResponse)
 
38
 
39
  from matchday.agent import MatchDayAgent # noqa: E402
40
  from matchday.agent_loop import run_agent_loop # noqa: E402
41
+ from matchday.agent_trace import ( # noqa: E402
42
+ AgentTrace,
43
+ ToolCallRecord,
44
+ evidence_from_result,
45
+ ranking_from_result,
46
+ result_source_labels,
47
+ )
48
  from matchday.intent import parse_intent # noqa: E402
49
  from matchday.models import TripRequest # noqa: E402
50
  from matchday.prompts import EXPLANATION_HINT # noqa: E402
 
60
 
61
  HERE = Path(__file__).parent
62
  INDEX_HTML = HERE / "index.html"
63
+ # Model label shown in the Agent Trace drawer (Best Agent provenance). Honest:
64
+ # 30B total / ~3B active MoE — the ≤32B-cap qualifier is the 30B total weight.
65
+ _TRACE_MODEL = "Nemotron-3-Nano-30B-A3B · 3B-active MoE · Modal A100"
66
 
67
 
68
  async def _warm_nemotron() -> None:
 
125
  return "fallback" if any(k in blob for k in keywords) else "done"
126
 
127
 
128
+ def _finalize_trace(trace: AgentTrace, trip, result, built_by: str) -> None:
129
+ """Populate the final intent/grounding/evidence/ranking/outcome on the trace.
130
+
131
+ Best-effort: the trace is cosmetic proof, so it must never raise and abort a
132
+ trip build. Surfaces the deterministic ranking formula (tier weights + the
133
+ per-package normalized dim scores) so a judge can see HOW the order was
134
+ decided, not just that it was.
135
+ """
136
+ try:
137
+ trace.set_intent(trip)
138
+ if getattr(result, "match_unrecognized", ""):
139
+ # Honest refusal: the named match isn't a real 2026 fixture.
140
+ trace.set_grounding(recognized=False, note=result.match_unrecognized)
141
+ trace.set_outcome(mode=built_by, status="clarify",
142
+ notes=list(result.degradation_notices),
143
+ model=_TRACE_MODEL, rounds=trace.rounds)
144
+ return
145
+ corrected = bool(getattr(result, "grounding_note", ""))
146
+ trace.set_grounding(
147
+ recognized=True, corrected=corrected,
148
+ kickoff=getattr(result, "kickoff_local", "") or "",
149
+ venue="BC Place",
150
+ match_name=(getattr(result, "grounded_match_name", "") or
151
+ (trip.match_name if trip is not None else "")),
152
+ note=getattr(result, "grounding_note", "") or "",
153
+ )
154
+ trace.set_evidence(evidence_from_result(result))
155
+ from matchday.scoring import BUDGET_WEIGHTS
156
+ tier = trip.budget_tier if trip is not None else "mid_range"
157
+ w = BUDGET_WEIGHTS.get(tier, BUDGET_WEIGHTS["mid_range"])
158
+ ranking, _records = ranking_from_result(
159
+ result, tier,
160
+ {"cost": w.cost, "buffer": w.buffer, "transit": w.transit},
161
+ )
162
+ trace.ranking = ranking
163
+ trace.set_outcome(mode=built_by, status=result.status,
164
+ notes=list(result.degradation_notices),
165
+ model=_TRACE_MODEL, rounds=trace.rounds)
166
+ except Exception as exc: # noqa: BLE001
167
+ logger.warning("trace finalization skipped: %s", exc)
168
+
169
+
170
  # Cached per-boot preflight (N35). Fail-fast ONLY on genuinely-doomed config
171
  # (missing SerpApi key — build_trip_packages cannot fetch live flights/hotels).
172
  # Modal cold-start is NOT a hard failure: it streams via _pulse heartbeats and
 
232
  yield _ev(type="error", text=f"⚠️ {why}")
233
  return
234
 
235
+ # The visible Agent Trace accumulator (Best Agent proof). Populated live as
236
+ # intent is extracted, the match is grounded, tools run, and packages are
237
+ # ranked — emitted to the Evidence drawer in REAL TIME via `trace` events.
238
+ trace = AgentTrace()
239
+ built_by = "agent" # flips to "deterministic" if the loop doesn't build
240
+
241
  yield _ev(type="commentary", text="Reading your trip request…")
242
  yield _ev(type="progress", step="read", status="done", text="Read your request")
243
  yield _ev(type="progress", step="extract", status="running", text="Understanding your trip")
 
271
  h = {}
272
  try:
273
  async for beat in _pulse(
274
+ run_agent_loop(agent, messages, trace=trace),
275
  h,
276
  "🧠 Nemotron is understanding your request & choosing tools",
277
  ):
278
  yield beat
279
  res = h.get("result")
280
+ # REAL-TIME trace: push the tool-call log to the drawer after
281
+ # each agent decision so the user sees the multi-step reasoning
282
+ # unfold (web_search → build_trip_packages), not just the end.
283
+ if res is not None:
284
+ yield _ev(type="trace", data=trace.to_dict())
285
  except Exception as exc:
286
  logger.warning("agent loop attempt %d failed (%s).", attempt, exc)
287
  res = None
 
354
  # clarification with real alternatives (Best-Agent honesty: never silently
355
  # plan a trip around a nonexistent fixture like "Canada vs Morocco").
356
  if result is not None and getattr(result, "match_unrecognized", ""):
357
+ _finalize_trace(trace, trip, result, built_by)
358
+ yield _ev(type="trace", data=trace.to_dict()) # grounding-refusal proof
359
  yield _ev(type="progress", step="ready", status="fallback", text="Match not found")
360
  yield _ev(type="clarify", text=result.match_unrecognized)
361
  return
 
364
  # the agent is unavailable, hedged to a non-build answer, or the loop failed.
365
  if result is None and not agent_text:
366
  parsed = parse_intent(user_text)
367
+ built_by = "deterministic"
368
+ trace.set_intent(parsed.trip_request, missing=parsed.missing)
369
+ yield _ev(type="trace", data=trace.to_dict()) # show extracted intent live
370
  trip = parsed.trip_request
371
  if trip is not None:
372
  yield _ev(type="greenlight", text=trip.summary())
 
379
  yield _ev(type="progress", step="weather", status="running")
380
  yield _ev(type="progress", step="nearby", status="running")
381
  hb = {}
382
+ _db_t0 = time.monotonic()
383
  try:
384
  async for beat in _pulse(
385
  build_trip_packages(trip),
 
389
  yield beat
390
  result = hb["result"]
391
  except Exception as exc:
392
+ trace.set_outcome(mode="deterministic", status="error",
393
+ notes=[f"build_trip_packages raised: {exc}"],
394
+ model=_TRACE_MODEL)
395
+ yield _ev(type="trace", data=trace.to_dict()) # honest error in the trace
396
  yield _ev(type="error", text=f"⚠️ {exc}")
397
  return
398
+ # The deterministic path bypasses the loop, so record its single
399
+ # build tool call here (honest: same canonical build_trip_packages).
400
+ if result is not None:
401
+ _db_dur = int((time.monotonic() - _db_t0) * 1000)
402
+ trace.add_tool_call(ToolCallRecord(
403
+ name="build_trip_packages",
404
+ args={"mode": "deterministic", **(trip.model_dump(mode="json") if hasattr(trip, "model_dump") else {})},
405
+ status="ok" if result.packages else "failed",
406
+ duration_ms=_db_dur,
407
+ detail=f"{len(result.packages)} package(s) scored",
408
+ sources=result_source_labels(result),
409
+ ))
410
+ yield _ev(type="trace", data=trace.to_dict())
411
  # Sync the display trip to the GROUNDED dates so greenlight +
412
  # itinerary match the packages (match was re-centered on the real
413
  # WC fixture inside build_trip_packages). Honesty: show the note.
 
424
  except Exception:
425
  pass
426
  if getattr(result, "match_unrecognized", ""):
427
+ _finalize_trace(trace, trip, result, built_by)
428
+ yield _ev(type="trace", data=trace.to_dict()) # grounding-refusal proof
429
  yield _ev(type="progress", step="ready", status="fallback", text="Match not found")
430
  yield _ev(type="clarify", text=result.match_unrecognized)
431
  return
432
  if getattr(result, "grounding_note", ""):
433
  yield _ev(type="commentary", text="📅 " + result.grounding_note)
434
  else:
435
+ trace.set_outcome(mode="deterministic", status="clarify",
436
+ notes=["Intent incomplete — asked for the missing detail."],
437
+ model=_TRACE_MODEL)
438
+ yield _ev(type="trace", data=trace.to_dict()) # show the missing slots honestly
439
  yield _ev(type="progress", step="ready", status="fallback", text="Need a detail from you")
440
  yield _ev(
441
  type="clarify",
 
446
 
447
  # Clarify / direct answer from the Brain (no packages to show).
448
  if result is None:
449
+ trace.set_outcome(mode="agent", status="clarify",
450
+ notes=["Brain answered without building packages."],
451
+ model=_TRACE_MODEL, rounds=trace.rounds)
452
+ yield _ev(type="trace", data=trace.to_dict()) # the reasoning that led to the question
453
  yield _ev(type="progress", step="ready", status="fallback", text="Need a detail from you")
454
  yield _ev(type="clarify", text=agent_text)
455
  return
 
489
  # Final: the full Layla-competitive render (status + cards + map + timeline).
490
  # leaflet_preloaded=True → the frontend already loaded Leaflet in <head>; the
491
  # map's inline init script is re-run after injection (see index.html).
492
+ _finalize_trace(trace, trip, result, built_by)
493
+ yield _ev(type="trace", data=trace.to_dict()) # final, complete trace for the drawer
494
  yield _ev(type="progress", step="itinerary", status="done")
495
  yield _ev(type="progress", step="links", status="done")
496
  yield _ev(type="progress", step="ready", status="done", text="Your packages are ready")
497
+ yield _ev(type="result", html=render_full(result, trip, leaflet_preloaded=True, trace=trace), explanation=explanation)
498
 
499
 
500
  @app.get("/", response_class=HTMLResponse)
index.html CHANGED
@@ -224,6 +224,71 @@
224
  box-shadow:0 6px 18px rgba(0,0,0,.25);display:none;}
225
  #md-fs-close.show{display:block;}
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  @media (max-width:980px){
228
  .app{grid-template-columns:1fr;height:auto;}
229
  .chat{border-right:0;border-bottom:1px solid var(--line);}
@@ -273,6 +338,7 @@
273
 
274
  <!-- MIDDLE: result -->
275
  <section class="col result">
 
276
  <div id="result">
277
  <div class="empty">
278
  <div class="big">🏟️</div>
@@ -455,9 +521,139 @@ function renderSteps(){
455
  box.innerHTML = html;
456
  }
457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  // ── Streaming via raw SSE ───────────────────────────────
459
  function handleEvent(ev, bubble){
460
  if (!ev) return;
 
 
 
 
 
 
 
 
 
461
  if (ev.type === "progress"){
462
  if (ev.step){
463
  stepState[ev.step] = {status: ev.status || "running", text: ev.text || ""};
@@ -538,6 +734,8 @@ async function runTrip(forcedText){
538
  running = true; sendBtn.disabled = true; resultHandled = false;
539
  lastRealAt = 0; lastRealMsg = "";
540
  stepState = {}; // reset progress steps for the new run
 
 
541
  addUserBubble(text);
542
  const bubble = addAssistantBubble();
543
  showSkeleton(bubble);
 
224
  box-shadow:0 6px 18px rgba(0,0,0,.25);display:none;}
225
  #md-fs-close.show{display:block;}
226
 
227
+ /* ── Agent Trace / Evidence drawer (live, streamed from `trace` SSE) ───────
228
+ A persistent host above the package area. It survives skeleton/clarify/
229
+ error swaps (so a grounding refusal — a core Best-Agent proof — stays
230
+ visible) and is fed by every `trace` event, so a judge watches the agent
231
+ reason tool-by-tool in real time. Rules mirror render.py _CSS so the live
232
+ drawer is identical to the standalone render_trace output. */
233
+ #md-live-trace{padding:16px 20px 0;}
234
+ #md-live-trace:empty{display:none;}
235
+ .md-pv{display:inline-flex;align-items:center;gap:4px;font-size:9.5px;font-weight:800;padding:1px 7px 1px 5px;border-radius:999px;}
236
+ .md-pv::before{content:"";width:5px;height:5px;border-radius:50%;display:inline-block;}
237
+ .md-pv-live{background:#ecfdf5;color:#047857;} .md-pv-live::before{background:#10b981;}
238
+ .md-pv-ex{background:#fffbeb;color:#b45309;} .md-pv-ex::before{background:#f59e0b;}
239
+ .md-pv-other{background:#f3f4f6;color:#4b5563;} .md-pv-other::before{background:#9ca3af;}
240
+ .md-trace{margin:22px 2px 6px;background:#fff;border:1px solid #eef0f3;border-radius:18px;
241
+ box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -14px rgba(17,24,39,.16);overflow:hidden;}
242
+ .md-trace > summary{list-style:none;cursor:pointer;padding:15px 18px;display:flex;align-items:center;
243
+ gap:11px;font-size:13.5px;font-weight:700;color:#111827;user-select:none;}
244
+ .md-trace > summary::-webkit-details-marker{display:none;}
245
+ .md-trace > summary .md-trace-ico{font-size:17px;}
246
+ .md-trace > summary .md-trace-chev{margin-left:auto;color:#9ca3af;font-size:13px;transition:transform .2s;flex:0 0 auto;}
247
+ .md-trace[open] > summary .md-trace-chev{transform:rotate(90deg);}
248
+ .md-trace > summary .md-trace-sub{font-size:12px;font-weight:600;color:#6b7280;}
249
+ .md-trace-body{padding:2px 18px 18px;display:grid;gap:14px;border-top:1px solid #f1f3f6;}
250
+ .md-trace-meta{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-size:12px;color:#6b7280;font-weight:600;margin-top:13px;}
251
+ .md-tag{font-size:10.5px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:4px 10px;border-radius:999px;}
252
+ .md-tag-agent{background:#ede9fe;color:#6d28d9;}
253
+ .md-tag-det{background:#fef3c7;color:#b45309;}
254
+ .md-tag-clarify{background:#dbeafe;color:#1d4ed8;}
255
+ .md-tag-failed{background:#fee2e2;color:#b91c1c;}
256
+ .md-trace-model{color:#374151;font-weight:700;}
257
+ .md-trace-sec h5{margin:0 0 8px;font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#6b7280;}
258
+ .md-slots{display:flex;flex-wrap:wrap;gap:7px;}
259
+ .md-slot{font-size:12px;background:#f3f4f6;border-radius:9px;padding:6px 11px;color:#374151;font-weight:600;}
260
+ .md-slot b{color:#111827;font-weight:800;}
261
+ .md-slot.miss{background:#fff7ed;color:#c2410c;}
262
+ .md-ground{font-size:13px;line-height:1.5;color:#374151;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:11px;padding:10px 13px;}
263
+ .md-ground.refused{background:#fffbeb;border-color:#fde68a;color:#92400e;}
264
+ .md-ground b{color:#065f46;} .md-ground.refused b{color:#92400e;}
265
+ .md-tcalls{list-style:none;margin:0;padding:0;display:grid;gap:8px;counter-reset:tc;}
266
+ .md-tcall{position:relative;background:#fafbfc;border:1px solid #eef0f3;border-radius:11px;padding:10px 12px 10px 38px;font-size:12.5px;}
267
+ .md-tcall::before{counter-increment:tc;content:counter(tc);position:absolute;left:11px;top:10px;width:20px;height:20px;
268
+ border-radius:50%;background:#7c3aed;color:#fff;font-size:11px;font-weight:800;display:flex;align-items:center;justify-content:center;}
269
+ .md-tcall.is-failed{border-color:#fecaca;background:#fef5f5;} .md-tcall.is-failed::before{background:#ef4444;}
270
+ .md-tcall.is-skipped{opacity:.7;} .md-tcall.is-skipped::before{background:#9ca3af;}
271
+ .md-tcall .tc-head{font-weight:800;color:#111827;font-family:'Space Grotesk',Inter,sans-serif;font-size:13px;}
272
+ .md-tcall .tc-meta{color:#6b7280;margin-top:2px;}
273
+ .md-tcall .tc-src{display:inline-flex;gap:5px;flex-wrap:wrap;margin-top:5px;}
274
+ .md-evi{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;}
275
+ .md-evi-cell{border:1px solid #eef0f3;border-radius:11px;padding:9px 11px;font-size:12px;}
276
+ .md-evi-cell .ec-name{font-weight:700;color:#111827;display:flex;align-items:center;gap:6px;}
277
+ .md-evi-cell .ec-stat{font-size:10.5px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;margin-top:3px;}
278
+ .md-evi-cell.live .ec-stat{color:#047857;} .md-evi-cell.example .ec-stat{color:#b45309;}
279
+ .md-evi-cell.failed .ec-stat{color:#b91c1c;} .md-evi-cell.skipped .ec-stat{color:#6b7280;}
280
+ .md-formula-w{font-size:12.5px;color:#374151;margin-bottom:10px;} .md-formula-w b{color:#111827;}
281
+ .md-rank-row{display:grid;grid-template-columns:22px 1fr auto;gap:10px;align-items:center;padding:7px 0;border-top:1px solid #f3f4f6;font-size:12.5px;}
282
+ .md-rank-row:first-of-type{border-top:0;}
283
+ .md-rank-row .rr-rk{font-weight:800;color:#9ca3af;font-family:'Space Grotesk',Inter,sans-serif;}
284
+ .md-rank-row .rr-lbl{font-weight:700;color:#111827;}
285
+ .md-rank-row .rr-cost{color:#6b7280;font-variant-numeric:tabular-nums;}
286
+ .md-dimbar{display:flex;gap:9px;margin-top:5px;align-items:center;font-size:10.5px;color:#6b7280;}
287
+ .md-dimbar .db{flex:1;height:6px;border-radius:999px;background:#eef0f3;overflow:hidden;position:relative;}
288
+ .md-dimbar .db > i{position:absolute;left:0;top:0;bottom:0;background:#7c3aed;border-radius:999px;}
289
+ .md-dimbar .db.b > i{background:#2563eb;} .md-dimbar .db.g > i{background:#16a34a;}
290
+ .md-trace-empty{padding:14px 18px;color:#9ca3af;font-size:13px;}
291
+
292
  @media (max-width:980px){
293
  .app{grid-template-columns:1fr;height:auto;}
294
  .chat{border-right:0;border-bottom:1px solid var(--line);}
 
338
 
339
  <!-- MIDDLE: result -->
340
  <section class="col result">
341
+ <div id="md-live-trace"></div>
342
  <div id="result">
343
  <div class="empty">
344
  <div class="big">🏟️</div>
 
521
  box.innerHTML = html;
522
  }
523
 
524
+ // ── Live Agent Trace / Evidence drawer ──────────────────
525
+ // Mirrors render.py render_trace: extracted intent → verified-fixture grounding
526
+ // → numbered tool calls → per-category live/example/failed evidence → the
527
+ // deterministic ranking formula + why each package won. Fed by `trace` SSE
528
+ // events; each event replaces the drawer with the accumulated state so far.
529
+ const TRACE_DOTS = {live:"🟢", example:"🟠", failed:"🔴", skipped:"⚪", partial:"🟡", ok:"•"};
530
+ const TRACE_TAG = {agent:["md-tag-agent","Agent"], deterministic:["md-tag-det","Deterministic"],
531
+ clarify:["md-tag-clarify","Clarify"], error:["md-tag-failed","Error"]};
532
+ const TRACE_W = {cost:"Affordable", buffer:"Safe arrival", transit:"Close",
533
+ affordability:"Affordable", arrival_buffer:"Safe arrival", proximity:"Close"};
534
+ const TRACE_DIM = {affordability:["Affordable",""], arrival_buffer:["Safe arrival","b"], proximity:["Close","g"]};
535
+ const TRACE_TIER = {budget:"Budget", mid_range:"Balanced", premium:"Premium"};
536
+ const TRACE_CAT = {flights:"Flights", hotels:"Hotels", weather:"Weather", amenities:"Nearby"};
537
+ const LIVE_SRC = new Set(["serpapi","openmeteo","osm"]);
538
+ function _num(g){ const n = Number(g); return Number.isFinite(n) ? String(n) : String(g); }
539
+ function _range(a,b){ if(!a&&!b) return ""; if(a&&b&&a!==b) return a+" → "+b; return String(a||b||""); }
540
+ function _slotV(v){ return (v===null||v===undefined||v==="") ? "—" : esc(v); }
541
+
542
+ function renderTraceLive(d){
543
+ const host = document.getElementById("md-live-trace");
544
+ if (!host || !d) return;
545
+ const mode = d.mode || "";
546
+ const tag = TRACE_TAG[mode] || ["md-tag-det", mode || "—"];
547
+ let meta = `<span class="md-tag ${tag[0]}">${esc(tag[1])}</span>`;
548
+ if (d.model) meta += `<span class="md-trace-model">${esc(d.model)}</span>`;
549
+ if (d.rounds) meta += `<span>${d.rounds} loop round${d.rounds!==1?"s":""}</span>`;
550
+ meta += `<span>outcome: ${esc(d.outcome_status || "—")}</span>`;
551
+
552
+ let sections = "";
553
+
554
+ // ① Extracted Trip Intent
555
+ const intent = d.intent || {}, missing = d.missing_slots || [];
556
+ if (intent && Object.keys(intent).length || missing.length){
557
+ const defs = [["Origin",intent.origin],["Match",intent.match_name],["Match date",intent.match_date],
558
+ ["Stay",_range(intent.check_in,intent.check_out)],["Travelers",intent.travelers],["Tier",intent.budget_tier]];
559
+ let slots = defs.map(([k,v])=>`<span class="md-slot"><b>${esc(k)}:</b> ${_slotV(v)}</span>`).join("");
560
+ slots += missing.map(m=>`<span class="md-slot miss">missing: ${esc(m)}</span>`).join("");
561
+ sections += `<div class="md-trace-sec"><h5>① Extracted Trip Intent</h5><div class="md-slots">${slots}</div></div>`;
562
+ }
563
+
564
+ // ② Match Grounding (verified 2026 fixtures, or honest refusal)
565
+ const g = d.grounding;
566
+ if (g){
567
+ if (g.recognized){
568
+ const bits = [g.corrected ? "<b>Date corrected</b> to the verified 2026 fixture"
569
+ : "<b>Match confirmed</b> against the verified 2026 schedule"];
570
+ if (g.kickoff) bits.push(`kickoff <b>${esc(g.kickoff)}</b>`);
571
+ if (g.venue) bits.push(`at <b>${esc(g.venue)}</b>`);
572
+ sections += `<div class="md-trace-sec"><h5>② Match Grounding</h5><div class="md-ground">${bits.join(" · ")}${g.note?". "+esc(g.note):""}</div></div>`;
573
+ } else {
574
+ const note = g.note || "Not a recognized 2026 fixture — the agent refused to invent a trip.";
575
+ sections += `<div class="md-trace-sec"><h5>② Match Grounding</h5><div class="md-ground refused"><b>Refused</b> — ${esc(note)}</div></div>`;
576
+ }
577
+ }
578
+
579
+ // ③ Tools Called (the multi-step proof)
580
+ const tcalls = d.tool_calls || [];
581
+ if (tcalls.length){
582
+ const rows = tcalls.map(t=>{
583
+ const st = t.status || "ok";
584
+ const cls = st==="failed"?" is-failed":(st==="skipped"?" is-skipped":"");
585
+ const src = (t.sources||[]).filter(s=>s).map(s=>`<span class="md-pv md-pv-${LIVE_SRC.has(s)?"live":"ex"}">${esc(s)}</span>`).join("");
586
+ const dur = t.duration_ms ? ` · ${t.duration_ms} ms` : "";
587
+ const args = t.args || {};
588
+ const argstr = args && Object.keys(args).length ? " · "+Object.entries(args).map(([k,v])=>`${esc(k)}=${esc(v)}`).join(", ") : "";
589
+ const detail = t.detail ? `<div class="tc-meta">${esc(t.detail)}</div>` : "";
590
+ return `<li class="md-tcall${cls}"><span class="tc-head">${esc(t.name||"tool")}</span>`+
591
+ `<div class="tc-meta">${esc(st)}${dur}${argstr}</div>${detail}`+
592
+ `${src?`<span class="tc-src">${src}</span>`:""}</li>`;
593
+ }).join("");
594
+ sections += `<div class="md-trace-sec"><h5>③ Tools Called</h5><ol class="md-tcalls">${rows}</ol></div>`;
595
+ }
596
+
597
+ // ④ Evidence per category (live vs example vs failed vs skipped)
598
+ const evi = d.evidence || {};
599
+ if (evi && Object.keys(evi).length){
600
+ const cells = ["flights","hotels","weather","amenities"].map(cat=>{
601
+ const st = evi[cat] || "skipped";
602
+ return `<div class="md-evi-cell ${st}"><div class="ec-name">${TRACE_DOTS[st]||"•"} ${esc(TRACE_CAT[cat]||cat)}</div><div class="ec-stat">${esc(st)}</div></div>`;
603
+ }).join("");
604
+ sections += `<div class="md-trace-sec"><h5>④ Evidence (live vs example)</h5><div class="md-evi">${cells}</div></div>`;
605
+ }
606
+
607
+ // ⑤ Ranking formula + why each package won
608
+ const rk = d.ranking || {};
609
+ if (rk.packages && rk.packages.length){
610
+ const w = rk.weights || {};
611
+ const wline = `<div class="md-formula-w"><b>${esc(TRACE_TIER[rk.tier]||rk.tier||"Balanced")}</b> tier — `+
612
+ `composite = ${Object.entries(w).map(([k,v])=>`${TRACE_W[k]||k}×${_num(v)}`).join(" + ")}</div>`;
613
+ const rows = rk.packages.map(p=>{
614
+ const sc = p.scores || {};
615
+ const bars = ["affordability","arrival_buffer","proximity"].map(k=>{
616
+ const val = Number(sc[k]||0), pct = Math.max(0,Math.min(100,Math.round(val*100)));
617
+ const dm = TRACE_DIM[k]||[k,""];
618
+ return `<span class="db ${dm[1]}" title="${dm[0]} ${_num(val)}"><i style="width:${pct}%;"></i></span>`;
619
+ }).join("");
620
+ const cost = Number(p.total_cost_cad||0);
621
+ return `<div class="md-rank-row"><span class="rr-rk">#${p.rank}</span>`+
622
+ `<span><span class="rr-lbl">${esc(p.label||"")}</span><span class="md-dimbar">${bars}</span>${p.why?" — "+esc(p.why):""}</span>`+
623
+ `<span class="rr-cost">$${Math.round(cost).toLocaleString()} CAD</span></div>`;
624
+ }).join("");
625
+ sections += `<div class="md-trace-sec"><h5>⑤ Ranking (deterministic)</h5>${wline}${rows}</div>`;
626
+ }
627
+
628
+ // Honesty / degradation notes
629
+ const notes = d.notes || [];
630
+ if (notes.length){
631
+ sections += `<div class="md-trace-sec"><h5>Notes</h5>`+
632
+ notes.map(n=>`<div class="md-rank-row" style="grid-template-columns:1fr;"><span>${esc(n)}</span></div>`).join("")+`</div>`;
633
+ }
634
+
635
+ const body = sections || `<div class="md-trace-empty">No trace recorded for this turn.</div>`;
636
+ host.innerHTML =
637
+ `<details class="md-trace" open><summary>`+
638
+ `<span class="md-trace-ico">🧭</span>`+
639
+ `<span>Agent Trace &amp; Evidence</span>`+
640
+ `<span class="md-trace-sub">how this trip was reasoned, tool-by-tool</span>`+
641
+ `<span class="md-trace-chev">▸</span></summary>`+
642
+ `<div class="md-trace-body"><div class="md-trace-meta">${meta}</div>${body}</div></details>`;
643
+ }
644
+
645
  // ── Streaming via raw SSE ───────────────────────────────
646
  function handleEvent(ev, bubble){
647
  if (!ev) return;
648
+ // Live Agent Trace / Evidence drawer (Best-Agent proof): every `trace` event
649
+ // repaints the persistent drawer, so reasoning streams tool-by-tool in real
650
+ // time. Rendered into #md-live-trace (outside #result) so it survives the
651
+ // skeleton→result swap AND the clarify/error clear — a grounding refusal
652
+ // (agent won't invent a trip around a fake match) therefore stays visible.
653
+ if (ev.type === "trace"){
654
+ renderTraceLive(ev.data);
655
+ return;
656
+ }
657
  if (ev.type === "progress"){
658
  if (ev.step){
659
  stepState[ev.step] = {status: ev.status || "running", text: ev.text || ""};
 
734
  running = true; sendBtn.disabled = true; resultHandled = false;
735
  lastRealAt = 0; lastRealMsg = "";
736
  stepState = {}; // reset progress steps for the new run
737
+ const _traceHost = document.getElementById("md-live-trace");
738
+ if (_traceHost) _traceHost.innerHTML = ""; // reset the live trace drawer for the new run
739
  addUserBubble(text);
740
  const bubble = addAssistantBubble();
741
  showSkeleton(bubble);
matchday/agent_loop.py CHANGED
@@ -25,12 +25,14 @@ import asyncio
25
  import json
26
  import logging
27
  import os
 
28
  from dataclasses import dataclass, field
29
  from datetime import datetime, timezone
30
  from typing import Any, Literal
31
 
32
  from pydantic import BaseModel, Field, field_validator
33
 
 
34
  from matchday.api_registry import registry
35
  from matchday.errors import format_validation_error
36
  from matchday.models import ScoredPackage
@@ -633,6 +635,29 @@ class AgentLoopResult:
633
  """The failure reason (only for ``fallback_to_deterministic``)."""
634
 
635
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  # ---------------------------------------------------------------------------
637
  # AgentLoop
638
  # ---------------------------------------------------------------------------
@@ -664,6 +689,7 @@ class AgentLoop:
664
  agent: Any,
665
  tools: dict[str, Any] | None = None,
666
  max_rounds: int = MAX_TOOL_ROUNDS,
 
667
  ) -> None:
668
  """Initialise the agent loop.
669
 
@@ -682,10 +708,16 @@ class AgentLoop:
682
  max_rounds:
683
  Maximum number of tool-call rounds (default 5). After this many
684
  rounds the loop forces a final answer or falls back.
 
 
 
 
 
685
  """
686
  self._agent = agent
687
  self._max_rounds = max_rounds
688
  self._allow_list = ToolAllowList()
 
689
 
690
  # Merge external tools if provided (built-in impls are always present)
691
  self._tool_impls: dict[str, Any] = dict(_TOOL_IMPLS)
@@ -725,6 +757,8 @@ class AgentLoop:
725
 
726
  while round_num < self._max_rounds:
727
  round_num += 1
 
 
728
 
729
  # ---- Step 1: Call the agent ----
730
  try:
@@ -929,6 +963,9 @@ class AgentLoop:
929
  )
930
  logger.warning("Blocked invalid tool name: %s", tool_name)
931
 
 
 
 
932
  self._inject_error_message(messages, tool_name, error_msg)
933
 
934
  if correction_used:
@@ -946,6 +983,9 @@ class AgentLoop:
946
  error_msg = format_validation_error(tool_name, exc)
947
  logger.warning("Argument validation error: %s", exc)
948
 
 
 
 
949
  self._inject_error_message(messages, tool_name, error_msg)
950
 
951
  if correction_used:
@@ -963,22 +1003,30 @@ class AgentLoop:
963
  if self._allow_list.is_duplicate(tool_name, validated_args, tool_history):
964
  # Silently skip: the result would be identical
965
  logger.info("Skipping duplicate tool call: %s", tool_name)
 
 
966
  return None
967
 
968
  # ---- Execute the tool ----
969
  impl = self._tool_impls.get(tool_name)
970
  if impl is None:
 
 
971
  return AgentLoopResult(
972
  type="fallback_to_deterministic",
973
  reason=f"No implementation registered for tool {tool_name!r}.",
974
  )
975
 
 
976
  try:
977
  tool_result = await asyncio.wait_for(
978
  impl(validated_args),
979
  timeout=TOOL_EXEC_TIMEOUT_SECONDS,
980
  )
981
  except asyncio.TimeoutError:
 
 
 
982
  return AgentLoopResult(
983
  type="fallback_to_deterministic",
984
  reason=(
@@ -987,26 +1035,65 @@ class AgentLoop:
987
  ),
988
  )
989
  except Exception as exc:
 
990
  logger.error("Tool %s execution error: %s", tool_name, exc)
 
 
991
  return AgentLoopResult(
992
  type="fallback_to_deterministic",
993
  reason=f"Tool {tool_name!r} execution failed: {exc}",
994
  )
995
 
 
 
 
996
  # ---- Handle clarify tool (terminal) ----
997
  if tool_result.get("type") == "final_answer":
 
998
  return AgentLoopResult(
999
  type="final_answer",
1000
  text=tool_result.get("text", ""),
1001
  )
1002
 
1003
  # ---- Return tool_called result ----
 
 
1004
  return AgentLoopResult(
1005
  type="tool_called",
1006
  tool=tool_name,
1007
  result=tool_result,
1008
  )
1009
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1010
  def _inject_error_message(
1011
  self,
1012
  messages: list[dict],
@@ -1045,6 +1132,7 @@ async def run_agent_loop(
1045
  messages: list[dict],
1046
  tools: dict[str, Any] | None = None,
1047
  max_rounds: int = MAX_TOOL_ROUNDS,
 
1048
  ) -> AgentLoopResult:
1049
  """Top-level convenience function for the agent loop.
1050
 
@@ -1058,11 +1146,15 @@ async def run_agent_loop(
1058
  Optional extra tools (merged with built-in allowlist).
1059
  max_rounds:
1060
  Maximum tool rounds (default 5).
 
 
 
 
1061
 
1062
  Returns
1063
  -------
1064
  AgentLoopResult
1065
  The loop outcome.
1066
  """
1067
- loop = AgentLoop(agent=agent, tools=tools, max_rounds=max_rounds)
1068
  return await loop.run(messages)
 
25
  import json
26
  import logging
27
  import os
28
+ import time
29
  from dataclasses import dataclass, field
30
  from datetime import datetime, timezone
31
  from typing import Any, Literal
32
 
33
  from pydantic import BaseModel, Field, field_validator
34
 
35
+ from matchday.agent_trace import AgentTrace, ToolCallRecord
36
  from matchday.api_registry import registry
37
  from matchday.errors import format_validation_error
38
  from matchday.models import ScoredPackage
 
635
  """The failure reason (only for ``fallback_to_deterministic``)."""
636
 
637
 
638
+ def _pkg_source_list(pkg: ScoredPackage) -> list[str]:
639
+ """Collect the distinct provenance sources on one scored package.
640
+
641
+ Used to populate the per-tool-call ``sources`` field of the agent trace
642
+ (e.g. ``["openmeteo", "osm", "serpapi"]``) so the visible trace states
643
+ WHICH live providers backed a ``build_trip_packages`` call.
644
+ """
645
+ out: set[str] = set()
646
+ f = getattr(pkg, "flight", None)
647
+ if f and getattr(f, "source", ""):
648
+ out.add(f.source)
649
+ h = getattr(pkg, "hotel", None)
650
+ if h and getattr(h, "source", ""):
651
+ out.add(h.source)
652
+ for w in (getattr(pkg, "weather", None) or []):
653
+ if getattr(w, "source", ""):
654
+ out.add(w.source)
655
+ for a in (getattr(pkg, "amenities", None) or []):
656
+ if getattr(a, "source", ""):
657
+ out.add(a.source)
658
+ return sorted(out)
659
+
660
+
661
  # ---------------------------------------------------------------------------
662
  # AgentLoop
663
  # ---------------------------------------------------------------------------
 
689
  agent: Any,
690
  tools: dict[str, Any] | None = None,
691
  max_rounds: int = MAX_TOOL_ROUNDS,
692
+ trace: AgentTrace | None = None,
693
  ) -> None:
694
  """Initialise the agent loop.
695
 
 
708
  max_rounds:
709
  Maximum number of tool-call rounds (default 5). After this many
710
  rounds the loop forces a final answer or falls back.
711
+ trace:
712
+ Optional :class:`~matchday.agent_trace.AgentTrace` accumulator.
713
+ When provided, every tool the loop attempts is appended to it
714
+ (name, validated args, status, duration, sources, one-line
715
+ detail) — the visible "tools called" proof for Best Agent.
716
  """
717
  self._agent = agent
718
  self._max_rounds = max_rounds
719
  self._allow_list = ToolAllowList()
720
+ self._trace = trace
721
 
722
  # Merge external tools if provided (built-in impls are always present)
723
  self._tool_impls: dict[str, Any] = dict(_TOOL_IMPLS)
 
757
 
758
  while round_num < self._max_rounds:
759
  round_num += 1
760
+ if self._trace is not None:
761
+ self._trace.rounds += 1
762
 
763
  # ---- Step 1: Call the agent ----
764
  try:
 
963
  )
964
  logger.warning("Blocked invalid tool name: %s", tool_name)
965
 
966
+ self._rec(tool_name, raw_args, "failed", 0,
967
+ "disallowed tool name — rejected by the allowlist", [])
968
+
969
  self._inject_error_message(messages, tool_name, error_msg)
970
 
971
  if correction_used:
 
983
  error_msg = format_validation_error(tool_name, exc)
984
  logger.warning("Argument validation error: %s", exc)
985
 
986
+ self._rec(tool_name, raw_args, "failed", 0,
987
+ "invalid arguments — error sent back to Nemotron to self-correct", [])
988
+
989
  self._inject_error_message(messages, tool_name, error_msg)
990
 
991
  if correction_used:
 
1003
  if self._allow_list.is_duplicate(tool_name, validated_args, tool_history):
1004
  # Silently skip: the result would be identical
1005
  logger.info("Skipping duplicate tool call: %s", tool_name)
1006
+ self._rec(tool_name, validated_args, "skipped", 0,
1007
+ "duplicate call — already ran this turn, skipped", [])
1008
  return None
1009
 
1010
  # ---- Execute the tool ----
1011
  impl = self._tool_impls.get(tool_name)
1012
  if impl is None:
1013
+ self._rec(tool_name, validated_args, "failed", 0,
1014
+ f"no implementation registered for {tool_name!r}", [])
1015
  return AgentLoopResult(
1016
  type="fallback_to_deterministic",
1017
  reason=f"No implementation registered for tool {tool_name!r}.",
1018
  )
1019
 
1020
+ _t0 = time.monotonic()
1021
  try:
1022
  tool_result = await asyncio.wait_for(
1023
  impl(validated_args),
1024
  timeout=TOOL_EXEC_TIMEOUT_SECONDS,
1025
  )
1026
  except asyncio.TimeoutError:
1027
+ _dur = int((time.monotonic() - _t0) * 1000)
1028
+ self._rec(tool_name, validated_args, "failed", _dur,
1029
+ f"timed out after {TOOL_EXEC_TIMEOUT_SECONDS:.0f}s", [])
1030
  return AgentLoopResult(
1031
  type="fallback_to_deterministic",
1032
  reason=(
 
1035
  ),
1036
  )
1037
  except Exception as exc:
1038
+ _dur = int((time.monotonic() - _t0) * 1000)
1039
  logger.error("Tool %s execution error: %s", tool_name, exc)
1040
+ self._rec(tool_name, validated_args, "failed", _dur,
1041
+ f"execution error: {exc}", [])
1042
  return AgentLoopResult(
1043
  type="fallback_to_deterministic",
1044
  reason=f"Tool {tool_name!r} execution failed: {exc}",
1045
  )
1046
 
1047
+ _dur = int((time.monotonic() - _t0) * 1000)
1048
+ detail, srcs = self._tool_summary(tool_name, tool_result)
1049
+
1050
  # ---- Handle clarify tool (terminal) ----
1051
  if tool_result.get("type") == "final_answer":
1052
+ self._rec(tool_name, validated_args, "ok", _dur, detail, srcs)
1053
  return AgentLoopResult(
1054
  type="final_answer",
1055
  text=tool_result.get("text", ""),
1056
  )
1057
 
1058
  # ---- Return tool_called result ----
1059
+ ok = bool(tool_result.get("success", True))
1060
+ self._rec(tool_name, validated_args, "ok" if ok else "failed", _dur, detail, srcs)
1061
  return AgentLoopResult(
1062
  type="tool_called",
1063
  tool=tool_name,
1064
  result=tool_result,
1065
  )
1066
 
1067
+ def _rec(self, name: str, args: Any, status: str,
1068
+ duration_ms: int, detail: str, sources: list[str]) -> None:
1069
+ """Append a tool-call record to the trace when one is attached."""
1070
+ if self._trace is None:
1071
+ return
1072
+ try:
1073
+ safe_args = args if isinstance(args, dict) else {"_raw": str(args)}
1074
+ self._trace.add_tool_call(ToolCallRecord(
1075
+ name=name, args=safe_args, status=status, # type: ignore[arg-type]
1076
+ duration_ms=duration_ms, detail=detail, sources=sources or [],
1077
+ ))
1078
+ except Exception: # the trace must never break a tool build
1079
+ logger.debug("trace record skipped for %s", name)
1080
+
1081
+ def _tool_summary(self, name: str, tool_result: dict[str, Any]) -> tuple[str, list[str]]:
1082
+ """One-line human summary + provenance sources for a tool result."""
1083
+ if name == "build_trip_packages":
1084
+ full = tool_result.get("full_result")
1085
+ pkgs = list(getattr(full, "packages", []) or [])
1086
+ srcs: list[str] = sorted({s for p in pkgs for s in _pkg_source_list(p)})
1087
+ n = len(pkgs)
1088
+ return (f"{n} package{'s' if n != 1 else ''} scored", srcs)
1089
+ if name == "web_search":
1090
+ results = tool_result.get("results", []) or []
1091
+ n = len(results)
1092
+ return (f"{n} web result{'s' if n != 1 else ''}", ["serpapi"])
1093
+ if name == "clarify":
1094
+ return ("asked you a clarifying question", [])
1095
+ return ("", [])
1096
+
1097
  def _inject_error_message(
1098
  self,
1099
  messages: list[dict],
 
1132
  messages: list[dict],
1133
  tools: dict[str, Any] | None = None,
1134
  max_rounds: int = MAX_TOOL_ROUNDS,
1135
+ trace: AgentTrace | None = None,
1136
  ) -> AgentLoopResult:
1137
  """Top-level convenience function for the agent loop.
1138
 
 
1146
  Optional extra tools (merged with built-in allowlist).
1147
  max_rounds:
1148
  Maximum tool rounds (default 5).
1149
+ trace:
1150
+ Optional :class:`~matchday.agent_trace.AgentTrace` to record every
1151
+ tool call into. Pass the SAME trace across successive calls to keep
1152
+ the multi-step decision log contiguous (e.g. web_search → build).
1153
 
1154
  Returns
1155
  -------
1156
  AgentLoopResult
1157
  The loop outcome.
1158
  """
1159
+ loop = AgentLoop(agent=agent, tools=tools, max_rounds=max_rounds, trace=trace)
1160
  return await loop.run(messages)
matchday/agent_trace.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AgentTrace — MatchDay's visible proof of agentic behavior (Best Agent).
2
+
3
+ A MatchDay turn is genuinely multi-step: the Brain (Nemotron) extracts intent,
4
+ grounds the match against verified WC2026 fixtures, picks tools, and Python
5
+ (Hands) executes every API call and scores every price. Until now that decision
6
+ chain lived only in logs / a disk JSONL trace (``record_trace.py``). This module
7
+ captures it in one structured record that renders *in the live UI*, so a judge
8
+ can see the agent actually reasoned — intent → grounding → multi-step tools →
9
+ evidence → ranking formula → fallback status — not a static page.
10
+
11
+ It adapts (reference-patterns-notes.md, the "Best Agent / production
12
+ architecture" docs — NOT Layla, which is product-flow only):
13
+ - §3 TurnContext: intent → trip_request → api_results → scored_packages.
14
+ - §6 typed streaming events (presentation = transport, never truth).
15
+ - §9 FailoverReason taxonomy (status as a small enum, not free text).
16
+ - §5 code-enforced green-light (intent validated before green-light).
17
+
18
+ Pure data, no I/O, no LLM. Accumulated by ``agent_loop`` (tool calls) and
19
+ ``app.py`` (intent/grounding/evidence/ranking/mode), then serialized by
20
+ ``render.render_trace`` into a collapsible, JS-free ``<details>`` panel + a
21
+ ``#md-trace`` JSON island. Every setter is defensive (best-effort; never raises)
22
+ so the trace can never break a trip build.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from datetime import date, datetime
28
+ from typing import Any, Literal
29
+
30
+ # Status vocabulary (§9 — small enum, not free text). ``live`` / ``example`` /
31
+ # ``failed`` describe provenance or outcome; ``partial`` lets a category show
32
+ # "ran but incomplete". ``skipped`` = tool ran but produced nothing usable.
33
+ TraceStatus = Literal["live", "example", "partial", "failed", "skipped", "ok"]
34
+
35
+ _CATEGORY_LABEL = {
36
+ "flights": "Flights",
37
+ "hotels": "Hotels",
38
+ "weather": "Weather",
39
+ "amenities": "Nearby spots",
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class ToolCallRecord:
45
+ """One tool the agent (or deterministic path) executed during a turn."""
46
+
47
+ name: str # "build_trip_packages" | "web_search" | "clarify"
48
+ args: dict[str, Any] = field(default_factory=dict)
49
+ status: TraceStatus = "ok" # ok | failed | skipped
50
+ duration_ms: int = 0
51
+ detail: str = "" # short human summary of what came back
52
+ sources: list[str] = field(default_factory=list) # e.g. ["serpapi","openmeteo","osm"]
53
+
54
+
55
+ @dataclass
56
+ class PackageRankRecord:
57
+ """One ranked package as the trace shows it (decides the visible order)."""
58
+
59
+ rank: int # 1-based
60
+ label: str # Cheapest | Safest Arrival | Closest to Stadium
61
+ total_cost_cad: float
62
+ scores: dict[str, float] = field(default_factory=dict)
63
+ # affordability/arrival_buffer/proximity are 0-1 normalized dims;
64
+ # composite is the weighted blend (0-1) that decided the order.
65
+ why: str = ""
66
+
67
+
68
+ @dataclass
69
+ class AgentTrace:
70
+ """The complete, inspectable decision record for one trip-planning turn.
71
+
72
+ Built incrementally:
73
+ 1. ``set_intent`` — messy prompt → structured slots (or missing).
74
+ 2. ``set_grounding`` — verified-fixture correction / refusal.
75
+ 3. ``add_tool_call`` (×) — each tool the loop executed.
76
+ 4. ``set_evidence`` — live/example/failed per data category.
77
+ 5. ``set_ranking`` — tier weights + per-package normalized scores.
78
+ 6. ``set_outcome`` — mode (agent|deterministic|clarify) + status.
79
+
80
+ ``to_dict`` is the single serialization point consumed by the renderer.
81
+ """
82
+
83
+ mode: str = "" # "agent" | "deterministic" | "clarify" | "error"
84
+ intent: dict[str, Any] | None = None
85
+ missing_slots: list[str] = field(default_factory=list)
86
+ grounding: dict[str, Any] | None = None
87
+ tool_calls: list[ToolCallRecord] = field(default_factory=list)
88
+ evidence: dict[str, str] = field(default_factory=dict) # category -> status
89
+ ranking: dict[str, Any] = field(default_factory=dict)
90
+ outcome_status: str = "" # complete | partial | failed | clarify | error
91
+ notes: list[str] = field(default_factory=list) # degradation / honesty notes
92
+ model: str = ""
93
+ rounds: int = 0 # agent-loop rounds actually consumed
94
+
95
+ # ------------------------------------------------------------------
96
+ # Mutators (all best-effort; trace must never break a build)
97
+ # ------------------------------------------------------------------
98
+ def set_intent(self, trip: Any | None, *, missing: list[str] | None = None) -> None:
99
+ """Record the extracted trip intent (structured slots), or missing slots.
100
+
101
+ ``trip`` is a ``TripRequest`` (has ``model_dump``); if None we record
102
+ only the missing-slot clarifying path.
103
+ """
104
+ try:
105
+ if missing is not None:
106
+ self.missing_slots = list(missing)
107
+ if trip is None:
108
+ return
109
+ d = trip.model_dump(mode="json") if hasattr(trip, "model_dump") else dict(trip)
110
+ self.intent = {
111
+ "origin": d.get("origin_airport"),
112
+ "match_name": d.get("match_name"),
113
+ "match_date": str(d.get("match_date", "")),
114
+ "check_in": str(d.get("check_in", "")),
115
+ "check_out": str(d.get("check_out", "")),
116
+ "travelers": d.get("travelers"),
117
+ "budget_tier": d.get("budget_tier"),
118
+ }
119
+ except Exception:
120
+ self.intent = None
121
+
122
+ def set_grounding(self, *, recognized: bool, corrected: bool = False,
123
+ kickoff: str = "", note: str = "", venue: str = "",
124
+ match_name: str = "") -> None:
125
+ """Record the verified-fixture grounding decision.
126
+
127
+ ``recognized=False`` means the matchup isn't a real 2026 fixture → the
128
+ trace (and UI) shows the honest refusal + alternatives, never a fake trip.
129
+ """
130
+ self.grounding = {
131
+ "recognized": bool(recognized),
132
+ "corrected": bool(corrected),
133
+ "kickoff": kickoff or "",
134
+ "venue": venue or "",
135
+ "match_name": match_name or "",
136
+ "note": note or "",
137
+ }
138
+
139
+ def add_tool_call(self, rec: ToolCallRecord) -> None:
140
+ """Append one executed tool call to the decision log."""
141
+ self.tool_calls.append(rec)
142
+
143
+ def set_evidence(self, mapping: dict[str, str]) -> None:
144
+ """Record per-category provenance/health: {flights: live, weather: example, ...}."""
145
+ self.evidence = {k: v for k, v in mapping.items() if k in _CATEGORY_LABEL}
146
+
147
+ def set_ranking(self, *, tier: str, weights: dict[str, float],
148
+ packages: list[PackageRankRecord]) -> None:
149
+ """Record the deterministic ranking: tier weights + per-package scores."""
150
+ self.ranking = {
151
+ "tier": tier,
152
+ "weights": dict(weights),
153
+ "packages": [
154
+ {
155
+ "rank": p.rank, "label": p.label,
156
+ "total_cost_cad": round(p.total_cost_cad, 2),
157
+ "scores": {k: round(float(v), 3) for k, v in p.scores.items()},
158
+ "why": p.why,
159
+ }
160
+ for p in packages
161
+ ],
162
+ }
163
+
164
+ def set_outcome(self, *, mode: str, status: str, notes: list[str] | None = None,
165
+ model: str = "", rounds: int = 0) -> None:
166
+ """Record the final mode + status of the turn."""
167
+ self.mode = mode
168
+ self.outcome_status = status
169
+ if notes:
170
+ self.notes = list(notes)
171
+ if model:
172
+ self.model = model
173
+ if rounds:
174
+ self.rounds = rounds
175
+
176
+ # ------------------------------------------------------------------
177
+ # Serialization
178
+ # ------------------------------------------------------------------
179
+ def to_dict(self) -> dict[str, Any]:
180
+ """Single serialization point for ``render_trace`` + the JSON island."""
181
+ return {
182
+ "mode": self.mode,
183
+ "model": self.model,
184
+ "rounds": self.rounds,
185
+ "intent": self.intent,
186
+ "missing_slots": list(self.missing_slots),
187
+ "grounding": self.grounding,
188
+ "tool_calls": [
189
+ {
190
+ "name": t.name,
191
+ "args": _sanitize_args(t.args),
192
+ "status": t.status,
193
+ "duration_ms": t.duration_ms,
194
+ "detail": t.detail,
195
+ "sources": list(t.sources),
196
+ }
197
+ for t in self.tool_calls
198
+ ],
199
+ "evidence": dict(self.evidence),
200
+ "ranking": self.ranking,
201
+ "outcome_status": self.outcome_status,
202
+ "notes": list(self.notes),
203
+ }
204
+
205
+
206
+ def _sanitize_args(args: dict[str, Any]) -> dict[str, Any]:
207
+ """Return a display-safe copy of tool args (no secrets ever live here, but
208
+ keep the surface minimal + JSON-serializable for the data island)."""
209
+ safe: dict[str, Any] = {}
210
+ for k, v in (args or {}).items():
211
+ if isinstance(v, (date, datetime)):
212
+ safe[k] = v.isoformat()
213
+ elif isinstance(v, (str, int, float, bool)) or v is None:
214
+ safe[k] = v
215
+ else:
216
+ safe[k] = str(v)
217
+ return safe
218
+
219
+
220
+ def evidence_from_result(result: Any) -> dict[str, str]:
221
+ """Derive per-category evidence status from a ``TripPackageResult``.
222
+
223
+ Reads ``degradation_notices`` for failed categories and the per-package
224
+ ``source`` fields for live-vs-example. Honest: a category is ``live`` only
225
+ if at least one value in it carries a live source (serpapi/openmeteo/osm);
226
+ ``example`` if only fixture fallbacks; ``failed`` if the category was down.
227
+ """
228
+ notices = " ".join(getattr(result, "degradation_notices", []) or []).lower()
229
+ out: dict[str, str] = {}
230
+ packages = getattr(result, "packages", []) or []
231
+ for cat in _CATEGORY_LABEL:
232
+ if cat == "flights":
233
+ blob = notices
234
+ if "flight" in blob:
235
+ out[cat] = "failed"
236
+ else:
237
+ srcs = [getattr(p.flight, "source", "") for p in packages]
238
+ out[cat] = _src_status(srcs)
239
+ elif cat == "hotels":
240
+ if "hotel" in notices:
241
+ out[cat] = "failed"
242
+ else:
243
+ srcs = [getattr(p.hotel, "source", "") for p in packages if p.hotel]
244
+ out[cat] = _src_status(srcs)
245
+ elif cat == "weather":
246
+ if "weather" in notices:
247
+ out[cat] = "failed"
248
+ else:
249
+ srcs = [getattr(w, "source", "") for p in packages for w in (p.weather or [])]
250
+ out[cat] = _src_status(srcs)
251
+ elif cat == "amenities":
252
+ if "amenit" in notices or "nearby" in notices:
253
+ out[cat] = "failed"
254
+ else:
255
+ srcs = [getattr(a, "source", "") for p in packages for a in (p.amenities or [])]
256
+ out[cat] = _src_status(srcs)
257
+ return out
258
+
259
+
260
+ def _src_status(sources: list[str]) -> str:
261
+ """Map a list of per-value source strings to one status word."""
262
+ live = {"serpapi", "openmeteo", "osm"}
263
+ srcs = [(s or "").lower() for s in sources if s]
264
+ if not srcs:
265
+ return "skipped"
266
+ if any(s in live for s in srcs):
267
+ return "live"
268
+ if all(s == "fallback" for s in srcs):
269
+ return "example"
270
+ return "live"
271
+
272
+
273
+ def result_source_labels(result: Any) -> list[str]:
274
+ """Distinct provenance-source labels across every value in a result.
275
+
276
+ Used by the deterministic build path (which bypasses the agent loop) to
277
+ still record an honest ``sources`` list on its synthetic
278
+ ``build_trip_packages`` tool-call trace entry.
279
+ """
280
+ out: set[str] = set()
281
+ for p in (getattr(result, "packages", []) or []):
282
+ f = getattr(p, "flight", None)
283
+ if f and getattr(f, "source", ""):
284
+ out.add(f.source)
285
+ h = getattr(p, "hotel", None)
286
+ if h and getattr(h, "source", ""):
287
+ out.add(h.source)
288
+ for w in (getattr(p, "weather", None) or []):
289
+ if getattr(w, "source", ""):
290
+ out.add(w.source)
291
+ for a in (getattr(p, "amenities", None) or []):
292
+ if getattr(a, "source", ""):
293
+ out.add(a.source)
294
+ return sorted(out)
295
+
296
+
297
+ def ranking_from_result(result: Any, tier: str,
298
+ weights: dict[str, float]) -> tuple[dict[str, Any], list[PackageRankRecord]]:
299
+ """Build the ranking record from a ``TripPackageResult`` + scoring weights.
300
+
301
+ Returns ``(ranking_dict_for_trace, package_records)``. The ``why`` per package
302
+ is the deterministic label rationale (same words the card shows).
303
+ """
304
+ _why = {
305
+ "Cheapest": "Lowest total cost of the three.",
306
+ "Safest Arrival": "Largest arrival buffer before kickoff.",
307
+ "Closest to Stadium": "Shortest walk from hotel to BC Place.",
308
+ }
309
+ pkgs = getattr(result, "packages", []) or []
310
+ records = [
311
+ PackageRankRecord(
312
+ rank=i + 1,
313
+ label=p.label,
314
+ total_cost_cad=float(getattr(p, "total_cost_cad", 0) or 0),
315
+ scores=dict(getattr(p, "scores", {}) or {}),
316
+ why=_why.get(p.label, ""),
317
+ )
318
+ for i, p in enumerate(pkgs)
319
+ ]
320
+ ranking = {
321
+ "tier": tier,
322
+ "weights": {k: round(float(v), 2) for k, v in (weights or {}).items()},
323
+ "packages": [
324
+ {
325
+ "rank": r.rank, "label": r.label,
326
+ "total_cost_cad": round(r.total_cost_cad, 2),
327
+ "scores": {k: round(float(v), 3) for k, v in r.scores.items()},
328
+ "why": r.why,
329
+ }
330
+ for r in records
331
+ ],
332
+ }
333
+ return ranking, records
matchday/render.py CHANGED
@@ -1,15 +1,22 @@
1
- """HTML rendering for MatchDay — premium, Layla-competitive cards + Leaflet map.
2
-
3
- Pure functions: a ``TripPackageResult`` -> HTML strings for the result panel.
4
- Every price / hotel / weather reading carries a provenance badge (I4) so judges
5
- can verify nothing is hallucinated: "● live" (serpapi/openmeteo/osm) vs
6
- "example" (fallback). The look is a refined travel-product aesthetic
7
- (Off-Brand spirit) white surfaces, layered soft shadows, restrained accent
8
- pops, generous whitespace, a flat CARTO map — not stock Gradio chrome.
 
 
 
 
 
9
  """
10
  from __future__ import annotations
11
 
 
12
  from html import escape
 
13
  from typing import Any
14
  from urllib.parse import quote as _urlquote
15
 
@@ -18,54 +25,28 @@ from matchday.trip_tool import TripPackageResult
18
 
19
  BC_PLACE_LAT = 49.2827
20
  BC_PLACE_LON = -123.1207
21
-
22
  YVR_LAT = 49.1939
23
  YVR_LON = -123.1844
24
  _STADIUM = "BC Place Stadium, Vancouver"
25
  _YVR = "YVR, Vancouver"
26
 
27
- # Google Maps keyless directions (U2: real routing deferred; ORS needs a key).
28
- # Crucially, ``origin`` is ALWAYS supplied and trip-specific (hotel coords/name
29
- # or YVR) so Maps never silently falls back to the user's detected location.
30
- def _gmaps(origin: str, destination: str, mode: str = "transit") -> str:
31
- return (
32
- "https://www.google.com/maps/dir/?api=1"
33
- f"&origin={_urlquote(origin)}&destination={_urlquote(destination)}"
34
- f"&travelmode={mode}"
35
- )
36
-
37
-
38
- def _hotel_origin(h) -> str:
39
- """Trip-specific origin for directions: hotel coords (precise) else its name."""
40
- if h and getattr(h, "latitude", None) and getattr(h, "longitude", None):
41
- return f"{h.latitude},{h.longitude}"
42
- if h and getattr(h, "name", None):
43
- return f"{h.name}, Vancouver"
44
- return _YVR
45
-
46
-
47
- # Rotating local-day highlights (Layla L524-563 = uniquely-named days). The cycle
48
- # is longer than any realistic World Cup trip (≤40 days) so day titles never repeat.
49
- _LOCAL_DAYS = [
50
- ("🏙️", "Stanley Park & the seawall",
51
- "Loop the Stanley Park seawall by bike — totem poles, Siwash Rock and Lions Gate Bridge views."),
52
- ("🍤", "Granville Island",
53
- "Public market, artisan food stalls and the brewery/theatre scene under the Granville Bridge."),
54
- ("🏮", "Gastown & Chinatown",
55
- "Steam clock and Maple Tree Square, then dim sum and the Dr. Sun Yat-Sen Classical Garden."),
56
- ("⛰️", "Grouse Mountain",
57
- "Hike the Grouse Grind or take the Skyride — city-to-sea panorama and ranger shows at the top."),
58
- ("🌉", "Capilano Suspension Bridge",
59
- "Cliffwalk and Treetops Adventure over the Capilano River gorge, a short SeaBus + shuttle away."),
60
- ("🏖️", "English Bay & Sunset Beach",
61
- "Beachfront stroll to the A-Maze-ing Laughter statues, sunset over Burrard Inlet."),
62
- ("🍦", "Main Street & Mount Pleasant",
63
- "Indie boutiques, third-wave coffee and murals along Main Street south of Broadway."),
64
- ("🛍️", "Robson Street & FIFA fan zone",
65
- "Downtown shopping strip, then the official FIFA Fan Festival and live-match screens."),
66
- ]
67
 
68
- # Per-label accent + matching soft tint (avoids color-mix for max compatibility).
69
  _LABEL_COLOR = {
70
  "Cheapest": "#16a34a",
71
  "Safest Arrival": "#2563eb",
@@ -76,10 +57,11 @@ _LABEL_TINT = {
76
  "Safest Arrival": "#dbeafe",
77
  "Closest to Stadium": "#ede9fe",
78
  }
79
- _LABEL_ICON = {
80
- "Cheapest": "💰",
81
- "Safest Arrival": "🛬",
82
- "Closest to Stadium": "📍",
 
83
  }
84
 
85
  # WMO weather code -> emoji (subset; unknown -> 🌡️).
@@ -90,94 +72,68 @@ _WMO_ICON: dict[int, str] = {
90
  95: "⛈️", 96: "⛈️", 99: "⛈️",
91
  }
92
 
93
- # Per-card hero photos (Layla-style photo-header cards). Verified-200 Unsplash,
94
- # soccer/stadium themed (on-brand for a World Cup planner). Cycled by card index;
95
- # the gradient underlay shows through if a photo is slow/blocked.
96
- _CARD_PHOTOS = [
97
- "https://images.unsplash.com/photo-1522778526097-ce0a22ceb253?w=900&q=80",
98
- "https://images.unsplash.com/photo-1517466787929-bc90951d0974?w=900&q=80",
 
 
 
 
 
 
 
 
 
 
 
 
99
  ]
 
 
100
 
101
  _CSS = """
102
  <style>
103
  .md-wrap{font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#111827;-webkit-font-smoothing:antialiased;}
104
 
105
- /* ── Provenance pills (the "no hallucinated prices" differentiator) ── */
106
- .md-pv{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:800;padding:1px 7px 1px 5px;
107
- border-radius:999px;letter-spacing:.03em;text-transform:uppercase;vertical-align:middle;white-space:nowrap;}
108
- .md-pv::before{content:"";width:6px;height:6px;border-radius:50%;display:inline-block;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  .md-pv-live{background:#ecfdf5;color:#047857;}
110
- .md-pv-live::before{background:#10b981;box-shadow:0 0 0 2px rgba(16,185,129,.2);}
111
  .md-pv-ex{background:#fffbeb;color:#b45309;}
112
  .md-pv-ex::before{background:#f59e0b;}
113
  .md-pv-other{background:#f3f4f6;color:#4b5563;}
114
  .md-pv-other::before{background:#9ca3af;}
115
 
116
- /* ── Section header ── */
117
- .md-sec-h{display:flex;align-items:center;gap:12px;margin:26px 2px 14px;}
118
  .md-sec-h .md-sec-t{font-size:12.5px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:#6b7280;white-space:nowrap;}
119
  .md-sec-h .md-sec-line{flex:1;height:1px;background:linear-gradient(90deg,#eef0f3,transparent);}
120
 
121
- /* ── Status pill ── */
122
- .md-status{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;padding:7px 14px;
123
- border-radius:999px;margin:2px 0 4px;}
124
- .md-status.complete{background:#ecfdf5;color:#047857;border:1px solid #a7f3d0;}
125
- .md-status.partial{background:#fffbeb;color:#b45309;border:1px solid #fde68a;}
126
- .md-status.failed{background:#fef2f2;color:#b91c1c;border:1px solid #fecaca;}
127
- .md-status .md-dot{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 0 3px rgba(16,185,129,.15);}
128
- .md-status .md-status-sub{font-weight:500;opacity:.85;margin-left:2px;}
129
-
130
- /* ── Package cards ── */
131
- .md-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:16px;margin:6px 0 2px;}
132
- .md-card{position:relative;background:#fff;border:1px solid #eef0f3;border-radius:18px;overflow:hidden;
133
- box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);
134
- display:flex;flex-direction:column;transition:transform .18s ease,box-shadow .18s ease;}
135
- .md-card:hover{transform:translateY(-2px);box-shadow:0 2px 6px rgba(17,24,39,.06),0 16px 34px -14px rgba(17,24,39,.22);}
136
- .md-card.is-top{border-color:#ddd6fe;box-shadow:0 2px 6px rgba(124,58,237,.10),0 20px 42px -14px rgba(124,58,237,.32);}
137
-
138
- /* Layla-style photo header: full-bleed photo with chip + best-match + price overlaid */
139
- .md-photo{position:relative;height:152px;flex:0 0 152px;overflow:hidden;
140
- background:linear-gradient(135deg,#1e1b4b 0%,#6d28d9 45%,#db2777 100%);}
141
- .md-photo-img{position:absolute;inset:0;background-size:cover;background-position:center;}
142
- .md-photo-ov{position:absolute;inset:0;
143
- background:linear-gradient(180deg,rgba(15,23,42,.30) 0%,rgba(15,23,42,0) 42%,rgba(15,23,42,.85) 100%);}
144
- .md-photo .md-chip{position:absolute;top:12px;left:12px;z-index:3;background:rgba(255,255,255,.94);
145
- color:var(--accent,#7c3aed);backdrop-filter:blur(6px);}
146
- .md-chip{display:inline-flex;align-items:center;gap:6px;
147
- font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;padding:5px 11px;border-radius:999px;}
148
- .md-chip .md-chip-ico{font-size:13px;}
149
- .md-topflag{position:absolute;top:12px;right:12px;z-index:3;background:linear-gradient(135deg,#7c3aed,#6d28d9);color:#fff;
150
- font-size:10px;font-weight:800;letter-spacing:.04em;padding:4px 10px 4px 8px;border-radius:999px;
151
- box-shadow:0 6px 14px -4px rgba(124,58,237,.5);display:inline-flex;align-items:center;gap:4px;}
152
- .md-price-on{position:absolute;left:0;right:0;bottom:11px;z-index:3;padding:0 16px;color:#fff;
153
- display:flex;align-items:baseline;gap:5px;}
154
- .md-price-on .md-cur{font-size:14px;font-weight:700;opacity:.92;}
155
- .md-price-on .md-amount{font-size:32px;font-weight:800;letter-spacing:-.02em;line-height:1;
156
- font-family:'Space Grotesk',Inter,sans-serif;text-shadow:0 2px 10px rgba(0,0,0,.45);}
157
- .md-price-on .md-price-sub{font-size:11px;font-weight:600;opacity:.85;margin-left:3px;}
158
- .md-card-body{padding:14px 20px 4px;}
159
- .md-facts{list-style:none;margin:0;padding:0 0 14px;display:grid;gap:11px;}
160
- .md-facts li{display:flex;gap:12px;align-items:flex-start;font-size:13.5px;line-height:1.4;}
161
- .md-facts .md-fico{font-size:16px;flex:0 0 22px;text-align:center;margin-top:1px;line-height:1;}
162
- .md-facts .md-fbody{min-width:0;}
163
- .md-facts .md-fbody b{color:#111827;font-weight:700;display:block;}
164
- .md-facts .md-fbody .md-fdet{display:block;color:#6b7280;font-size:12.5px;margin-top:2px;}
165
- /* per-option booking CTAs (Layla-style): a real booking/directions button for
166
- flight, hotel, and transit — not one generic web search. */
167
- .md-card-cta{padding:2px 22px 18px;}
168
- .md-cta-row{display:flex;flex-wrap:wrap;gap:8px;}
169
- .md-cta{display:inline-flex;align-items:center;gap:6px;text-decoration:none;font-size:13px;font-weight:700;
170
- color:#fff;background:var(--accent,#7c3aed);padding:9px 15px;border-radius:11px;
171
- box-shadow:0 6px 14px -6px rgba(17,24,39,.35);transition:.15s;}
172
- .md-cta:hover{filter:brightness(1.07);transform:translateY(-1px);}
173
- .md-cta-f{background:#1d4ed8;} /* flight — sky blue */
174
- .md-cta-h{background:#047857;} /* hotel — emerald */
175
- .md-cta-t{background:#374151;} /* transit — slate */
176
-
177
- /* ── Map ── */
178
- .md-map-wrap{position:relative;border:1px solid #eef0f3;border-radius:18px;overflow:hidden;
179
- box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);}
180
- #matchday-map{height:430px;background:#eef0f3;}
181
  .md-map-tag{position:absolute;top:12px;left:12px;z-index:500;background:#fff;border:1px solid #eef0f3;border-radius:999px;
182
  padding:6px 13px;font-size:12px;font-weight:700;color:#374151;box-shadow:0 2px 10px rgba(17,24,39,.10);
183
  display:inline-flex;align-items:center;gap:8px;}
@@ -186,45 +142,126 @@ _CSS = """
186
  padding:7px 12px;font:inherit;font-size:12px;font-weight:700;color:#374151;cursor:pointer;
187
  box-shadow:0 2px 10px rgba(17,24,39,.10);display:inline-flex;align-items:center;gap:5px;}
188
  .md-fs-btn:hover{background:#f7f8fa;}
189
- .md-legend{display:flex;gap:18px;flex-wrap:wrap;margin:13px 3px 0;font-size:12px;color:#6b7280;}
190
  .md-legend span{display:inline-flex;align-items:center;gap:7px;}
191
-
192
- /* leaflet marker polish */
193
  .md-pin{width:18px;height:18px;border-radius:50%;background:var(--c,#7c3aed);border:3px solid #fff;
194
- box-shadow:0 2px 6px rgba(17,24,39,.35);}
195
- .md-pin-stadium{width:30px;height:30px;background:#111827;border:4px solid #fff;display:flex;align-items:center;
196
- justify-content:center;font-size:15px;}
197
  .leaflet-popup-content-wrapper{border-radius:12px;box-shadow:0 8px 24px -8px rgba(17,24,39,.25);}
198
  .leaflet-popup-content{margin:10px 13px;font-size:13px;font-family:Inter,sans-serif;}
199
  .leaflet-control-zoom a{border-radius:8px!important;}
 
200
 
201
- /* full-screen map (toggled from index.html) */
202
- #matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;height:100vh!important;
203
- width:100vw!important;border-radius:0!important;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
- /* ── Day-by-day timeline ── */
206
  .md-tl{display:grid;gap:11px;margin-top:6px;}
207
  .md-day{position:relative;background:#fff;border:1px solid #eef0f3;border-radius:16px;padding:14px 16px 14px 60px;
208
- box-shadow:0 1px 2px rgba(17,24,39,.04);transition:box-shadow .18s ease;}
 
209
  .md-day:hover{box-shadow:0 4px 14px -8px rgba(17,24,39,.18);}
 
 
 
 
210
  .md-day-ico{position:absolute;left:14px;top:14px;width:32px;height:32px;border-radius:10px;background:#f3f4f6;
211
  display:flex;align-items:center;justify-content:center;font-size:16px;}
212
  .md-day-lbl{font-size:10.5px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#9ca3af;}
213
  .md-day-title{font-size:15px;font-weight:700;color:#111827;margin:2px 0 4px;letter-spacing:-.01em;}
214
  .md-day-body{font-size:13px;color:#6b7280;line-height:1.5;}
215
  .md-day-body b{color:#374151;}
216
- .md-day.is-match{border-color:#bbf7d0;background:linear-gradient(180deg,#f0fdf4,#fff 60%);
217
- box-shadow:0 2px 8px rgba(16,185,129,.12);}
218
- .md-day.is-match .md-day-ico{background:#dcfce7;}
219
- .md-day.is-match .md-day-lbl{color:#047857;}
220
- .md-day.is-match .md-day-title{color:#065f46;}
221
 
222
- /* ── Explanation (Nemotron's comparison) ── */
223
- .md-explain{background:linear-gradient(135deg,#eef2ff,#f5f3ff);border:1px solid #ddd6fe;border-radius:16px;
224
- padding:16px 18px;margin-top:6px;color:#3730a3;font-size:13.5px;line-height:1.6;}
225
- .md-explain b{color:#4c1d95;}
226
- .md-explain-h{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:800;letter-spacing:.04em;
227
- text-transform:uppercase;color:#6d28d9;margin-bottom:8px;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  </style>
229
  """
230
 
@@ -233,7 +270,8 @@ def _e(x: Any) -> str:
233
  return escape(str(x)) if x is not None else ""
234
 
235
 
236
- def _prov_badge(source: str | None) -> str:
 
237
  s = (source or "").lower()
238
  if s == "fallback":
239
  return '<span class="md-pv md-pv-ex">example</span>'
@@ -244,39 +282,216 @@ def _prov_badge(source: str | None) -> str:
244
  return f'<span class="md-pv md-pv-other">{_e(source)}</span>'
245
 
246
 
247
- def _section(title: str) -> str:
248
- return f'<div class="md-sec-h"><span class="md-sec-t">{_e(title)}</span><span class="md-sec-line"></span></div>'
249
 
250
 
251
- def _weather_fact(pkg: ScoredPackage) -> str:
252
- if not pkg.weather:
253
- return (
254
- '<li><span class="md-fico">🌤️</span><span class="md-fbody"><b>Forecast unavailable</b>'
255
- '<span class="md-fdet">match-day weather could not be loaded</span></span></li>'
256
- )
257
- w = pkg.weather[0]
258
- icon = _WMO_ICON.get(w.weather_code, "🌡️")
259
  return (
260
- f'<li><span class="md-fico">{icon}</span><span class="md-fbody"><b>{w.temp_min_c:g}–{w.temp_max_c:g}°C</b>'
261
- f'<span class="md-fdet">{_e(w.date)} · match day · {w.precipitation_probability:g}% rain '
262
- f'{_prov_badge(w.source)}</span></span></li>'
263
  )
264
 
265
 
266
- def render_card(pkg: ScoredPackage, is_top: bool = False, idx: int = 0, trip=None) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  accent = _LABEL_COLOR.get(pkg.label, "#7c3aed")
268
  tint = _LABEL_TINT.get(pkg.label, "#ede9fe")
269
  icon = _LABEL_ICON.get(pkg.label, "✅")
270
- photo = _CARD_PHOTOS[idx % len(_CARD_PHOTOS)]
271
  f = pkg.flight
272
  h = pkg.hotel
 
 
 
273
 
274
- # Trip params for trip-specific, honestly-labeled action links (K5/U4).
275
- ci = getattr(trip, "check_in", None)
276
- co = getattr(trip, "check_out", None)
277
- trav = getattr(trip, "travelers", 1) or 1
 
 
 
 
 
 
 
 
278
 
279
- # Hotel fact
 
 
 
 
 
 
 
280
  if h:
281
  bits = []
282
  if h.total_price_cad is not None:
@@ -287,143 +502,68 @@ def render_card(pkg: ScoredPackage, is_top: bool = False, idx: int = 0, trip=Non
287
  bits.append(f"{h.distance_to_stadium_km:g} km to BC Place")
288
  hotel_fact = (
289
  f'<li><span class="md-fico">🏨</span><span class="md-fbody"><b>{_e(h.name)}</b>'
290
- f'<span class="md-fdet">{" · ".join(bits)} {_prov_badge(h.source)}</span></span></li>'
291
- )
292
- else:
293
- hotel_fact = (
294
- '<li><span class="md-fico">🏨</span><span class="md-fbody"><b>Flight-only package</b>'
295
- '<span class="md-fdet">no hotel bundled for this option</span></span></li>'
296
  )
297
-
298
- # Arrival buffer
299
- buffer = pkg.arrival_buffer_hours
300
- if buffer >= 0:
301
- buf_b, buf_d = f"+{buffer:g}h before kickoff", "arrival buffer"
302
  else:
303
- buf_b, buf_d = f"{buffer:g}h — lands AFTER kickoff", "⚠️ risky arrival"
 
304
 
305
- topflag = '<span class="md-topflag">★ Best match</span>' if is_top else ""
306
-
307
- # ── Flight action link: live deep link if SerpApi gave one, else an honest
308
- # round-trip Skyscanner SEARCH for the exact route + check-in/check-out +
309
- # travelers. Never labeled "Book confirmed" — prices aren't guaranteed (K5).
310
- if getattr(f, "booking_url", None):
311
- flight_url, flight_label = f.booking_url, "✈️ Open this flight"
312
- else:
313
- dep = (ci or f.arrival_time.date()).strftime("%y%m%d")
314
- flight_url = (
315
- f"https://www.skyscanner.com/transport/flights/{f.origin.lower()}/"
316
- f"{f.destination.lower()}/{dep}"
317
  )
318
- if co is not None:
319
- flight_url += f"/{co.strftime('%y%m%d')}/"
320
- else:
321
- flight_url += "/"
322
- flight_url += f"?adults={int(trav)}"
323
- flight_label = "✈️ Search this flight"
324
-
325
- # ── Hotel action link: live Google-Hotels deep link if present, else an
326
- # honest Booking.com SEARCH for this hotel + check-in/out + adults (K5).
327
- if h and getattr(h, "booking_url", None):
328
- hotel_url, hotel_label = h.booking_url, "🏨 Open this hotel"
329
- elif h:
330
- ss_q = h.name if "vancouver" in h.name.lower() else f"{h.name} Vancouver"
331
- parts = [f"ss={_urlquote(ss_q)}"]
332
- if ci is not None:
333
- parts.append(f"checkin={ci.strftime('%Y-%m-%d')}")
334
- if co is not None:
335
- parts.append(f"checkout={co.strftime('%Y-%m-%d')}")
336
- parts.append(f"group_adults={int(trav)}")
337
- hotel_url = "https://www.booking.com/searchresults.html?" + "&".join(parts)
338
- hotel_label = "🏨 Search this hotel"
339
  else:
340
- hotel_url = hotel_label = None
341
-
342
- # ── Ground-transport links: EXPLICIT trip-specific origins so Maps never
343
- # uses the user's current location (U2). Hotel→BC Place (walk if close, else
344
- # transit), YVR→Hotel, Hotel→YVR (rideshare/taxi style). No booking claim.
345
- horg = _hotel_origin(h)
346
- walk_ok = bool(pkg.hotel_to_stadium_min and pkg.hotel_to_stadium_min <= 20)
347
- transit_links = [
348
- ("🚶 Hotel → BC Place" if walk_ok else "🚌 Hotel → BC Place",
349
- _gmaps(horg, _STADIUM, "walking" if walk_ok else "transit")),
350
- ("🚇 YVR → Hotel", _gmaps(_YVR, horg, "transit")),
351
- ("🚕 Hotel → YVR", _gmaps(horg, _YVR, "driving")),
352
- ]
353
-
354
- cta: list[str] = [
355
- f'<a class="md-cta md-cta-f" href="{_e(flight_url)}" target="_blank" '
356
- f'rel="noopener noreferrer">{flight_label} →</a>'
357
- ]
358
- if hotel_url:
359
- cta.append(
360
- f'<a class="md-cta md-cta-h" href="{_e(hotel_url)}" target="_blank" '
361
- f'rel="noopener noreferrer">{hotel_label} →</a>'
362
- )
363
- for lbl, url in transit_links:
364
- cta.append(
365
- f'<a class="md-cta md-cta-t" href="{_e(url)}" target="_blank" '
366
- f'rel="noopener noreferrer">{lbl} →</a>'
367
- )
368
 
369
  return f"""
370
- <article class="md-card{' is-top' if is_top else ''}" style="--accent:{accent};--tint:{tint}">
 
 
371
  <div class="md-photo">
372
  <div class="md-photo-img" style="background-image:url('{photo}')"></div>
373
  <div class="md-photo-ov"></div>
374
- <span class="md-chip"><span class="md-chip-ico">{icon}</span>{_e(pkg.label)}</span>
375
  {topflag}
376
  <div class="md-price-on"><span class="md-cur">CA$</span><span class="md-amount">{pkg.total_cost_cad:,.0f}</span><span class="md-price-sub">total</span></div>
377
  </div>
378
  <div class="md-card-body">
379
- <ul class="md-facts">
380
- <li><span class="md-fico">✈️</span><span class="md-fbody"><b>{_e(f.airline)} {_e(f.flight_number)}</b>
381
- <span class="md-fdet">{_e(f.origin)} → {_e(f.destination)} · lands {f.arrival_time:%H:%M} {_prov_badge(f.source)}</span></span></li>
382
- {hotel_fact}
383
- {_weather_fact(pkg)}
384
- <li><span class="md-fico">⏱️</span><span class="md-fbody"><b>{buf_b}</b><span class="md-fdet">{buf_d}</span></span></li>
385
- <li><span class="md-fico">🚶</span><span class="md-fbody"><b>{pkg.hotel_to_stadium_min} min walk</b>
386
- <span class="md-fdet">hotel BC Place · {len(pkg.amenities)} nearby spots</span></span></li>
387
- </ul>
388
- <div class="md-card-cta"><div class="md-cta-row">{"".join(cta)}</div></div>
 
 
 
 
389
  </div>
 
390
  </article>
391
  """
392
 
393
 
394
- def render_cards(result: TripPackageResult, trip=None) -> str:
395
- if not result.packages:
396
- return '<div class="md-row" style="padding:14px;color:#6b7280">No packages could be formed from the available data.</div>'
397
- cards = "".join(
398
- render_card(p, is_top=(i == 0), idx=i, trip=trip)
399
- for i, p in enumerate(result.packages)
400
- )
401
- return f'{_CSS}<div class="md-wrap">{_section("Your 3 ranked packages")}<div class="md-cards">{cards}</div></div>'
402
-
403
-
404
- def render_status_bar(result: TripPackageResult) -> str:
405
- cls = result.status
406
- notices = " · ".join(result.degradation_notices) if result.degradation_notices else "all systems live"
407
- label = {"complete": "Live data", "partial": "Partial data", "failed": "Degraded"}.get(cls, cls)
408
- return (
409
- f'{_CSS}<div class="md-wrap"><span class="md-status {cls}"><span class="md-dot"></span>{_e(label)}'
410
- f'<span class="md-status-sub">· {len(result.packages)} packages · {_e(notices)}</span></span></div>'
411
- )
412
-
413
-
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:
427
  h = p.hotel
428
  if h and h.latitude and h.longitude and (h.latitude, h.longitude) not in seen:
429
  seen.add((h.latitude, h.longitude))
@@ -431,8 +571,9 @@ def _js_markers(result: TripPackageResult) -> str:
431
  name = escape(h.name)
432
  lines.append(
433
  f"bb.push([{h.latitude},{h.longitude}]);"
434
- f"L.marker([{h.latitude},{h.longitude}],{{icon:L.divIcon({{className:'',html:'<div class=\\\"md-pin\\\" style=\\\"--c:{accent}\\\"></div>',iconSize:[18,18],iconAnchor:[9,9],popupAnchor:[0,-8]}})}})"
435
  f".addTo(map).bindPopup('<b>🏨 {name}</b><br>{escape(p.label)}');"
 
436
  )
437
  lines.append(
438
  f"L.polyline([[{h.latitude},{h.longitude}],[{BC_PLACE_LAT},{BC_PLACE_LON}]],"
@@ -440,7 +581,7 @@ def _js_markers(result: TripPackageResult) -> str:
440
  )
441
  # POIs (first package that has them)
442
  for p in result.packages:
443
- for a in (p.amenities or [])[:18]:
444
  if a.latitude and a.longitude and (a.latitude, a.longitude) not in seen:
445
  seen.add((a.latitude, a.longitude))
446
  lines.append(
@@ -450,33 +591,39 @@ def _js_markers(result: TripPackageResult) -> str:
450
  )
451
  if p.amenities:
452
  break
 
 
453
  lines.append(
454
- "try{map.fitBounds(bb,{padding:[34,34],maxZoom:16});}catch(e){}"
 
 
 
 
 
 
455
  )
456
  return "\n".join(lines)
457
 
458
 
459
- def render_map(result: TripPackageResult, leaflet_preloaded: bool = False) -> str:
460
- # When the frontend preloaded Leaflet in <head> (gradio.Server path), skip the
461
- # CDN tags so re-running the inline init script after injection is dependency-free.
462
  _cdn = "" if leaflet_preloaded else (
463
  ' <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>\n'
464
  ' <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>\n'
465
  )
466
  return f"""
467
- {_CSS}
468
  <div class="md-wrap">
469
- {_cdn}{_section("On the map")}
 
470
  <div class="md-map-wrap">
471
- <span class="md-map-tag"><span class="md-mdot" style="background:#111827"></span>BC Place</span>
472
- <button class="md-fs-btn" type="button" onclick="window.matchdayFullscreen&amp;&amp;matchdayFullscreen()">⛶ Full screen</button>
473
  <div id="matchday-map"></div>
474
  </div>
475
  <div class="md-legend">
476
- <span><span class="md-mdot" style="background:#111827"></span>BC Place Stadium</span>
477
- <span><span class="md-mdot" style="background:#16a34a"></span>Cheapest hotel</span>
478
- <span><span class="md-mdot" style="background:#2563eb"></span>Safest-arrival hotel</span>
479
- <span><span class="md-mdot" style="background:#7c3aed"></span>Closest hotel</span>
480
  <span><span class="md-mdot" style="background:#9ca3af"></span>Nearby spots</span>
481
  </div>
482
  <script>
@@ -488,32 +635,17 @@ def render_map(result: TripPackageResult, leaflet_preloaded: bool = False) -> st
488
  el._lmap=map; window._matchdayMap=map;
489
  L.tileLayer('https://{{s}}.basemaps.cartocdn.com/light_all/{{z}}/{{x}}/{{y}}.png',{{subdomains:'abcd',maxZoom:20,attribution:'&amp;copy; OpenStreetMap &amp;copy; CARTO'}}).addTo(map);
490
  var stadiumIcon=L.divIcon({{className:'',html:'<div class="md-pin md-pin-stadium">🏟️</div>',iconSize:[30,30],iconAnchor:[15,15],popupAnchor:[0,-12]}});
491
- {_js_markers(result)}
492
  }})();
493
  </script>
494
  </div>
495
  """
496
 
497
 
498
- def render_timeline(trip, result: TripPackageResult) -> str:
499
- """Day-by-day itinerary — unique, date-aware, realistic (A2 + Layla L509-569).
500
-
501
- Each day is assigned ONE role by priority, so the same filler text never
502
- repeats across days and departure language appears on exactly one day:
503
-
504
- 1. match_date → MATCH DAY (hotel→BC Place, fan zone, weather, return)
505
- 2. check_out → DEPARTURE (checkout, hotel→YVR, return flight)
506
- 3. check_in → ARRIVAL (flight, YVR→hotel transfer, check-in, evening)
507
- 4. otherwise → LOCAL EXPLORE (rotating named Vancouver highlight)
508
-
509
- When check_in == match_date (land + match same day) the match role wins but
510
- the body still notes the arrival flight. Weather is matched per date from the
511
- top package's forecast. The selected flight / hotel / venue are referenced.
512
- """
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:
@@ -528,76 +660,401 @@ def render_timeline(trip, result: TripPackageResult) -> str:
528
  w = wx_by_date.get(d)
529
  if not w:
530
  return ""
531
- icon = _WMO_ICON.get(w.weather_code, "🌡️")
532
  return (
533
- f' <span class="md-fdet" style="margin-left:6px">{icon} '
534
- f"{w.temp_min_c:g}–{w.temp_max_c:g}°C · {w.precipitation_probability:g}% rain "
535
- f'{_prov_badge(w.source)}</span>'
536
  )
537
 
538
  items: list[str] = []
539
  d = trip.check_in
540
  idx = 0
541
  local_i = 0
 
542
  while d <= trip.check_out:
543
  idx += 1
544
  head = f"Day {idx} · {d:%a %b %d}"
545
-
546
  if d == trip.match_date:
547
  arrive = ""
548
  if d == trip.check_in and top:
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.",
556
  "is-match",
557
  )
 
558
  elif d == trip.check_out:
559
  icon, lbl, title, body, cls = (
560
  "🛫", "Departure", f"{head} — heading home",
561
- f"Check out of <b>{_e(hotel_name)}</b>, grab a last Vancouver "
562
- f"breakfast, then head to YVR for {flight_bit} home. Safe travels!",
563
  "",
564
  )
 
565
  elif d == trip.check_in and top:
566
  icon, lbl, title, body, cls = (
567
  "✈️", "Arrival", head,
568
- f"Land via {flight_bit} and take the Canada Line into town. "
569
- f"Check in to <b>{_e(hotel_name)}</b>, then an easy first evening "
570
- f"near the hotel.{_wx_note(d)}",
571
  "",
572
  )
 
573
  else:
574
  li, lname, ldesc = _LOCAL_DAYS[local_i % len(_LOCAL_DAYS)]
575
  local_i += 1
576
  icon, lbl, title, body, cls = (
577
- li, "Free day", f"{head} — {lname}",
578
- f"{ldesc}{_wx_note(d)}",
579
- "",
580
  )
581
-
582
  items.append(
583
- f'<div class="md-day {cls}"><div class="md-day-ico">{icon}</div>'
 
584
  f'<div class="md-day-lbl">{lbl}</div>'
585
  f'<div class="md-day-title">{title}</div>'
586
  f'<div class="md-day-body">{body}</div></div>'
587
  )
588
  d += timedelta(days=1)
 
 
 
589
  return (
590
- f'{_CSS}<div class="md-wrap">{_section("Day-by-day itinerary")}'
 
591
  f'<div class="md-tl">{"".join(items)}</div></div>'
592
  )
593
 
594
 
595
- def render_full(result: TripPackageResult, trip=None, leaflet_preloaded: bool = False) -> str:
596
- out = (
597
- render_status_bar(result)
598
- + render_cards(result, trip=trip)
599
- + render_map(result, leaflet_preloaded=leaflet_preloaded)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  )
601
- if trip is not None:
602
- out += render_timeline(trip, result)
603
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTML rendering for MatchDay — a media-rich travel *artifact*, not a dashboard.
2
+
3
+ Pure functions turning a ``TripPackageResult`` into one cohesive result canvas:
4
+ a trip header an enhanced Leaflet map clickable, image-led package cards
5
+ a clickable day-by-day itinerary. A hidden JSON ``data island`` carries the
6
+ per-package detail (images, why-it-won, risks, confidence inputs, action links)
7
+ that the frontend reads to drive the detail drawer, the sticky action bar, and
8
+ selection-aware map focus.
9
+
10
+ Every price / hotel / weather reading still carries a quiet provenance pill
11
+ (``● live`` vs ``example``) — the no-hallucination guarantee — but it no longer
12
+ dominates the layout. The look is a refined travel-product aesthetic (Off-Brand
13
+ spirit): white surfaces, layered soft shadows, image headers, a flat CARTO map.
14
  """
15
  from __future__ import annotations
16
 
17
+ import json
18
  from html import escape
19
+ from datetime import date
20
  from typing import Any
21
  from urllib.parse import quote as _urlquote
22
 
 
25
 
26
  BC_PLACE_LAT = 49.2827
27
  BC_PLACE_LON = -123.1207
 
28
  YVR_LAT = 49.1939
29
  YVR_LON = -123.1844
30
  _STADIUM = "BC Place Stadium, Vancouver"
31
  _YVR = "YVR, Vancouver"
32
 
33
+ # ── Curated imagery (Unsplash, soccer / Vancouver / transit / hospitality) ────
34
+ # A broken URL degrades gracefully: cards/drawer fall back to a gradient, so a
35
+ # single blocked image never breaks the layout. cycled by index.
36
+ IMG = {
37
+ "stadium": "https://images.unsplash.com/photo-1522778526097-ce0a22ceb253?w=1200&q=80",
38
+ "match": "https://images.unsplash.com/photo-1551958219-acbc608c6377?w=1200&q=80",
39
+ "hotel": "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1000&q=80",
40
+ "flight": "https://images.unsplash.com/photo-1436491865332-7a61a109cc05?w=1000&q=80",
41
+ "transit": "https://images.unsplash.com/photo-1474487548417-781cb71495f3?w=1000&q=80",
42
+ "downtown": "https://images.unsplash.com/photo-1559511260-66a654ae982a?w=1000&q=80",
43
+ "gastown": "https://images.unsplash.com/photo-1582450871972-ab5ca941660b?w=1000&q=80",
44
+ "stanley": "https://images.unsplash.com/photo-1502175353174-a7a44e84da10?w=1000&q=80",
45
+ "food": "https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=1000&q=80",
46
+ }
47
+ _CARD_PHOTO = [IMG["match"], IMG["stadium"], IMG["downtown"]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ # Per-label accent + matching soft tint + icon.
50
  _LABEL_COLOR = {
51
  "Cheapest": "#16a34a",
52
  "Safest Arrival": "#2563eb",
 
57
  "Safest Arrival": "#dbeafe",
58
  "Closest to Stadium": "#ede9fe",
59
  }
60
+ _LABEL_ICON = {"Cheapest": "💰", "Safest Arrival": "🛬", "Closest to Stadium": "📍"}
61
+ _LABEL_BLURB = {
62
+ "Cheapest": "Lowest total cost of the three — best when budget leads.",
63
+ "Safest Arrival": "Lands earliest against kickoff — most cushion for delays.",
64
+ "Closest to Stadium": "Shortest walk from the hotel to BC Place.",
65
  }
66
 
67
  # WMO weather code -> emoji (subset; unknown -> 🌡️).
 
72
  95: "⛈️", 96: "⛈️", 99: "⛈️",
73
  }
74
 
75
+ # Rotating local-day highlights longer than any WC trip so titles never repeat.
76
+ _LOCAL_DAYS = [
77
+ ("🏙️", "Stanley Park & the seawall",
78
+ "Loop the Stanley Park seawall by bike — totem poles, Siwav Rock and Lions Gate Bridge views."),
79
+ ("🍤", "Granville Island",
80
+ "Public market, artisan food stalls and the brewery/theatre scene under the Granville Bridge."),
81
+ ("🏮", "Gastown & Chinatown",
82
+ "Steam clock and Maple Tree Square, then dim sum and the Dr. Sun Yat-Sen Classical Garden."),
83
+ ("⛰️", "Grouse Mountain",
84
+ "Hike the Grouse Grind or take the Skyride — city-to-sea panorama and ranger shows at the top."),
85
+ ("🌉", "Capilano Suspension Bridge",
86
+ "Cliffwalk and Treetops Adventure over the Capilano River gorge, a short SeaBus + shuttle away."),
87
+ ("🏖️", "English Bay & Sunset Beach",
88
+ "Beachfront stroll to the A-Maze-ing Laughter statues, sunset over Burrard Inlet."),
89
+ ("🍦", "Main Street & Mount Pleasant",
90
+ "Indie boutiques, third-wave coffee and murals along Main Street south of Broadway."),
91
+ ("🛍️", "Robson Street & FIFA fan zone",
92
+ "Downtown shopping strip, then the official FIFA Fan Festival and live-match screens."),
93
  ]
94
+ _LOCAL_IMG = [IMG["stanley"], IMG["food"], IMG["gastown"], IMG["downtown"],
95
+ IMG["stanley"], IMG["downtown"], IMG["food"], IMG["gastown"]]
96
 
97
  _CSS = """
98
  <style>
99
  .md-wrap{font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#111827;-webkit-font-smoothing:antialiased;}
100
 
101
+ /* ── Trip artifact header (a generated travel doc, not app chrome) ── */
102
+ .md-trip{position:relative;border-radius:20px;overflow:hidden;margin-bottom:18px;min-height:188px;
103
+ display:flex;align-items:flex-end;
104
+ background:linear-gradient(135deg,#0f172a 0%,#1e1b4b 45%,#5b21b6 100%);box-shadow:0 12px 30px -14px rgba(17,24,39,.4);}
105
+ .md-trip-img{position:absolute;inset:0;background-size:cover;background-position:center;}
106
+ .md-trip-ov{position:absolute;inset:0;background:linear-gradient(180deg,rgba(15,23,42,.15) 0%,rgba(15,23,42,.45) 48%,rgba(15,23,42,.93) 100%);}
107
+ .md-trip-c{position:relative;z-index:2;padding:18px 22px 19px;color:#fff;width:100%;}
108
+ .md-trip-k{font-size:11px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#c4b5fd;
109
+ display:inline-flex;align-items:center;gap:8px;background:rgba(255,255,255,.10);padding:5px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.16);}
110
+ .md-trip h2{margin:11px 0 6px;font-family:'Space Grotesk',Inter,sans-serif;font-size:24px;font-weight:700;letter-spacing:-.02em;line-height:1.1;}
111
+ .md-trip .md-route{font-size:13.5px;color:rgba(255,255,255,.92);font-weight:500;}
112
+ .md-tiers{display:flex;gap:7px;margin-top:13px;flex-wrap:wrap;}
113
+ .md-tier{font-size:11.5px;font-weight:700;padding:6px 13px;border-radius:999px;background:rgba(255,255,255,.10);
114
+ color:rgba(255,255,255,.78);border:1px solid rgba(255,255,255,.14);cursor:pointer;transition:.15s;}
115
+ .md-tier:hover{background:rgba(255,255,255,.2);color:#fff;}
116
+ .md-tier.is-on{background:#fff;color:#0f172a;border-color:#fff;box-shadow:0 4px 12px -3px rgba(0,0,0,.4);}
117
+
118
+ /* ── quiet provenance pills ── */
119
+ .md-pv{display:inline-flex;align-items:center;gap:4px;font-size:9.5px;font-weight:800;padding:1px 7px 1px 5px;
120
+ border-radius:999px;letter-spacing:.04em;text-transform:uppercase;vertical-align:middle;white-space:nowrap;}
121
+ .md-pv::before{content:"";width:5px;height:5px;border-radius:50%;display:inline-block;}
122
  .md-pv-live{background:#ecfdf5;color:#047857;}
123
+ .md-pv-live::before{background:#10b981;}
124
  .md-pv-ex{background:#fffbeb;color:#b45309;}
125
  .md-pv-ex::before{background:#f59e0b;}
126
  .md-pv-other{background:#f3f4f6;color:#4b5563;}
127
  .md-pv-other::before{background:#9ca3af;}
128
 
129
+ .md-sec-h{display:flex;align-items:center;gap:12px;margin:22px 2px 12px;}
 
130
  .md-sec-h .md-sec-t{font-size:12.5px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:#6b7280;white-space:nowrap;}
131
  .md-sec-h .md-sec-line{flex:1;height:1px;background:linear-gradient(90deg,#eef0f3,transparent);}
132
 
133
+ /* ── map ── */
134
+ .md-map-wrap{position:relative;border:1px solid #eef0f3;border-radius:20px;overflow:hidden;
135
+ box-shadow:0 1px 2px rgba(17,24,39,.04),0 10px 26px -14px rgba(17,24,39,.18);}
136
+ #matchday-map{height:420px;background:#eef0f3;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  .md-map-tag{position:absolute;top:12px;left:12px;z-index:500;background:#fff;border:1px solid #eef0f3;border-radius:999px;
138
  padding:6px 13px;font-size:12px;font-weight:700;color:#374151;box-shadow:0 2px 10px rgba(17,24,39,.10);
139
  display:inline-flex;align-items:center;gap:8px;}
 
142
  padding:7px 12px;font:inherit;font-size:12px;font-weight:700;color:#374151;cursor:pointer;
143
  box-shadow:0 2px 10px rgba(17,24,39,.10);display:inline-flex;align-items:center;gap:5px;}
144
  .md-fs-btn:hover{background:#f7f8fa;}
145
+ .md-legend{display:flex;gap:18px;flex-wrap:wrap;margin:12px 3px 0;font-size:11.5px;color:#6b7280;}
146
  .md-legend span{display:inline-flex;align-items:center;gap:7px;}
 
 
147
  .md-pin{width:18px;height:18px;border-radius:50%;background:var(--c,#7c3aed);border:3px solid #fff;
148
+ box-shadow:0 2px 6px rgba(17,24,39,.35);transition:transform .15s,box-shadow .15s;}
149
+ .md-pin.is-active{transform:scale(1.35);box-shadow:0 0 0 6px rgba(124,58,237,.18),0 4px 10px rgba(17,24,39,.4);}
150
+ .md-pin-stadium{width:30px;height:30px;background:#111827;border:4px solid #fff;display:flex;align-items:center;justify-content:center;font-size:15px;}
151
  .leaflet-popup-content-wrapper{border-radius:12px;box-shadow:0 8px 24px -8px rgba(17,24,39,.25);}
152
  .leaflet-popup-content{margin:10px 13px;font-size:13px;font-family:Inter,sans-serif;}
153
  .leaflet-control-zoom a{border-radius:8px!important;}
154
+ #matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;height:100vh!important;width:100vw!important;border-radius:0!important;}
155
 
156
+ /* ── package cards: the card IS the button ── */
157
+ .md-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;margin:6px 0 2px;}
158
+ .md-card{position:relative;background:#fff;border:1px solid #eef0f3;border-radius:20px;overflow:hidden;cursor:pointer;
159
+ box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);
160
+ display:flex;flex-direction:column;transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease;outline:none;}
161
+ .md-card:hover{transform:translateY(-3px);box-shadow:0 2px 6px rgba(17,24,39,.06),0 18px 36px -14px rgba(17,24,39,.24);}
162
+ .md-card:focus-visible{box-shadow:0 0 0 3px var(--accent,#7c3aed);}
163
+ .md-card.is-selected{border-color:var(--accent,#7c3aed);box-shadow:0 0 0 2px var(--accent,#7c3aed),0 18px 38px -14px rgba(17,24,39,.3);}
164
+ .md-photo{position:relative;height:140px;flex:0 0 140px;overflow:hidden;background:linear-gradient(135deg,#1e1b4b 0%,#6d28d9 45%,#db2777 100%);}
165
+ .md-photo-img{position:absolute;inset:0;background-size:cover;background-position:center;}
166
+ .md-photo-ov{position:absolute;inset:0;background:linear-gradient(180deg,rgba(15,23,42,.28) 0%,rgba(15,23,42,0) 42%,rgba(15,23,42,.86) 100%);}
167
+ .md-chip{position:absolute;top:12px;left:12px;z-index:3;background:rgba(255,255,255,.94);color:var(--accent,#7c3aed);
168
+ display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;
169
+ padding:5px 11px;border-radius:999px;backdrop-filter:blur(6px);}
170
+ .md-topflag{position:absolute;top:12px;right:12px;z-index:3;background:linear-gradient(135deg,#7c3aed,#6d28d9);color:#fff;
171
+ font-size:10px;font-weight:800;letter-spacing:.04em;padding:4px 10px 4px 8px;border-radius:999px;
172
+ box-shadow:0 6px 14px -4px rgba(124,58,237,.5);display:inline-flex;align-items:center;gap:4px;}
173
+ .md-price-on{position:absolute;left:0;right:0;bottom:11px;z-index:3;padding:0 16px;color:#fff;display:flex;align-items:baseline;gap:5px;}
174
+ .md-price-on .md-cur{font-size:14px;font-weight:700;opacity:.92;}
175
+ .md-price-on .md-amount{font-size:32px;font-weight:800;letter-spacing:-.02em;line-height:1;font-family:'Space Grotesk',Inter,sans-serif;text-shadow:0 2px 10px rgba(0,0,0,.45);}
176
+ .md-price-on .md-price-sub{font-size:11px;font-weight:600;opacity:.85;margin-left:3px;}
177
+ .md-card-body{padding:14px 18px 6px;}
178
+ .md-facts{list-style:none;margin:0;padding:0 0 12px;display:grid;gap:10px;}
179
+ .md-facts li{display:flex;gap:11px;align-items:flex-start;font-size:13px;line-height:1.4;}
180
+ .md-facts .md-fico{font-size:16px;flex:0 0 22px;text-align:center;margin-top:1px;line-height:1;}
181
+ .md-facts .md-fbody{min-width:0;}
182
+ .md-facts .md-fbody b{color:#111827;font-weight:700;display:block;}
183
+ .md-facts .md-fbody .md-fdet{display:block;color:#6b7280;font-size:12px;margin-top:2px;}
184
+ .md-why{font-size:12.5px;color:#374151;background:var(--tint,#ede9fe);border-radius:11px;padding:9px 12px;margin:0 0 12px;line-height:1.45;}
185
+ .md-why b{color:var(--accent,#7c3aed);}
186
+ .md-card-foot{padding:0 18px 16px;font-size:11.5px;color:#9ca3af;font-weight:600;display:flex;align-items:center;gap:7px;}
187
+ .md-match-bar{height:5px;border-radius:999px;background:#eef0f3;overflow:hidden;flex:1;max-width:84px;}
188
+ .md-match-bar > i{display:block;height:100%;background:var(--accent,#7c3aed);}
189
+ /* Manual action links — always server-rendered so they work without the JS
190
+ drawer. stopPropagation keeps a link click from triggering card-select. */
191
+ .md-actions{padding:0 18px 16px;display:flex;flex-wrap:wrap;gap:7px;border-top:1px solid #f1f3f6;padding-top:12px;}
192
+ .md-act{font-size:11.5px;font-weight:700;color:var(--accent,#7c3aed);background:var(--tint,#ede9fe);
193
+ border-radius:999px;padding:6px 12px;text-decoration:none;white-space:nowrap;transition:filter .15s,transform .15s;}
194
+ .md-act:hover{filter:brightness(.96);transform:translateY(-1px);}
195
 
196
+ /* ── itinerary (clickable days) ── */
197
  .md-tl{display:grid;gap:11px;margin-top:6px;}
198
  .md-day{position:relative;background:#fff;border:1px solid #eef0f3;border-radius:16px;padding:14px 16px 14px 60px;
199
+ box-shadow:0 1px 2px rgba(17,24,39,.04);transition:box-shadow .18s ease,border-color .18s ease;min-height:58px;overflow:hidden;}
200
+ .md-day-img{position:absolute;right:0;top:0;bottom:0;width:120px;background-size:cover;background-position:center;opacity:.0;}
201
  .md-day:hover{box-shadow:0 4px 14px -8px rgba(17,24,39,.18);}
202
+ .md-day.is-match{border-color:#bbf7d0;background:linear-gradient(180deg,#f0fdf4,#fff 60%);box-shadow:0 2px 8px rgba(16,185,129,.12);}
203
+ .md-day.is-match .md-day-ico{background:#dcfce7;}
204
+ .md-day.is-match .md-day-lbl{color:#047857;}
205
+ .md-day.is-match .md-day-title{color:#065f46;}
206
  .md-day-ico{position:absolute;left:14px;top:14px;width:32px;height:32px;border-radius:10px;background:#f3f4f6;
207
  display:flex;align-items:center;justify-content:center;font-size:16px;}
208
  .md-day-lbl{font-size:10.5px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#9ca3af;}
209
  .md-day-title{font-size:15px;font-weight:700;color:#111827;margin:2px 0 4px;letter-spacing:-.01em;}
210
  .md-day-body{font-size:13px;color:#6b7280;line-height:1.5;}
211
  .md-day-body b{color:#374151;}
 
 
 
 
 
212
 
213
+ /* ── Agent Trace drawer (Best Agent visible proof — collapsible, JS-free) ── */
214
+ .md-trace{margin:22px 2px 6px;background:#fff;border:1px solid #eef0f3;border-radius:18px;
215
+ box-shadow:0 1px 2px rgba(17,24,39,.04),0 8px 22px -14px rgba(17,24,39,.16);overflow:hidden;}
216
+ .md-trace > summary{list-style:none;cursor:pointer;padding:15px 18px;display:flex;align-items:center;
217
+ gap:11px;font-size:13.5px;font-weight:700;color:#111827;user-select:none;}
218
+ .md-trace > summary::-webkit-details-marker{display:none;}
219
+ .md-trace > summary .md-trace-ico{font-size:17px;}
220
+ .md-trace > summary .md-trace-chev{margin-left:auto;color:#9ca3af;font-size:13px;transition:transform .2s;flex:0 0 auto;}
221
+ .md-trace[open] > summary .md-trace-chev{transform:rotate(90deg);}
222
+ .md-trace > summary .md-trace-sub{font-size:12px;font-weight:600;color:#6b7280;}
223
+ .md-trace-body{padding:2px 18px 18px;display:grid;gap:14px;border-top:1px solid #f1f3f6;}
224
+ .md-trace-meta{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-size:12px;color:#6b7280;font-weight:600;margin-top:13px;}
225
+ .md-tag{font-size:10.5px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;padding:4px 10px;border-radius:999px;}
226
+ .md-tag-agent{background:#ede9fe;color:#6d28d9;}
227
+ .md-tag-det{background:#fef3c7;color:#b45309;}
228
+ .md-tag-clarify{background:#dbeafe;color:#1d4ed8;}
229
+ .md-tag-failed{background:#fee2e2;color:#b91c1c;}
230
+ .md-trace-model{color:#374151;font-weight:700;}
231
+ .md-trace-sec h5{margin:0 0 8px;font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#6b7280;}
232
+ .md-slots{display:flex;flex-wrap:wrap;gap:7px;}
233
+ .md-slot{font-size:12px;background:#f3f4f6;border-radius:9px;padding:6px 11px;color:#374151;font-weight:600;}
234
+ .md-slot b{color:#111827;font-weight:800;}
235
+ .md-slot.miss{background:#fff7ed;color:#c2410c;}
236
+ .md-ground{font-size:13px;line-height:1.5;color:#374151;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:11px;padding:10px 13px;}
237
+ .md-ground.refused{background:#fffbeb;border-color:#fde68a;color:#92400e;}
238
+ .md-ground b{color:#065f46;} .md-ground.refused b{color:#92400e;}
239
+ .md-tcalls{list-style:none;margin:0;padding:0;display:grid;gap:8px;counter-reset:tc;}
240
+ .md-tcall{position:relative;background:#fafbfc;border:1px solid #eef0f3;border-radius:11px;padding:10px 12px 10px 38px;font-size:12.5px;}
241
+ .md-tcall::before{counter-increment:tc;content:counter(tc);position:absolute;left:11px;top:10px;width:20px;height:20px;
242
+ border-radius:50%;background:#7c3aed;color:#fff;font-size:11px;font-weight:800;display:flex;align-items:center;justify-content:center;}
243
+ .md-tcall.is-failed{border-color:#fecaca;background:#fef5f5;} .md-tcall.is-failed::before{background:#ef4444;}
244
+ .md-tcall.is-skipped{opacity:.7;} .md-tcall.is-skipped::before{background:#9ca3af;}
245
+ .md-tcall .tc-head{font-weight:800;color:#111827;font-family:'Space Grotesk',Inter,sans-serif;font-size:13px;}
246
+ .md-tcall .tc-meta{color:#6b7280;margin-top:2px;}
247
+ .md-tcall .tc-src{display:inline-flex;gap:5px;flex-wrap:wrap;margin-top:5px;}
248
+ .md-evi{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;}
249
+ .md-evi-cell{border:1px solid #eef0f3;border-radius:11px;padding:9px 11px;font-size:12px;}
250
+ .md-evi-cell .ec-name{font-weight:700;color:#111827;display:flex;align-items:center;gap:6px;}
251
+ .md-evi-cell .ec-stat{font-size:10.5px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;margin-top:3px;}
252
+ .md-evi-cell.live .ec-stat{color:#047857;} .md-evi-cell.example .ec-stat{color:#b45309;}
253
+ .md-evi-cell.failed .ec-stat{color:#b91c1c;} .md-evi-cell.skipped .ec-stat{color:#6b7280;}
254
+ .md-formula-w{font-size:12.5px;color:#374151;margin-bottom:10px;} .md-formula-w b{color:#111827;}
255
+ .md-rank-row{display:grid;grid-template-columns:22px 1fr auto;gap:10px;align-items:center;padding:7px 0;border-top:1px solid #f3f4f6;font-size:12.5px;}
256
+ .md-rank-row:first-of-type{border-top:0;}
257
+ .md-rank-row .rr-rk{font-weight:800;color:#9ca3af;font-family:'Space Grotesk',Inter,sans-serif;}
258
+ .md-rank-row .rr-lbl{font-weight:700;color:#111827;}
259
+ .md-rank-row .rr-cost{color:#6b7280;font-variant-numeric:tabular-nums;}
260
+ .md-dimbar{display:flex;gap:9px;margin-top:5px;align-items:center;font-size:10.5px;color:#6b7280;}
261
+ .md-dimbar .db{flex:1;height:6px;border-radius:999px;background:#eef0f3;overflow:hidden;position:relative;}
262
+ .md-dimbar .db > i{position:absolute;left:0;top:0;bottom:0;background:#7c3aed;border-radius:999px;}
263
+ .md-dimbar .db.b > i{background:#2563eb;} .md-dimbar .db.g > i{background:#16a34a;}
264
+ .md-trace-empty{padding:14px 18px;color:#9ca3af;font-size:13px;}
265
  </style>
266
  """
267
 
 
270
  return escape(str(x)) if x is not None else ""
271
 
272
 
273
+ def _pv(source: str | None) -> str:
274
+ """Quiet provenance pill: live / example / other."""
275
  s = (source or "").lower()
276
  if s == "fallback":
277
  return '<span class="md-pv md-pv-ex">example</span>'
 
282
  return f'<span class="md-pv md-pv-other">{_e(source)}</span>'
283
 
284
 
285
+ def _wmo(code: int) -> str:
286
+ return _WMO_ICON.get(code, "🌡️")
287
 
288
 
289
+ def _gmaps(origin: str, destination: str, mode: str = "transit") -> str:
 
 
 
 
 
 
 
290
  return (
291
+ "https://www.google.com/maps/dir/?api=1"
292
+ f"&origin={_urlquote(origin)}&destination={_urlquote(destination)}"
293
+ f"&travelmode={mode}"
294
  )
295
 
296
 
297
+ def _hotel_origin(h) -> str:
298
+ if h and getattr(h, "latitude", None) and getattr(h, "longitude", None):
299
+ return f"{h.latitude},{h.longitude}"
300
+ if h and getattr(h, "name", None):
301
+ return f"{h.name}, Vancouver"
302
+ return _YVR
303
+
304
+
305
+ def _match_score(pkg: ScoredPackage) -> int:
306
+ """0-100 relative ranking score (the deterministic composite, scaled)."""
307
+ comp = (pkg.scores or {}).get("composite")
308
+ try:
309
+ return int(round(max(0.0, min(1.0, float(comp))) * 100)) if comp is not None else 70
310
+ except (TypeError, ValueError):
311
+ return 70
312
+
313
+
314
+ def _max_precip(pkg: ScoredPackage) -> float:
315
+ if not pkg.weather:
316
+ return 0.0
317
+ return max(w.precipitation_probability for w in pkg.weather)
318
+
319
+
320
+ def _pkg_risks(pkg: ScoredPackage) -> list[str]:
321
+ risks: list[str] = []
322
+ b = pkg.arrival_buffer_hours
323
+ if b < 0:
324
+ risks.append("Flight lands after kickoff — you'd miss the start.")
325
+ elif b < 2:
326
+ risks.append("Tight arrival window — under 2h before kickoff.")
327
+ if _max_precip(pkg) >= 50:
328
+ risks.append("High rain chance on a trip day.")
329
+ h = pkg.hotel
330
+ if h and pkg.hotel_to_stadium_min and pkg.hotel_to_stadium_min > 25:
331
+ risks.append(f"Hotel is a {pkg.hotel_to_stadium_min}-min walk to BC Place.")
332
+ if any(_src_is_example(x) for x in (_flight_src(pkg), _hotel_src(pkg))):
333
+ risks.append("Some figures are example/demo data, not live.")
334
+ return risks
335
+
336
+
337
+ def _flight_src(pkg: ScoredPackage) -> str:
338
+ return getattr(pkg.flight, "source", "") or ""
339
+
340
+
341
+ def _hotel_src(pkg: ScoredPackage) -> str:
342
+ return getattr(pkg.hotel, "source", "") or "" if pkg.hotel else ""
343
+
344
+
345
+ def _src_is_example(s: str) -> bool:
346
+ return (s or "").lower() == "fallback"
347
+
348
+
349
+ def _flight_links(pkg: ScoredPackage, trip) -> tuple[str, str]:
350
+ f = pkg.flight
351
+ if getattr(f, "booking_url", None):
352
+ return f.booking_url, "✈️ Open this flight"
353
+ ci = getattr(trip, "check_in", None) or f.arrival_time.date()
354
+ co = getattr(trip, "check_out", None)
355
+ dep = ci.strftime("%y%m%d")
356
+ url = f"https://www.skyscanner.com/transport/flights/{f.origin.lower()}/{f.destination.lower()}/{dep}/"
357
+ if co is not None:
358
+ url += f"{co.strftime('%y%m%d')}/"
359
+ else:
360
+ url += "/"
361
+ url += f"?adults={int(getattr(trip, 'travelers', 1) or 1)}"
362
+ return url, "✈️ Search this flight"
363
+
364
+
365
+ def _hotel_links(pkg: ScoredPackage, trip) -> tuple[str | None, str]:
366
+ h = pkg.hotel
367
+ if not h:
368
+ return None, ""
369
+ if getattr(h, "booking_url", None):
370
+ return h.booking_url, "🏨 Open this hotel"
371
+ ci = getattr(trip, "check_in", None)
372
+ co = getattr(trip, "check_out", None)
373
+ ss_q = h.name if "vancouver" in h.name.lower() else f"{h.name} Vancouver"
374
+ parts = [f"ss={_urlquote(ss_q)}"]
375
+ if ci is not None:
376
+ parts.append(f"checkin={ci.strftime('%Y-%m-%d')}")
377
+ if co is not None:
378
+ parts.append(f"checkout={co.strftime('%Y-%m-%d')}")
379
+ parts.append(f"group_adults={int(getattr(trip, 'travelers', 1) or 1)}")
380
+ return "https://www.booking.com/searchresults.html?" + "&".join(parts), "🏨 Search this hotel"
381
+
382
+
383
+ def _transit_links(pkg: ScoredPackage) -> list[tuple[str, str, str]]:
384
+ h = pkg.hotel
385
+ horg = _hotel_origin(h)
386
+ walk = bool(pkg.hotel_to_stadium_min and pkg.hotel_to_stadium_min <= 20)
387
+ return [
388
+ ("🚶 Hotel → BC Place" if walk else "🚌 Hotel → BC Place",
389
+ _gmaps(horg, _STADIUM, "walking" if walk else "transit"), "walking" if walk else "transit"),
390
+ ("🚇 YVR → Hotel", _gmaps(_YVR, horg, "transit"), "transit"),
391
+ ("🚕 Hotel → YVR", _gmaps(horg, _YVR, "driving"), "driving"),
392
+ ]
393
+
394
+
395
+ # ── Per-package detail dict (serialized into the data island) ────────────────
396
+ def _package_json(pkg: ScoredPackage, idx: int, trip) -> dict:
397
+ f = pkg.flight
398
+ h = pkg.hotel
399
+ furl, flabel = _flight_links(pkg, trip)
400
+ hurl, hlabel = _hotel_links(pkg, trip)
401
+ accent = _LABEL_COLOR.get(pkg.label, "#7c3aed")
402
+ buf = pkg.arrival_buffer_hours
403
+ buf_txt = (f"+{buf:g}h before kickoff" if buf >= 0 else f"{buf:g}h — lands AFTER kickoff")
404
+ hotel_facts: list[list[str]] = []
405
+ if h:
406
+ if h.total_price_cad is not None:
407
+ hotel_facts.append(["Stay total", f"${format(round(h.total_price_cad), ',')} CAD"])
408
+ if h.rating is not None:
409
+ hotel_facts.append(["Rating", f"{h.rating:g} / 5"])
410
+ if h.distance_to_stadium_km is not None:
411
+ hotel_facts.append(["To BC Place", f"{h.distance_to_stadium_km:g} km · {pkg.hotel_to_stadium_min} min walk"])
412
+ return {
413
+ "id": f"pkg-{idx}",
414
+ "idx": idx,
415
+ "label": pkg.label,
416
+ "accent": accent,
417
+ "icon": _LABEL_ICON.get(pkg.label, "✅"),
418
+ "is_top": idx == 0,
419
+ "match": _match_score(pkg),
420
+ "total_cost_cad": round(pkg.total_cost_cad),
421
+ "why": _LABEL_BLURB.get(pkg.label, ""),
422
+ "risks": _pkg_risks(pkg),
423
+ "buffer_hours": buf,
424
+ "buffer_text": buf_txt,
425
+ "walk_min": pkg.hotel_to_stadium_min,
426
+ "flight": {
427
+ "title": f"{_e(f.airline)} {_e(f.flight_number)}",
428
+ "route": f"{_e(f.origin)} → {_e(f.destination)}",
429
+ "arrival": f"{f.arrival_time:%H:%M}",
430
+ "source": _flight_src(pkg),
431
+ "image": IMG["flight"],
432
+ "why": f"Lands {f.arrival_time:%H:%M}, {buf_txt}.",
433
+ "facts": [["Airline", _e(f.airline)], ["Flight", _e(f.flight_number)],
434
+ ["Route", f"{_e(f.origin)} → {_e(f.destination)}"],
435
+ ["Arrives YVR", f"{f.arrival_time:%H:%M} local"], ["Origin", _e(f.origin)]],
436
+ "action": {"label": flabel, "url": furl},
437
+ },
438
+ "hotel": ({
439
+ "title": _e(h.name),
440
+ "sub": " · ".join(x for x in [
441
+ f"${format(round(h.total_price_cad), ',')} total" if h.total_price_cad is not None else None,
442
+ f"{h.rating:g}★" if h.rating is not None else None,
443
+ f"{h.distance_to_stadium_km:g} km to BC Place" if h.distance_to_stadium_km is not None else None,
444
+ ] if x),
445
+ "source": _hotel_src(pkg),
446
+ "image": IMG["hotel"],
447
+ "lat": h.latitude, "lng": h.longitude,
448
+ "why": f"{h.distance_to_stadium_km:g} km / {pkg.hotel_to_stadium_min}-min walk to BC Place.",
449
+ "facts": hotel_facts,
450
+ "action": {"label": hlabel, "url": hurl},
451
+ } if h else None),
452
+ "weather": [
453
+ {"date": str(w.date), "icon": _wmo(w.weather_code),
454
+ "temp": f"{w.temp_min_c:g}–{w.temp_max_c:g}°C", "rain": w.precipitation_probability,
455
+ "source": w.source}
456
+ for w in (pkg.weather or [])
457
+ ],
458
+ "transit": [{"label": l, "url": u, "mode": m} for l, u, m in _transit_links(pkg)],
459
+ "nearby": [{"name": _e(a.name), "category": _e(a.category)} for a in (pkg.amenities or [])[:8]],
460
+ }
461
+
462
+
463
+ # ── Card HTML (clickable; the card is the button) ────────────────────────────
464
+ def _card_html(pkg: ScoredPackage, idx: int, trip) -> str:
465
  accent = _LABEL_COLOR.get(pkg.label, "#7c3aed")
466
  tint = _LABEL_TINT.get(pkg.label, "#ede9fe")
467
  icon = _LABEL_ICON.get(pkg.label, "✅")
 
468
  f = pkg.flight
469
  h = pkg.hotel
470
+ photo = _CARD_PHOTO[idx % len(_CARD_PHOTO)]
471
+ topflag = '<span class="md-topflag">★ Best match</span>' if idx == 0 else ""
472
+ score = _match_score(pkg)
473
 
474
+ # Manual action links always rendered server-side so a user can open a
475
+ # real flight/hotel/transit search even before the JS drawer mounts. Each
476
+ # opens externally (final booking stays off-app); stopPropagation keeps the
477
+ # link click from selecting the card.
478
+ furl, flabel = _flight_links(pkg, trip)
479
+ hurl, hlabel = _hotel_links(pkg, trip)
480
+ acts = [f'<a class="md-act" href="{_e(furl)}" target="_blank" rel="noopener">{_e(flabel)}</a>']
481
+ if hurl:
482
+ acts.append(f'<a class="md-act" href="{_e(hurl)}" target="_blank" rel="noopener">{_e(hlabel)}</a>')
483
+ for lbl, turl, _mode in _transit_links(pkg):
484
+ acts.append(f'<a class="md-act" href="{_e(turl)}" target="_blank" rel="noopener">{_e(lbl)}</a>')
485
+ actions_html = f'<div class="md-actions" onclick="event.stopPropagation()">{"".join(acts)}</div>'
486
 
487
+ # flight fact
488
+ buf = pkg.arrival_buffer_hours
489
+ if buf >= 0:
490
+ buf_b, buf_d = f"+{buf:g}h before kickoff", "arrival buffer"
491
+ else:
492
+ buf_b, buf_d = f"{buf:g}h — lands AFTER kickoff", "⚠️ risky arrival"
493
+
494
+ # hotel fact
495
  if h:
496
  bits = []
497
  if h.total_price_cad is not None:
 
502
  bits.append(f"{h.distance_to_stadium_km:g} km to BC Place")
503
  hotel_fact = (
504
  f'<li><span class="md-fico">🏨</span><span class="md-fbody"><b>{_e(h.name)}</b>'
505
+ f'<span class="md-fdet">{" · ".join(bits)} {_pv(h.source)}</span></span></li>'
 
 
 
 
 
506
  )
 
 
 
 
 
507
  else:
508
+ hotel_fact = ('<li><span class="md-fico">🏨</span><span class="md-fbody"><b>Flight-only package</b>'
509
+ '<span class="md-fdet">no hotel bundled</span></span></li>')
510
 
511
+ # weather fact (match day)
512
+ mw = pkg.weather[0] if pkg.weather else None
513
+ if mw:
514
+ wx_fact = (
515
+ f'<li><span class="md-fico">{_wmo(mw.weather_code)}</span><span class="md-fbody">'
516
+ f'<b>{mw.temp_min_c:g}–{mw.temp_max_c:g}°C match day</b>'
517
+ f'<span class="md-fdet">{mw.precipitation_probability:g}% rain {_pv(mw.source)}</span></span></li>'
 
 
 
 
 
518
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  else:
520
+ wx_fact = ('<li><span class="md-fico">🌤️</span><span class="md-fbody"><b>Forecast unavailable</b>'
521
+ '<span class="md-fdet">match-day weather could not load</span></span></li>')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
 
523
  return f"""
524
+ <article class="md-card" data-md-id="pkg-{idx}" data-md-idx="{idx}" tabindex="0" role="button"
525
+ aria-label="{_e(pkg.label)} package, {pkg.total_cost_cad:,.0f} CAD total"
526
+ style="--accent:{accent};--tint:{tint}">
527
  <div class="md-photo">
528
  <div class="md-photo-img" style="background-image:url('{photo}')"></div>
529
  <div class="md-photo-ov"></div>
530
+ <span class="md-chip"><span>{icon}</span>{_e(pkg.label)}</span>
531
  {topflag}
532
  <div class="md-price-on"><span class="md-cur">CA$</span><span class="md-amount">{pkg.total_cost_cad:,.0f}</span><span class="md-price-sub">total</span></div>
533
  </div>
534
  <div class="md-card-body">
535
+ <ul class="md-facts">
536
+ <li><span class="md-fico">✈️</span><span class="md-fbody"><b>{_e(f.airline)} {_e(f.flight_number)}</b>
537
+ <span class="md-fdet">{_e(f.origin)} → {_e(f.destination)} · lands {f.arrival_time:%H:%M} {_pv(f.source)}</span></span></li>
538
+ {hotel_fact}
539
+ {wx_fact}
540
+ <li><span class="md-fico">⏱️</span><span class="md-fbody"><b>{buf_b}</b><span class="md-fdet">{buf_d}</span></span></li>
541
+ </ul>
542
+ <div class="md-why"><b>Why this won:</b> {_e(_LABEL_BLURB.get(pkg.label, ""))}</div>
543
+ </div>
544
+ <div class="md-card-foot">
545
+ <span>match</span>
546
+ <span class="md-match-bar"><i style="width:{score}%"></i></span>
547
+ <span>{score}/100</span>
548
+ <span style="margin-left:auto;opacity:.7">tap for details →</span>
549
  </div>
550
+ {actions_html}
551
  </article>
552
  """
553
 
554
 
555
+ # ── Map JS (markers + selection focus exposed to the shell) ──────────────────
556
+ def _map_js(result: TripPackageResult) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  lines: list[str] = []
 
 
558
  kickoff = result.kickoff_local or "kickoff TBD"
559
  lines.append(
560
  f"var bb=[[{BC_PLACE_LAT},{BC_PLACE_LON}]];"
561
+ f"window.__MD_STADIUM=L.marker([{BC_PLACE_LAT},{BC_PLACE_LON}],{{icon:stadiumIcon}})"
562
  f".addTo(map).bindPopup('<b>🏟️ BC Place Stadium</b><br>Match venue · {kickoff}');"
563
  )
564
+ lines.append("window.__MD_MARKERS={};")
565
  seen: set[tuple[float, float]] = {(BC_PLACE_LAT, BC_PLACE_LON)}
566
+ for i, p in enumerate(result.packages):
567
  h = p.hotel
568
  if h and h.latitude and h.longitude and (h.latitude, h.longitude) not in seen:
569
  seen.add((h.latitude, h.longitude))
 
571
  name = escape(h.name)
572
  lines.append(
573
  f"bb.push([{h.latitude},{h.longitude}]);"
574
+ f"var m{i}=L.marker([{h.latitude},{h.longitude}],{{icon:L.divIcon({{className:'',html:'<div class=\\\"md-pin\\\" data-md-idx=\\\"{i}\\\" style=\\\"--c:{accent}\\\"></div>',iconSize:[18,18],iconAnchor:[9,9],popupAnchor:[0,-8]}})}})"
575
  f".addTo(map).bindPopup('<b>🏨 {name}</b><br>{escape(p.label)}');"
576
+ f"window.__MD_MARKERS[{i}]={{marker:m{i},lat:{h.latitude},lng:{h.longitude}}};"
577
  )
578
  lines.append(
579
  f"L.polyline([[{h.latitude},{h.longitude}],[{BC_PLACE_LAT},{BC_PLACE_LON}]],"
 
581
  )
582
  # POIs (first package that has them)
583
  for p in result.packages:
584
+ for a in (p.amenities or [])[:16]:
585
  if a.latitude and a.longitude and (a.latitude, a.longitude) not in seen:
586
  seen.add((a.latitude, a.longitude))
587
  lines.append(
 
591
  )
592
  if p.amenities:
593
  break
594
+ lines.append("try{map.fitBounds(bb,{padding:[34,34],maxZoom:16});}catch(e){}")
595
+ # expose a focus helper the shell calls on card selection
596
  lines.append(
597
+ "window.__MD_FOCUS=function(idx){"
598
+ "var m=window.__MD_MARKERS&&window.__MD_MARKERS[idx];"
599
+ "if(!m||!window._matchdayMap)return;"
600
+ "window._matchdayMap.panTo([m.lat,m.lng]);"
601
+ "if(m.marker&&m.marker.openPopup)m.marker.openPopup();"
602
+ "document.querySelectorAll('.md-pin').forEach(function(p){p.classList.toggle('is-active',String(p.getAttribute('data-md-idx'))===String(idx));});"
603
+ "};"
604
  )
605
  return "\n".join(lines)
606
 
607
 
608
+ def _map_html(result: TripPackageResult, leaflet_preloaded: bool) -> str:
 
 
609
  _cdn = "" if leaflet_preloaded else (
610
  ' <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>\n'
611
  ' <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>\n'
612
  )
613
  return f"""
 
614
  <div class="md-wrap">
615
+ {_cdn}
616
+ <div class="md-sec-h"><span class="md-sec-t">On the map</span><span class="md-sec-line"></span></div>
617
  <div class="md-map-wrap">
618
+ <span class="md-map-tag"><span class="md-mdot" style="background:#111827"></span>BC Place · Vancouver</span>
619
+ <button class="md-fs-btn" type="button" onclick="window.matchdayFullscreen&amp;&amp;matchdayFullscreen()">⛶ Full map</button>
620
  <div id="matchday-map"></div>
621
  </div>
622
  <div class="md-legend">
623
+ <span><span class="md-mdot" style="background:#111827"></span>BC Place</span>
624
+ <span><span class="md-mdot" style="background:#16a34a"></span>Cheapest</span>
625
+ <span><span class="md-mdot" style="background:#2563eb"></span>Safest arrival</span>
626
+ <span><span class="md-mdot" style="background:#7c3aed"></span>Closest</span>
627
  <span><span class="md-mdot" style="background:#9ca3af"></span>Nearby spots</span>
628
  </div>
629
  <script>
 
635
  el._lmap=map; window._matchdayMap=map;
636
  L.tileLayer('https://{{s}}.basemaps.cartocdn.com/light_all/{{z}}/{{x}}/{{y}}.png',{{subdomains:'abcd',maxZoom:20,attribution:'&amp;copy; OpenStreetMap &amp;copy; CARTO'}}).addTo(map);
637
  var stadiumIcon=L.divIcon({{className:'',html:'<div class="md-pin md-pin-stadium">🏟️</div>',iconSize:[30,30],iconAnchor:[15,15],popupAnchor:[0,-12]}});
638
+ {_map_js(result)}
639
  }})();
640
  </script>
641
  </div>
642
  """
643
 
644
 
645
+ # ── Itinerary (clickable; match day references the selected package) ─────────
646
+ def _timeline_html(trip, result: TripPackageResult) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  from datetime import timedelta
 
648
  top = result.packages[0] if result.packages else None
 
649
  kickoff = (", " + result.kickoff_local) if getattr(result, "kickoff_local", "") else ""
650
  wx_by_date = {}
651
  if top and top.weather:
 
660
  w = wx_by_date.get(d)
661
  if not w:
662
  return ""
 
663
  return (
664
+ f' <span class="md-fdet" style="margin-left:6px">{_wmo(w.weather_code)} '
665
+ f"{w.temp_min_c:g}–{w.temp_max_c:g}°C · {w.precipitation_probability:g}% rain {_pv(w.source)}</span>"
 
666
  )
667
 
668
  items: list[str] = []
669
  d = trip.check_in
670
  idx = 0
671
  local_i = 0
672
+ nights = max(1, (trip.check_out - trip.check_in).days)
673
  while d <= trip.check_out:
674
  idx += 1
675
  head = f"Day {idx} · {d:%a %b %d}"
 
676
  if d == trip.match_date:
677
  arrive = ""
678
  if d == trip.check_in and top:
679
  arrive = f"Land via {flight_bit}, drop bags at <b>{_e(hotel_name)}</b>, then "
680
  icon, lbl, title, body, cls = (
681
  "🏟️", "Match day", f"{head} — MATCH DAY",
682
+ f"{arrive}<b>{_e(trip.match_name)}</b> at BC Place{kickoff}. Soak up the FIFA fan "
683
+ f"zone first, then it's a short <b><span data-md-walk>{top.hotel_to_stadium_min if top else 'few'}</span>-min walk</b> "
684
+ f"from <b>{_e(hotel_name)}</b>.{_wx_note(d)} Head back after full-time.",
 
685
  "is-match",
686
  )
687
+ img = IMG["stadium"]
688
  elif d == trip.check_out:
689
  icon, lbl, title, body, cls = (
690
  "🛫", "Departure", f"{head} — heading home",
691
+ f"Check out of <b>{_e(hotel_name)}</b>, grab a last Vancouver breakfast, then head to "
692
+ f"YVR for {flight_bit} home. Safe travels!",
693
  "",
694
  )
695
+ img = IMG["flight"]
696
  elif d == trip.check_in and top:
697
  icon, lbl, title, body, cls = (
698
  "✈️", "Arrival", head,
699
+ f"Land via {flight_bit} and take the Canada Line into town. Check in to "
700
+ f"<b>{_e(hotel_name)}</b>, then an easy first evening near the hotel.{_wx_note(d)}",
 
701
  "",
702
  )
703
+ img = IMG["transit"]
704
  else:
705
  li, lname, ldesc = _LOCAL_DAYS[local_i % len(_LOCAL_DAYS)]
706
  local_i += 1
707
  icon, lbl, title, body, cls = (
708
+ li, "Free day", f"{head} — {lname}", f"{ldesc}{_wx_note(d)}", "",
 
 
709
  )
710
+ img = _LOCAL_IMG[(local_i - 1) % len(_LOCAL_IMG)]
711
  items.append(
712
+ f'<div class="md-day {cls}"><div class="md-day-img" style="background-image:url(\'{img}\')"></div>'
713
+ f'<div class="md-day-ico">{icon}</div>'
714
  f'<div class="md-day-lbl">{lbl}</div>'
715
  f'<div class="md-day-title">{title}</div>'
716
  f'<div class="md-day-body">{body}</div></div>'
717
  )
718
  d += timedelta(days=1)
719
+
720
+ trip_len = "day" if nights == 1 else "days"
721
+ head = f"{nights + 1}-{trip_len.title() if False else 'Day'} World Cup Vancouver Trip".replace("Day", "Day")
722
  return (
723
+ f'<div class="md-wrap">'
724
+ f'<div class="md-sec-h"><span class="md-sec-t">Day-by-day itinerary</span><span class="md-sec-line"></span></div>'
725
  f'<div class="md-tl">{"".join(items)}</div></div>'
726
  )
727
 
728
 
729
+ def _trip_header(trip, result: TripPackageResult) -> str:
730
+ """The artifact header: title, route, dates, travelers, tier pills."""
731
+ if trip is None:
732
+ return ""
733
+ origin = getattr(trip, "origin_airport", "—")
734
+ ci = getattr(trip, "check_in", None)
735
+ co = getattr(trip, "check_out", None)
736
+ trav = getattr(trip, "travelers", 1) or 1
737
+ tier = getattr(trip, "budget_tier", "mid_range")
738
+ nights = max(1, (co - ci).days) if (ci and co) else 3
739
+ date_txt = f"{ci:%b %-d}–{co:%-d}" if (ci and co) else "your dates"
740
+ match_name = getattr(trip, "match_name", "the match") or "the match"
741
+ tier_pretty = {"budget": "Budget", "mid_range": "Balanced", "premium": "Premium"}.get(tier, "Balanced")
742
+ tiers = [
743
+ ("💰 Budget", "budget"),
744
+ ("⚖️ Balanced", "mid_range"),
745
+ ("💎 Premium", "premium"),
746
+ ]
747
+ tier_html = "".join(
748
+ f'<span class="md-tier{" is-on" if k == tier else ""}" data-md-tier="{k}">{lbl}</span>'
749
+ for lbl, k in tiers
750
+ )
751
+ return f"""
752
+ <div class="md-trip">
753
+ <div class="md-trip-img" style="background-image:url('{IMG["stadium"]}')"></div>
754
+ <div class="md-trip-ov"></div>
755
+ <div class="md-trip-c">
756
+ <span class="md-trip-k">⚽ {nights}-night World Cup trip · Vancouver</span>
757
+ <h2>{_e(match_name)}</h2>
758
+ <div class="md-route"><b>{_e(origin)} → Vancouver</b> · {date_txt} · {trav} traveler{'s' if trav > 1 else ''} · {tier_pretty}</div>
759
+ <div class="md-tiers">{tier_html}</div>
760
+ </div>
761
+ </div>
762
+ """
763
+
764
+
765
+ # ── Agent Trace / Evidence drawer (Best Agent visible proof) ──────────────────
766
+
767
+ # Inline json import is already at module top. ``AgentTrace`` imported lazily
768
+ # inside render_trace so render.py has no hard dependency on agent_trace.
769
+
770
+ _TIER_TITLE = {"budget": "Budget", "mid_range": "Balanced", "premium": "Premium"}
771
+ _DIM_TITLE = {"affordability": "Affordable", "arrival_buffer": "Safe arrival", "proximity": "Close"}
772
+ _DIM_CLS = {"affordability": "", "arrival_buffer": "b", "proximity": "g"}
773
+ # Weight keys (cost/buffer/transit from ScoringWeights) → the same dimension
774
+ # titles the per-package bars use, so the ranking formula and the bars read in
775
+ # the SAME vocabulary. Covers both key styles so any trace renders correctly.
776
+ _W_TITLE = {
777
+ "cost": "Affordable", "buffer": "Safe arrival", "transit": "Close",
778
+ "affordability": "Affordable", "arrival_buffer": "Safe arrival", "proximity": "Close",
779
+ }
780
+ _EVI_DOTS = {"live": "🟢", "example": "🟠", "failed": "🔴", "skipped": "⚪", "partial": "🟡", "ok": "•"}
781
+ _TAG = {
782
+ "agent": ("md-tag-agent", "Agent"),
783
+ "deterministic": ("md-tag-det", "Deterministic"),
784
+ "clarify": ("md-tag-clarify", "Clarify"),
785
+ "error": ("md-tag-failed", "Error"),
786
+ }
787
+
788
+
789
+ def render_trace(trace=None) -> str:
790
+ """Render the Agent Trace / Evidence drawer from an ``AgentTrace`` (or its dict).
791
+
792
+ A collapsible, JS-free ``<details>`` panel proving the turn was genuinely
793
+ agentic: extracted intent → verified-fixture grounding → numbered tool calls
794
+ → per-category live/example/failed evidence → the ranking formula + why each
795
+ package won. A ``#md-trace`` JSON island carries the same data for the
796
+ frontend drawer to live-update from ``trace`` SSE events. Returns "" if no
797
+ trace was supplied, so callers can always append it.
798
+ """
799
+ if trace is None:
800
+ return ""
801
+ if hasattr(trace, "to_dict"):
802
+ try:
803
+ d = trace.to_dict()
804
+ except Exception:
805
+ return ""
806
+ elif isinstance(trace, dict):
807
+ d = trace
808
+ else:
809
+ return ""
810
+
811
+ mode = d.get("mode", "") or ""
812
+ model = d.get("model", "") or ""
813
+ rounds = d.get("rounds", 0) or 0
814
+ tag_cls, tag_txt = _TAG.get(mode or "error", ("md-tag-det", mode or "—"))
815
+
816
+ # ── Summary header ──
817
+ meta_bits = [f'<span class="md-tag {tag_cls}">{_e(tag_txt)}</span>']
818
+ if model:
819
+ meta_bits.append(f'<span class="md-trace-model">{_e(model)}</span>')
820
+ if rounds:
821
+ meta_bits.append(f'<span>{int(rounds)} loop round{"s" if rounds != 1 else ""}</span>')
822
+ meta_bits.append(f'<span>outcome: {_e(d.get("outcome_status", "") or "—")}</span>')
823
+ summary = (
824
+ '<details class="md-trace" open>'
825
+ '<summary>'
826
+ '<span class="md-trace-ico">🧭</span>'
827
+ '<span>Agent Trace &amp; Evidence</span>'
828
+ '<span class="md-trace-sub">how this trip was reasoned, tool-by-tool</span>'
829
+ '<span class="md-trace-chev">▸</span>'
830
+ '</summary>'
831
+ f'<div class="md-trace-body">'
832
+ f'<div class="md-trace-meta">{"".join(meta_bits)}</div>'
833
+ )
834
+
835
+ sections: list[str] = []
836
+
837
+ # ── 1. Extracted Trip Intent ──
838
+ intent = d.get("intent") or {}
839
+ missing = d.get("missing_slots") or []
840
+ if intent or missing:
841
+ slot_defs = [
842
+ ("Origin", intent.get("origin")),
843
+ ("Match", intent.get("match_name")),
844
+ ("Match date", intent.get("match_date")),
845
+ ("Stay", _fmt_range(intent.get("check_in"), intent.get("check_out"))),
846
+ ("Travelers", intent.get("travelers")),
847
+ ("Tier", intent.get("budget_tier")),
848
+ ]
849
+ slots = "".join(
850
+ f'<span class="md-slot"><b>{_e(k)}:</b> {_e(v) if v not in (None, "") else "—"}</span>'
851
+ for k, v in slot_defs
852
+ )
853
+ if missing:
854
+ slots += "".join(
855
+ f'<span class="md-slot miss">missing: {_e(m)}</span>' for m in missing
856
+ )
857
+ sections.append(
858
+ f'<div class="md-trace-sec"><h5>① Extracted Trip Intent</h5>'
859
+ f'<div class="md-slots">{slots}</div></div>'
860
+ )
861
+
862
+ # ── 2. Match grounding (verified fixtures) ──
863
+ g = d.get("grounding")
864
+ if g:
865
+ if g.get("recognized"):
866
+ bits = []
867
+ if g.get("corrected"):
868
+ bits.append("<b>Date corrected</b> to the verified 2026 fixture")
869
+ else:
870
+ bits.append("<b>Match confirmed</b> against the verified 2026 schedule")
871
+ if g.get("kickoff"):
872
+ bits.append(f"kickoff <b>{_e(g.get('kickoff'))}</b>")
873
+ if g.get("venue"):
874
+ bits.append(f"at <b>{_e(g.get('venue'))}</b>")
875
+ note = g.get("note", "")
876
+ body = " · ".join(bits) + (f". {_e(note)}" if note else "")
877
+ sections.append(
878
+ f'<div class="md-trace-sec"><h5>② Match Grounding</h5>'
879
+ f'<div class="md-ground">{body}</div></div>'
880
+ )
881
+ else:
882
+ note = g.get("note") or "Not a recognized 2026 fixture — the agent refused to invent a trip."
883
+ sections.append(
884
+ f'<div class="md-trace-sec"><h5>② Match Grounding</h5>'
885
+ f'<div class="md-ground refused"><b>Refused</b> — {_e(note)}</div></div>'
886
+ )
887
+
888
+ # ── 3. Tool calls (the multi-step proof) ──
889
+ tcalls = d.get("tool_calls") or []
890
+ if tcalls:
891
+ rows = []
892
+ for t in tcalls:
893
+ st = t.get("status", "ok")
894
+ cls = " is-failed" if st == "failed" else (" is-skipped" if st == "skipped" else "")
895
+ src = "".join(
896
+ f'<span class="md-pv md-pv-{"live" if s in ("serpapi","openmeteo","osm") else "ex"}">{_e(s)}</span>'
897
+ for s in (t.get("sources") or []) if s
898
+ )
899
+ dur = f' · {t.get("duration_ms",0)} ms' if t.get("duration_ms") else ""
900
+ head = _e(t.get("name", "tool"))
901
+ args = t.get("args") or {}
902
+ argstr = ", ".join(f"{_e(k)}={_e(v)}" for k, v in args.items()) if args else ""
903
+ meta = f'<div class="tc-meta">{_e(st)}{dur}{" · " + argstr if argstr else ""}</div>'
904
+ detail = f'<div class="tc-meta">{_e(t.get("detail",""))}</div>' if t.get("detail") else ""
905
+ rows.append(
906
+ f'<li class="md-tcall{cls}"><span class="tc-head">{head}</span>{meta}{detail}'
907
+ f'<span class="tc-src">{src}</span></li>'
908
+ )
909
+ sections.append(
910
+ f'<div class="md-trace-sec"><h5>③ Tools Called</h5>'
911
+ f'<ol class="md-tcalls">{"".join(rows)}</ol></div>'
912
+ )
913
+
914
+ # ── 4. Evidence per category (live vs example vs failed) ──
915
+ evi = d.get("evidence") or {}
916
+ if evi:
917
+ cells = []
918
+ names = {"flights": "Flights", "hotels": "Hotels", "weather": "Weather", "amenities": "Nearby"}
919
+ for cat in ("flights", "hotels", "weather", "amenities"):
920
+ st = evi.get(cat, "skipped")
921
+ dot = _EVI_DOTS.get(st, "•")
922
+ cells.append(
923
+ f'<div class="md-evi-cell {st}"><div class="ec-name">{dot} {names.get(cat, cat)}</div>'
924
+ f'<div class="ec-stat">{_e(st)}</div></div>'
925
+ )
926
+ sections.append(
927
+ f'<div class="md-trace-sec"><h5>④ Evidence (live vs example)</h5>'
928
+ f'<div class="md-evi">{"".join(cells)}</div></div>'
929
+ )
930
+
931
+ # ── 5. Ranking formula + why each won ──
932
+ rk = d.get("ranking") or {}
933
+ if rk.get("packages"):
934
+ weights = rk.get("weights") or {}
935
+ tier = rk.get("tier", "")
936
+ wline = (
937
+ f'<div class="md-formula-w"><b>{_e(_TIER_TITLE.get(tier, tier) or "Balanced")}</b> tier — '
938
+ f'composite = {" + ".join(f"{_W_TITLE.get(k, k)}×{float(v):g}" for k, v in weights.items())}</div>'
939
+ )
940
+ rows = []
941
+ for p in rk["packages"]:
942
+ sc = p.get("scores") or {}
943
+ bars = "".join(
944
+ f'<span class="db {_DIM_CLS.get(k,"")}" title="{_DIM_TITLE.get(k,k)} {float(sc.get(k,0)):g}">'
945
+ f'<i style="width:{max(0,min(100,int(round(float(sc.get(k,0))*100))))}%;"></i></span>'
946
+ for k in ("affordability", "arrival_buffer", "proximity")
947
+ )
948
+ cost = p.get("total_cost_cad", 0) or 0
949
+ rows.append(
950
+ f'<div class="md-rank-row"><span class="rr-rk">#{p.get("rank")}</span>'
951
+ f'<span><span class="rr-lbl">{_e(p.get("label",""))}</span>'
952
+ f'<span class="md-dimbar">{bars}</span>'
953
+ f'{(" — " + _e(p.get("why",""))) if p.get("why") else ""}</span>'
954
+ f'<span class="rr-cost">${cost:,.0f} CAD</span></div>'
955
+ )
956
+ sections.append(
957
+ f'<div class="md-trace-sec"><h5>⑤ Ranking (deterministic)</h5>{wline}'
958
+ f'{"".join(rows)}</div>'
959
+ )
960
+
961
+ notes = d.get("notes") or []
962
+ if notes:
963
+ sections.append(
964
+ '<div class="md-trace-sec"><h5>Notes</h5>'
965
+ + "".join(f'<div class="md-rank-row" style="grid-template-columns:1fr;"><span>{_e(n)}</span></div>' for n in notes)
966
+ + '</div>'
967
+ )
968
+
969
+ if not sections:
970
+ body = '<div class="md-trace-empty">No trace recorded for this turn.</div>'
971
+ else:
972
+ body = "".join(sections)
973
+
974
+ island = (
975
+ f'<script type="application/json" id="md-trace">{json.dumps(d, ensure_ascii=False, default=str)}</script>'
976
+ )
977
+ return f'{summary}{body}</div></details>{island}'
978
+
979
+
980
+ def _fmt_range(a, b) -> str:
981
+ if not a and not b:
982
+ return ""
983
+ if a and b and a != b:
984
+ return f"{a} → {b}"
985
+ return str(a or b or "")
986
+
987
+
988
+ def render_full(result: TripPackageResult, trip=None, leaflet_preloaded: bool = False, trace=None) -> str:
989
+ """Compose the full result canvas + JSON data island.
990
+
991
+ The Agent Trace / Evidence drawer is NOT emitted here: it is rendered LIVE by
992
+ the frontend from the ``trace`` SSE stream (real-time Best-Agent proof — the
993
+ final ``trace`` event at app.py always fires before this ``result`` event).
994
+ Appending it here would duplicate the frontend drawer, so ``render_trace`` is
995
+ kept for standalone use but no longer threaded into ``render_full``. The
996
+ ``trace`` arg is retained for call-site back-compat (app.py passes it).
997
+ """
998
+ if not result.packages:
999
+ why = " · ".join(result.degradation_notices) if result.degradation_notices else "No packages could be formed."
1000
+ return (
1001
+ f'{_CSS}<div class="md-wrap" style="padding:18px;color:#6b7280;line-height:1.55">'
1002
+ f'<b style="color:#b91c1c">Couldn’t build packages.</b><br>{_e(why)}</div>'
1003
+ )
1004
+
1005
+ cards = "".join(_card_html(p, i, trip) for i, p in enumerate(result.packages))
1006
+ cards_block = (
1007
+ f'<div class="md-wrap">{_trip_header(trip, result)}'
1008
+ f'<div class="md-sec-h"><span class="md-sec-t">Your 3 ranked packages</span><span class="md-sec-line"></span></div>'
1009
+ f'<div class="md-cards">{cards}</div></div>'
1010
  )
1011
+
1012
+ island = {
1013
+ "trip": {
1014
+ "origin": getattr(trip, "origin_airport", "") if trip else "",
1015
+ "match_name": getattr(trip, "match_name", "") if trip else "",
1016
+ "check_in": str(getattr(trip, "check_in", "")) if trip else "",
1017
+ "check_out": str(getattr(trip, "check_out", "")) if trip else "",
1018
+ "travelers": getattr(trip, "travelers", 1) if trip else 1,
1019
+ "budget_tier": getattr(trip, "budget_tier", "mid_range") if trip else "mid_range",
1020
+ },
1021
+ "kickoff": getattr(result, "kickoff_local", "") or "",
1022
+ "packages": [_package_json(p, i, trip) for i, p in enumerate(result.packages)],
1023
+ }
1024
+ island_html = (
1025
+ f'<script type="application/json" id="md-data">{json.dumps(island, ensure_ascii=False)}</script>'
1026
+ )
1027
+
1028
+ timeline = _timeline_html(trip, result) if trip is not None else ""
1029
+ # _CSS emitted exactly ONCE here (the fragments below are style-free).
1030
+ # NOTE: the Agent Trace drawer is rendered live by the frontend from `trace`
1031
+ # SSE events — it is intentionally NOT appended here (would duplicate it).
1032
+ return _CSS + cards_block + _map_html(result, leaflet_preloaded) + timeline + island_html
1033
+
1034
+
1035
+ # Back-compat shims (older callers / tests may import these names). Each is a
1036
+ # self-sufficient standalone render, so each prepends the stylesheet once.
1037
+ def render_cards(result, trip=None):
1038
+ return _CSS + "".join(_card_html(p, i, trip) for i, p in enumerate(getattr(result, "packages", []) or []))
1039
+
1040
+
1041
+ def render_card(pkg, is_top: bool = False, idx: int = 0, trip=None) -> str:
1042
+ """Render a single package card (older single-card API).
1043
+
1044
+ ``is_top`` is accepted for back-compat but no longer changes rendering — the
1045
+ redesigned card conveys rank via its label/accent, not a top-flag (the top
1046
+ card already shows its own ``★ Best match`` chip when ``idx == 0``).
1047
+ """
1048
+ return _CSS + _card_html(pkg, idx, trip)
1049
+
1050
+
1051
+ def render_status_bar(result):
1052
+ return ""
1053
+
1054
+
1055
+ def render_map(result, leaflet_preloaded: bool = False):
1056
+ return _CSS + _map_html(result, leaflet_preloaded)
1057
+
1058
+
1059
+ def render_timeline(trip, result):
1060
+ return _CSS + _timeline_html(trip, result)
matchday/scoring.py CHANGED
@@ -33,6 +33,7 @@ from zoneinfo import ZoneInfo
33
 
34
  from matchday.errors import FailoverReason
35
  from matchday.models import Flight, Hotel, ScoredPackage, Weather
 
36
 
37
 
38
  # ---------------------------------------------------------------------------
@@ -249,6 +250,7 @@ def score_options(
249
  match_date: date,
250
  budget_tier: str = "mid_range",
251
  reason_out: list | None = None,
 
252
  ) -> list[ScoredPackage]:
253
  """Score and rank travel packages from candidate flights and hotels.
254
 
@@ -309,8 +311,14 @@ def score_options(
309
  reason_out.append(FailoverReason.SCORING_EMPTY)
310
  return []
311
 
 
 
 
 
 
 
 
312
  # -- Filter late arrivals -----------------------------------------------
313
- kickoff = _default_kickoff_dt(match_date)
314
  on_time_flights = filter_late_arrivals(flights, kickoff)
315
 
316
  # -- Edge case: all flights are late ------------------------------------
 
33
 
34
  from matchday.errors import FailoverReason
35
  from matchday.models import Flight, Hotel, ScoredPackage, Weather
36
+ from matchday.wc2026 import kickoff_datetime
37
 
38
 
39
  # ---------------------------------------------------------------------------
 
250
  match_date: date,
251
  budget_tier: str = "mid_range",
252
  reason_out: list | None = None,
253
+ kickoff: datetime | None = None,
254
  ) -> list[ScoredPackage]:
255
  """Score and rank travel packages from candidate flights and hotels.
256
 
 
311
  reason_out.append(FailoverReason.SCORING_EMPTY)
312
  return []
313
 
314
+ # -- Resolve the REAL kickoff instant -----------------------------------
315
+ # When the caller passes a verified fixture kickoff (e.g. "12:00 PT" for
316
+ # Canada vs Qatar), score arrival buffers against THAT, not a hard-coded
317
+ # 7 PM. Otherwise default to the BC Place evening kickoff.
318
+ if kickoff is None:
319
+ kickoff = _default_kickoff_dt(match_date)
320
+
321
  # -- Filter late arrivals -----------------------------------------------
 
322
  on_time_flights = filter_late_arrivals(flights, kickoff)
323
 
324
  # -- Edge case: all flights are late ------------------------------------
matchday/trip_tool.py CHANGED
@@ -473,6 +473,11 @@ async def build_trip_packages(trip_request: TripRequest) -> TripPackageResult:
473
  weather_data = []
474
 
475
  score_reason: list = []
 
 
 
 
 
476
  scored_packages = scoring.score_options(
477
  flights=flights_data,
478
  hotels=hotels_data,
@@ -480,6 +485,7 @@ async def build_trip_packages(trip_request: TripRequest) -> TripPackageResult:
480
  match_date=trip_request.match_date,
481
  budget_tier=trip_request.budget_tier,
482
  reason_out=score_reason,
 
483
  )
484
  if not scored_packages and score_reason:
485
  # Surface the SCORING_* taxonomy reason (N15) instead of an opaque
 
473
  weather_data = []
474
 
475
  score_reason: list = []
476
+ # Score arrival buffers against the REAL kickoff instant (e.g. a grounded
477
+ # noon match), not the default 7 PM — so "safe arrival" means actually
478
+ # before this match starts.
479
+ from matchday.wc2026 import kickoff_datetime
480
+ real_kickoff = kickoff_datetime(trip_request.match_date, kickoff_local)
481
  scored_packages = scoring.score_options(
482
  flights=flights_data,
483
  hotels=hotels_data,
 
485
  match_date=trip_request.match_date,
486
  budget_tier=trip_request.budget_tier,
487
  reason_out=score_reason,
488
+ kickoff=real_kickoff,
489
  )
490
  if not scored_packages and score_reason:
491
  # Surface the SCORING_* taxonomy reason (N15) instead of an opaque
matchday/wc2026.py CHANGED
@@ -238,3 +238,40 @@ def ground_match(match_name: str, user_match_date: date, check_in: date, check_o
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"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"]
241
+
242
+
243
+ # Local-time zone code -> IANA zone, for converting a fixture kickoff ("12:00 PT")
244
+ # into an exact, timezone-aware instant. Only US/CA host zones appear in the 2026
245
+ # schedule. Used so arrival-buffer scoring compares the flight (YVR-local arrival)
246
+ # against the REAL kickoff, not a hard-coded 7 PM.
247
+ _TZ_BY_CODE: dict[str, str] = {
248
+ "PT": "America/Vancouver",
249
+ "MT": "America/Denver",
250
+ "CT": "America/Chicago",
251
+ "ET": "America/New_York",
252
+ }
253
+
254
+
255
+ def kickoff_datetime(match_date: date, kickoff: str):
256
+ """Parse a fixture kickoff ("12:00 PT") into a timezone-aware datetime.
257
+
258
+ Returns ``None`` when the kickoff string is empty or unparseable, so callers
259
+ fall back to a sensible default (e.g. 7 PM) rather than guessing.
260
+ """
261
+ import re
262
+ from datetime import datetime
263
+ from zoneinfo import ZoneInfo
264
+
265
+ if not kickoff:
266
+ return None
267
+ m = re.match(r"\s*(\d{1,2}):(\d{2})\s*([A-Z]{2})\s*", kickoff)
268
+ if not m:
269
+ return None
270
+ hh, mm, code = int(m.group(1)), int(m.group(2)), m.group(3)
271
+ tzname = _TZ_BY_CODE.get(code)
272
+ if not tzname:
273
+ return None
274
+ return datetime(
275
+ match_date.year, match_date.month, match_date.day, hh, mm,
276
+ tzinfo=ZoneInfo(tzname),
277
+ )