Spaces:
Running on Zero
Running on Zero
| """DiscoverRoute — gradio.Server entrypoint (Hugging Face Space app_file). | |
| Off-Brand custom frontend: instead of Gradio's default column layout, this serves | |
| a hand-built app-shell (ui/shell.py) from ``app.get("/")`` and exposes the planner | |
| as ``@app.api`` endpoints called from the browser via ``@gradio/client`` (the | |
| required path for ZeroGPU). | |
| Run locally: python app.py | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import sys | |
| from pathlib import Path | |
| # The package lives in src/ and is not pip-installed on the Space container — | |
| # put it on the path before any discoverroute import (also makes plain | |
| # `python app.py` work locally without PYTHONPATH). | |
| sys.path.insert(0, str(Path(__file__).resolve().parent / "src")) | |
| from fastapi.responses import HTMLResponse | |
| from gradio import Server | |
| # ZeroGPU scans for @spaces.GPU functions AT STARTUP — import the module that | |
| # defines one (cheap: model/torch loading stays lazy inside the function). | |
| import discoverroute.narrate.llm # noqa: F401 (registers spaces.GPU) | |
| from discoverroute.pipeline import plan_route | |
| from discoverroute.ui import map as mapui | |
| from discoverroute.ui import shell | |
| N_ALTERNATIVES = 3 | |
| app = Server() | |
| def _alt_label(i: int, alt, plain) -> str: | |
| extra = round(alt.discovery.time_min + alt.discovery.dwell_s / 60.0 - plain.time_min) | |
| from collections import Counter | |
| top = Counter(p.category for p in alt.pois).most_common(2) | |
| flavor = ", ".join(c.replace("_", " ") for c, _ in top) | |
| n = len(alt.pois) | |
| return (f"Option {i + 1} · {alt.discovery.distance_m / 1000:.1f} km · +{extra} min · " | |
| f"{n} place{'' if n == 1 else 's'} ({flavor})") | |
| def suggest(query: str = "") -> list: | |
| """Autocomplete Start/Destination from the local POI-name index.""" | |
| from discoverroute.routing.geocode import suggest as _suggest | |
| typed = (query or "").strip() | |
| matches = list(_suggest(typed)) | |
| if typed and typed not in matches: | |
| matches = [typed] + matches | |
| return matches or ([typed] if typed else []) | |
| def plan(start: str, dest: str, mode: str = "walk", budget: float = 0.5, | |
| vibe: str = "", adventurousness: float = 0.3, | |
| prefer_green: float = 0.0, prefer_quiet: float = 0.0, | |
| profile: str = "", city: str = "") -> dict: | |
| """Plan a route and return everything the custom frontend renders.""" | |
| try: | |
| profile_obj = json.loads(profile) if profile else {} | |
| except Exception: # noqa: BLE001 | |
| profile_obj = {} | |
| result = plan_route( | |
| start_query=start, dest_query=dest, mode=mode, budget=budget, vibe=vibe, | |
| adventurousness=adventurousness, prefer_green=prefer_green, | |
| prefer_quiet=prefer_quiet, profile=profile_obj, n_alternatives=N_ALTERNATIVES, | |
| city=city, | |
| ) | |
| if result.error: | |
| return { | |
| "error": result.error, | |
| "map_html": mapui.empty_map("Hmm — " + result.error.split(". ")[0].rstrip(".") + "."), | |
| } | |
| geo = { | |
| "start": list(result.start) if result.start else None, | |
| "end": list(result.end) if result.end else None, | |
| "mode": (mode or "walk").lower(), | |
| "start_label": start, "end_label": dest, | |
| } | |
| alts = result.alternatives or [] | |
| if not alts: # honest no-detour (or budget 0): plain route + stump state | |
| return { | |
| "error": None, | |
| "no_detour": True, | |
| "map_html": mapui.render_routes( | |
| plain=result.plain, start=result.start, end=result.end), | |
| "summary_md": result.summary_md, | |
| "interpretation_md": result.interpretation_md, | |
| "itinerary_md": result.itinerary_md, | |
| "nodetour_html": _nodetour_html(), | |
| "alternatives": [], | |
| "last_cats": [], | |
| "export": _export_data(result.plain, []), | |
| **geo, | |
| } | |
| alternatives = [] | |
| for i, alt in enumerate(alts): | |
| alternatives.append({ | |
| "label": _alt_label(i, alt, result.plain), | |
| "map_html": mapui.render_routes( | |
| plain=result.plain, discovery=alt.discovery, pois=alt.pois, | |
| start=result.start, end=result.end), | |
| "summary_md": alt.summary_md, | |
| "itinerary_md": alt.itinerary_md, | |
| "export": _export_data(alt.discovery, alt.pois), | |
| }) | |
| return { | |
| "error": None, | |
| "no_detour": False, | |
| "interpretation_md": result.interpretation_md, | |
| "alternatives": alternatives, | |
| "last_cats": [p.category for p in alts[0].pois], | |
| **geo, | |
| } | |
| def _export_data(route, pois) -> dict: | |
| """Lat/lon waypoints + the real polyline, so the browser can build a Google/ | |
| Apple Maps link or a GPX file without re-planning.""" | |
| from discoverroute.data import taxonomy | |
| return { | |
| "waypoints": [ | |
| {"lat": round(p.lat, 6), "lon": round(p.lon, 6), | |
| "name": taxonomy.display_label(p)} | |
| for p in (pois or []) | |
| ], | |
| "coords": [[round(c[0], 6), round(c[1], 6)] | |
| for c in (getattr(route, "coords", None) or [])], | |
| } | |
| def _nodetour_html() -> str: | |
| from discoverroute.ui import design | |
| return design.NO_DETOUR_HTML | |
| def homepage() -> str: | |
| return shell.index_html() | |
| def warmup() -> None: | |
| """Preload graph + CSR + POIs + the vibe embedder so the first request is fast.""" | |
| try: | |
| from discoverroute.routing import graph as g | |
| from discoverroute.routing import pois as poimod | |
| g.load_graph() | |
| g.graph_csr() | |
| poimod.load_pois() | |
| print("[warmup] routing graph + POIs ready", flush=True) | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"[warmup] graph FAILED: {exc}", flush=True) | |
| # Pre-warm the secondary cities: pull each from the HF dataset (if not already | |
| # local) and load it into memory NOW, at boot, so the first user to pick a city | |
| # waits 0 s — and request-time stays fully offline (files are local by then). | |
| from discoverroute import config | |
| from discoverroute.routing import area as area_mod | |
| for slug in config.PREWARM_CITIES: | |
| try: | |
| area_mod._city_area(slug) # downloads if needed + loads + caches | |
| print(f"[warmup] city ready: {slug}", flush=True) | |
| except Exception as exc: # noqa: BLE001 - one bad city must not block boot | |
| print(f"[warmup] city {slug} skipped: {exc}", flush=True) | |
| try: | |
| from discoverroute.interpret import embed | |
| embed.vibe_to_affinity("quiet green wander") | |
| print("[warmup] vibe embedder ready", flush=True) | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"[warmup] embedder skipped: {exc}", flush=True) | |
| # Trace-push self-check: prints whether HF_TOKEN is set + can write the dataset. | |
| try: | |
| from discoverroute.narrate import trace | |
| trace.selftest() | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"[trace] selftest skipped: {exc}", flush=True) | |
| if __name__ == "__main__": | |
| warmup() | |
| # _frontend=False: do NOT mount Gradio's default SPA at "/", so our custom | |
| # app-shell route (@app.get("/")) wins. The @app.api endpoints + @gradio/client | |
| # queue still work (that's the API engine, independent of the default UI). | |
| app.launch(show_error=True, _frontend=False) | |