File size: 7,392 Bytes
10cf611
 
 
 
 
 
f00d669
 
 
 
 
10cf611
71d9edc
 
 
 
 
 
 
10cf611
 
 
f00d669
bd037ff
 
 
 
f00d669
 
10cf611
f00d669
 
 
10cf611
f00d669
 
 
10cf611
f00d669
 
 
2c5a5d1
10cf611
2c5a5d1
f00d669
 
10cf611
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1add89c
10cf611
 
 
 
 
 
f00d669
 
 
10cf611
1add89c
f00d669
 
 
10cf611
 
2c5a5d1
10cf611
f00d669
2c5a5d1
 
 
 
 
 
 
f00d669
10cf611
 
 
 
 
 
 
 
 
 
 
 
2c5a5d1
 
10cf611
 
 
f00d669
10cf611
 
 
 
 
 
 
2c5a5d1
10cf611
f00d669
10cf611
 
 
 
 
 
2c5a5d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10cf611
f00d669
 
10cf611
 
 
f00d669
 
10cf611
 
 
f00d669
 
10cf611
 
f00d669
 
 
 
 
 
 
 
b34c92a
1add89c
 
 
 
 
 
 
 
 
 
 
 
b34c92a
 
10cf611
b34c92a
 
 
f00d669
2b4da11
 
 
 
 
 
 
f00d669
 
 
10cf611
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""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})")


@app.api(name="suggest")
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 [])


@app.api(name="plan")
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


@app.get("/", response_class=HTMLResponse)
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)