Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- app.py +174 -7
- index.html +369 -0
- matchday/agent_loop.py +2 -4
- matchday/app.py +49 -73
- matchday/errors.py +62 -0
- matchday/render.py +90 -6
- matchday/traces/session_001.jsonl +4 -4
app.py
CHANGED
|
@@ -1,16 +1,183 @@
|
|
| 1 |
-
"""HF Space entry point
|
| 2 |
|
| 3 |
-
|
| 4 |
-
``
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import os
|
| 8 |
import sys
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
#
|
| 11 |
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 12 |
|
| 13 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
if __name__ == "__main__":
|
| 16 |
-
|
|
|
|
| 1 |
+
"""MatchDay — HF Space entry point (gradio.Server mode, N1 / Off-Brand).
|
| 2 |
|
| 3 |
+
The Space runs THIS file. It is a `gradio.Server` app: a fully custom
|
| 4 |
+
``index.html`` frontend is served at ``/`` while ``@app.api("plan_trip")`` is an
|
| 5 |
+
async generator that streams N12-typed JSON events through Gradio's queue (SSE),
|
| 6 |
+
so the frontend updates live as Nemotron decides → Python scores → Nemotron
|
| 7 |
+
explains. This is the Off-Brand path: a bespoke UI powered by Gradio's backend
|
| 8 |
+
(queuing, concurrency, Spaces hosting) — not stock Gradio components.
|
| 9 |
+
|
| 10 |
+
Brain + Hands: Nemotron (on Modal) never calls an API or names a price; Python
|
| 11 |
+
executes every call and scores every value. Every figure carries provenance.
|
| 12 |
+
|
| 13 |
+
The gr.Blocks streaming version is retained at ``matchday/app.py`` as a fallback.
|
| 14 |
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
import os
|
| 20 |
import sys
|
| 21 |
+
from datetime import date
|
| 22 |
+
from pathlib import Path
|
| 23 |
|
| 24 |
+
# Repo-root importability when the Space runs this file directly.
|
| 25 |
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 26 |
|
| 27 |
+
from fastapi.responses import HTMLResponse # noqa: E402
|
| 28 |
+
from gradio import Server # noqa: E402
|
| 29 |
+
|
| 30 |
+
from matchday.agent import MatchDayAgent # noqa: E402
|
| 31 |
+
from matchday.agent_loop import BuildTripPackagesArgs # noqa: E402
|
| 32 |
+
from matchday.intent import parse_intent # noqa: E402
|
| 33 |
+
from matchday.models import TripRequest # noqa: E402
|
| 34 |
+
from matchday.prompts import EXPLANATION_HINT # noqa: E402
|
| 35 |
+
from matchday.render import render_full # noqa: E402
|
| 36 |
+
from matchday.trip_tool import build_trip_packages, format_for_nemotron # noqa: E402
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger(__name__)
|
| 39 |
+
logging.basicConfig(level=logging.INFO)
|
| 40 |
+
|
| 41 |
+
# Nemotron primary, deterministic fallback. Flip to False to force the
|
| 42 |
+
# deterministic path (fast demo / Modal-down insurance).
|
| 43 |
+
USE_AGENT = True
|
| 44 |
+
|
| 45 |
+
HERE = Path(__file__).parent
|
| 46 |
+
INDEX_HTML = HERE / "index.html"
|
| 47 |
+
|
| 48 |
+
app = Server()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _ev(**payload) -> str:
|
| 52 |
+
"""Serialize a typed stream event (N12) as a JSON string for the SSE stream."""
|
| 53 |
+
return json.dumps(payload, ensure_ascii=False)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
|
| 57 |
+
return TripRequest(
|
| 58 |
+
origin_airport=a.origin_airport,
|
| 59 |
+
match_name=a.match_name or "the match",
|
| 60 |
+
match_date=date.fromisoformat(a.match_date),
|
| 61 |
+
check_in=date.fromisoformat(a.check_in),
|
| 62 |
+
check_out=date.fromisoformat(a.check_out),
|
| 63 |
+
travelers=a.travelers,
|
| 64 |
+
budget_tier=a.budget_tier,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> str:
|
| 69 |
+
"""Round 2 — Nemotron compares the packages. Best-effort ('' on failure)."""
|
| 70 |
+
args_json = json.dumps(trip.model_dump(mode="json"))
|
| 71 |
+
convo = [
|
| 72 |
+
{"role": "user", "content": user_text},
|
| 73 |
+
{
|
| 74 |
+
"role": "assistant",
|
| 75 |
+
"content": "",
|
| 76 |
+
"tool_calls": [{
|
| 77 |
+
"id": "call_build",
|
| 78 |
+
"type": "function",
|
| 79 |
+
"function": {"name": "build_trip_packages", "arguments": args_json},
|
| 80 |
+
}],
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"role": "tool",
|
| 84 |
+
"tool_call_id": "call_build",
|
| 85 |
+
"name": "build_trip_packages",
|
| 86 |
+
"content": format_for_nemotron(result),
|
| 87 |
+
},
|
| 88 |
+
{"role": "user", "content": EXPLANATION_HINT},
|
| 89 |
+
]
|
| 90 |
+
try:
|
| 91 |
+
r2 = await agent.run(convo, tools=[]) # no tools → Nemotron must write text
|
| 92 |
+
return (r2.get("text") or "").strip()
|
| 93 |
+
except Exception as exc:
|
| 94 |
+
logger.warning("explanation round failed: %s", exc)
|
| 95 |
+
return ""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.api(name="plan_trip", concurrency_limit=4, stream_every=0.5)
|
| 99 |
+
async def plan_trip(user_text: str) -> str:
|
| 100 |
+
"""Stream the agentic trip build as typed events (N12 + N10).
|
| 101 |
+
|
| 102 |
+
Yields: commentary (progress beats, sent immediately) → greenlight
|
| 103 |
+
(parsed trip) | clarify | error → result (full cards+map+timeline render
|
| 104 |
+
+ Nemotron's explanation). Falls back to the deterministic parser if the
|
| 105 |
+
agent is unavailable or hedges.
|
| 106 |
+
"""
|
| 107 |
+
yield _ev(type="commentary", text="Reading your trip request…")
|
| 108 |
+
|
| 109 |
+
agent = None
|
| 110 |
+
if USE_AGENT:
|
| 111 |
+
try:
|
| 112 |
+
agent = MatchDayAgent()
|
| 113 |
+
except Exception as exc:
|
| 114 |
+
logger.warning("agent init failed (%s); deterministic path.", exc)
|
| 115 |
+
|
| 116 |
+
# Round 1 — Nemotron decides which tool to call.
|
| 117 |
+
r1: dict = {"tool_calls": []}
|
| 118 |
+
if agent is not None:
|
| 119 |
+
yield _ev(type="commentary", text="🤖 Nemotron is choosing your best options…")
|
| 120 |
+
try:
|
| 121 |
+
r1 = await agent.run([{"role": "user", "content": user_text}])
|
| 122 |
+
except Exception as exc:
|
| 123 |
+
logger.warning("agent round 1 failed (%s).", exc)
|
| 124 |
+
r1 = {"tool_calls": []}
|
| 125 |
+
|
| 126 |
+
# Resolve a validated trip: Nemotron's args first, then deterministic parse.
|
| 127 |
+
tool_calls = r1.get("tool_calls") or []
|
| 128 |
+
trip: TripRequest | None = None
|
| 129 |
+
if tool_calls and tool_calls[0].get("name") == "build_trip_packages":
|
| 130 |
+
try:
|
| 131 |
+
trip = _args_to_trip(
|
| 132 |
+
BuildTripPackagesArgs.model_validate(tool_calls[0].get("arguments", {}))
|
| 133 |
+
)
|
| 134 |
+
except Exception:
|
| 135 |
+
trip = None
|
| 136 |
+
if trip is None:
|
| 137 |
+
parsed = parse_intent(user_text)
|
| 138 |
+
if parsed.trip_request is not None:
|
| 139 |
+
trip = parsed.trip_request
|
| 140 |
+
|
| 141 |
+
if trip is None:
|
| 142 |
+
clarify_q = ""
|
| 143 |
+
if tool_calls and tool_calls[0].get("name") == "clarify":
|
| 144 |
+
clarify_q = tool_calls[0].get("arguments", {}).get("question", "")
|
| 145 |
+
if not clarify_q:
|
| 146 |
+
clarify_q = parse_intent(user_text).question
|
| 147 |
+
yield _ev(
|
| 148 |
+
type="clarify",
|
| 149 |
+
text=clarify_q
|
| 150 |
+
or "Tell me where you're flying from and which match you want to see.",
|
| 151 |
+
)
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
yield _ev(type="greenlight", text=trip.summary())
|
| 155 |
+
yield _ev(
|
| 156 |
+
type="commentary",
|
| 157 |
+
text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…",
|
| 158 |
+
)
|
| 159 |
+
try:
|
| 160 |
+
result = await build_trip_packages(trip)
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
yield _ev(type="error", text=f"⚠️ {exc}")
|
| 163 |
+
return
|
| 164 |
+
|
| 165 |
+
yield _ev(type="commentary", text="🗺️ Scoring 3 packages — Nemotron is writing your comparison…")
|
| 166 |
+
explanation = ""
|
| 167 |
+
if agent is not None:
|
| 168 |
+
explanation = await _agent_explain(agent, user_text, trip, result)
|
| 169 |
+
|
| 170 |
+
# Final: the full Layla-competitive render (status + cards + map + timeline).
|
| 171 |
+
# leaflet_preloaded=True → the frontend already loaded Leaflet in <head>; the
|
| 172 |
+
# map's inline init script is re-run after injection (see index.html).
|
| 173 |
+
yield _ev(type="result", html=render_full(result, trip, leaflet_preloaded=True), explanation=explanation)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@app.get("/", response_class=HTMLResponse)
|
| 177 |
+
async def homepage():
|
| 178 |
+
with open(INDEX_HTML, "r", encoding="utf-8") as fh:
|
| 179 |
+
return fh.read()
|
| 180 |
+
|
| 181 |
|
| 182 |
if __name__ == "__main__":
|
| 183 |
+
app.launch(server_port=int(os.environ.get("PORT", "7860")), show_error=True)
|
index.html
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>MatchDay — AI trip planner for the 2026 FIFA World Cup, Vancouver</title>
|
| 7 |
+
|
| 8 |
+
<!-- Leaflet preloaded in <head> so the injected map's inline init runs instantly (N1). -->
|
| 9 |
+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
| 10 |
+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
| 11 |
+
|
| 12 |
+
<style>
|
| 13 |
+
:root{
|
| 14 |
+
--violet:#7c3aed; --violet-d:#6d28d9; --emerald:#10b981; --amber:#f59e0b;
|
| 15 |
+
--ink:#111827; --ink2:#374151; --mut:#6b7280; --mut2:#9ca3af;
|
| 16 |
+
--line:#e5e7eb; --panel:#f9fafb; --gray:#f3f4f6;
|
| 17 |
+
}
|
| 18 |
+
*{box-sizing:border-box;}
|
| 19 |
+
html,body{margin:0;height:100%;}
|
| 20 |
+
body{
|
| 21 |
+
font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
| 22 |
+
color:var(--ink); background:#fff; -webkit-font-smoothing:antialiased;
|
| 23 |
+
}
|
| 24 |
+
::-webkit-scrollbar{width:9px;height:9px;}
|
| 25 |
+
::-webkit-scrollbar-thumb{background:#d1d5db;border-radius:6px;}
|
| 26 |
+
|
| 27 |
+
/* ── Top bar ─────────────────────────────────────────── */
|
| 28 |
+
.topbar{
|
| 29 |
+
display:flex;align-items:center;gap:14px;padding:12px 20px;
|
| 30 |
+
border-bottom:1px solid var(--line);
|
| 31 |
+
background:linear-gradient(100deg,#0f172a,#1e3a8a);color:#fff;position:relative;z-index:5;
|
| 32 |
+
}
|
| 33 |
+
.topbar .logo{font-size:21px;font-weight:800;letter-spacing:-.02em;display:flex;align-items:center;gap:8px;}
|
| 34 |
+
.topbar .tag{font-size:13px;opacity:.85;font-weight:500;}
|
| 35 |
+
.topbar .sp{flex:1;}
|
| 36 |
+
.topbar .pill{font-size:11px;font-weight:600;background:rgba(255,255,255,.14);
|
| 37 |
+
padding:4px 10px;border-radius:999px;border:1px solid rgba(255,255,255,.22);}
|
| 38 |
+
.topbar .pill b{color:#c4b5fd;}
|
| 39 |
+
|
| 40 |
+
/* ── 3-column layout ─────────────────────────────────── */
|
| 41 |
+
.app{display:grid;grid-template-columns:minmax(320px,35%) minmax(0,45%) minmax(180px,20%);
|
| 42 |
+
height:calc(100vh - 53px);}
|
| 43 |
+
.col{display:flex;flex-direction:column;min-height:0;min-width:0;}
|
| 44 |
+
.chat{border-right:1px solid var(--line);}
|
| 45 |
+
.sidebar{border-left:1px solid var(--line);background:var(--panel);overflow:auto;}
|
| 46 |
+
|
| 47 |
+
/* ── Chat column ─────────────────────────────────────── */
|
| 48 |
+
#chat{flex:1;overflow-y:auto;padding:18px 18px 8px;display:flex;flex-direction:column;gap:14px;}
|
| 49 |
+
.hero{background:var(--gray);border:1px solid var(--line);border-radius:14px;padding:16px;}
|
| 50 |
+
.hero h2{margin:0 0 6px;font-size:15px;}
|
| 51 |
+
.hero p{margin:0;font-size:13px;color:var(--mut);line-height:1.5;}
|
| 52 |
+
.msg{display:flex;gap:10px;max-width:92%;animation:fade .25s ease;}
|
| 53 |
+
.msg.user{align-self:flex-end;flex-direction:row-reverse;}
|
| 54 |
+
.a-avatar{width:30px;height:30px;border-radius:50%;background:#1e3a8a;color:#fff;
|
| 55 |
+
display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0 30px;}
|
| 56 |
+
.a-body{background:var(--gray);border:1px solid var(--line);border-radius:14px;
|
| 57 |
+
padding:11px 14px;min-width:0;}
|
| 58 |
+
.msg.user .a-body{background:var(--violet);color:#fff;border-color:var(--violet-d);}
|
| 59 |
+
.a-content{font-size:14px;line-height:1.5;}
|
| 60 |
+
.a-beat{display:flex;align-items:center;gap:9px;font-size:13.5px;color:var(--ink2);
|
| 61 |
+
font-weight:500;padding-top:2px;}
|
| 62 |
+
.spin{display:inline-block;animation:spin 1s linear infinite;color:var(--violet);}
|
| 63 |
+
@keyframes spin{to{transform:rotate(360deg);}}
|
| 64 |
+
@keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
|
| 65 |
+
|
| 66 |
+
.green{background:#f0fdf4;border:1px solid #bbf7d0;color:#166534;border-radius:12px;
|
| 67 |
+
padding:10px 13px;font-size:14px;font-weight:600;}
|
| 68 |
+
.clarify{background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;border-radius:12px;padding:12px 14px;font-size:14.5px;}
|
| 69 |
+
.err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b;border-radius:12px;padding:11px 13px;font-size:14px;}
|
| 70 |
+
.nemotron-note{font-size:11px;color:var(--mut2);margin-top:7px;font-weight:600;letter-spacing:.02em;}
|
| 71 |
+
.msg.user .nemotron-note,.msg.user .a-beat{color:rgba(255,255,255,.92);}
|
| 72 |
+
|
| 73 |
+
/* input row */
|
| 74 |
+
.composer{border-top:1px solid var(--line);padding:12px 14px;background:#fff;}
|
| 75 |
+
.composer textarea{width:100%;border:1px solid #d1d5db;border-radius:12px;padding:11px 13px;
|
| 76 |
+
font:inherit;font-size:14px;resize:none;outline:none;min-height:48px;}
|
| 77 |
+
.composer textarea:focus{border-color:var(--violet);box-shadow:0 0 0 3px rgba(124,58,237,.15);}
|
| 78 |
+
.composer .row{display:flex;align-items:center;gap:8px;margin-top:8px;}
|
| 79 |
+
.examples{display:flex;flex-wrap:wrap;gap:6px;padding:0 18px 10px;}
|
| 80 |
+
.chip{font-size:12px;background:#fff;border:1px solid var(--line);border-radius:999px;
|
| 81 |
+
padding:5px 11px;color:var(--ink2);cursor:pointer;transition:.15s;}
|
| 82 |
+
.chip:hover{border-color:var(--violet);color:var(--violet);}
|
| 83 |
+
button.primary{background:var(--violet);color:#fff;border:0;border-radius:10px;padding:9px 16px;
|
| 84 |
+
font:inherit;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;}
|
| 85 |
+
button.primary:hover{background:var(--violet-d);}
|
| 86 |
+
button.primary:disabled{opacity:.55;cursor:not-allowed;}
|
| 87 |
+
.hint{font-size:12px;color:var(--mut);}
|
| 88 |
+
|
| 89 |
+
/* ── Result column ───────────────────────────────────── */
|
| 90 |
+
.result{overflow-y:auto;}
|
| 91 |
+
#result{padding:16px 18px 24px;}
|
| 92 |
+
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;
|
| 93 |
+
height:100%;text-align:center;color:var(--mut2);padding:30px;}
|
| 94 |
+
.empty .big{font-size:46px;margin-bottom:10px;}
|
| 95 |
+
.empty h3{margin:0 0 6px;font-size:16px;color:var(--ink2);}
|
| 96 |
+
.empty p{margin:0;font-size:13px;max-width:320px;line-height:1.5;}
|
| 97 |
+
|
| 98 |
+
/* ── Sidebar ─────────────────────────────────────────── */
|
| 99 |
+
.sidebar .sec{padding:16px 16px 14px;border-bottom:1px solid var(--line);}
|
| 100 |
+
.sidebar h4{margin:0 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);}
|
| 101 |
+
.brain{display:flex;gap:10px;align-items:flex-start;}
|
| 102 |
+
.brain .b{font-size:20px;}
|
| 103 |
+
.brain .t{font-size:12.5px;line-height:1.45;color:var(--ink2);}
|
| 104 |
+
.brain .t b{color:var(--violet);}
|
| 105 |
+
.legend-row{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--ink2);margin:5px 0;}
|
| 106 |
+
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;}
|
| 107 |
+
.dot.live{background:var(--emerald);}
|
| 108 |
+
.dot.fallback{background:var(--amber);}
|
| 109 |
+
.badges{display:flex;flex-wrap:wrap;gap:5px;}
|
| 110 |
+
.badge{font-size:10.5px;font-weight:600;background:#eef2ff;color:#3730a3;border:1px solid #c7d2fe;
|
| 111 |
+
border-radius:6px;padding:3px 7px;}
|
| 112 |
+
.fact{font-size:12.5px;color:var(--ink2);line-height:1.5;margin:4px 0;}
|
| 113 |
+
.fact b{color:var(--ink);}
|
| 114 |
+
|
| 115 |
+
/* full-screen map */
|
| 116 |
+
#matchday-map.fs{position:fixed!important;inset:0!important;z-index:99998!important;
|
| 117 |
+
height:100vh!important;width:100vw!important;border-radius:0!important;}
|
| 118 |
+
#md-fs-close{position:fixed;top:16px;right:18px;z-index:99999;background:#fff;color:var(--ink);
|
| 119 |
+
font-weight:700;border:0;border-radius:10px;padding:9px 15px;cursor:pointer;font-size:14px;
|
| 120 |
+
box-shadow:0 4px 14px rgba(0,0,0,.25);display:none;}
|
| 121 |
+
#md-fs-close.show{display:block;}
|
| 122 |
+
|
| 123 |
+
@media (max-width:980px){
|
| 124 |
+
.app{grid-template-columns:1fr;height:auto;}
|
| 125 |
+
.chat{border-right:0;border-bottom:1px solid var(--line);}
|
| 126 |
+
.sidebar{border-left:0;border-top:1px solid var(--line);}
|
| 127 |
+
.result{min-height:60vh;}
|
| 128 |
+
}
|
| 129 |
+
</style>
|
| 130 |
+
</head>
|
| 131 |
+
<body>
|
| 132 |
+
<header class="topbar">
|
| 133 |
+
<div class="logo">⚽ MatchDay</div>
|
| 134 |
+
<div class="tag">Your AI trip planner for the <b>2026 FIFA World Cup · Vancouver</b></div>
|
| 135 |
+
<div class="sp"></div>
|
| 136 |
+
<span class="pill">Brain <b>Nemotron-3-Nano-30B</b> · Hands <b>Python</b> · <b>gradio.Server</b></span>
|
| 137 |
+
</header>
|
| 138 |
+
|
| 139 |
+
<div class="app">
|
| 140 |
+
<!-- LEFT: conversation -->
|
| 141 |
+
<section class="col chat">
|
| 142 |
+
<div id="chat">
|
| 143 |
+
<div class="hero">
|
| 144 |
+
<h2>👋 Plan your World Cup trip</h2>
|
| 145 |
+
<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>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div class="examples" id="examples">
|
| 150 |
+
<span class="chip">Flying from Montreal, Canada vs Qatar, mid-range, June 26-29, just me</span>
|
| 151 |
+
<span class="chip">Toronto to see Brazil vs Germany, premium, July 12, 2 adults</span>
|
| 152 |
+
<span class="chip">From Halifax, Canada vs Morocco June 18, couple, luxury</span>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<div class="composer">
|
| 156 |
+
<textarea id="prompt" rows="2" placeholder="e.g. Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me"
|
| 157 |
+
aria-label="Describe your trip"></textarea>
|
| 158 |
+
<div class="row">
|
| 159 |
+
<button class="primary" id="send" onclick="runTrip()">🏈 Plan my trip</button>
|
| 160 |
+
<span class="hint">Enter to send · Enter+Shift for newline</span>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</section>
|
| 164 |
+
|
| 165 |
+
<!-- MIDDLE: result -->
|
| 166 |
+
<section class="col result">
|
| 167 |
+
<div id="result">
|
| 168 |
+
<div class="empty">
|
| 169 |
+
<div class="big">🏟️</div>
|
| 170 |
+
<h3>Your trip packages will appear here</h3>
|
| 171 |
+
<p>Once you send your request, Nemotron picks the tools and Python scores real flights, hotels, weather and nearby spots — ranked on an interactive map.</p>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</section>
|
| 175 |
+
|
| 176 |
+
<!-- RIGHT: sidebar -->
|
| 177 |
+
<aside class="col sidebar">
|
| 178 |
+
<div class="sec">
|
| 179 |
+
<h4>How it works</h4>
|
| 180 |
+
<div class="brain">
|
| 181 |
+
<div class="b">🧠</div>
|
| 182 |
+
<div class="t"><b>Brain:</b> Nemotron-3-Nano-30B (3B-active MoE) on Modal A100. It chooses the tools and writes the comparisons. It <b>never</b> calls an API or names a price.</div>
|
| 183 |
+
</div>
|
| 184 |
+
<div class="brain" style="margin-top:10px;">
|
| 185 |
+
<div class="b">🤲</div>
|
| 186 |
+
<div class="t"><b>Hands:</b> deterministic Python calls SerpApi / Open-Meteo / OpenStreetMap, scores packages with a fixed formula, and tags every value with provenance.</div>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
<div class="sec">
|
| 190 |
+
<h4>Provenance — no hallucinated prices</h4>
|
| 191 |
+
<div class="legend-row"><span class="dot live"></span> <b>● live</b> — real SerpApi / Open-Meteo / OSM reading</div>
|
| 192 |
+
<div class="legend-row"><span class="dot fallback"></span> <b>example</b> — fixture (demo fallback)</div>
|
| 193 |
+
</div>
|
| 194 |
+
<div class="sec">
|
| 195 |
+
<h4>Built for Build Small</h4>
|
| 196 |
+
<div class="badges">
|
| 197 |
+
<span class="badge">🎨 Off-Brand</span>
|
| 198 |
+
<span class="badge">🤖 Best Agent</span>
|
| 199 |
+
<span class="badge">🟩 Nemotron</span>
|
| 200 |
+
<span class="badge">🟢 Modal</span>
|
| 201 |
+
<span class="badge">📡 Trace</span>
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
<div class="sec">
|
| 205 |
+
<h4>The match</h4>
|
| 206 |
+
<div class="fact">🏟️ <b>BC Place Stadium</b>, Vancouver</div>
|
| 207 |
+
<div class="fact">⏰ ~7:00 PM PT kickoff</div>
|
| 208 |
+
<div class="fact">📍 Downtown — walkable from central hotels</div>
|
| 209 |
+
</div>
|
| 210 |
+
</aside>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<button id="md-fs-close" onclick="matchdayFullscreen(false)" aria-label="Exit full screen">✕ Close</button>
|
| 214 |
+
|
| 215 |
+
<script>
|
| 216 |
+
const EXAMPLES = [
|
| 217 |
+
"Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me",
|
| 218 |
+
"I want to fly from Toronto to see Brazil vs Germany, premium, July 12, 2 adults",
|
| 219 |
+
"Take me from Ottawa to Vancouver for Canada vs Qatar on 2026-06-26, budget",
|
| 220 |
+
"From Halifax, Canada vs Morocco June 18, couple, luxury",
|
| 221 |
+
];
|
| 222 |
+
// Pre-fill the first example for instant demo.
|
| 223 |
+
document.getElementById("prompt").value = EXAMPLES[0];
|
| 224 |
+
|
| 225 |
+
let running = false;
|
| 226 |
+
let resultHandled = false; // the result event repeats on `complete` — render it once
|
| 227 |
+
const chatEl = document.getElementById("chat");
|
| 228 |
+
const resultEl = document.getElementById("result");
|
| 229 |
+
const promptEl = document.getElementById("prompt");
|
| 230 |
+
const sendBtn = document.getElementById("send");
|
| 231 |
+
|
| 232 |
+
// ── DOM helpers ────────────────────────────────────────
|
| 233 |
+
function addUserBubble(text){
|
| 234 |
+
const m = document.createElement("div");
|
| 235 |
+
m.className = "msg user";
|
| 236 |
+
m.innerHTML = `<div class="a-avatar">🧑</div><div class="a-body"><div class="a-content"></div></div>`;
|
| 237 |
+
m.querySelector(".a-content").textContent = text;
|
| 238 |
+
chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight;
|
| 239 |
+
return m;
|
| 240 |
+
}
|
| 241 |
+
function addAssistantBubble(){
|
| 242 |
+
const m = document.createElement("div");
|
| 243 |
+
m.className = "msg assistant";
|
| 244 |
+
m.innerHTML =
|
| 245 |
+
`<div class="a-avatar">🤖</div>
|
| 246 |
+
<div class="a-body">
|
| 247 |
+
<div class="a-content"></div>
|
| 248 |
+
<div class="a-beat"><span class="spin">◐</span><span class="beat-text">Reading your trip request…</span></div>
|
| 249 |
+
</div>`;
|
| 250 |
+
chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight;
|
| 251 |
+
return m;
|
| 252 |
+
}
|
| 253 |
+
function setBeat(bubble, text){
|
| 254 |
+
const beat = bubble.querySelector(".a-beat");
|
| 255 |
+
if (!beat) return;
|
| 256 |
+
beat.style.display = "flex";
|
| 257 |
+
beat.querySelector(".beat-text").textContent = text;
|
| 258 |
+
}
|
| 259 |
+
function hideBeat(bubble){
|
| 260 |
+
const beat = bubble.querySelector(".a-beat");
|
| 261 |
+
if (beat) beat.style.display = "none";
|
| 262 |
+
}
|
| 263 |
+
function esc(s){ const d=document.createElement("div"); d.textContent=s==null?"":String(s); return d.innerHTML; }
|
| 264 |
+
|
| 265 |
+
// ── Streaming via raw SSE (verified wire format) ───────
|
| 266 |
+
function handleEvent(ev, bubble){
|
| 267 |
+
if (!ev) return;
|
| 268 |
+
if (ev.type === "commentary"){
|
| 269 |
+
setBeat(bubble, ev.text);
|
| 270 |
+
} else if (ev.type === "greenlight"){
|
| 271 |
+
bubble.querySelector(".a-content").innerHTML =
|
| 272 |
+
`<div class="green">✅ Planning your trip: ${esc(ev.text)}</div>`;
|
| 273 |
+
} else if (ev.type === "clarify"){
|
| 274 |
+
bubble.querySelector(".a-content").innerHTML = `<div class="clarify">💬 ${esc(ev.text)}</div>`;
|
| 275 |
+
hideBeat(bubble);
|
| 276 |
+
} else if (ev.type === "error"){
|
| 277 |
+
bubble.querySelector(".a-content").innerHTML = `<div class="err">${esc(ev.text)}</div>`;
|
| 278 |
+
hideBeat(bubble);
|
| 279 |
+
} else if (ev.type === "result"){
|
| 280 |
+
if (resultHandled) return; // `complete` re-emits the final yield — render once
|
| 281 |
+
resultHandled = true;
|
| 282 |
+
hideBeat(bubble);
|
| 283 |
+
bubble.querySelector(".a-content").innerHTML =
|
| 284 |
+
`<div class="green">✅ Built 3 ranked packages → see them on the map</div>`;
|
| 285 |
+
resultEl.innerHTML = ev.html;
|
| 286 |
+
activateScripts(resultEl); // re-init the injected Leaflet map
|
| 287 |
+
if (ev.explanation && ev.explanation.trim()){
|
| 288 |
+
const nb = document.createElement("div");
|
| 289 |
+
nb.className = "msg assistant";
|
| 290 |
+
const safe = esc(ev.explanation).replace(/\n/g,"<br>");
|
| 291 |
+
nb.innerHTML =
|
| 292 |
+
`<div class="a-avatar">🤖</div><div class="a-body">
|
| 293 |
+
<div class="a-content"><b style="color:#1e3a8a">🤖 Nemotron compares your options</b><br>${safe}
|
| 294 |
+
<div class="nemotron-note">written by Nemotron-3-Nano-30B · grounded in the scored data</div>
|
| 295 |
+
</div></div>`;
|
| 296 |
+
chatEl.appendChild(nb);
|
| 297 |
+
}
|
| 298 |
+
chatEl.scrollTop = chatEl.scrollHeight;
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function activateScripts(root){
|
| 303 |
+
// innerHTML-inserted <script> tags don't auto-run — re-create them so the
|
| 304 |
+
// Leaflet init (and any other inline script in the result fragment) executes.
|
| 305 |
+
root.querySelectorAll("script").forEach(old=>{
|
| 306 |
+
const s = document.createElement("script");
|
| 307 |
+
if (old.src){ s.src = old.src; } else { s.textContent = old.textContent; }
|
| 308 |
+
old.replaceWith(s);
|
| 309 |
+
});
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
async function runTrip(forcedText){
|
| 313 |
+
if (running) return;
|
| 314 |
+
const text = (forcedText == null ? promptEl.value : forcedText).trim();
|
| 315 |
+
if (!text) return;
|
| 316 |
+
running = true; sendBtn.disabled = true;
|
| 317 |
+
addUserBubble(text);
|
| 318 |
+
const bubble = addAssistantBubble();
|
| 319 |
+
|
| 320 |
+
try{
|
| 321 |
+
const resp = await fetch("/gradio_api/call/plan_trip", {
|
| 322 |
+
method: "POST", headers: {"Content-Type":"application/json"},
|
| 323 |
+
body: JSON.stringify({ data: [text] }),
|
| 324 |
+
});
|
| 325 |
+
const j = await resp.json();
|
| 326 |
+
const eventId = j && j.event_id;
|
| 327 |
+
if (!eventId){ bubble.querySelector(".a-content").innerHTML = `<div class="err">No event id returned from the queue.</div>`; hideBeat(bubble); return; }
|
| 328 |
+
|
| 329 |
+
const es = new EventSource("/gradio_api/call/plan_trip/" + eventId);
|
| 330 |
+
es.addEventListener("generating", (e)=>{
|
| 331 |
+
try{ const arr = JSON.parse(e.data); handleEvent(JSON.parse(arr[0]), bubble); }catch(_){}
|
| 332 |
+
});
|
| 333 |
+
es.addEventListener("complete", (e)=>{
|
| 334 |
+
try{ const arr = JSON.parse(e.data); handleEvent(arr && arr[0] ? JSON.parse(arr[0]) : null, bubble); }catch(_){}
|
| 335 |
+
es.close(); finish();
|
| 336 |
+
});
|
| 337 |
+
es.addEventListener("error", ()=>{ es.close(); finish(); });
|
| 338 |
+
}catch(err){
|
| 339 |
+
bubble.querySelector(".a-content").innerHTML = `<div class="err">⚠️ ${esc(err.message)}</div>`;
|
| 340 |
+
hideBeat(bubble); finish();
|
| 341 |
+
}
|
| 342 |
+
}
|
| 343 |
+
function finish(){ running = false; sendBtn.disabled = false; resultHandled = false; }
|
| 344 |
+
|
| 345 |
+
// ── Full-screen map (Layla frame 24) ───────────────────
|
| 346 |
+
function matchdayFullscreen(on){
|
| 347 |
+
const mapEl = document.getElementById("matchday-map");
|
| 348 |
+
const closeBtn = document.getElementById("md-fs-close");
|
| 349 |
+
if (!mapEl) return;
|
| 350 |
+
if (on === undefined) on = !mapEl.classList.contains("fs");
|
| 351 |
+
mapEl.classList.toggle("fs", on);
|
| 352 |
+
closeBtn.classList.toggle("show", on);
|
| 353 |
+
setTimeout(()=>{ if (window._matchdayMap){ window._matchdayMap.invalidateSize(); } }, 120);
|
| 354 |
+
}
|
| 355 |
+
window.matchdayFullscreen = matchdayFullscreen;
|
| 356 |
+
|
| 357 |
+
// ── Examples + keyboard ────────────────────────────────
|
| 358 |
+
document.getElementById("examples").addEventListener("click", (e)=>{
|
| 359 |
+
const chip = e.target.closest(".chip");
|
| 360 |
+
if (!chip) return;
|
| 361 |
+
promptEl.value = chip.textContent;
|
| 362 |
+
runTrip(chip.textContent);
|
| 363 |
+
});
|
| 364 |
+
promptEl.addEventListener("keydown", (e)=>{
|
| 365 |
+
if (e.key === "Enter" && !e.shiftKey){ e.preventDefault(); runTrip(); }
|
| 366 |
+
});
|
| 367 |
+
</script>
|
| 368 |
+
</body>
|
| 369 |
+
</html>
|
matchday/agent_loop.py
CHANGED
|
@@ -31,6 +31,7 @@ from typing import Any, Literal
|
|
| 31 |
from pydantic import BaseModel, Field, field_validator
|
| 32 |
|
| 33 |
from matchday.api_registry import registry
|
|
|
|
| 34 |
from matchday.models import ScoredPackage
|
| 35 |
|
| 36 |
logger = logging.getLogger(__name__)
|
|
@@ -948,10 +949,7 @@ class AgentLoop:
|
|
| 948 |
try:
|
| 949 |
validated_args = self._allow_list.validate_args(tool_name, raw_args)
|
| 950 |
except Exception as exc:
|
| 951 |
-
error_msg = (
|
| 952 |
-
f"Argument validation failed for {tool_name!r}: {exc}. "
|
| 953 |
-
"Please correct the arguments and call the tool again."
|
| 954 |
-
)
|
| 955 |
logger.warning("Argument validation error: %s", exc)
|
| 956 |
|
| 957 |
self._inject_error_message(messages, tool_name, error_msg)
|
|
|
|
| 31 |
from pydantic import BaseModel, Field, field_validator
|
| 32 |
|
| 33 |
from matchday.api_registry import registry
|
| 34 |
+
from matchday.errors import format_validation_error
|
| 35 |
from matchday.models import ScoredPackage
|
| 36 |
|
| 37 |
logger = logging.getLogger(__name__)
|
|
|
|
| 949 |
try:
|
| 950 |
validated_args = self._allow_list.validate_args(tool_name, raw_args)
|
| 951 |
except Exception as exc:
|
| 952 |
+
error_msg = format_validation_error(tool_name, exc)
|
|
|
|
|
|
|
|
|
|
| 953 |
logger.warning("Argument validation error: %s", exc)
|
| 954 |
|
| 955 |
self._inject_error_message(messages, tool_name, error_msg)
|
matchday/app.py
CHANGED
|
@@ -1,17 +1,17 @@
|
|
| 1 |
"""MatchDay — Gradio app for the 2026 FIFA World Cup in Vancouver.
|
| 2 |
|
| 3 |
-
|
| 4 |
and extracts the trip intent -> Python (Hands) executes build_trip_packages and
|
| 5 |
-
scores every price deterministically -> Nemotron writes the comparison.
|
| 6 |
-
|
| 7 |
-
|
|
|
|
| 8 |
|
| 9 |
Brain + Hands: Nemotron never calls an API or names a price; Python does. Every
|
| 10 |
figure carries a provenance badge (live vs example).
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
-
import asyncio
|
| 15 |
import json
|
| 16 |
import logging
|
| 17 |
from datetime import date
|
|
@@ -23,7 +23,7 @@ from matchday.agent_loop import BuildTripPackagesArgs
|
|
| 23 |
from matchday.intent import parse_intent
|
| 24 |
from matchday.models import TripRequest
|
| 25 |
from matchday.prompts import EXPLANATION_HINT
|
| 26 |
-
from matchday.render import render_full
|
| 27 |
from matchday.trip_tool import build_trip_packages, format_for_nemotron
|
| 28 |
|
| 29 |
logger = logging.getLogger(__name__)
|
|
@@ -82,10 +82,11 @@ def _explain_html(text: str) -> str:
|
|
| 82 |
)
|
| 83 |
|
| 84 |
|
| 85 |
-
def
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
| 89 |
)
|
| 90 |
|
| 91 |
|
|
@@ -102,12 +103,7 @@ def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest:
|
|
| 102 |
|
| 103 |
|
| 104 |
async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> str:
|
| 105 |
-
"""Round 2: Nemotron compares the packages. Best-effort ('' on failure).
|
| 106 |
-
|
| 107 |
-
Builds a tool_call/tool_result pair from the executed trip so Nemotron sees
|
| 108 |
-
the packages regardless of whether it or the deterministic parser drove the
|
| 109 |
-
build.
|
| 110 |
-
"""
|
| 111 |
args_json = json.dumps(trip.model_dump(mode="json"))
|
| 112 |
convo = [
|
| 113 |
{"role": "user", "content": user_text},
|
|
@@ -136,92 +132,72 @@ async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> st
|
|
| 136 |
return ""
|
| 137 |
|
| 138 |
|
| 139 |
-
async def
|
| 140 |
-
"""
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
args; if it clarifies/declines but the deterministic parser can still extract
|
| 144 |
-
a complete trip, we build anyway (the 30B model is sometimes over-cautious).
|
| 145 |
-
Only a genuinely incomplete request yields a clarifying question. Nemotron
|
| 146 |
-
always writes the final comparison (round 2).
|
| 147 |
"""
|
| 148 |
-
|
| 149 |
-
agent = MatchDayAgent()
|
| 150 |
-
except Exception as exc:
|
| 151 |
-
logger.warning("agent init failed (%s); deterministic fallback.", exc)
|
| 152 |
-
return None
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
|
|
|
| 161 |
tool_calls = r1.get("tool_calls") or []
|
| 162 |
trip: TripRequest | None = None
|
| 163 |
-
|
| 164 |
if tool_calls and tool_calls[0].get("name") == "build_trip_packages":
|
| 165 |
try:
|
| 166 |
trip = _args_to_trip(
|
| 167 |
BuildTripPackagesArgs.model_validate(tool_calls[0].get("arguments", {}))
|
| 168 |
)
|
| 169 |
-
except Exception
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
# Safety net: if Nemotron clarified/declined/gave bad args, try deterministic.
|
| 173 |
if trip is None:
|
| 174 |
parsed = parse_intent(user_text)
|
| 175 |
if parsed.trip_request is not None:
|
| 176 |
trip = parsed.trip_request
|
| 177 |
|
| 178 |
if trip is None:
|
| 179 |
-
# Genuinely incomplete: surface a clarifying question (Nemotron's, if any).
|
| 180 |
clarify_q = ""
|
| 181 |
if tool_calls and tool_calls[0].get("name") == "clarify":
|
| 182 |
clarify_q = tool_calls[0].get("arguments", {}).get("question", "")
|
| 183 |
if not clarify_q:
|
| 184 |
clarify_q = parse_intent(user_text).question
|
| 185 |
-
|
| 186 |
clarify_q or "Tell me where you're flying from and which match you want to see."
|
| 187 |
)
|
|
|
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
return _greenlight_html(trip.summary()) + f'<div style="color:#991b1b;padding:12px">⚠️ {exc}</div>'
|
| 193 |
-
|
| 194 |
-
explanation = await _agent_explain(agent, user_text, trip, result)
|
| 195 |
-
return (
|
| 196 |
-
_greenlight_html(trip.summary())
|
| 197 |
-
+ render_full(result)
|
| 198 |
-
+ (_explain_html(explanation) if explanation else "")
|
| 199 |
)
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
async def _deterministic(user_text: str) -> str:
|
| 203 |
-
parsed = parse_intent(user_text)
|
| 204 |
-
if parsed.trip_request is None:
|
| 205 |
-
return _clarify_html(parsed.question)
|
| 206 |
-
greenlight = _greenlight_html(parsed.trip_request.summary())
|
| 207 |
try:
|
| 208 |
-
result = await build_trip_packages(
|
| 209 |
except Exception as exc:
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
async def _plan(user_text: str) -> str:
|
| 215 |
-
if USE_AGENT:
|
| 216 |
-
html = await _agentic(user_text)
|
| 217 |
-
if html is not None:
|
| 218 |
-
return html
|
| 219 |
-
return await _deterministic(user_text)
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
-
|
| 223 |
-
"""Gradio handler (sync wrapper around the async agentic pipeline)."""
|
| 224 |
-
return asyncio.run(_plan(user_text))
|
| 225 |
|
| 226 |
|
| 227 |
with gr.Blocks(title="MatchDay — FIFA World Cup Vancouver") as demo:
|
|
|
|
| 1 |
"""MatchDay — Gradio app for the 2026 FIFA World Cup in Vancouver.
|
| 2 |
|
| 3 |
+
Streaming agentic flow: Nemotron (Brain, on Modal) decides which tool to call
|
| 4 |
and extracts the trip intent -> Python (Hands) executes build_trip_packages and
|
| 5 |
+
scores every price deterministically -> Nemotron writes the comparison. The
|
| 6 |
+
handler is an async generator that yields staged status between the real phases
|
| 7 |
+
(U4 progressive rendering + G1 staged narration), so the UI updates live instead
|
| 8 |
+
of freezing. Falls back to the deterministic parser if the agent is unavailable.
|
| 9 |
|
| 10 |
Brain + Hands: Nemotron never calls an API or names a price; Python does. Every
|
| 11 |
figure carries a provenance badge (live vs example).
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
|
|
| 15 |
import json
|
| 16 |
import logging
|
| 17 |
from datetime import date
|
|
|
|
| 23 |
from matchday.intent import parse_intent
|
| 24 |
from matchday.models import TripRequest
|
| 25 |
from matchday.prompts import EXPLANATION_HINT
|
| 26 |
+
from matchday.render import _CSS, render_full
|
| 27 |
from matchday.trip_tool import build_trip_packages, format_for_nemotron
|
| 28 |
|
| 29 |
logger = logging.getLogger(__name__)
|
|
|
|
| 82 |
)
|
| 83 |
|
| 84 |
|
| 85 |
+
def _progress_html(msg: str) -> str:
|
| 86 |
+
"""Staged status card between phases (U4 + G1 — narration tied to real progress)."""
|
| 87 |
+
return _CSS + (
|
| 88 |
+
'<div class="md-wrap"><div class="md-progress">'
|
| 89 |
+
f'<span class="spin">⏳</span> <span>{msg}</span></div></div>'
|
| 90 |
)
|
| 91 |
|
| 92 |
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> str:
|
| 106 |
+
"""Round 2: Nemotron compares the packages. Best-effort ('' on failure)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
args_json = json.dumps(trip.model_dump(mode="json"))
|
| 108 |
convo = [
|
| 109 |
{"role": "user", "content": user_text},
|
|
|
|
| 132 |
return ""
|
| 133 |
|
| 134 |
|
| 135 |
+
async def plan_trip(user_text: str):
|
| 136 |
+
"""Streaming handler: yields staged status during the real agent phases
|
| 137 |
+
(deciding -> searching -> scoring -> final cards+map+timeline). Falls back
|
| 138 |
+
to the deterministic parser if Nemotron is unavailable or hedges.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
"""
|
| 140 |
+
yield _progress_html("Reading your trip request…")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
+
agent = None
|
| 143 |
+
if USE_AGENT:
|
| 144 |
+
try:
|
| 145 |
+
agent = MatchDayAgent()
|
| 146 |
+
except Exception as exc:
|
| 147 |
+
logger.warning("agent init failed (%s); deterministic path.", exc)
|
| 148 |
+
|
| 149 |
+
# Round 1 — Nemotron decides which tool to call.
|
| 150 |
+
r1: dict = {"tool_calls": []}
|
| 151 |
+
if agent is not None:
|
| 152 |
+
yield _progress_html("🤖 Nemotron is choosing your best options…")
|
| 153 |
+
try:
|
| 154 |
+
r1 = await agent.run([{"role": "user", "content": user_text}])
|
| 155 |
+
except Exception as exc:
|
| 156 |
+
logger.warning("agent round 1 failed (%s).", exc)
|
| 157 |
+
r1 = {"tool_calls": []}
|
| 158 |
|
| 159 |
+
# Resolve a validated trip: Nemotron's args first, then deterministic parse.
|
| 160 |
tool_calls = r1.get("tool_calls") or []
|
| 161 |
trip: TripRequest | None = None
|
|
|
|
| 162 |
if tool_calls and tool_calls[0].get("name") == "build_trip_packages":
|
| 163 |
try:
|
| 164 |
trip = _args_to_trip(
|
| 165 |
BuildTripPackagesArgs.model_validate(tool_calls[0].get("arguments", {}))
|
| 166 |
)
|
| 167 |
+
except Exception:
|
| 168 |
+
trip = None
|
|
|
|
|
|
|
| 169 |
if trip is None:
|
| 170 |
parsed = parse_intent(user_text)
|
| 171 |
if parsed.trip_request is not None:
|
| 172 |
trip = parsed.trip_request
|
| 173 |
|
| 174 |
if trip is None:
|
|
|
|
| 175 |
clarify_q = ""
|
| 176 |
if tool_calls and tool_calls[0].get("name") == "clarify":
|
| 177 |
clarify_q = tool_calls[0].get("arguments", {}).get("question", "")
|
| 178 |
if not clarify_q:
|
| 179 |
clarify_q = parse_intent(user_text).question
|
| 180 |
+
yield _clarify_html(
|
| 181 |
clarify_q or "Tell me where you're flying from and which match you want to see."
|
| 182 |
)
|
| 183 |
+
return
|
| 184 |
|
| 185 |
+
green = _greenlight_html(trip.summary())
|
| 186 |
+
yield green + _progress_html(
|
| 187 |
+
"✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
try:
|
| 190 |
+
result = await build_trip_packages(trip)
|
| 191 |
except Exception as exc:
|
| 192 |
+
yield green + f'<div style="color:#991b1b;padding:12px">⚠️ {exc}</div>'
|
| 193 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
+
yield green + _progress_html("🗺️ Scoring 3 packages · Nemotron is writing your comparison…")
|
| 196 |
+
explanation = ""
|
| 197 |
+
if agent is not None:
|
| 198 |
+
explanation = await _agent_explain(agent, user_text, trip, result)
|
| 199 |
|
| 200 |
+
yield green + render_full(result, trip) + (_explain_html(explanation) if explanation else "")
|
|
|
|
|
|
|
| 201 |
|
| 202 |
|
| 203 |
with gr.Blocks(title="MatchDay — FIFA World Cup Vancouver") as demo:
|
matchday/errors.py
CHANGED
|
@@ -442,6 +442,68 @@ def _classify_by_exception(
|
|
| 442 |
import json # noqa: E402 (imported here to keep it close to usage)
|
| 443 |
|
| 444 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
# ---------------------------------------------------------------------------
|
| 446 |
# jittered_backoff — exponential backoff with random jitter
|
| 447 |
# ---------------------------------------------------------------------------
|
|
|
|
| 442 |
import json # noqa: E402 (imported here to keep it close to usage)
|
| 443 |
|
| 444 |
|
| 445 |
+
# ---------------------------------------------------------------------------
|
| 446 |
+
# N7 — LLM-friendly Pydantic validation-error formatter
|
| 447 |
+
# ---------------------------------------------------------------------------
|
| 448 |
+
# Source pattern: Claude Code/utils/toolErrors.ts (categorize into missing /
|
| 449 |
+
# unexpected / type-mismatch, each with the field name). Adapted for Pydantic v2
|
| 450 |
+
# ValidationError so Nemotron's ONE correction attempt (A4) gets a concise,
|
| 451 |
+
# model-readable reason instead of a raw Pydantic dump — raising the chance the
|
| 452 |
+
# retry succeeds instead of falling back to the deterministic parser.
|
| 453 |
+
|
| 454 |
+
# Per-field hints keyed by the tool-argument field name (shared across tools).
|
| 455 |
+
_FIELD_HINTS: dict[str, str] = {
|
| 456 |
+
"match_date": "Expected format: YYYY-MM-DD.",
|
| 457 |
+
"check_in": "Expected format: YYYY-MM-DD.",
|
| 458 |
+
"check_out": "Expected format: YYYY-MM-DD.",
|
| 459 |
+
"origin_airport": "Must be a 3-letter IATA airport code (e.g. YUL, YYZ, YVR).",
|
| 460 |
+
"travelers": "Must be a whole number from 1 to 6.",
|
| 461 |
+
"budget_tier": "Must be one of: budget, mid_range, premium.",
|
| 462 |
+
"query": "Must be a non-empty search string.",
|
| 463 |
+
"max_results": "Must be a whole number from 1 to 10.",
|
| 464 |
+
"question": "Must be a non-empty clarifying question.",
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def format_validation_error(tool_name: str, exc: BaseException, max_lines: int = 4) -> str:
|
| 469 |
+
"""Turn a tool-argument validation failure into a concise, model-readable
|
| 470 |
+
message (N7). Categorizes each error as missing-required / wrong-type /
|
| 471 |
+
invalid-value with the field name + a hint, head-truncated to ``max_lines``.
|
| 472 |
+
|
| 473 |
+
Falls back to a short ``str(exc)`` when ``exc`` is not a Pydantic
|
| 474 |
+
``ValidationError`` (e.g. a plain ``ValueError`` from a custom validator).
|
| 475 |
+
"""
|
| 476 |
+
lines: list[str] = []
|
| 477 |
+
errors_fn = getattr(exc, "errors", None)
|
| 478 |
+
if callable(errors_fn):
|
| 479 |
+
try:
|
| 480 |
+
for err in errors_fn()[:max_lines]:
|
| 481 |
+
loc = err.get("loc") or ()
|
| 482 |
+
field = str(loc[-1]) if loc else "argument"
|
| 483 |
+
etype = (err.get("type") or "").lower()
|
| 484 |
+
msg = (err.get("msg") or "invalid value").rstrip(".")
|
| 485 |
+
inp = err.get("input")
|
| 486 |
+
hint = _FIELD_HINTS.get(field, "")
|
| 487 |
+
if etype == "missing" or "required" in msg.lower():
|
| 488 |
+
line = f"Required parameter '{field}' is missing."
|
| 489 |
+
elif "literal" in etype or "enum" in etype or "pattern" in etype:
|
| 490 |
+
line = f"Parameter '{field}' has an invalid value {inp!r}."
|
| 491 |
+
elif etype and ("int" in etype or "float" in etype or etype.endswith("_type")):
|
| 492 |
+
line = f"Parameter '{field}' has the wrong type (got {inp!r})."
|
| 493 |
+
else:
|
| 494 |
+
line = f"Parameter '{field}': {msg} (got {inp!r})."
|
| 495 |
+
lines.append(f"{line} {hint}".strip())
|
| 496 |
+
except Exception: # never let the formatter itself throw
|
| 497 |
+
lines = []
|
| 498 |
+
if not lines:
|
| 499 |
+
lines = [str(exc)[:200]]
|
| 500 |
+
body = " ".join(lines[:max_lines])
|
| 501 |
+
return (
|
| 502 |
+
f"The tool call to {tool_name!r} was rejected because: {body} "
|
| 503 |
+
f"Fix these arguments and call {tool_name!r} again."
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
|
| 507 |
# ---------------------------------------------------------------------------
|
| 508 |
# jittered_backoff — exponential backoff with random jitter
|
| 509 |
# ---------------------------------------------------------------------------
|
matchday/render.py
CHANGED
|
@@ -51,7 +51,25 @@ _CSS = """
|
|
| 51 |
.md-live{background:#dcfce7;color:#166534;}
|
| 52 |
.md-example{background:#fef3c7;color:#92400e;}
|
| 53 |
.md-sub{color:#64748b;font-size:11px;}
|
| 54 |
-
#matchday-map{height:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
</style>
|
| 56 |
"""
|
| 57 |
|
|
@@ -167,13 +185,21 @@ def _js_markers(result: TripPackageResult) -> str:
|
|
| 167 |
return "\n".join(lines)
|
| 168 |
|
| 169 |
|
| 170 |
-
def render_map(result: TripPackageResult) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
return f"""
|
| 172 |
{_CSS}
|
| 173 |
<div class="md-wrap">
|
| 174 |
-
<
|
|
|
|
|
|
|
|
|
|
| 175 |
<div id="matchday-map"></div>
|
| 176 |
-
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
| 177 |
<script>
|
| 178 |
(function(){{
|
| 179 |
var el=document.getElementById('matchday-map');
|
|
@@ -181,6 +207,7 @@ def render_map(result: TripPackageResult) -> str:
|
|
| 181 |
if(el._lmap){{el._lmap.remove();}}
|
| 182 |
var map=L.map('matchday-map').setView([{BC_PLACE_LAT},{BC_PLACE_LON}],15);
|
| 183 |
el._lmap=map;
|
|
|
|
| 184 |
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{maxZoom:19,attribution:'© OpenStreetMap'}}).addTo(map);
|
| 185 |
var stadiumIcon=L.divIcon({{html:'🏟️',iconSize:[26,26],iconAnchor:[13,13]}});
|
| 186 |
var hotelIcon=L.divIcon({{html:'🏨',iconSize:[22,22],iconAnchor:[11,11]}});
|
|
@@ -192,5 +219,62 @@ def render_map(result: TripPackageResult) -> str:
|
|
| 192 |
"""
|
| 193 |
|
| 194 |
|
| 195 |
-
def
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
.md-live{background:#dcfce7;color:#166534;}
|
| 52 |
.md-example{background:#fef3c7;color:#92400e;}
|
| 53 |
.md-sub{color:#64748b;font-size:11px;}
|
| 54 |
+
#matchday-map{height:460px;border-radius:12px;border:1px solid #e2e8f0;margin-top:10px;}
|
| 55 |
+
.md-map-bar{display:flex;align-items:center;justify-content:space-between;margin-top:6px;}
|
| 56 |
+
.md-fs-btn{font-size:12px;font-weight:600;color:#1e3a8a;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:5px 12px;cursor:pointer;}
|
| 57 |
+
.md-fs-overlay{position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:9999;display:none;}
|
| 58 |
+
.md-fs-overlay.on{display:block;}
|
| 59 |
+
.md-fs-map{position:absolute;inset:18px;border-radius:12px;overflow:hidden;}
|
| 60 |
+
.md-fs-close{position:absolute;top:24px;right:28px;z-index:10000;background:#fff;color:#0f172a;font-weight:700;border:none;border-radius:8px;padding:8px 14px;cursor:pointer;font-size:14px;}
|
| 61 |
+
.md-progress{font-family:-apple-system,Roboto,sans-serif;padding:18px;background:linear-gradient(135deg,#0f172a,#1e3a8a);color:#fff;border-radius:14px;font-size:16px;font-weight:600;margin:10px 0;min-height:60px;display:flex;align-items:center;gap:12px;}
|
| 62 |
+
.md-progress .spin{display:inline-block;animation:mdspin 1s linear infinite;}
|
| 63 |
+
@keyframes mdspin{to{transform:rotate(360deg);}}
|
| 64 |
+
.md-timeline{margin:14px 0;padding:0 4px;}
|
| 65 |
+
.md-tl-row{display:flex;gap:14px;padding:10px 0;border-left:3px solid #e2e8f0;margin-left:14px;padding-left:18px;position:relative;}
|
| 66 |
+
.md-tl-row.match{border-left-color:#16a34a;}
|
| 67 |
+
.md-tl-dot{position:absolute;left:-15px;top:10px;width:28px;height:28px;border-radius:50%;background:#fff;border:2px solid #1e3a8a;display:flex;align-items:center;justify-content:center;font-size:15px;}
|
| 68 |
+
.md-tl-row.match .md-tl-dot{border-color:#16a34a;background:#dcfce7;}
|
| 69 |
+
.md-tl-title{font-weight:700;color:#0f172a;font-size:14px;}
|
| 70 |
+
.md-tl-row.match .md-tl-title{color:#166534;}
|
| 71 |
+
.md-tl-text{color:#475569;font-size:13px;margin-top:2px;line-height:1.4;}
|
| 72 |
+
.md-tl-h{font-size:14px;font-weight:700;color:#1e3a8a;margin:14px 0 4px;}
|
| 73 |
</style>
|
| 74 |
"""
|
| 75 |
|
|
|
|
| 185 |
return "\n".join(lines)
|
| 186 |
|
| 187 |
|
| 188 |
+
def render_map(result: TripPackageResult, leaflet_preloaded: bool = False) -> str:
|
| 189 |
+
# When the frontend preloaded Leaflet in <head> (gradio.Server path), skip the
|
| 190 |
+
# CDN tags so re-running the inline init script after injection is dependency-free.
|
| 191 |
+
_cdn = "" if leaflet_preloaded else (
|
| 192 |
+
' <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>\n'
|
| 193 |
+
' <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>\n'
|
| 194 |
+
)
|
| 195 |
return f"""
|
| 196 |
{_CSS}
|
| 197 |
<div class="md-wrap">
|
| 198 |
+
{_cdn} <div class="md-map-bar">
|
| 199 |
+
<span class="md-sub">🏟️ BC Place · 🏨 hotels · 📍 nearby spots — drag & zoom to explore</span>
|
| 200 |
+
<button class="md-fs-btn" type="button" onclick="window.matchdayFullscreen&&matchdayFullscreen()">⛶ Full screen</button>
|
| 201 |
+
</div>
|
| 202 |
<div id="matchday-map"></div>
|
|
|
|
| 203 |
<script>
|
| 204 |
(function(){{
|
| 205 |
var el=document.getElementById('matchday-map');
|
|
|
|
| 207 |
if(el._lmap){{el._lmap.remove();}}
|
| 208 |
var map=L.map('matchday-map').setView([{BC_PLACE_LAT},{BC_PLACE_LON}],15);
|
| 209 |
el._lmap=map;
|
| 210 |
+
window._matchdayMap=map;
|
| 211 |
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{maxZoom:19,attribution:'© OpenStreetMap'}}).addTo(map);
|
| 212 |
var stadiumIcon=L.divIcon({{html:'🏟️',iconSize:[26,26],iconAnchor:[13,13]}});
|
| 213 |
var hotelIcon=L.divIcon({{html:'🏨',iconSize:[22,22],iconAnchor:[11,11]}});
|
|
|
|
| 219 |
"""
|
| 220 |
|
| 221 |
|
| 222 |
+
def render_timeline(trip, result: TripPackageResult) -> str:
|
| 223 |
+
"""Day-by-day vertical itinerary (A2): arrival → match day → explore.
|
| 224 |
+
|
| 225 |
+
Uses the top package's flight/hotel to anchor Day 1; the match day is the
|
| 226 |
+
highlighted centerpiece.
|
| 227 |
+
"""
|
| 228 |
+
from datetime import timedelta
|
| 229 |
+
|
| 230 |
+
top = result.packages[0] if result.packages else None
|
| 231 |
+
items: list[str] = []
|
| 232 |
+
d = trip.check_in
|
| 233 |
+
idx = 0
|
| 234 |
+
while d <= trip.check_out:
|
| 235 |
+
idx += 1
|
| 236 |
+
if d == trip.check_in and top:
|
| 237 |
+
hotel = top.hotel.name if top.hotel else "your hotel"
|
| 238 |
+
icon, title, body, cls = (
|
| 239 |
+
"✈️", f"Day {idx} · {d:%a %b %d}",
|
| 240 |
+
f"Fly <b>{_e(top.flight.airline)} {_e(top.flight.flight_number)}</b> "
|
| 241 |
+
f"({_e(top.flight.origin)}→YVR, lands {top.flight.arrival_time:%H:%M}); "
|
| 242 |
+
f"check in to {_e(hotel)}.",
|
| 243 |
+
"",
|
| 244 |
+
)
|
| 245 |
+
elif d == trip.match_date:
|
| 246 |
+
icon, title, body, cls = (
|
| 247 |
+
"🏟️", f"Day {idx} · {d:%a %b %d} — MATCH DAY",
|
| 248 |
+
f"<b>{_e(trip.match_name)}</b> at BC Place Stadium, 7:00 PM PT kickoff. "
|
| 249 |
+
"Soak up the fan zone, then head in early.",
|
| 250 |
+
"match",
|
| 251 |
+
)
|
| 252 |
+
elif d < trip.match_date:
|
| 253 |
+
icon, title, body, cls = (
|
| 254 |
+
"🏙️", f"Day {idx} · {d:%a %b %d}",
|
| 255 |
+
"Arrive & settle — Stanley Park, Granville Island, the seawall.",
|
| 256 |
+
"",
|
| 257 |
+
)
|
| 258 |
+
else:
|
| 259 |
+
icon, title, body, cls = (
|
| 260 |
+
"🗺️", f"Day {idx} · {d:%a %b %d}",
|
| 261 |
+
"Day after the match: brunch, last sights, fly home.",
|
| 262 |
+
"",
|
| 263 |
+
)
|
| 264 |
+
items.append(
|
| 265 |
+
f'<div class="md-tl-row {cls}"><div class="md-tl-dot">{icon}</div>'
|
| 266 |
+
f'<div><div class="md-tl-title">{title}</div>'
|
| 267 |
+
f'<div class="md-tl-text">{body}</div></div></div>'
|
| 268 |
+
)
|
| 269 |
+
d += timedelta(days=1)
|
| 270 |
+
return (
|
| 271 |
+
f'{_CSS}<div class="md-wrap"><div class="md-tl-h">📅 Day-by-day itinerary</div>'
|
| 272 |
+
f'<div class="md-timeline">{"".join(items)}</div></div>'
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def render_full(result: TripPackageResult, trip=None, leaflet_preloaded: bool = False) -> str:
|
| 277 |
+
out = render_status_bar(result) + render_cards(result) + render_map(result, leaflet_preloaded=leaflet_preloaded)
|
| 278 |
+
if trip is not None:
|
| 279 |
+
out += render_timeline(trip, result)
|
| 280 |
+
return out
|
matchday/traces/session_001.jsonl
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
{"ts": "2026-06-
|
| 2 |
-
{"ts": "2026-06-
|
| 3 |
-
{"ts": "2026-06-
|
| 4 |
-
{"ts": "2026-06-
|
|
|
|
| 1 |
+
{"ts": "2026-06-14T06:00:58.930615+00:00", "event": "user", "content": "Flying from Montreal, want Canada vs Qatar, mid-range, June 26-29, just me"}
|
| 2 |
+
{"ts": "2026-06-14T06:04:55.078756+00:00", "event": "nemotron_decision", "tool_calls": [{"name": "build_trip_packages", "arguments": {"origin_airport": "YUL", "check_out": "2026-06-29", "check_in": "2026-06-26", "match_name": "Canada vs Qatar", "match_date": "2026-06-26", "budget_tier": "mid_range", "travelers": 1}, "id": "call_20bef97cf4a04a64a0865288"}], "text": "\n"}
|
| 3 |
+
{"ts": "2026-06-14T06:05:07.290104+00:00", "event": "tool_result", "tool": "build_trip_packages", "trip": "YUL -> Vancouver, 2026-06-26 to 2026-06-29, mid_range budget, 1 traveler, watching Canada vs Qatar on 2026-06-26.", "status": "complete", "degradation": ["Photos unavailable"], "packages": [{"label": "Closest to Stadium", "total_cost_cad": 1122.0, "flight": "Flair Airlines F8 107 (lands 11:00)", "hotel": "Moda Hotel", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}, {"label": "Cheapest", "total_cost_cad": 901.0, "flight": "Flair Airlines F8 107 (lands 11:00)", "hotel": "Hotel At The Waldorf", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}, {"label": "Safest Arrival", "total_cost_cad": 1202.0, "flight": "Air Canada AC 303 (lands 10:45)", "hotel": "Moda Hotel", "provenance": {"flight": "serpapi", "hotel": "serpapi"}}], "compact": "STATUS: complete | 1 degradation(s) | 3 package(s) scored\n DEGRADATION: Photos unavailable\n#1 Closest to Stadium | $1,122.00 CAD | Flight Flair Airlines F8 107 lands 11:00 from YUL (source=serpapi) | Hotel Moda Hotel 0.354km to BC Place 3.8\u2605 (source=serpapi) | +8.0h buffer | 4 min walk | rain risk 39%\n#2 Cheapest | $901.00 CAD | Flight Flair Airlines F8 107 lands 11:00 from YUL (source=serpapi) | Hotel Hotel At The Waldorf 3.367km to BC Place 3.6\u2605 (source=serpapi) | +8.0h buffer | 40 min walk | rain risk 39%\n#3 Safest Arrival | $1,202.00 CAD | Flight Air Canada AC 303 lands 10:45 from YUL (source=serpapi) | Hotel Moda Hotel 0.354km to BC Place 3.8\u2605 (source=serpapi) | +8.2h buffer | 4 min walk | rain risk 39%"}
|
| 4 |
+
{"ts": "2026-06-14T06:05:10.956201+00:00", "event": "nemotron_explanation", "text": "#1 Closest to Stadium | $1,122.00 CAD | Airline: Flair Airlines | Arrival: 11:00 | Hotel: Moda Hotel | Distance: 0.354km | Weather: rain risk 39% (example: \"rain risk 39%\") \n#2 Cheapest | $901.00 CAD | Airline: Flair Airlines | Arrival: 11:00 | Hotel: Hotel At The Waldorf | Distance: 3.367km | Weather: rain risk 39% (example: \"rain risk 39%\") \n#3 Safest Arrival | $1,202.00 CAD | Airline: Air Canada | Arrival: 10:45 | Hotel: Moda Hotel | Distance: 0.354km | Weather: rain risk 39% (example: \"rain risk 39%\") \n**Recommendation:** Choose #2 Cheapest for lowest cost with reliable arrival timing."}
|