mzidan000 commited on
Commit
342f056
·
verified ·
1 Parent(s): 433072e

Upload folder using huggingface_hub

Browse files
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 (per Codex recommendation):
5
- - Maximum 2 tool rounds (Nemotron calls a tool -> result returned -> one more
6
- turn to maybe call a second tool or produce final answer).
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`` (2) 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,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 2). After this many
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 2).
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 (organic or [])[:max_results]:
 
 
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
- # Per-option booking CTAs (Layla-style). Hotels carry a REAL SerpApi deep
257
- # link (Hotel.booking_url from Google Hotels). Flights expose no resolved
258
- # booking URL (booking_token needs a deferred SerpApi call), so we deep-link
259
- # to Skyscanner for the exact route+date — a real booking site, not a generic
260
- # web search. Transit links to Google Maps directions to BC Place.
261
- fdate = f.arrival_time.strftime("%Y-%m-%d")
262
- flight_url = (
263
- f"https://www.skyscanner.com/transport/flights/{f.origin.lower()}/"
264
- f"{f.destination.lower()}/{fdate}/"
265
- )
 
 
 
 
 
 
 
 
 
 
266
  if h and getattr(h, "booking_url", None):
267
- hotel_url, hotel_label = h.booking_url, "🏨 Book hotel"
268
  elif h:
269
- hotel_url = (
270
- "https://www.google.com/travel/hotels?q="
271
- + _urlquote(f"{h.name} Vancouver BC Place")
272
- )
273
- hotel_label = "🏨 Find hotel"
 
 
 
 
274
  else:
275
  hotel_url = hotel_label = None
276
- transit_url = (
277
- "https://www.google.com/maps/dir/?api=1&destination=BC%20Place%20"
278
- "Stadium%2C%20Vancouver&travelmode=transit"
279
- )
280
- hotel_btn = (
281
- f'<a class="md-cta md-cta-h" href="{_e(hotel_url)}" target="_blank" '
282
- f'rel="noopener noreferrer">{hotel_label} →</a>'
283
- if hotel_url else ""
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(render_card(p, is_top=(i == 0), idx=i) for i, p in enumerate(result.packages))
 
 
 
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 (A2): arrival match day explore."""
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if d == trip.check_in and top:
424
- hotel = top.hotel.name if top.hotel else "your hotel"
 
 
 
 
425
  icon, lbl, title, body, cls = (
426
- "️", "Arrival", f"Day {idx} · {d:%a %b %d}",
427
- f"Land via <b>{_e(top.flight.airline)} {_e(top.flight.flight_number)}</b> "
428
- f"({_e(top.flight.origin)} YVR) and check in to <b>{_e(hotel)}</b>. Settle in, grab dinner downtown.",
429
- "",
 
 
430
  )
431
- elif d == trip.match_date:
432
  icon, lbl, title, body, cls = (
433
- "🏟️", "Match day", f"Day {idx} · {d:%a %b %d} MATCH DAY",
434
- f"<b>{_e(trip.match_name)}</b> at BC Place, 7:00 PM PT. Hit the fan zone first, "
435
- "then walk from your hotel it's minutes away.",
436
- "is-match",
437
  )
438
- elif d < trip.match_date:
439
  icon, lbl, title, body, cls = (
440
- "🏙️", "Explore", f"Day {idx} · {d:%a %b %d}",
441
- "Stanley Park seawall, Granville Island, Gastown Vancouver is built for walking.",
 
 
442
  "",
443
  )
444
  else:
 
 
445
  icon, lbl, title, body, cls = (
446
- "🗺️", "Heading home", f"Day {idx} · {d:%a %b %d}",
447
- "Brunch and last sights, then back to YVR. Safe flight home.",
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: