"""MatchDay — HF Space entry point (gradio.Server mode, N1 / Off-Brand). The Space runs THIS file. It is a `gradio.Server` app: a fully custom ``index.html`` frontend is served at ``/`` while ``@app.api("plan_trip")`` is an async generator that streams N12-typed JSON events through Gradio's queue (SSE), so the frontend updates live as Nemotron decides → Python scores → Nemotron explains. This is the Off-Brand path: a bespoke UI powered by Gradio's backend (queuing, concurrency, Spaces hosting) — not stock Gradio components. Brain + Hands: Nemotron (on Modal) never calls an API or names a price; Python executes every call and scores every value. Every figure carries provenance. The gr.Blocks streaming version is retained at ``matchday/app.py`` as a fallback. """ from __future__ import annotations import json import logging import os import sys from datetime import date from pathlib import Path # Repo-root importability when the Space runs this file directly. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from fastapi.responses import HTMLResponse # noqa: E402 from gradio import Server # noqa: E402 from matchday.agent import MatchDayAgent # noqa: E402 from matchday.agent_loop import BuildTripPackagesArgs # noqa: E402 from matchday.intent import parse_intent # noqa: E402 from matchday.models import TripRequest # noqa: E402 from matchday.prompts import EXPLANATION_HINT # noqa: E402 from matchday.render import render_full # noqa: E402 from matchday.trip_tool import build_trip_packages, format_for_nemotron # noqa: E402 logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) # Nemotron primary, deterministic fallback. Flip to False to force the # deterministic path (fast demo / Modal-down insurance). USE_AGENT = True HERE = Path(__file__).parent INDEX_HTML = HERE / "index.html" app = Server() def _ev(**payload) -> str: """Serialize a typed stream event (N12) as a JSON string for the SSE stream.""" return json.dumps(payload, ensure_ascii=False) def _args_to_trip(a: BuildTripPackagesArgs) -> TripRequest: return TripRequest( origin_airport=a.origin_airport, match_name=a.match_name or "the match", match_date=date.fromisoformat(a.match_date), check_in=date.fromisoformat(a.check_in), check_out=date.fromisoformat(a.check_out), travelers=a.travelers, budget_tier=a.budget_tier, ) async def _agent_explain(agent, user_text: str, trip: TripRequest, result) -> str: """Round 2 — Nemotron compares the packages. Best-effort ('' on failure).""" args_json = json.dumps(trip.model_dump(mode="json")) convo = [ {"role": "user", "content": user_text}, { "role": "assistant", "content": "", "tool_calls": [{ "id": "call_build", "type": "function", "function": {"name": "build_trip_packages", "arguments": args_json}, }], }, { "role": "tool", "tool_call_id": "call_build", "name": "build_trip_packages", "content": format_for_nemotron(result), }, {"role": "user", "content": EXPLANATION_HINT}, ] try: r2 = await agent.run(convo, tools=[]) # no tools → Nemotron must write text return (r2.get("text") or "").strip() except Exception as exc: logger.warning("explanation round failed: %s", exc) return "" @app.api(name="plan_trip", concurrency_limit=4, stream_every=0.5) async def plan_trip(user_text: str) -> str: """Stream the agentic trip build as typed events (N12 + N10). Yields: commentary (progress beats, sent immediately) → greenlight (parsed trip) | clarify | error → result (full cards+map+timeline render + Nemotron's explanation). Falls back to the deterministic parser if the agent is unavailable or hedges. """ yield _ev(type="commentary", text="Reading your trip request…") agent = None if USE_AGENT: try: agent = MatchDayAgent() except Exception as exc: logger.warning("agent init failed (%s); deterministic path.", exc) # Round 1 — Nemotron decides which tool to call. r1: dict = {"tool_calls": []} if agent is not None: yield _ev(type="commentary", text="🤖 Nemotron is choosing your best options…") try: r1 = await agent.run([{"role": "user", "content": user_text}]) except Exception as exc: logger.warning("agent round 1 failed (%s).", exc) r1 = {"tool_calls": []} # Resolve a validated trip: Nemotron's args first, then deterministic parse. tool_calls = r1.get("tool_calls") or [] trip: TripRequest | None = None if tool_calls and tool_calls[0].get("name") == "build_trip_packages": try: trip = _args_to_trip( BuildTripPackagesArgs.model_validate(tool_calls[0].get("arguments", {})) ) except Exception: trip = None if trip is None: parsed = parse_intent(user_text) if parsed.trip_request is not None: trip = parsed.trip_request if trip is None: clarify_q = "" if tool_calls and tool_calls[0].get("name") == "clarify": clarify_q = tool_calls[0].get("arguments", {}).get("question", "") if not clarify_q: clarify_q = parse_intent(user_text).question yield _ev( type="clarify", text=clarify_q or "Tell me where you're flying from and which match you want to see.", ) return yield _ev(type="greenlight", text=trip.summary()) yield _ev( type="commentary", text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…", ) try: result = await build_trip_packages(trip) except Exception as exc: yield _ev(type="error", text=f"⚠️ {exc}") return yield _ev(type="commentary", text="🗺️ Scoring 3 packages — Nemotron is writing your comparison…") explanation = "" if agent is not None: explanation = await _agent_explain(agent, user_text, trip, result) # Final: the full Layla-competitive render (status + cards + map + timeline). # leaflet_preloaded=True → the frontend already loaded Leaflet in ; the # map's inline init script is re-run after injection (see index.html). yield _ev(type="result", html=render_full(result, trip, leaflet_preloaded=True), explanation=explanation) @app.get("/", response_class=HTMLResponse) async def homepage(): with open(INDEX_HTML, "r", encoding="utf-8") as fh: return fh.read() if __name__ == "__main__": app.launch(server_port=int(os.environ.get("PORT", "7860")), show_error=True)