Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- app.py +100 -2
- index.html +198 -0
- matchday/agent_loop.py +93 -1
- matchday/agent_trace.py +333 -0
- matchday/render.py +798 -341
- matchday/scoring.py +9 -1
- matchday/trip_tool.py +6 -0
- matchday/wc2026.py +37 -0
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 & 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 —
|
| 2 |
-
|
| 3 |
-
Pure functions
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 28 |
-
#
|
| 29 |
-
#
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
""
|
| 40 |
-
|
| 41 |
-
|
| 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
|
| 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 |
-
|
| 81 |
-
"
|
| 82 |
-
"
|
|
|
|
| 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 |
-
#
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
"
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/* ──
|
| 106 |
-
.md-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
.md-pv-live{background:#ecfdf5;color:#047857;}
|
| 110 |
-
.md-pv-live::before{background:#10b981;
|
| 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 |
-
|
| 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 |
-
/* ──
|
| 122 |
-
.md-
|
| 123 |
-
|
| 124 |
-
|
| 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:
|
| 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-
|
| 196 |
-
|
| 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 |
-
/*
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
-
/* ──
|
| 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 |
-
/* ──
|
| 223 |
-
.md-
|
| 224 |
-
|
| 225 |
-
.md-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
| 248 |
-
return
|
| 249 |
|
| 250 |
|
| 251 |
-
def
|
| 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 |
-
|
| 261 |
-
f
|
| 262 |
-
f
|
| 263 |
)
|
| 264 |
|
| 265 |
|
| 266 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)} {
|
| 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 |
-
|
|
|
|
| 304 |
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
<
|
| 386 |
-
|
| 387 |
-
</
|
| 388 |
-
<div class="md-card-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
</div>
|
|
|
|
| 390 |
</article>
|
| 391 |
"""
|
| 392 |
|
| 393 |
|
| 394 |
-
|
| 395 |
-
|
| 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 [])[:
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
)
|
| 456 |
return "\n".join(lines)
|
| 457 |
|
| 458 |
|
| 459 |
-
def
|
| 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}
|
|
|
|
| 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&&matchdayFullscreen()">⛶ Full
|
| 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
|
| 477 |
-
<span><span class="md-mdot" style="background:#16a34a"></span>Cheapest
|
| 478 |
-
<span><span class="md-mdot" style="background:#2563eb"></span>Safest
|
| 479 |
-
<span><span class="md-mdot" style="background:#7c3aed"></span>Closest
|
| 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:'&copy; OpenStreetMap &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 |
-
{
|
| 492 |
}})();
|
| 493 |
</script>
|
| 494 |
</div>
|
| 495 |
"""
|
| 496 |
|
| 497 |
|
| 498 |
-
|
| 499 |
-
|
| 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">{
|
| 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 |
-
"
|
| 554 |
-
f"{
|
| 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"
|
| 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"
|
| 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-
|
|
|
|
| 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'
|
|
|
|
| 591 |
f'<div class="md-tl">{"".join(items)}</div></div>'
|
| 592 |
)
|
| 593 |
|
| 594 |
|
| 595 |
-
def
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
)
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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&&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:'&copy; OpenStreetMap &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 & 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 |
+
)
|