Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- app.py +35 -0
- index.html +68 -0
- matchday/.DS_Store +0 -0
- matchday/agent_loop.py +6 -6
- matchday/apis/__init__.py +6 -1
- matchday/apis/web.py +24 -2
- matchday/render.py +178 -51
app.py
CHANGED
|
@@ -104,6 +104,17 @@ async def _pulse(coro, holder, message, interval: int = 9):
|
|
| 104 |
yield _ev(type="commentary", text=f"{message} ({int(time.monotonic() - start)}s)")
|
| 105 |
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
# Cached per-boot preflight (N35). Fail-fast ONLY on genuinely-doomed config
|
| 108 |
# (missing SerpApi key — build_trip_packages cannot fetch live flights/hotels).
|
| 109 |
# Modal cold-start is NOT a hard failure: it streams via _pulse heartbeats and
|
|
@@ -170,6 +181,8 @@ async def plan_trip(user_text: str) -> str:
|
|
| 170 |
return
|
| 171 |
|
| 172 |
yield _ev(type="commentary", text="Reading your trip request…")
|
|
|
|
|
|
|
| 173 |
|
| 174 |
agent = None
|
| 175 |
if USE_AGENT:
|
|
@@ -261,6 +274,10 @@ async def plan_trip(user_text: str) -> str:
|
|
| 261 |
type="commentary",
|
| 262 |
text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…",
|
| 263 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
hb = {}
|
| 265 |
try:
|
| 266 |
async for beat in _pulse(
|
|
@@ -274,6 +291,7 @@ async def plan_trip(user_text: str) -> str:
|
|
| 274 |
yield _ev(type="error", text=f"⚠️ {exc}")
|
| 275 |
return
|
| 276 |
else:
|
|
|
|
| 277 |
yield _ev(
|
| 278 |
type="clarify",
|
| 279 |
text=parsed.question
|
|
@@ -283,9 +301,23 @@ async def plan_trip(user_text: str) -> str:
|
|
| 283 |
|
| 284 |
# Clarify / direct answer from the Brain (no packages to show).
|
| 285 |
if result is None:
|
|
|
|
| 286 |
yield _ev(type="clarify", text=agent_text)
|
| 287 |
return
|
| 288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
# ── Render. greenlight confirms the captured intent just before the packages.
|
| 290 |
if trip is not None:
|
| 291 |
yield _ev(type="greenlight", text=trip.summary())
|
|
@@ -308,6 +340,9 @@ async def plan_trip(user_text: str) -> str:
|
|
| 308 |
# Final: the full Layla-competitive render (status + cards + map + timeline).
|
| 309 |
# leaflet_preloaded=True → the frontend already loaded Leaflet in <head>; the
|
| 310 |
# map's inline init script is re-run after injection (see index.html).
|
|
|
|
|
|
|
|
|
|
| 311 |
yield _ev(type="result", html=render_full(result, trip, leaflet_preloaded=True), explanation=explanation)
|
| 312 |
|
| 313 |
|
|
|
|
| 104 |
yield _ev(type="commentary", text=f"{message} ({int(time.monotonic() - start)}s)")
|
| 105 |
|
| 106 |
|
| 107 |
+
def _notice_status(result, *keywords: str) -> str:
|
| 108 |
+
"""Map a data category to ``done`` | ``fallback`` from REAL degradation notices.
|
| 109 |
+
|
| 110 |
+
Honest per-category progress: if ``build_trip_packages`` reported a category
|
| 111 |
+
as unavailable, that step is ``fallback``; otherwise ``done``. Tied to the
|
| 112 |
+
real dispatch outcome — never a cosmetic timer.
|
| 113 |
+
"""
|
| 114 |
+
blob = " ".join(result.degradation_notices or "").lower()
|
| 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
|
|
|
|
| 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")
|
| 186 |
|
| 187 |
agent = None
|
| 188 |
if USE_AGENT:
|
|
|
|
| 274 |
type="commentary",
|
| 275 |
text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…",
|
| 276 |
)
|
| 277 |
+
yield _ev(type="progress", step="flights", status="running")
|
| 278 |
+
yield _ev(type="progress", step="hotels", status="running")
|
| 279 |
+
yield _ev(type="progress", step="weather", status="running")
|
| 280 |
+
yield _ev(type="progress", step="nearby", status="running")
|
| 281 |
hb = {}
|
| 282 |
try:
|
| 283 |
async for beat in _pulse(
|
|
|
|
| 291 |
yield _ev(type="error", text=f"⚠️ {exc}")
|
| 292 |
return
|
| 293 |
else:
|
| 294 |
+
yield _ev(type="progress", step="ready", status="fallback", text="Need a detail from you")
|
| 295 |
yield _ev(
|
| 296 |
type="clarify",
|
| 297 |
text=parsed.question
|
|
|
|
| 301 |
|
| 302 |
# Clarify / direct answer from the Brain (no packages to show).
|
| 303 |
if result is None:
|
| 304 |
+
yield _ev(type="progress", step="ready", status="fallback", text="Need a detail from you")
|
| 305 |
yield _ev(type="clarify", text=agent_text)
|
| 306 |
return
|
| 307 |
|
| 308 |
+
# ── Honest per-category progress from the REAL dispatch outcome (N10/I1):
|
| 309 |
+
# each data step is done/fallback according to build_trip_packages' own
|
| 310 |
+
# degradation notices — never a cosmetic timer.
|
| 311 |
+
if trip is not None:
|
| 312 |
+
yield _ev(type="progress", step="extract", status="done", text="Trip details captured")
|
| 313 |
+
yield _ev(type="progress", step="flights", status=_notice_status(result, "flight"))
|
| 314 |
+
yield _ev(type="progress", step="hotels", status=_notice_status(result, "hotels unavailable", "hotels,"))
|
| 315 |
+
yield _ev(type="progress", step="weather", status=_notice_status(result, "weather"))
|
| 316 |
+
yield _ev(type="progress", step="nearby", status=_notice_status(result, "amenities", "nearby"))
|
| 317 |
+
yield _ev(type="progress", step="score", status="done" if result.packages else "fallback")
|
| 318 |
+
yield _ev(type="progress", step="itinerary", status="running", text="Building itinerary")
|
| 319 |
+
yield _ev(type="progress", step="links", status="running", text="Preparing links")
|
| 320 |
+
|
| 321 |
# ── Render. greenlight confirms the captured intent just before the packages.
|
| 322 |
if trip is not None:
|
| 323 |
yield _ev(type="greenlight", text=trip.summary())
|
|
|
|
| 340 |
# Final: the full Layla-competitive render (status + cards + map + timeline).
|
| 341 |
# leaflet_preloaded=True → the frontend already loaded Leaflet in <head>; the
|
| 342 |
# map's inline init script is re-run after injection (see index.html).
|
| 343 |
+
yield _ev(type="progress", step="itinerary", status="done")
|
| 344 |
+
yield _ev(type="progress", step="links", status="done")
|
| 345 |
+
yield _ev(type="progress", step="ready", status="done", text="Your packages are ready")
|
| 346 |
yield _ev(type="result", html=render_full(result, trip, leaflet_preloaded=True), explanation=explanation)
|
| 347 |
|
| 348 |
|
index.html
CHANGED
|
@@ -145,6 +145,30 @@
|
|
| 145 |
.empty p{margin:0;font-size:13.5px;max-width:360px;line-height:1.6;color:#94a3b8;}
|
| 146 |
|
| 147 |
/* ── "Building your trip" skeleton state (premium wait UX) ── */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
.building{padding:2px 0;}
|
| 149 |
.build-head{display:flex;align-items:center;gap:11px;background:linear-gradient(135deg,#0f172a,#1e3a8a);
|
| 150 |
color:#fff;border-radius:16px;padding:14px 17px;margin-bottom:18px;box-shadow:var(--shadow-md);
|
|
@@ -371,6 +395,9 @@ function showSkeleton(bubble){
|
|
| 371 |
<span class="elapsed">· 0:00</span>
|
| 372 |
<div class="cold-hint" style="display:none"></div>
|
| 373 |
</div>
|
|
|
|
|
|
|
|
|
|
| 374 |
<div class="sk-cards">
|
| 375 |
${`<div class="sk-card"><div class="sk big"></div><div class="sk line w80"></div><div class="sk line w60"></div><div class="sk line"></div><div class="sk line w80"></div></div>`.repeat(3)}
|
| 376 |
</div>
|
|
@@ -397,9 +424,49 @@ function startTimer(bubble){
|
|
| 397 |
}
|
| 398 |
function stopTimer(){ if (elapsedTimer){ clearInterval(elapsedTimer); elapsedTimer = null; } }
|
| 399 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
// ── Streaming via raw SSE ───────────────────────────────
|
| 401 |
function handleEvent(ev, bubble){
|
| 402 |
if (!ev) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
if (ev.type === "commentary"){
|
| 404 |
lastRealAt = performance.now(); lastRealMsg = ev.text || "";
|
| 405 |
setStatus(bubble, lastRealMsg);
|
|
@@ -470,6 +537,7 @@ async function runTrip(forcedText){
|
|
| 470 |
if (!text) return;
|
| 471 |
running = true; sendBtn.disabled = true; resultHandled = false;
|
| 472 |
lastRealAt = 0; lastRealMsg = "";
|
|
|
|
| 473 |
addUserBubble(text);
|
| 474 |
const bubble = addAssistantBubble();
|
| 475 |
showSkeleton(bubble);
|
|
|
|
| 145 |
.empty p{margin:0;font-size:13.5px;max-width:360px;line-height:1.6;color:#94a3b8;}
|
| 146 |
|
| 147 |
/* ── "Building your trip" skeleton state (premium wait UX) ── */
|
| 148 |
+
/* Layla-style visible progress steps (N10): real tool activity + provenance,
|
| 149 |
+
never raw chain-of-thought. Driven by backend `progress` events. */
|
| 150 |
+
.steps{display:flex;flex-direction:column;gap:5px;margin-bottom:18px;
|
| 151 |
+
background:#fff;border:1px solid var(--line2);border-radius:14px;padding:13px 15px;box-shadow:var(--shadow-sm);}
|
| 152 |
+
.steps-h{font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);margin-bottom:7px;}
|
| 153 |
+
.step{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--mut2);transition:color .2s;}
|
| 154 |
+
.step .si{width:18px;height:18px;border-radius:50%;flex:0 0 18px;display:inline-flex;
|
| 155 |
+
align-items:center;justify-content:center;font-size:11px;background:#eef0f3;color:transparent;}
|
| 156 |
+
.step.is-running{color:var(--ink2);font-weight:600;}
|
| 157 |
+
.step.is-running .si{background:var(--violet);color:#fff;}
|
| 158 |
+
.step.is-running .si::after{content:"";width:7px;height:7px;border-radius:50%;background:#fff;
|
| 159 |
+
animation:pulse 1.1s infinite;}
|
| 160 |
+
.step.is-done{color:var(--ink2);}
|
| 161 |
+
.step.is-done .si{background:var(--emerald);color:#fff;}
|
| 162 |
+
.step.is-done .si::after{content:"✓";}
|
| 163 |
+
.step.is-fallback{color:#b45309;}
|
| 164 |
+
.step.is-fallback .si{background:#fef3c7;color:#b45309;}
|
| 165 |
+
.step.is-fallback .si::after{content:"!";}
|
| 166 |
+
.step.is-unavailable{color:var(--mut2);}
|
| 167 |
+
.step.is-unavailable .si{background:#f3f4f6;color:var(--mut2);}
|
| 168 |
+
.step.is-unavailable .si::after{content:"–";}
|
| 169 |
+
.step .st{flex:1;min-width:0;}
|
| 170 |
+
.step .sd{font-size:11.5px;color:var(--mut2);font-weight:500;}
|
| 171 |
+
.step.is-running .sd,.step.is-done .sd{color:inherit;opacity:.75;}
|
| 172 |
.building{padding:2px 0;}
|
| 173 |
.build-head{display:flex;align-items:center;gap:11px;background:linear-gradient(135deg,#0f172a,#1e3a8a);
|
| 174 |
color:#fff;border-radius:16px;padding:14px 17px;margin-bottom:18px;box-shadow:var(--shadow-md);
|
|
|
|
| 395 |
<span class="elapsed">· 0:00</span>
|
| 396 |
<div class="cold-hint" style="display:none"></div>
|
| 397 |
</div>
|
| 398 |
+
<div class="steps" id="md-steps">
|
| 399 |
+
<div class="steps-h">Planning your trip</div>
|
| 400 |
+
</div>
|
| 401 |
<div class="sk-cards">
|
| 402 |
${`<div class="sk-card"><div class="sk big"></div><div class="sk line w80"></div><div class="sk line w60"></div><div class="sk line"></div><div class="sk line w80"></div></div>`.repeat(3)}
|
| 403 |
</div>
|
|
|
|
| 424 |
}
|
| 425 |
function stopTimer(){ if (elapsedTimer){ clearInterval(elapsedTimer); elapsedTimer = null; } }
|
| 426 |
|
| 427 |
+
// ── Visible agent progress steps (N10 / Layla tripProgress) ───────────
|
| 428 |
+
// Ordered checklist of SAFE tool activity + provenance. Driven by real backend
|
| 429 |
+
// `progress` events; never exposes raw chain-of-thought. Each step's status is
|
| 430 |
+
// pending → running → done | fallback | unavailable.
|
| 431 |
+
const STEPS = [
|
| 432 |
+
{key:"read", label:"Reading your request"},
|
| 433 |
+
{key:"extract", label:"Extracting trip details"},
|
| 434 |
+
{key:"flights", label:"Checking flights"},
|
| 435 |
+
{key:"hotels", label:"Checking hotels"},
|
| 436 |
+
{key:"weather", label:"Checking weather"},
|
| 437 |
+
{key:"nearby", label:"Checking nearby spots"},
|
| 438 |
+
{key:"score", label:"Scoring packages"},
|
| 439 |
+
{key:"itinerary", label:"Building itinerary"},
|
| 440 |
+
{key:"links", label:"Preparing booking & transit links"},
|
| 441 |
+
{key:"ready", label:"Packages ready"},
|
| 442 |
+
];
|
| 443 |
+
let stepState = {};
|
| 444 |
+
function renderSteps(){
|
| 445 |
+
const box = document.getElementById("md-steps");
|
| 446 |
+
if (!box) return;
|
| 447 |
+
let html = '<div class="steps-h">Planning your trip</div>';
|
| 448 |
+
for (const s of STEPS){
|
| 449 |
+
const st = stepState[s.key];
|
| 450 |
+
const status = st ? st.status : "pending";
|
| 451 |
+
const cls = status === "pending" ? "" : "is-" + status;
|
| 452 |
+
const detail = (st && st.text) ? `<span class="sd"> · ${esc(st.text)}</span>` : "";
|
| 453 |
+
html += `<div class="step ${cls}"><span class="si"></span><span class="st">${s.label}${detail}</span></div>`;
|
| 454 |
+
}
|
| 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 || ""};
|
| 464 |
+
renderSteps();
|
| 465 |
+
if (ev.step === "ready" && ev.status === "done"){ setStatus(bubble, "Done — see your packages →"); }
|
| 466 |
+
else if (ev.text){ setStatus(bubble, ev.text); }
|
| 467 |
+
}
|
| 468 |
+
return;
|
| 469 |
+
}
|
| 470 |
if (ev.type === "commentary"){
|
| 471 |
lastRealAt = performance.now(); lastRealMsg = ev.text || "";
|
| 472 |
setStatus(bubble, lastRealMsg);
|
|
|
|
| 537 |
if (!text) return;
|
| 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);
|
matchday/.DS_Store
ADDED
|
Binary file (10.2 kB). View file
|
|
|
matchday/agent_loop.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
"""
|
| 2 |
MatchDay -- Bounded Nemotron agent loop with controlled tool use.
|
| 3 |
|
| 4 |
-
Architecture
|
| 5 |
-
-
|
| 6 |
-
|
| 7 |
- Tool-name allowlist: ["build_trip_packages", "web_search", "clarify"]
|
| 8 |
- Pydantic argument validation before every tool call
|
| 9 |
- Duplicate-call detection (same tool + same args in same turn)
|
|
@@ -641,7 +641,7 @@ class AgentLoopResult:
|
|
| 641 |
class AgentLoop:
|
| 642 |
"""Bounded agent loop that orchestrates Nemotron tool use.
|
| 643 |
|
| 644 |
-
The loop runs at most ``MAX_TOOL_ROUNDS`` (
|
| 645 |
1. Send the current message list to the agent (Nemotron).
|
| 646 |
2. Parse the agent's response.
|
| 647 |
3. If the agent calls a tool:
|
|
@@ -680,7 +680,7 @@ class AgentLoop:
|
|
| 680 |
The built-in allowlist always takes precedence; external tools
|
| 681 |
are checked separately.
|
| 682 |
max_rounds:
|
| 683 |
-
Maximum number of tool-call rounds (default
|
| 684 |
rounds the loop forces a final answer or falls back.
|
| 685 |
"""
|
| 686 |
self._agent = agent
|
|
@@ -1057,7 +1057,7 @@ async def run_agent_loop(
|
|
| 1057 |
tools:
|
| 1058 |
Optional extra tools (merged with built-in allowlist).
|
| 1059 |
max_rounds:
|
| 1060 |
-
Maximum tool rounds (default
|
| 1061 |
|
| 1062 |
Returns
|
| 1063 |
-------
|
|
|
|
| 1 |
"""
|
| 2 |
MatchDay -- Bounded Nemotron agent loop with controlled tool use.
|
| 3 |
|
| 4 |
+
Architecture:
|
| 5 |
+
- Up to MAX_TOOL_ROUNDS (5) tool rounds per turn — a ceiling, not a target.
|
| 6 |
+
Happy-path turns exit at 2-3 rounds (understand -> build -> explain).
|
| 7 |
- Tool-name allowlist: ["build_trip_packages", "web_search", "clarify"]
|
| 8 |
- Pydantic argument validation before every tool call
|
| 9 |
- Duplicate-call detection (same tool + same args in same turn)
|
|
|
|
| 641 |
class AgentLoop:
|
| 642 |
"""Bounded agent loop that orchestrates Nemotron tool use.
|
| 643 |
|
| 644 |
+
The loop runs at most ``MAX_TOOL_ROUNDS`` (5) turns. Each turn:
|
| 645 |
1. Send the current message list to the agent (Nemotron).
|
| 646 |
2. Parse the agent's response.
|
| 647 |
3. If the agent calls a tool:
|
|
|
|
| 680 |
The built-in allowlist always takes precedence; external tools
|
| 681 |
are checked separately.
|
| 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
|
|
|
|
| 1057 |
tools:
|
| 1058 |
Optional extra tools (merged with built-in allowlist).
|
| 1059 |
max_rounds:
|
| 1060 |
+
Maximum tool rounds (default 5).
|
| 1061 |
|
| 1062 |
Returns
|
| 1063 |
-------
|
matchday/apis/__init__.py
CHANGED
|
@@ -12,8 +12,13 @@ Normalizers are grouped by data source:
|
|
| 12 |
|
| 13 |
- ``weather`` — Open-Meteo daily forecast (keyless).
|
| 14 |
- ``pois`` — OpenStreetMap points of interest near BC Place (keyless).
|
| 15 |
-
- ``routes`` — OpenRouteService walking/transit routes (ORS key).
|
| 16 |
- ``flights`` / ``hotels`` / ``web`` — SerpApi (Google Flights/Hotels/Search).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"""
|
| 18 |
|
| 19 |
# Load .env first so the registry's is_enabled() checks see API keys.
|
|
|
|
| 12 |
|
| 13 |
- ``weather`` — Open-Meteo daily forecast (keyless).
|
| 14 |
- ``pois`` — OpenStreetMap points of interest near BC Place (keyless).
|
|
|
|
| 15 |
- ``flights`` / ``hotels`` / ``web`` — SerpApi (Google Flights/Hotels/Search).
|
| 16 |
+
|
| 17 |
+
NOTE (honesty): no ``routes`` normalizer is registered here. Transit time is a
|
| 18 |
+
deterministic walking-speed proxy computed in ``scoring.py``
|
| 19 |
+
(``distance_to_stadium_km / 5 km/h``), not live OpenRouteService routing
|
| 20 |
+
(plan U2 two-phase routes dispatch is deferred). The card "Transit" CTA links
|
| 21 |
+
to Google Maps directions rather than a computed ORS route.
|
| 22 |
"""
|
| 23 |
|
| 24 |
# Load .env first so the registry's is_enabled() checks see API keys.
|
matchday/apis/web.py
CHANGED
|
@@ -26,6 +26,26 @@ _SERPAPI_URL = "https://serpapi.com/search"
|
|
| 26 |
_REQUEST_TIMEOUT = 30.0
|
| 27 |
_SNIPPET_CAP = 160
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
class WebSearchInput(BaseModel):
|
| 31 |
"""Input for the web_search normalizer."""
|
|
@@ -38,11 +58,13 @@ class WebSearchInput(BaseModel):
|
|
| 38 |
|
| 39 |
def _normalize(organic: list[dict[str, Any]] | None, max_results: int) -> list[dict[str, str]]:
|
| 40 |
out: list[dict[str, str]] = []
|
| 41 |
-
for r in
|
|
|
|
|
|
|
| 42 |
title = (r.get("title") or "").strip()
|
| 43 |
url = (r.get("link") or r.get("url") or "").strip()
|
| 44 |
snippet = (r.get("snippet") or "").strip()[:_SNIPPET_CAP]
|
| 45 |
-
if title and url:
|
| 46 |
out.append({"title": title, "url": url, "snippet": snippet})
|
| 47 |
return out
|
| 48 |
|
|
|
|
| 26 |
_REQUEST_TIMEOUT = 30.0
|
| 27 |
_SNIPPET_CAP = 160
|
| 28 |
|
| 29 |
+
# I6 domain restriction — enforced in Python, not just promised in the prompt.
|
| 30 |
+
# The agent prompt tells Nemotron web_search is "restricted to fifa.com,
|
| 31 |
+
# vancouverfwc26.ca, bcplace.com, translink.ca"; this filter makes that true by
|
| 32 |
+
# dropping any SerpApi result whose URL is not on (a subdomain of) one of these.
|
| 33 |
+
# Substring match is intentionally lenient (matches www./subdomains/paths) and
|
| 34 |
+
# never raises — a result that doesn't match is simply omitted.
|
| 35 |
+
_ALLOWED_DOMAINS = (
|
| 36 |
+
"fifa.com",
|
| 37 |
+
"vancouverfwc26.ca",
|
| 38 |
+
"bcplace.com",
|
| 39 |
+
"translink.ca",
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _domain_allowed(url: str) -> bool:
|
| 44 |
+
if not url:
|
| 45 |
+
return False
|
| 46 |
+
low = url.lower()
|
| 47 |
+
return any(d in low for d in _ALLOWED_DOMAINS)
|
| 48 |
+
|
| 49 |
|
| 50 |
class WebSearchInput(BaseModel):
|
| 51 |
"""Input for the web_search normalizer."""
|
|
|
|
| 58 |
|
| 59 |
def _normalize(organic: list[dict[str, Any]] | None, max_results: int) -> list[dict[str, str]]:
|
| 60 |
out: list[dict[str, str]] = []
|
| 61 |
+
for r in organic or []:
|
| 62 |
+
if len(out) >= max_results:
|
| 63 |
+
break
|
| 64 |
title = (r.get("title") or "").strip()
|
| 65 |
url = (r.get("link") or r.get("url") or "").strip()
|
| 66 |
snippet = (r.get("snippet") or "").strip()[:_SNIPPET_CAP]
|
| 67 |
+
if title and url and _domain_allowed(url): # I6 — drop off-allowlist results
|
| 68 |
out.append({"title": title, "url": url, "snippet": snippet})
|
| 69 |
return out
|
| 70 |
|
matchday/render.py
CHANGED
|
@@ -19,6 +19,52 @@ from matchday.trip_tool import TripPackageResult
|
|
| 19 |
BC_PLACE_LAT = 49.2827
|
| 20 |
BC_PLACE_LON = -123.1207
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# Per-label accent + matching soft tint (avoids color-mix for max compatibility).
|
| 23 |
_LABEL_COLOR = {
|
| 24 |
"Cheapest": "#16a34a",
|
|
@@ -217,7 +263,7 @@ def _weather_fact(pkg: ScoredPackage) -> str:
|
|
| 217 |
)
|
| 218 |
|
| 219 |
|
| 220 |
-
def render_card(pkg: ScoredPackage, is_top: bool = False, idx: int = 0) -> str:
|
| 221 |
accent = _LABEL_COLOR.get(pkg.label, "#7c3aed")
|
| 222 |
tint = _LABEL_TINT.get(pkg.label, "#ede9fe")
|
| 223 |
icon = _LABEL_ICON.get(pkg.label, "✅")
|
|
@@ -225,6 +271,11 @@ def render_card(pkg: ScoredPackage, is_top: bool = False, idx: int = 0) -> str:
|
|
| 225 |
f = pkg.flight
|
| 226 |
h = pkg.hotel
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
# Hotel fact
|
| 229 |
if h:
|
| 230 |
bits = []
|
|
@@ -253,35 +304,67 @@ def render_card(pkg: ScoredPackage, is_top: bool = False, idx: int = 0) -> str:
|
|
| 253 |
|
| 254 |
topflag = '<span class="md-topflag">★ Best match</span>' if is_top else ""
|
| 255 |
|
| 256 |
-
#
|
| 257 |
-
#
|
| 258 |
-
#
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
if h and getattr(h, "booking_url", None):
|
| 267 |
-
hotel_url, hotel_label = h.booking_url, "🏨
|
| 268 |
elif h:
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
else:
|
| 275 |
hotel_url = hotel_label = None
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
)
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
if
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
|
| 286 |
return f"""
|
| 287 |
<article class="md-card{' is-top' if is_top else ''}" style="--accent:{accent};--tint:{tint}">
|
|
@@ -302,20 +385,19 @@ def render_card(pkg: ScoredPackage, is_top: bool = False, idx: int = 0) -> str:
|
|
| 302 |
<li><span class="md-fico">🚶</span><span class="md-fbody"><b>{pkg.hotel_to_stadium_min} min walk</b>
|
| 303 |
<span class="md-fdet">hotel → BC Place · {len(pkg.amenities)} nearby spots</span></span></li>
|
| 304 |
</ul>
|
| 305 |
-
<div class="md-card-cta"><div class="md-cta-row">
|
| 306 |
-
<a class="md-cta md-cta-f" href="{flight_url}" target="_blank" rel="noopener noreferrer">✈️ Book flight →</a>
|
| 307 |
-
{hotel_btn}
|
| 308 |
-
<a class="md-cta md-cta-t" href="{transit_url}" target="_blank" rel="noopener noreferrer">🚖 Transit →</a>
|
| 309 |
-
</div></div>
|
| 310 |
</div>
|
| 311 |
</article>
|
| 312 |
"""
|
| 313 |
|
| 314 |
|
| 315 |
-
def render_cards(result: TripPackageResult) -> str:
|
| 316 |
if not result.packages:
|
| 317 |
return '<div class="md-row" style="padding:14px;color:#6b7280">No packages could be formed from the available data.</div>'
|
| 318 |
-
cards = "".join(
|
|
|
|
|
|
|
|
|
|
| 319 |
return f'{_CSS}<div class="md-wrap">{_section("Your 3 ranked packages")}<div class="md-cards">{cards}</div></div>'
|
| 320 |
|
| 321 |
|
|
@@ -411,42 +493,87 @@ def render_map(result: TripPackageResult, leaflet_preloaded: bool = False) -> st
|
|
| 411 |
|
| 412 |
|
| 413 |
def render_timeline(trip, result: TripPackageResult) -> str:
|
| 414 |
-
"""Day-by-day itinerary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
from datetime import timedelta
|
| 416 |
|
| 417 |
top = result.packages[0] if result.packages else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
items: list[str] = []
|
| 419 |
d = trip.check_in
|
| 420 |
idx = 0
|
|
|
|
| 421 |
while d <= trip.check_out:
|
| 422 |
idx += 1
|
| 423 |
-
|
| 424 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
icon, lbl, title, body, cls = (
|
| 426 |
-
"
|
| 427 |
-
f"
|
| 428 |
-
|
| 429 |
-
""
|
|
|
|
|
|
|
| 430 |
)
|
| 431 |
-
elif d == trip.
|
| 432 |
icon, lbl, title, body, cls = (
|
| 433 |
-
"
|
| 434 |
-
f"<b>{_e(
|
| 435 |
-
"then
|
| 436 |
-
"
|
| 437 |
)
|
| 438 |
-
elif d
|
| 439 |
icon, lbl, title, body, cls = (
|
| 440 |
-
"
|
| 441 |
-
"
|
|
|
|
|
|
|
| 442 |
"",
|
| 443 |
)
|
| 444 |
else:
|
|
|
|
|
|
|
| 445 |
icon, lbl, title, body, cls = (
|
| 446 |
-
|
| 447 |
-
"
|
| 448 |
"",
|
| 449 |
)
|
|
|
|
| 450 |
items.append(
|
| 451 |
f'<div class="md-day {cls}"><div class="md-day-ico">{icon}</div>'
|
| 452 |
f'<div class="md-day-lbl">{lbl}</div>'
|
|
@@ -463,7 +590,7 @@ def render_timeline(trip, result: TripPackageResult) -> str:
|
|
| 463 |
def render_full(result: TripPackageResult, trip=None, leaflet_preloaded: bool = False) -> str:
|
| 464 |
out = (
|
| 465 |
render_status_bar(result)
|
| 466 |
-
+ render_cards(result)
|
| 467 |
+ render_map(result, leaflet_preloaded=leaflet_preloaded)
|
| 468 |
)
|
| 469 |
if trip is not None:
|
|
|
|
| 19 |
BC_PLACE_LAT = 49.2827
|
| 20 |
BC_PLACE_LON = -123.1207
|
| 21 |
|
| 22 |
+
YVR_LAT = 49.1939
|
| 23 |
+
YVR_LON = -123.1844
|
| 24 |
+
_STADIUM = "BC Place Stadium, Vancouver"
|
| 25 |
+
_YVR = "YVR, Vancouver"
|
| 26 |
+
|
| 27 |
+
# Google Maps keyless directions (U2: real routing deferred; ORS needs a key).
|
| 28 |
+
# Crucially, ``origin`` is ALWAYS supplied and trip-specific (hotel coords/name
|
| 29 |
+
# or YVR) so Maps never silently falls back to the user's detected location.
|
| 30 |
+
def _gmaps(origin: str, destination: str, mode: str = "transit") -> str:
|
| 31 |
+
return (
|
| 32 |
+
"https://www.google.com/maps/dir/?api=1"
|
| 33 |
+
f"&origin={_urlquote(origin)}&destination={_urlquote(destination)}"
|
| 34 |
+
f"&travelmode={mode}"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _hotel_origin(h) -> str:
|
| 39 |
+
"""Trip-specific origin for directions: hotel coords (precise) else its name."""
|
| 40 |
+
if h and getattr(h, "latitude", None) and getattr(h, "longitude", None):
|
| 41 |
+
return f"{h.latitude},{h.longitude}"
|
| 42 |
+
if h and getattr(h, "name", None):
|
| 43 |
+
return f"{h.name}, Vancouver"
|
| 44 |
+
return _YVR
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Rotating local-day highlights (Layla L524-563 = uniquely-named days). The cycle
|
| 48 |
+
# is longer than any realistic World Cup trip (≤40 days) so day titles never repeat.
|
| 49 |
+
_LOCAL_DAYS = [
|
| 50 |
+
("🏙️", "Stanley Park & the seawall",
|
| 51 |
+
"Loop the Stanley Park seawall by bike — totem poles, Siwash Rock and Lions Gate Bridge views."),
|
| 52 |
+
("🍤", "Granville Island",
|
| 53 |
+
"Public market, artisan food stalls and the brewery/theatre scene under the Granville Bridge."),
|
| 54 |
+
("🏮", "Gastown & Chinatown",
|
| 55 |
+
"Steam clock and Maple Tree Square, then dim sum and the Dr. Sun Yat-Sen Classical Garden."),
|
| 56 |
+
("⛰️", "Grouse Mountain",
|
| 57 |
+
"Hike the Grouse Grind or take the Skyride — city-to-sea panorama and ranger shows at the top."),
|
| 58 |
+
("🌉", "Capilano Suspension Bridge",
|
| 59 |
+
"Cliffwalk and Treetops Adventure over the Capilano River gorge, a short SeaBus + shuttle away."),
|
| 60 |
+
("🏖️", "English Bay & Sunset Beach",
|
| 61 |
+
"Beachfront stroll to the A-Maze-ing Laughter statues, sunset over Burrard Inlet."),
|
| 62 |
+
("🍦", "Main Street & Mount Pleasant",
|
| 63 |
+
"Indie boutiques, third-wave coffee and murals along Main Street south of Broadway."),
|
| 64 |
+
("🛍️", "Robson Street & FIFA fan zone",
|
| 65 |
+
"Downtown shopping strip, then the official FIFA Fan Festival and live-match screens."),
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
# Per-label accent + matching soft tint (avoids color-mix for max compatibility).
|
| 69 |
_LABEL_COLOR = {
|
| 70 |
"Cheapest": "#16a34a",
|
|
|
|
| 263 |
)
|
| 264 |
|
| 265 |
|
| 266 |
+
def render_card(pkg: ScoredPackage, is_top: bool = False, idx: int = 0, trip=None) -> str:
|
| 267 |
accent = _LABEL_COLOR.get(pkg.label, "#7c3aed")
|
| 268 |
tint = _LABEL_TINT.get(pkg.label, "#ede9fe")
|
| 269 |
icon = _LABEL_ICON.get(pkg.label, "✅")
|
|
|
|
| 271 |
f = pkg.flight
|
| 272 |
h = pkg.hotel
|
| 273 |
|
| 274 |
+
# Trip params for trip-specific, honestly-labeled action links (K5/U4).
|
| 275 |
+
ci = getattr(trip, "check_in", None)
|
| 276 |
+
co = getattr(trip, "check_out", None)
|
| 277 |
+
trav = getattr(trip, "travelers", 1) or 1
|
| 278 |
+
|
| 279 |
# Hotel fact
|
| 280 |
if h:
|
| 281 |
bits = []
|
|
|
|
| 304 |
|
| 305 |
topflag = '<span class="md-topflag">★ Best match</span>' if is_top else ""
|
| 306 |
|
| 307 |
+
# ── Flight action link: live deep link if SerpApi gave one, else an honest
|
| 308 |
+
# round-trip Skyscanner SEARCH for the exact route + check-in/check-out +
|
| 309 |
+
# travelers. Never labeled "Book confirmed" — prices aren't guaranteed (K5).
|
| 310 |
+
if getattr(f, "booking_url", None):
|
| 311 |
+
flight_url, flight_label = f.booking_url, "✈️ Open this flight"
|
| 312 |
+
else:
|
| 313 |
+
dep = (ci or f.arrival_time.date()).strftime("%y%m%d")
|
| 314 |
+
flight_url = (
|
| 315 |
+
f"https://www.skyscanner.com/transport/flights/{f.origin.lower()}/"
|
| 316 |
+
f"{f.destination.lower()}/{dep}"
|
| 317 |
+
)
|
| 318 |
+
if co is not None:
|
| 319 |
+
flight_url += f"/{co.strftime('%y%m%d')}/"
|
| 320 |
+
else:
|
| 321 |
+
flight_url += "/"
|
| 322 |
+
flight_url += f"?adults={int(trav)}"
|
| 323 |
+
flight_label = "✈️ Search this flight"
|
| 324 |
+
|
| 325 |
+
# ── Hotel action link: live Google-Hotels deep link if present, else an
|
| 326 |
+
# honest Booking.com SEARCH for this hotel + check-in/out + adults (K5).
|
| 327 |
if h and getattr(h, "booking_url", None):
|
| 328 |
+
hotel_url, hotel_label = h.booking_url, "🏨 Open this hotel"
|
| 329 |
elif h:
|
| 330 |
+
ss_q = h.name if "vancouver" in h.name.lower() else f"{h.name} Vancouver"
|
| 331 |
+
parts = [f"ss={_urlquote(ss_q)}"]
|
| 332 |
+
if ci is not None:
|
| 333 |
+
parts.append(f"checkin={ci.strftime('%Y-%m-%d')}")
|
| 334 |
+
if co is not None:
|
| 335 |
+
parts.append(f"checkout={co.strftime('%Y-%m-%d')}")
|
| 336 |
+
parts.append(f"group_adults={int(trav)}")
|
| 337 |
+
hotel_url = "https://www.booking.com/searchresults.html?" + "&".join(parts)
|
| 338 |
+
hotel_label = "🏨 Search this hotel"
|
| 339 |
else:
|
| 340 |
hotel_url = hotel_label = None
|
| 341 |
+
|
| 342 |
+
# ── Ground-transport links: EXPLICIT trip-specific origins so Maps never
|
| 343 |
+
# uses the user's current location (U2). Hotel→BC Place (walk if close, else
|
| 344 |
+
# transit), YVR→Hotel, Hotel→YVR (rideshare/taxi style). No booking claim.
|
| 345 |
+
horg = _hotel_origin(h)
|
| 346 |
+
walk_ok = bool(pkg.hotel_to_stadium_min and pkg.hotel_to_stadium_min <= 20)
|
| 347 |
+
transit_links = [
|
| 348 |
+
("🚶 Hotel → BC Place" if walk_ok else "🚌 Hotel → BC Place",
|
| 349 |
+
_gmaps(horg, _STADIUM, "walking" if walk_ok else "transit")),
|
| 350 |
+
("🚇 YVR → Hotel", _gmaps(_YVR, horg, "transit")),
|
| 351 |
+
("🚕 Hotel → YVR", _gmaps(horg, _YVR, "driving")),
|
| 352 |
+
]
|
| 353 |
+
|
| 354 |
+
cta: list[str] = [
|
| 355 |
+
f'<a class="md-cta md-cta-f" href="{_e(flight_url)}" target="_blank" '
|
| 356 |
+
f'rel="noopener noreferrer">{flight_label} →</a>'
|
| 357 |
+
]
|
| 358 |
+
if hotel_url:
|
| 359 |
+
cta.append(
|
| 360 |
+
f'<a class="md-cta md-cta-h" href="{_e(hotel_url)}" target="_blank" '
|
| 361 |
+
f'rel="noopener noreferrer">{hotel_label} →</a>'
|
| 362 |
+
)
|
| 363 |
+
for lbl, url in transit_links:
|
| 364 |
+
cta.append(
|
| 365 |
+
f'<a class="md-cta md-cta-t" href="{_e(url)}" target="_blank" '
|
| 366 |
+
f'rel="noopener noreferrer">{lbl} →</a>'
|
| 367 |
+
)
|
| 368 |
|
| 369 |
return f"""
|
| 370 |
<article class="md-card{' is-top' if is_top else ''}" style="--accent:{accent};--tint:{tint}">
|
|
|
|
| 385 |
<li><span class="md-fico">🚶</span><span class="md-fbody"><b>{pkg.hotel_to_stadium_min} min walk</b>
|
| 386 |
<span class="md-fdet">hotel → BC Place · {len(pkg.amenities)} nearby spots</span></span></li>
|
| 387 |
</ul>
|
| 388 |
+
<div class="md-card-cta"><div class="md-cta-row">{"".join(cta)}</div></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
</div>
|
| 390 |
</article>
|
| 391 |
"""
|
| 392 |
|
| 393 |
|
| 394 |
+
def render_cards(result: TripPackageResult, trip=None) -> str:
|
| 395 |
if not result.packages:
|
| 396 |
return '<div class="md-row" style="padding:14px;color:#6b7280">No packages could be formed from the available data.</div>'
|
| 397 |
+
cards = "".join(
|
| 398 |
+
render_card(p, is_top=(i == 0), idx=i, trip=trip)
|
| 399 |
+
for i, p in enumerate(result.packages)
|
| 400 |
+
)
|
| 401 |
return f'{_CSS}<div class="md-wrap">{_section("Your 3 ranked packages")}<div class="md-cards">{cards}</div></div>'
|
| 402 |
|
| 403 |
|
|
|
|
| 493 |
|
| 494 |
|
| 495 |
def render_timeline(trip, result: TripPackageResult) -> str:
|
| 496 |
+
"""Day-by-day itinerary — unique, date-aware, realistic (A2 + Layla L509-569).
|
| 497 |
+
|
| 498 |
+
Each day is assigned ONE role by priority, so the same filler text never
|
| 499 |
+
repeats across days and departure language appears on exactly one day:
|
| 500 |
+
|
| 501 |
+
1. match_date → MATCH DAY (hotel→BC Place, fan zone, weather, return)
|
| 502 |
+
2. check_out → DEPARTURE (checkout, hotel→YVR, return flight)
|
| 503 |
+
3. check_in → ARRIVAL (flight, YVR→hotel transfer, check-in, evening)
|
| 504 |
+
4. otherwise → LOCAL EXPLORE (rotating named Vancouver highlight)
|
| 505 |
+
|
| 506 |
+
When check_in == match_date (land + match same day) the match role wins but
|
| 507 |
+
the body still notes the arrival flight. Weather is matched per date from the
|
| 508 |
+
top package's forecast. The selected flight / hotel / venue are referenced.
|
| 509 |
+
"""
|
| 510 |
from datetime import timedelta
|
| 511 |
|
| 512 |
top = result.packages[0] if result.packages else None
|
| 513 |
+
wx_by_date = {}
|
| 514 |
+
if top and top.weather:
|
| 515 |
+
wx_by_date = {w.date: w for w in top.weather}
|
| 516 |
+
hotel_name = top.hotel.name if (top and top.hotel) else "your hotel"
|
| 517 |
+
flight_bit = (
|
| 518 |
+
f"<b>{_e(top.flight.airline)} {_e(top.flight.flight_number)}</b> "
|
| 519 |
+
f"({_e(top.flight.origin)} → YVR)"
|
| 520 |
+
) if top else "your flight"
|
| 521 |
+
|
| 522 |
+
def _wx_note(d) -> str:
|
| 523 |
+
w = wx_by_date.get(d)
|
| 524 |
+
if not w:
|
| 525 |
+
return ""
|
| 526 |
+
icon = _WMO_ICON.get(w.weather_code, "🌡️")
|
| 527 |
+
return (
|
| 528 |
+
f' <span class="md-fdet" style="margin-left:6px">{icon} '
|
| 529 |
+
f"{w.temp_min_c:g}–{w.temp_max_c:g}°C · {w.precipitation_probability:g}% rain "
|
| 530 |
+
f'{_prov_badge(w.source)}</span>'
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
items: list[str] = []
|
| 534 |
d = trip.check_in
|
| 535 |
idx = 0
|
| 536 |
+
local_i = 0
|
| 537 |
while d <= trip.check_out:
|
| 538 |
idx += 1
|
| 539 |
+
head = f"Day {idx} · {d:%a %b %d}"
|
| 540 |
+
|
| 541 |
+
if d == trip.match_date:
|
| 542 |
+
arrive = ""
|
| 543 |
+
if d == trip.check_in and top:
|
| 544 |
+
arrive = f"Land via {flight_bit}, drop bags at <b>{_e(hotel_name)}</b>, then "
|
| 545 |
icon, lbl, title, body, cls = (
|
| 546 |
+
"🏟️", "Match day", f"{head} — MATCH DAY",
|
| 547 |
+
f"{arrive}<b>{_e(trip.match_name)}</b> at BC Place, ~7:00 PM PT. "
|
| 548 |
+
"Soak up the FIFA fan zone first, then it's a short "
|
| 549 |
+
f"{top.hotel_to_stadium_min if top else 'few'}-min walk from "
|
| 550 |
+
f"<b>{_e(hotel_name)}</b>.{_wx_note(d)} Head back after full-time.",
|
| 551 |
+
"is-match",
|
| 552 |
)
|
| 553 |
+
elif d == trip.check_out:
|
| 554 |
icon, lbl, title, body, cls = (
|
| 555 |
+
"🛫", "Departure", f"{head} — heading home",
|
| 556 |
+
f"Check out of <b>{_e(hotel_name)}</b>, grab a last Vancouver "
|
| 557 |
+
f"breakfast, then head to YVR for {flight_bit} home. Safe travels!",
|
| 558 |
+
"",
|
| 559 |
)
|
| 560 |
+
elif d == trip.check_in and top:
|
| 561 |
icon, lbl, title, body, cls = (
|
| 562 |
+
"✈️", "Arrival", head,
|
| 563 |
+
f"Land via {flight_bit} and take the Canada Line into town. "
|
| 564 |
+
f"Check in to <b>{_e(hotel_name)}</b>, then an easy first evening "
|
| 565 |
+
f"near the hotel.{_wx_note(d)}",
|
| 566 |
"",
|
| 567 |
)
|
| 568 |
else:
|
| 569 |
+
li, lname, ldesc = _LOCAL_DAYS[local_i % len(_LOCAL_DAYS)]
|
| 570 |
+
local_i += 1
|
| 571 |
icon, lbl, title, body, cls = (
|
| 572 |
+
li, "Free day", f"{head} — {lname}",
|
| 573 |
+
f"{ldesc}{_wx_note(d)}",
|
| 574 |
"",
|
| 575 |
)
|
| 576 |
+
|
| 577 |
items.append(
|
| 578 |
f'<div class="md-day {cls}"><div class="md-day-ico">{icon}</div>'
|
| 579 |
f'<div class="md-day-lbl">{lbl}</div>'
|
|
|
|
| 590 |
def render_full(result: TripPackageResult, trip=None, leaflet_preloaded: bool = False) -> str:
|
| 591 |
out = (
|
| 592 |
render_status_bar(result)
|
| 593 |
+
+ render_cards(result, trip=trip)
|
| 594 |
+ render_map(result, leaflet_preloaded=leaflet_preloaded)
|
| 595 |
)
|
| 596 |
if trip is not None:
|