"""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. ``matchday/app.py`` is a compatibility shim that imports and launches this same app, so ``python3 -m matchday.app`` runs the identical non-decorative path. Reference patterns (3-codebase study, see MATCHDAY_UNCONSTRAINED_PLAN.md): - N1 gradio.Server custom-frontend architecture (Off-Brand badge): https://huggingface.co/blog/introducing-gradio-server ("Why @app.api()…"). - N35 preflight gate (fail-fast on missing SerpApi key): Claude Code utils/preflightChecks.tsx:1-60. """ from __future__ import annotations import asyncio import json import logging import os import sys import time 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 run_agent_loop # noqa: E402 from matchday.agent_trace import ( # noqa: E402 AgentTrace, ToolCallRecord, evidence_from_result, ranking_from_result, result_source_labels, validate_packages, ) from matchday.intent import parse_intent, _find_match # noqa: E402 from matchday.models import TripRequest # noqa: E402 from matchday.wc2026 import resolve_match # 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" # Model label shown in the Agent Trace drawer (Best Agent provenance). Honest: # 30B total / ~3B active MoE — the ≤32B-cap qualifier is the 30B total weight. _TRACE_MODEL = "Nemotron-3-Nano-30B-A3B · 3B-active MoE · Modal A100" async def _warm_nemotron() -> None: """Best-effort warm generate on Space startup so the FIRST user query isn't stuck behind a Modal cold start (~2 min warm from the weight cache). Runs as a fire-and-forget background task; never blocks startup, never raises. """ try: agent = MatchDayAgent() await asyncio.wait_for( agent.run([{"role": "user", "content": "warmup ping"}]), timeout=240 ) logger.info("startup warmup ping completed — Nemotron container is hot") except Exception as exc: # noqa: BLE001 — best-effort, must not break boot logger.info("startup warmup ping ended early (%s)", repr(exc)[:80]) async def _startup_warmup() -> None: """Server startup hook — schedule the warmup without blocking boot.""" asyncio.create_task(_warm_nemotron()) app = Server(on_startup=[_startup_warmup]) 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) async def _pulse(coro, holder, message, interval: int = 9): """Run ``coro`` to completion, yielding a commentary heartbeat every ``interval`` seconds (carrying elapsed seconds) so the SSE stream is never silent during a long Modal cold-start or SerpApi phase. Stashes the result in ``holder['result']``; re-raises if ``coro`` raised. Usage:: h = {} async for beat in _pulse(coro, h, msg): yield beat value = h["result"] """ task = asyncio.ensure_future(coro) start = time.monotonic() while True: done, _ = await asyncio.wait({task}, timeout=interval) if task in done: holder["result"] = task.result() return yield _ev(type="commentary", text=f"{message} ({int(time.monotonic() - start)}s)") def _notice_status(result, *keywords: str) -> str: """Map a data category to ``done`` | ``fallback`` from REAL degradation notices. Honest per-category progress: if ``build_trip_packages`` reported a category as unavailable, that step is ``fallback``; otherwise ``done``. Tied to the real dispatch outcome — never a cosmetic timer. """ blob = " ".join(result.degradation_notices or "").lower() return "fallback" if any(k in blob for k in keywords) else "done" def _precheck_unrecognized_match(user_text: str): """Generic pre-agent fixture validator (grounding honesty, option 1). Deterministically parse the request and ground the named match against the verified 2026 fixture table BEFORE the agent picks a tool. If the user named a matchup that isn't a real 2026 fixture, return ``(refusal_note, trip)`` so ``plan_trip`` can refuse honestly with the closest real alternatives and stop — without ever invoking the agent loop. Why this exists: Nemotron routes its own tools and, for some non-fixture matchups (e.g. "Canada vs Morocco"), can non-deterministically choose ``clarify`` over ``build_trip_packages``. When it does, the grounding-refusal path (the "isn't a 2026 fixture … Canada plays: …" note produced inside the build tool) never runs, so the demo promises a refusal it never delivers. Grounding the match deterministically up front guarantees every non-fixture match is refused honestly, regardless of how the model routes. Returns ``(note, trip_request)`` when a match is named AND unrecognized; ``None`` otherwise (no match named, parse failed, or the match IS real — proceed to the normal agent path). Never raises. """ try: parsed = parse_intent(user_text) except Exception: # noqa: BLE001 — must never break the turn return None trip = getattr(parsed, "trip_request", None) match_name = (getattr(trip, "match_name", "") or "") if trip is not None else "" if not match_name or match_name == "the match": # _find_match's fallback sentinel return None match_name = _clean_match_name(match_name) # drop trailing month ("Morocco June" -> "Morocco") try: res = resolve_match(match_name) except Exception: # noqa: BLE001 return None if res.recognized or not res.note: return None try: # carry the CLEANED name onto the trip so the trace drawer matches the note trip = trip.model_copy(update={"match_name": match_name}) except Exception: # noqa: BLE001 pass return res.note, trip _MONTH_TOKENS = { "january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december", } def _clean_match_name(name: str) -> str: """Strip a trailing month token from each team in an 'A vs B' match name. ``parse_intent``'s ``_find_match`` greedily appends the next capitalized word to a team name, so 'Canada vs Morocco June 18' parses to 'Canada vs Morocco June' — the month leaks into the team and would surface in the refusal note as "Morocco June plays: Brazil". Trimming trailing month tokens restores the real team names for a clean note. Conservative: only strips trailing month tokens, leaves everything else intact (multi-word teams unaffected). """ if " vs " not in name: return name def _strip(trial: str) -> str: parts = trial.split() while parts and parts[-1].lower().rstrip(".") in _MONTH_TOKENS: parts.pop() return " ".join(parts) a, b = name.split(" vs ", 1) return f"{_strip(a)} vs {_strip(b)}" _DEFAULT_GREETING = ( "I'd love to plan your FIFA 2026 World Cup trip! Tell me where you're " "flying from (e.g. 'Montreal' or 'YUL'), which match you'd like to see, " "and the dates." ) def _precheck_chitchat(user_text: str): """Deterministic reply for pure chit-chat / empty prompts, BEFORE the agent. A greeting or content-free message ("hi", "hello", "thanks", "test") has no origin, date, or match to plan around. Replying with ``parse_intent``'s clarifying question deterministically turns what would be a multi-second to multi-minute Modal cold-start wait (for a Nemotron call that would only clarify anyway) into an instant answer. Same pre-agent seam as the fixture validator — no agent_loop / Modal change. Conservative: fires ONLY when BOTH origin and date are absent AND no 'X vs Y' match is named, so any real (even partial) trip request still reaches the fixture validator / agent. Returns ``(reply, missing_slots)`` for chit-chat, or ``None`` to proceed normally. Never raises. """ try: parsed = parse_intent(user_text) except Exception: # noqa: BLE001 — must never break the turn return None # `missing` only ever holds origin and/or date; len >= 2 => both absent. if len(parsed.missing) >= 2 and not _find_match(user_text): return parsed.question or _DEFAULT_GREETING, list(parsed.missing) return None def _finalize_trace(trace: AgentTrace, trip, result, built_by: str) -> None: """Populate the final intent/grounding/evidence/ranking/outcome on the trace. Best-effort: the trace is cosmetic proof, so it must never raise and abort a trip build. Surfaces the deterministic ranking formula (tier weights + the per-package normalized dim scores) so a judge can see HOW the order was decided, not just that it was. """ try: trace.set_intent(trip) if getattr(result, "match_unrecognized", ""): # Honest refusal: the named match isn't a real 2026 fixture. trace.set_grounding(recognized=False, note=result.match_unrecognized) trace.set_outcome(mode=built_by, status="clarify", notes=list(result.degradation_notices), model=_TRACE_MODEL, rounds=trace.rounds) return corrected = bool(getattr(result, "grounding_note", "")) trace.set_grounding( recognized=True, corrected=corrected, kickoff=getattr(result, "kickoff_local", "") or "", venue="BC Place", match_name=(getattr(result, "grounded_match_name", "") or (trip.match_name if trip is not None else "")), note=getattr(result, "grounding_note", "") or "", ) trace.set_evidence(evidence_from_result(result)) from matchday.scoring import BUDGET_WEIGHTS tier = trip.budget_tier if trip is not None else "mid_range" w = BUDGET_WEIGHTS.get(tier, BUDGET_WEIGHTS["mid_range"]) ranking, _records = ranking_from_result( result, tier, {"cost": w.cost, "buffer": w.buffer, "transit": w.transit}, ) trace.ranking = ranking trace.set_outcome(mode=built_by, status=result.status, notes=list(result.degradation_notices), model=_TRACE_MODEL, rounds=trace.rounds) except Exception as exc: # noqa: BLE001 logger.warning("trace finalization skipped: %s", exc) # Cached per-boot preflight (N35). Fail-fast ONLY on genuinely-doomed config # (missing SerpApi key — build_trip_packages cannot fetch live flights/hotels). # Modal cold-start is NOT a hard failure: it streams via _pulse heartbeats and # the loop degrades to the deterministic parser, so we don't gate on it. _PREFLIGHT_OK: bool | None = None def _preflight() -> tuple[bool, str]: """Return (ok, reason). ``reason`` is empty when ok. Cached positive.""" global _PREFLIGHT_OK if _PREFLIGHT_OK: return True, "" if not os.environ.get("SERPAPI_API_KEY"): return False, ( "SerpApi key is not set on this Space — live flight & hotel search is " "unavailable. Add SERPAPI_API_KEY in Settings → Secrets, then restart." ) _PREFLIGHT_OK = True return True, "" 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. """ ok, why = _preflight() # N35 — fail-fast on doomed config (missing key) if not ok: yield _ev(type="error", text=f"⚠️ {why}") return # The visible Agent Trace accumulator (Best Agent proof). Populated live as # intent is extracted, the match is grounded, tools run, and packages are # ranked — emitted to the Evidence drawer in REAL TIME via `trace` events. trace = AgentTrace() built_by = "agent" # flips to "deterministic" if the loop doesn't build yield _ev(type="commentary", text="Reading your trip request…") yield _ev(type="progress", step="read", status="done", text="Read your request") yield _ev(type="progress", step="extract", status="running", text="Understanding your trip") # ── Pre-agent chit-chat guard: a greeting / empty prompt ("hi", "thanks") # has no origin, date, or match. Reply deterministically and instantly # instead of waking Nemotron for a call that would only clarify — turns a # Modal cold-start wait into an immediate answer. Conservative: only fires # when nothing trip-related was said, so real (even partial) requests still # reach the fixture validator / agent. _chat = _precheck_chitchat(user_text) if _chat is not None: _chat_text, _chat_missing = _chat trace.set_intent(None, missing=_chat_missing) trace.set_outcome( mode="deterministic", status="clarify", notes=["Pre-agent chit-chat check: no trip details (origin / date / match) yet."], model=_TRACE_MODEL, rounds=0, ) yield _ev(type="trace", data=trace.to_dict()) yield _ev(type="progress", step="extract", status="done", text="Heard you") yield _ev(type="progress", step="ready", status="fallback", text="Tell me your trip") yield _ev(type="clarify", text=_chat_text) return # ── Generic pre-agent fixture validator (grounding honesty). Ground the named # match deterministically BEFORE the agent picks a tool: a non-real 2026 # fixture is refused with the closest real alternatives and we stop, so the # refusal never depends on Nemotron choosing build_trip_packages over clarify. _pre = _precheck_unrecognized_match(user_text) if _pre is not None: _refusal_note, _pre_trip = _pre trace.set_intent(_pre_trip) trace.set_grounding(recognized=False, note=_refusal_note) trace.set_outcome( mode="deterministic", status="clarify", notes=["Pre-agent fixture check: named match is not a real 2026 fixture."], model=_TRACE_MODEL, rounds=0, ) yield _ev(type="trace", data=trace.to_dict()) # grounding-refusal proof yield _ev(type="progress", step="extract", status="done", text="Match checked") yield _ev(type="progress", step="ready", status="fallback", text="Match not found") yield _ev(type="clarify", text=_refusal_note) return agent = None if USE_AGENT: try: # Nemotron reasoning toggle (NVIDIA Nemotron Quest + Best Agent): the # official Nemotron-3-Nano usage guide serves complex planning turns # with thinking ON (chain-of-thought before the tool call). Default # OFF to preserve the verified fast tool-routing path; set # MATCHDAY_THINKING=1 on the Space to turn on reasoning for the # agent's decide/ground/explain turns. thinking = os.environ.get("MATCHDAY_THINKING", "").lower() in ("1", "true", "yes") agent = MatchDayAgent(thinking=thinking) except Exception as exc: logger.warning("agent init failed (%s); deterministic path.", exc) # ── Smart path: the bounded agent loop (K1). Nemotron UNDERSTANDS the # request, may GROUND itself with web_search (I6), may CLARIFY to capture # intent (P7), and calls build_trip_packages when ready. The loop validates # args, dedups, and self-corrects one malformed call (A4). No more bypass. messages: list[dict] = [{"role": "user", "content": user_text}] trip: TripRequest | None = None result = None # TripPackageResult produced inside the loop's build call agent_text = "" # a clarify question or direct answer from the Brain if agent is not None: yield _ev(type="commentary", text="🧠 Nemotron is understanding your request…") for attempt in range(3): # cap grounding rounds (web_search → build) h = {} try: async for beat in _pulse( run_agent_loop(agent, messages, trace=trace), h, "🧠 Nemotron is understanding your request & choosing tools", ): yield beat res = h.get("result") # REAL-TIME trace: push the tool-call log to the drawer after # each agent decision so the user sees the multi-step reasoning # unfold (web_search → build_trip_packages), not just the end. if res is not None: yield _ev(type="trace", data=trace.to_dict()) except Exception as exc: logger.warning("agent loop attempt %d failed (%s).", attempt, exc) res = None if res is None: break if res.type == "tool_called" and res.tool == "build_trip_packages": result = res.result.get("full_result") trip = res.result.get("trip") # Sync the display trip to the GROUNDED dates + canonical match # name (the match was re-centered on the real WC fixture inside # the tool), and surface any correction note to the user. if result is not None and getattr(result, "grounded_match_date", None) and trip is not None: upd = { "match_date": result.grounded_match_date, "check_in": result.grounded_check_in, "check_out": result.grounded_check_out, } if getattr(result, "grounded_match_name", ""): upd["match_name"] = result.grounded_match_name try: trip = trip.model_copy(update=upd) except Exception: pass if result is not None and getattr(result, "grounding_note", ""): yield _ev(type="commentary", text="📅 " + result.grounding_note) break if res.type == "tool_called" and res.tool == "web_search": # Brain grounded itself — thread the result back so it can build. tcid = f"call_ws_{attempt}" messages.append({ "role": "assistant", "content": "", "tool_calls": [{ "id": tcid, "type": "function", "function": { "name": "web_search", "arguments": json.dumps(res.result.get("query") or {}), }, }], }) messages.append({ "role": "tool", "tool_call_id": tcid, "name": "web_search", "content": json.dumps(res.result, ensure_ascii=False)[:1200], }) yield _ev( type="commentary", text="🔎 Grounded with a web search — now building your packages…", ) continue if res.type == "final_answer": agent_text = res.text or "" break # fallback_to_deterministic → EXPLICIT + user-visible degrade. Never # silently swap in the deterministic path. Most commonly this is a # Modal cold-start timeout (see the agent_loop reason) — tell the user # honestly so the wait / fast-mode result is understood, not hidden. if res.type == "fallback_to_deterministic": yield _ev( type="commentary", text="🌡️ Nemotron is warming up on Modal (cold start) — " "building your packages in fast mode now, then I'll " "still compare them live.", ) break # If the agent's build already flagged an unrecognized match, surface it as a # clarification with real alternatives (Best-Agent honesty: never silently # plan a trip around a nonexistent fixture like "Canada vs Morocco"). if result is not None and getattr(result, "match_unrecognized", ""): _finalize_trace(trace, trip, result, built_by) yield _ev(type="trace", data=trace.to_dict()) # grounding-refusal proof yield _ev(type="progress", step="ready", status="fallback", text="Match not found") yield _ev(type="clarify", text=result.match_unrecognized) return # ── Deterministic fallback (K3): parse intent + build directly. Used when # the agent is unavailable, hedged to a non-build answer, or the loop failed. if result is None and not agent_text: parsed = parse_intent(user_text) built_by = "deterministic" trace.set_intent(parsed.trip_request, missing=parsed.missing) yield _ev(type="trace", data=trace.to_dict()) # show extracted intent live trip = parsed.trip_request if trip is not None: yield _ev(type="greenlight", text=trip.summary()) yield _ev( type="commentary", text="✈️ Scanning airlines · 🏨 Finding hotels near BC Place · 🌤️ Checking the match-day forecast…", ) yield _ev(type="progress", step="flights", status="running") yield _ev(type="progress", step="hotels", status="running") yield _ev(type="progress", step="weather", status="running") yield _ev(type="progress", step="nearby", status="running") hb = {} _db_t0 = time.monotonic() try: async for beat in _pulse( build_trip_packages(trip), hb, "✈️ Scanning airlines · 🏨 hotels near BC Place · 🌤️ weather", ): yield beat result = hb["result"] except Exception as exc: trace.set_outcome(mode="deterministic", status="error", notes=[f"build_trip_packages raised: {exc}"], model=_TRACE_MODEL) yield _ev(type="trace", data=trace.to_dict()) # honest error in the trace yield _ev(type="error", text=f"⚠️ {exc}") return # The deterministic path bypasses the loop, so record its single # build tool call here (honest: same canonical build_trip_packages). if result is not None: _db_dur = int((time.monotonic() - _db_t0) * 1000) trace.add_tool_call(ToolCallRecord( name="build_trip_packages", args={"mode": "deterministic", **(trip.model_dump(mode="json") if hasattr(trip, "model_dump") else {})}, status="ok" if result.packages else "failed", duration_ms=_db_dur, detail=f"{len(result.packages)} package(s) scored", sources=result_source_labels(result), )) yield _ev(type="trace", data=trace.to_dict()) # Sync the display trip to the GROUNDED dates so greenlight + # itinerary match the packages (match was re-centered on the real # WC fixture inside build_trip_packages). Honesty: show the note. if getattr(result, "grounded_match_date", None): upd = { "match_date": result.grounded_match_date, "check_in": result.grounded_check_in, "check_out": result.grounded_check_out, } if getattr(result, "grounded_match_name", ""): upd["match_name"] = result.grounded_match_name try: trip = trip.model_copy(update=upd) except Exception: pass if getattr(result, "match_unrecognized", ""): _finalize_trace(trace, trip, result, built_by) yield _ev(type="trace", data=trace.to_dict()) # grounding-refusal proof yield _ev(type="progress", step="ready", status="fallback", text="Match not found") yield _ev(type="clarify", text=result.match_unrecognized) return if getattr(result, "grounding_note", ""): yield _ev(type="commentary", text="📅 " + result.grounding_note) else: trace.set_outcome(mode="deterministic", status="clarify", notes=["Intent incomplete — asked for the missing detail."], model=_TRACE_MODEL) yield _ev(type="trace", data=trace.to_dict()) # show the missing slots honestly yield _ev(type="progress", step="ready", status="fallback", text="Need a detail from you") yield _ev( type="clarify", text=parsed.question or "Tell me where you're flying from and which match you want to see.", ) return # Clarify / direct answer from the Brain (no packages to show). if result is None: trace.set_outcome(mode="agent", status="clarify", notes=["Brain answered without building packages."], model=_TRACE_MODEL, rounds=trace.rounds) yield _ev(type="trace", data=trace.to_dict()) # the reasoning that led to the question yield _ev(type="progress", step="ready", status="fallback", text="Need a detail from you") yield _ev(type="clarify", text=agent_text) return # ── Honest per-category progress from the REAL dispatch outcome (N10/I1): # each data step is done/fallback according to build_trip_packages' own # degradation notices — never a cosmetic timer. if trip is not None: yield _ev(type="progress", step="extract", status="done", text="Trip details captured") yield _ev(type="progress", step="flights", status=_notice_status(result, "flight")) yield _ev(type="progress", step="hotels", status=_notice_status(result, "hotels unavailable", "hotels,")) yield _ev(type="progress", step="weather", status=_notice_status(result, "weather")) yield _ev(type="progress", step="nearby", status=_notice_status(result, "amenities", "nearby")) yield _ev(type="progress", step="score", status="done" if result.packages else "fallback") yield _ev(type="progress", step="itinerary", status="running", text="Building itinerary") yield _ev(type="progress", step="links", status="running", text="Preparing links") # ── Self-check / validation gate (core agentic #8 self-check / #10 safe # recommendation): verify the built output before recommending — no invented # match, sane price band, every flight lands before kickoff, stay brackets # the match day. Pure + defensive; surfaces honest pass/warn/fail, never blocks. _val = validate_packages(result, trip) trace.set_validation(_val) _vpass = sum(1 for c in _val if c.get("status") == "pass") _vfail = sum(1 for c in _val if c.get("status") == "fail") _vwarn = sum(1 for c in _val if c.get("status") == "warn") _vtxt = (f"Validated {_vpass}/{len(_val)}" + (" · flagged" if (_vfail or _vwarn) else " · all clear")) yield _ev(type="progress", step="validate", status="done", text=_vtxt) yield _ev(type="trace", data=trace.to_dict()) # stream the ⑥ section live # ── Render. greenlight confirms the captured intent just before the packages. if trip is not None: yield _ev(type="greenlight", text=trip.summary()) yield _ev(type="commentary", text="🗺️ Nemotron is comparing your 3 packages…") explanation = "" if agent is not None: he = {} try: async for beat in _pulse( _agent_explain(agent, user_text, trip, result), he, "🗺️ Nemotron is comparing your 3 packages…", ): yield beat explanation = he["result"] except Exception as exc: logger.warning("explanation round failed (%s).", exc) explanation = "" # 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). _finalize_trace(trace, trip, result, built_by) yield _ev(type="trace", data=trace.to_dict()) # final, complete trace for the drawer yield _ev(type="progress", step="itinerary", status="done") yield _ev(type="progress", step="links", status="done") yield _ev(type="progress", step="ready", status="done", text="Your packages are ready") yield _ev(type="result", html=render_full(result, trip, leaflet_preloaded=True, trace=trace), 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)