mzidan000 commited on
Commit
f40be87
·
verified ·
1 Parent(s): 6561aee

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. app.py +11 -1
  2. index.html +36 -13
  3. matchday/agent.py +25 -3
  4. matchday/agent_loop.py +55 -7
  5. matchday/render.py +42 -9
app.py CHANGED
@@ -230,7 +230,17 @@ async def plan_trip(user_text: str) -> str:
230
  if res.type == "final_answer":
231
  agent_text = res.text or ""
232
  break
233
- # fallback_to_deterministic → drop through to the deterministic path
 
 
 
 
 
 
 
 
 
 
234
  break
235
 
236
  # ── Deterministic fallback (K3): parse intent + build directly. Used when
 
230
  if res.type == "final_answer":
231
  agent_text = res.text or ""
232
  break
233
+ # fallback_to_deterministic → EXPLICIT + user-visible degrade. Never
234
+ # silently swap in the deterministic path. Most commonly this is a
235
+ # Modal cold-start timeout (see the agent_loop reason) — tell the user
236
+ # honestly so the wait / fast-mode result is understood, not hidden.
237
+ if res.type == "fallback_to_deterministic":
238
+ yield _ev(
239
+ type="commentary",
240
+ text="🌡️ Nemotron is warming up on Modal (cold start) — "
241
+ "building your packages in fast mode now, then I'll "
242
+ "still compare them live.",
243
+ )
244
  break
245
 
246
  # ── Deterministic fallback (K3): parse intent + build directly. Used when
index.html CHANGED
@@ -10,7 +10,7 @@
10
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
11
  <link rel="preconnect" href="https://fonts.googleapis.com">
12
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
14
 
15
  <style>
16
  :root{
@@ -22,6 +22,10 @@
22
  --bg:#f7f8fa; --surface:#ffffff;
23
  --shadow-sm:0 1px 2px rgba(17,24,39,.04),0 2px 6px -2px rgba(17,24,39,.08);
24
  --shadow-md:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);
 
 
 
 
25
  }
26
  *{box-sizing:border-box;}
27
  html,body{margin:0;height:100%;}
@@ -62,10 +66,24 @@
62
 
63
  /* ── Chat column ─────────────────────────────────────── */
64
  #chat{flex:1;overflow-y:auto;padding:20px 18px 10px;display:flex;flex-direction:column;gap:15px;}
65
- .hero{background:var(--surface);border:1px solid var(--line2);border-radius:16px;padding:17px 18px;
66
- box-shadow:var(--shadow-sm);}
67
- .hero h2{margin:0 0 7px;font-size:15.5px;font-weight:800;letter-spacing:-.01em;}
68
- .hero p{margin:0;font-size:13px;color:var(--mut);line-height:1.55;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  .msg{display:flex;gap:10px;max-width:94%;animation:fade .3s ease;}
71
  .msg.user{align-self:flex-end;flex-direction:row-reverse;}
@@ -117,10 +135,11 @@
117
  .result{overflow-y:auto;background:var(--bg);}
118
  #result{padding:18px 20px 28px;}
119
  .empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
120
- height:100%;text-align:center;color:var(--mut2);padding:40px 30px;}
121
- .empty .big{font-size:50px;margin-bottom:14px;filter:drop-shadow(0 6px 10px rgba(0,0,0,.08));}
122
- .empty h3{margin:0 0 8px;font-size:17px;color:var(--ink2);font-weight:700;}
123
- .empty p{margin:0;font-size:13.5px;max-width:340px;line-height:1.55;}
 
124
 
125
  /* ── "Building your trip" skeleton state (premium wait UX) ── */
126
  .building{padding:2px 0;}
@@ -199,8 +218,12 @@
199
  <section class="col chat">
200
  <div id="chat">
201
  <div class="hero">
202
- <h2>👋 Plan your World Cup trip</h2>
203
- <p>Tell me where you're flying from, the match you want, dates and budget. I'll build 3 ranked packages — cheapest flight, safest arrival, closest hotel to BC Place — with live prices and honest provenance.</p>
 
 
 
 
204
  </div>
205
  </div>
206
 
@@ -225,8 +248,8 @@
225
  <div id="result">
226
  <div class="empty">
227
  <div class="big">🏟️</div>
228
- <h3>Your trip packages will appear here</h3>
229
- <p>Send your request and Nemotron picks the tools while Python scores real flights, hotels, weather and nearby spots — ranked on an interactive map.</p>
230
  </div>
231
  </div>
232
  </section>
 
10
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
11
  <link rel="preconnect" href="https://fonts.googleapis.com">
12
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
14
 
15
  <style>
16
  :root{
 
22
  --bg:#f7f8fa; --surface:#ffffff;
23
  --shadow-sm:0 1px 2px rgba(17,24,39,.04),0 2px 6px -2px rgba(17,24,39,.08);
24
  --shadow-md:0 1px 2px rgba(17,24,39,.04),0 8px 22px -12px rgba(17,24,39,.16);
25
+ /* FIFA World Cup 2026 inspired palette (vibrant poster accents on dark) */
26
+ --wc-magenta:#db2777; --wc-violet:#7c3aed; --wc-blue:#2563eb;
27
+ --wc-teal:#0d9488; --wc-gold:#fbbf24; --wc-pink:#f472b6;
28
+ --display:'Space Grotesk',Inter,-apple-system,sans-serif;
29
  }
30
  *{box-sizing:border-box;}
31
  html,body{margin:0;height:100%;}
 
66
 
67
  /* ── Chat column ─────────────────────────────────────── */
68
  #chat{flex:1;overflow-y:auto;padding:20px 18px 10px;display:flex;flex-direction:column;gap:15px;}
69
+ /* FIFA 2026 themed hero real stadium imagery on a vibrant gradient fallback
70
+ (looks premium even before the photo finishes loading or if it's blocked). */
71
+ .hero{position:relative;overflow:hidden;border:0;border-radius:18px;min-height:220px;
72
+ background:
73
+ url('https://images.unsplash.com/photo-1522778526097-ce0a22ceb253?w=1600&q=80') center/cover no-repeat,
74
+ linear-gradient(135deg,#1e1b4b 0%,#6d28d9 45%,#db2777 100%);
75
+ box-shadow:var(--shadow-md);display:flex;align-items:flex-end;}
76
+ .hero-overlay{position:absolute;inset:0;
77
+ background:linear-gradient(180deg,rgba(15,23,42,.18) 0%,rgba(15,23,42,.55) 52%,rgba(15,23,42,.93) 100%);}
78
+ .hero-content{position:relative;z-index:2;padding:20px 22px 21px;color:#fff;width:100%;}
79
+ .hero-badge{display:inline-block;font-size:10.5px;font-weight:800;letter-spacing:.09em;text-transform:uppercase;
80
+ color:#fff;background:linear-gradient(135deg,var(--wc-magenta),var(--wc-violet));
81
+ padding:5px 11px;border-radius:999px;margin-bottom:11px;box-shadow:0 4px 12px -3px rgba(219,39,119,.5);}
82
+ .hero h2{margin:0 0 8px;font-family:var(--display);font-size:25px;font-weight:700;line-height:1.08;
83
+ letter-spacing:-.025em;color:#fff;}
84
+ .hero h2 .hero-accent{background:linear-gradient(135deg,var(--wc-gold),var(--wc-pink));
85
+ -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;}
86
+ .hero p{margin:0;font-size:13px;color:rgba(255,255,255,.92);line-height:1.55;max-width:48ch;}
87
 
88
  .msg{display:flex;gap:10px;max-width:94%;animation:fade .3s ease;}
89
  .msg.user{align-self:flex-end;flex-direction:row-reverse;}
 
135
  .result{overflow-y:auto;background:var(--bg);}
136
  #result{padding:18px 20px 28px;}
137
  .empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
138
+ height:100%;text-align:center;padding:50px 30px;
139
+ background:radial-gradient(125% 90% at 50% 0%,#1e293b 0%,#0f172a 72%);color:#cbd5e1;}
140
+ .empty .big{font-size:66px;margin-bottom:16px;filter:drop-shadow(0 8px 26px rgba(56,189,248,.35));}
141
+ .empty h3{margin:0 0 9px;font-family:var(--display);font-size:19px;color:#fff;font-weight:700;letter-spacing:-.015em;}
142
+ .empty p{margin:0;font-size:13.5px;max-width:360px;line-height:1.6;color:#94a3b8;}
143
 
144
  /* ── "Building your trip" skeleton state (premium wait UX) ── */
145
  .building{padding:2px 0;}
 
218
  <section class="col chat">
219
  <div id="chat">
220
  <div class="hero">
221
+ <div class="hero-overlay"></div>
222
+ <div class="hero-content">
223
+ <span class="hero-badge">⚽ FIFA World Cup 2026 · Vancouver</span>
224
+ <h2>Plan your <span class="hero-accent">World Cup</span> trip</h2>
225
+ <p>Tell me where you're flying from, the match, your dates &amp; budget. I'll build 3 ranked packages — cheapest flight, safest arrival, closest hotel to BC Place — with live prices &amp; honest provenance.</p>
226
+ </div>
227
  </div>
228
  </div>
229
 
 
248
  <div id="result">
249
  <div class="empty">
250
  <div class="big">🏟️</div>
251
+ <h3>Your Vancouver 2026 packages appear here</h3>
252
+ <p>Send your request and Nemotron picks the tools while Python scores real flights, hotels, weather and nearby spots — ranked on an interactive map of BC Place.</p>
253
  </div>
254
  </div>
255
  </section>
matchday/agent.py CHANGED
@@ -22,7 +22,12 @@ from typing import Any
22
  import modal
23
 
24
  from matchday.agent_loop import BuildTripPackagesArgs, ClarifyArgs, WebSearchArgs
25
- from matchday.errors import classify_error
 
 
 
 
 
26
  from matchday.prompts import build_system_prompt
27
 
28
  # The Modal app name (must match `modal.App("matchday-spike")` in modal_spike.py
@@ -121,9 +126,26 @@ class MatchDayAgent:
121
  # Re-raise so the AgentLoop can degrade gracefully (fallback to the
122
  # deterministic parser); the classified reason is logged here.
123
  classified = classify_error(exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  logger.error(
125
- "Nemotron generate failed: %s (reason=%s, retryable=%s, degrade=%s)",
126
- exc, classified.reason.value, classified.retryable, classified.should_degrade,
 
127
  )
128
  raise
129
 
 
22
  import modal
23
 
24
  from matchday.agent_loop import BuildTripPackagesArgs, ClarifyArgs, WebSearchArgs
25
+ from matchday.errors import (
26
+ ClassifiedError,
27
+ FailoverReason,
28
+ classify_error,
29
+ user_message_for,
30
+ )
31
  from matchday.prompts import build_system_prompt
32
 
33
  # The Modal app name (must match `modal.App("matchday-spike")` in modal_spike.py
 
126
  # Re-raise so the AgentLoop can degrade gracefully (fallback to the
127
  # deterministic parser); the classified reason is logged here.
128
  classified = classify_error(exc)
129
+ # A timeout on the Nemotron generate call is the model still LOADING
130
+ # (Modal cold start ~90-150s), not a network fault. classify_error
131
+ # would tag it NETWORK_TIMEOUT ("check your connection") — wrong, and
132
+ # the reason callers use to decide on degradation. Re-tag as
133
+ # MODEL_COLD_START so the user gets an honest "warming up" message and
134
+ # the loop degrades to deterministic cleanly.
135
+ if isinstance(exc, TimeoutError): # asyncio.TimeoutError ⊂ TimeoutError
136
+ classified = ClassifiedError(
137
+ reason=FailoverReason.MODEL_COLD_START,
138
+ message=f"Nemotron generate timed out (cold start?): {exc!r}",
139
+ status_code=None,
140
+ retryable=True,
141
+ retry_after_seconds=None,
142
+ should_degrade=True,
143
+ user_message=user_message_for(FailoverReason.MODEL_COLD_START),
144
+ )
145
  logger.error(
146
+ "Nemotron generate failed: %s: %r (reason=%s, retryable=%s, degrade=%s)",
147
+ type(exc).__name__, exc, classified.reason.value,
148
+ classified.retryable, classified.should_degrade,
149
  )
150
  raise
151
 
matchday/agent_loop.py CHANGED
@@ -24,6 +24,7 @@ from __future__ import annotations
24
  import asyncio
25
  import json
26
  import logging
 
27
  from dataclasses import dataclass, field
28
  from datetime import datetime, timezone
29
  from typing import Any, Literal
@@ -47,8 +48,23 @@ happy-path turns exit at 2-3 rounds (Nemotron calls build_trip_packages, then
47
  explains). 5 fits the hard path (4 tool rounds + 1 explain) with the grace call
48
  as a safety net; reference agents cap far higher (hermes 90, deepagents 9999)."""
49
 
50
- PER_TOOL_TIMEOUT_SECONDS: float = 30.0
51
- """Timeout per individual tool execution."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  MAX_RESULT_TOKENS: int = 2_000
54
  """Upper bound on the tool-result string fed back to the model. Tool results
@@ -705,6 +721,7 @@ class AgentLoop:
705
  round_num = 0
706
  tool_history: list[tuple[str, dict[str, Any]]] = []
707
  correction_used = False
 
708
 
709
  while round_num < self._max_rounds:
710
  round_num += 1
@@ -713,10 +730,41 @@ class AgentLoop:
713
  try:
714
  response = await self._call_agent(messages)
715
  except Exception as exc:
716
- logger.error("Agent call failed on round %d: %s", round_num, exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
717
  return AgentLoopResult(
718
  type="fallback_to_deterministic",
719
- reason=f"Agent call failed on round {round_num}: {exc}",
 
 
 
 
 
 
720
  )
721
 
722
  # ---- Step 2: Check for tool calls ----
@@ -833,7 +881,7 @@ class AgentLoop:
833
  try:
834
  return await asyncio.wait_for(
835
  self._agent.run(messages),
836
- timeout=PER_TOOL_TIMEOUT_SECONDS,
837
  )
838
  except asyncio.TimeoutError:
839
  raise # re-raised so the caller handles it
@@ -928,14 +976,14 @@ class AgentLoop:
928
  try:
929
  tool_result = await asyncio.wait_for(
930
  impl(validated_args),
931
- timeout=PER_TOOL_TIMEOUT_SECONDS,
932
  )
933
  except asyncio.TimeoutError:
934
  return AgentLoopResult(
935
  type="fallback_to_deterministic",
936
  reason=(
937
  f"Tool {tool_name!r} timed out after "
938
- f"{PER_TOOL_TIMEOUT_SECONDS}s."
939
  ),
940
  )
941
  except Exception as exc:
 
24
  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
 
48
  explains). 5 fits the hard path (4 tool rounds + 1 explain) with the grace call
49
  as a safety net; reference agents cap far higher (hermes 90, deepagents 9999)."""
50
 
51
+ PER_TOOL_TIMEOUT_SECONDS: float = float(os.environ.get("MATCHDAY_WEB_TIMEOUT", "30"))
52
+ """Deadline for a single fast tool call (e.g. one SerpApi ``web_search``)."""
53
+
54
+ # Agent (Nemotron) inference call deadline. This MUST tolerate Modal COLD START:
55
+ # loading the 60GB Nemotron weights from the volume cache takes ~90-150s after
56
+ # the container scales up from zero. A 30s deadline here silently timed out the
57
+ # FIRST agent round on every cold-started query -> the smart loop fell back to
58
+ # the deterministic parser and Nemotron never actually decided / grounded /
59
+ # clarified (the crux bug behind "the agent isn't smart"). Warm calls finish in
60
+ # ~5-15s, well under this ceiling. Tune via MATCHDAY_AGENT_TIMEOUT.
61
+ AGENT_CALL_TIMEOUT_SECONDS: float = float(os.environ.get("MATCHDAY_AGENT_TIMEOUT", "150"))
62
+ """Deadline for one Nemotron inference call (cold-start tolerant)."""
63
+
64
+ # build_trip_packages fans out flights + hotels + weather + OSM (multiple SerpApi
65
+ # round-trips) and can take 30-60s; the old shared 30s could cut it mid-build.
66
+ TOOL_EXEC_TIMEOUT_SECONDS: float = float(os.environ.get("MATCHDAY_TOOL_TIMEOUT", "90"))
67
+ """Deadline for a multi-API tool such as ``build_trip_packages``."""
68
 
69
  MAX_RESULT_TOKENS: int = 2_000
70
  """Upper bound on the tool-result string fed back to the model. Tool results
 
721
  round_num = 0
722
  tool_history: list[tuple[str, dict[str, Any]]] = []
723
  correction_used = False
724
+ cold_start_retried = False # one retry tolerated for Modal cold start
725
 
726
  while round_num < self._max_rounds:
727
  round_num += 1
 
730
  try:
731
  response = await self._call_agent(messages)
732
  except Exception as exc:
733
+ # Never log an empty str(exc) asyncio.TimeoutError stringifies
734
+ # to "" (the blank "Agent call failed on round 1: " in the logs).
735
+ # Capture the type + repr so cold-start vs parse vs OOM is
736
+ # diagnosable. A TimeoutError here is a Modal COLD START (the 60GB
737
+ # model is still loading), NOT a network fault — flag it so the
738
+ # reason callers use to degrade is honest.
739
+ is_cold = isinstance(exc, TimeoutError) # asyncio.TimeoutError ⊂ TimeoutError (3.11+)
740
+ logger.error(
741
+ "Agent call failed on round %d: %s: %r%s",
742
+ round_num, type(exc).__name__, exc,
743
+ " [MODEL_COLD_START — Nemotron still loading]" if is_cold else "",
744
+ )
745
+ # Modal cold start has HIGH variance (90-250s observed — a single
746
+ # deadline can't cover it). But the first (timed-out) call already
747
+ # TRIGGERED the server-side model load; by the time we loop back,
748
+ # the container is usually warm. Retry round 1 ONCE before
749
+ # degrading — this is what turns a guaranteed cold-start failure
750
+ # into a successful first query.
751
+ if is_cold and round_num == 1 and not cold_start_retried:
752
+ cold_start_retried = True
753
+ logger.warning(
754
+ "Round 1 cold-start timeout (>=%.0fs) — retrying once "
755
+ "(container should be warm now).",
756
+ AGENT_CALL_TIMEOUT_SECONDS,
757
+ )
758
+ continue
759
  return AgentLoopResult(
760
  type="fallback_to_deterministic",
761
+ reason=(
762
+ f"Nemotron cold-start timeout on round {round_num} "
763
+ f"(>={AGENT_CALL_TIMEOUT_SECONDS:.0f}s)"
764
+ if is_cold else
765
+ f"Agent call failed on round {round_num}: "
766
+ f"{type(exc).__name__}: {exc!r}"
767
+ ),
768
  )
769
 
770
  # ---- Step 2: Check for tool calls ----
 
881
  try:
882
  return await asyncio.wait_for(
883
  self._agent.run(messages),
884
+ timeout=AGENT_CALL_TIMEOUT_SECONDS,
885
  )
886
  except asyncio.TimeoutError:
887
  raise # re-raised so the caller handles it
 
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=(
985
  f"Tool {tool_name!r} timed out after "
986
+ f"{TOOL_EXEC_TIMEOUT_SECONDS}s."
987
  ),
988
  )
989
  except Exception as exc:
matchday/render.py CHANGED
@@ -11,6 +11,7 @@ from __future__ import annotations
11
 
12
  from html import escape
13
  from typing import Any
 
14
 
15
  from matchday.models import ScoredPackage
16
  from matchday.trip_tool import TripPackageResult
@@ -100,12 +101,17 @@ _CSS = """
100
  .md-facts .md-fbody{min-width:0;}
101
  .md-facts .md-fbody b{color:#111827;font-weight:700;display:block;}
102
  .md-facts .md-fbody .md-fdet{display:block;color:#6b7280;font-size:12.5px;margin-top:2px;}
103
- /* booking CTA (Layla checklist) — honest search link, never a fabricated URL */
 
104
  .md-card-cta{padding:2px 22px 18px;}
 
105
  .md-cta{display:inline-flex;align-items:center;gap:6px;text-decoration:none;font-size:13px;font-weight:700;
106
- color:#fff;background:var(--accent,#7c3aed);padding:9px 16px;border-radius:11px;
107
- box-shadow:0 6px 14px -6px rgba(124,58,237,.45);transition:.15s;}
108
  .md-cta:hover{filter:brightness(1.07);transform:translateY(-1px);}
 
 
 
109
 
110
  /* ── Map ── */
111
  .md-map-wrap{position:relative;border:1px solid #eef0f3;border-radius:18px;overflow:hidden;
@@ -231,11 +237,34 @@ def render_card(pkg: ScoredPackage, is_top: bool = False) -> str:
231
 
232
  topflag = '<span class="md-topflag">★ Best match</span>' if is_top else ""
233
 
234
- # Honest booking CTA: a real Google search for this exact route + date
235
- # (never a fabricated booking URL provenance stays clean).
236
- cta_url = (
237
- f"https://www.google.com/search?q=flights+{f.origin}+to+{f.destination}+"
238
- f"{f.arrival_time:%B}+{f.arrival_time.day}+2026"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  )
240
 
241
  return f"""
@@ -255,7 +284,11 @@ def render_card(pkg: ScoredPackage, is_top: bool = False) -> str:
255
  <li><span class="md-fico">🚶</span><span class="md-fbody"><b>{pkg.hotel_to_stadium_min} min walk</b>
256
  <span class="md-fdet">hotel → BC Place · {len(pkg.amenities)} nearby spots</span></span></li>
257
  </ul>
258
- <div class="md-card-cta"><a class="md-cta" href="{cta_url}" target="_blank" rel="noopener noreferrer">✈️ Book this flight →</a></div>
 
 
 
 
259
  </article>
260
  """
261
 
 
11
 
12
  from html import escape
13
  from typing import Any
14
+ from urllib.parse import quote as _urlquote
15
 
16
  from matchday.models import ScoredPackage
17
  from matchday.trip_tool import TripPackageResult
 
101
  .md-facts .md-fbody{min-width:0;}
102
  .md-facts .md-fbody b{color:#111827;font-weight:700;display:block;}
103
  .md-facts .md-fbody .md-fdet{display:block;color:#6b7280;font-size:12.5px;margin-top:2px;}
104
+ /* per-option booking CTAs (Layla-style): a real booking/directions button for
105
+ flight, hotel, and transit — not one generic web search. */
106
  .md-card-cta{padding:2px 22px 18px;}
107
+ .md-cta-row{display:flex;flex-wrap:wrap;gap:8px;}
108
  .md-cta{display:inline-flex;align-items:center;gap:6px;text-decoration:none;font-size:13px;font-weight:700;
109
+ color:#fff;background:var(--accent,#7c3aed);padding:9px 15px;border-radius:11px;
110
+ box-shadow:0 6px 14px -6px rgba(17,24,39,.35);transition:.15s;}
111
  .md-cta:hover{filter:brightness(1.07);transform:translateY(-1px);}
112
+ .md-cta-f{background:#1d4ed8;} /* flight — sky blue */
113
+ .md-cta-h{background:#047857;} /* hotel — emerald */
114
+ .md-cta-t{background:#374151;} /* transit — slate */
115
 
116
  /* ── Map ── */
117
  .md-map-wrap{position:relative;border:1px solid #eef0f3;border-radius:18px;overflow:hidden;
 
237
 
238
  topflag = '<span class="md-topflag">★ Best match</span>' if is_top else ""
239
 
240
+ # Per-option booking CTAs (Layla-style). Hotels carry a REAL SerpApi deep
241
+ # link (Hotel.booking_url from Google Hotels). Flights expose no resolved
242
+ # booking URL (booking_token needs a deferred SerpApi call), so we deep-link
243
+ # to Skyscanner for the exact route+date — a real booking site, not a generic
244
+ # web search. Transit links to Google Maps directions to BC Place.
245
+ fdate = f.arrival_time.strftime("%Y-%m-%d")
246
+ flight_url = (
247
+ f"https://www.skyscanner.com/transport/flights/{f.origin.lower()}/"
248
+ f"{f.destination.lower()}/{fdate}/"
249
+ )
250
+ if h and getattr(h, "booking_url", None):
251
+ hotel_url, hotel_label = h.booking_url, "🏨 Book hotel"
252
+ elif h:
253
+ hotel_url = (
254
+ "https://www.google.com/travel/hotels?q="
255
+ + _urlquote(f"{h.name} Vancouver BC Place")
256
+ )
257
+ hotel_label = "🏨 Find hotel"
258
+ else:
259
+ hotel_url = hotel_label = None
260
+ transit_url = (
261
+ "https://www.google.com/maps/dir/?api=1&destination=BC%20Place%20"
262
+ "Stadium%2C%20Vancouver&travelmode=transit"
263
+ )
264
+ hotel_btn = (
265
+ f'<a class="md-cta md-cta-h" href="{_e(hotel_url)}" target="_blank" '
266
+ f'rel="noopener noreferrer">{hotel_label} →</a>'
267
+ if hotel_url else ""
268
  )
269
 
270
  return f"""
 
284
  <li><span class="md-fico">🚶</span><span class="md-fbody"><b>{pkg.hotel_to_stadium_min} min walk</b>
285
  <span class="md-fdet">hotel → BC Place · {len(pkg.amenities)} nearby spots</span></span></li>
286
  </ul>
287
+ <div class="md-card-cta"><div class="md-cta-row">
288
+ <a class="md-cta md-cta-f" href="{flight_url}" target="_blank" rel="noopener noreferrer">✈️ Book flight →</a>
289
+ {hotel_btn}
290
+ <a class="md-cta md-cta-t" href="{transit_url}" target="_blank" rel="noopener noreferrer">🚖 Transit →</a>
291
+ </div></div>
292
  </article>
293
  """
294