Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- app.py +11 -1
- index.html +36 -13
- matchday/agent.py +25 -3
- matchday/agent_loop.py +55 -7
- 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 →
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
.hero
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|
| 121 |
-
|
| 122 |
-
.empty
|
| 123 |
-
.empty
|
|
|
|
| 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 |
-
<
|
| 203 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 & budget. I'll build 3 ranked packages — cheapest flight, safest arrival, closest hotel to BC Place — with live prices & 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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 =
|
| 51 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 717 |
return AgentLoopResult(
|
| 718 |
type="fallback_to_deterministic",
|
| 719 |
-
reason=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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=
|
| 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"{
|
| 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
|
|
|
|
| 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
|
| 107 |
-
box-shadow:0 6px 14px -6px rgba(
|
| 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 |
-
#
|
| 235 |
-
# (
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"><
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|