coreprinciple commited on
Commit
10cf611
·
1 Parent(s): b34c92a

v2 with model changed and using gradio server func

Browse files
.gitignore CHANGED
@@ -30,3 +30,6 @@ test_output.log
30
  # NOTE: data/*.graphml and data/*.parquet are the offline build artifacts.
31
  # They are committed for the Hugging Face Space so it needs no runtime download.
32
  # If they exceed Space size limits, switch to git-lfs (see PROGRESS.md).
 
 
 
 
30
  # NOTE: data/*.graphml and data/*.parquet are the offline build artifacts.
31
  # They are committed for the Hugging Face Space so it needs no runtime download.
32
  # If they exceed Space size limits, switch to git-lfs (see PROGRESS.md).
33
+
34
+ # Inference trace logs (Open Trace) — written at runtime, pushed to HF Dataset
35
+ logs/
DEPLOY.md CHANGED
@@ -40,28 +40,31 @@ app, source, tests, and `data/` artifacts are pushed.
40
 
41
  ## Enable the narration LLM (optional)
42
 
43
- The app runs CPU-only out of the box (grounded **template** narration). To turn on
44
- the Qwen3.5-9B generative narration:
45
 
46
  1. In the Space **Settings → Hardware**, select a **ZeroGPU** tier.
47
- 2. The `@spaces.GPU` decorator on `narrate/llm.py::generate` activates automatically.
48
- `narrate()` only calls the LLM when a GPU is present, and **falls back to the
49
- grounded template if the LLM output fails the zero-hallucination gate** so the
50
- 0% gate holds either way.
 
51
 
52
  To force the LLM on/off regardless of hardware, set the Space variable
53
  `DISCOVERROUTE_USE_LLM` to `1` / `0`.
54
 
55
- ## Optional: live Google verification of the final stops
56
 
57
- Set the Space **secret** `GOOGLE_MAPS_API_KEY` (Google Cloud APIs Places API
58
- (New) enabled, billing on) and each planned route live-verifies its ~8 chosen
59
- stops: permanently-closed detection, open-right-now, rating. Notes:
60
- - Hours fields bill at the Enterprise SKU → ~1,000 free lookups/month ≈ 125
61
- routes; ~$0.15/route beyond. Only the final stops are queried, never stored
62
- (Google ToS), and OSM remains the routing/candidate base.
63
- - Without the key the app is fully offline: open-now still works from OSM
64
- `opening_hours` tags (~31% of POIs carry them).
 
 
65
 
66
  ## Refreshing the data snapshot
67
 
 
40
 
41
  ## Enable the narration LLM (optional)
42
 
43
+ The app runs CPU-only out of the box (grounded **template** narration + keyword/
44
+ embedding vibe weights). To turn on the in-Space MiniCPM5-1B generative path:
45
 
46
  1. In the Space **Settings → Hardware**, select a **ZeroGPU** tier.
47
+ 2. The `@spaces.GPU` decorator on `narrate/llm.py::run_inference` activates
48
+ automatically. Vibe→weights (Call 1) and narration (Call 2) only call the model
49
+ when a GPU is present, and **fall back to the keyword matcher / grounded template
50
+ if the model is absent or its output fails validation / the zero-hallucination
51
+ gate** — so the app is correct either way. Weights are pulled from the HF Hub.
52
 
53
  To force the LLM on/off regardless of hardware, set the Space variable
54
  `DISCOVERROUTE_USE_LLM` to `1` / `0`.
55
 
56
+ ## Off the Grid no external APIs
57
 
58
+ DiscoverRoute is OSM-only: there is **no** Google Places (or any cloud) dependency.
59
+ Open-now comes from OSM `opening_hours` tags (~31% of POIs carry them); the 1B model
60
+ runs inside the Space on ZeroGPU. Nothing leaves the Space at request time.
61
+
62
+ ## Open Trace optional inference trace dataset
63
+
64
+ Every model call logs a row to `logs/traces.jsonl`. To also publish them to an HF
65
+ Dataset, set the Space **secret** `HF_TOKEN` (write scope) rows are then pushed
66
+ async to `DISCOVERROUTE_TRACE_REPO` (default `build-small-hackathon/discoverroute-traces`).
67
+ Without the token, logging stays local (graceful no-op).
68
 
69
  ## Refreshing the data snapshot
70
 
HACKATHON_DEPLOYMENT.md CHANGED
@@ -59,9 +59,11 @@ git push -u origin main
59
 
60
  **In Space Settings:**
61
 
62
- 1. **Hardware:** Select **ZeroGPU** (for optional Qwen3.5-9B narration LLM)
63
- - The app works CPU-only with template narration (no GPU needed)
64
- - GPU enables the enhanced generative narration (optional polish)
 
 
65
 
66
  2. **Environment Variables (for "Off the Grid" badge):**
67
  ```
@@ -179,7 +181,7 @@ Go to https://huggingface.co/build-small-hackathon and submit:
179
  ## Post-Launch
180
 
181
  - Monitor Space logs for errors (Settings → Logs)
182
- - If narration LLM is enabled (ZeroGPU), watch for Qwen3.5-9B load/unload messages
183
  - Share the build story on Twitter / Hacker News / forums (Field Notes badge)
184
 
185
  **All code is ready. User only needs to: auth with HF → run the git push commands above → submit.**
 
59
 
60
  **In Space Settings:**
61
 
62
+ 1. **Hardware:** Select **ZeroGPU** (for the in-Space MiniCPM5-1B model — a ≤4B
63
+ Tiny Titan, weights pulled from the HF Hub)
64
+ - The app works CPU-only with the keyword/embedding vibe path + template
65
+ narration (no GPU needed)
66
+ - GPU enables the generative vibe→weights (Call 1) and narration (Call 2)
67
 
68
  2. **Environment Variables (for "Off the Grid" badge):**
69
  ```
 
181
  ## Post-Launch
182
 
183
  - Monitor Space logs for errors (Settings → Logs)
184
+ - If the LLM is enabled (ZeroGPU), watch for MiniCPM5-1B load/unload messages
185
  - Share the build story on Twitter / Hacker News / forums (Field Notes badge)
186
 
187
  **All code is ready. User only needs to: auth with HF → run the git push commands above → submit.**
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🗺️
4
  colorFrom: green
5
  colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.17.3
8
  app_file: app.py
9
  pinned: false
10
  license: mit
@@ -41,7 +41,8 @@ for the per-brick build log, `FIELD_NOTES.md` for the build story, and
41
  viewpoint + a bookshop over five cafés, within budget.
42
  - **Adventurousness**: low → well-documented places; high → injects hidden gems.
43
  - **Grounded narration**: an itinerary that names only real waypoints, behind a
44
- hard zero-hallucination gate (optional Qwen3.5-9B enhancer, gated by the same).
 
45
  - **Alternative routes**: up to three genuinely distinct options.
46
  - **Persistent taste profile**: standing preferences + saved places, per device,
47
  blended with each trip's mood. No accounts.
 
4
  colorFrom: green
5
  colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 6.18.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
 
41
  viewpoint + a bookshop over five cafés, within budget.
42
  - **Adventurousness**: low → well-documented places; high → injects hidden gems.
43
  - **Grounded narration**: an itinerary that names only real waypoints, behind a
44
+ hard zero-hallucination gate (optional in-Space MiniCPM5-1B enhancer a ≤4B
45
+ Tiny Titan on ZeroGPU — gated by the same).
46
  - **Alternative routes**: up to three genuinely distinct options.
47
  - **Persistent taste profile**: standing preferences + saved places, per device,
48
  blended with each trip's mood. No accounts.
app.py CHANGED
@@ -1,249 +1,118 @@
1
- """DiscoverRoute — Gradio app (Hugging Face Space entrypoint).
 
 
 
 
 
2
 
3
  Run locally: python app.py
4
- UI design ported from the Claude-design handoff (low-poly clay-sticker look):
5
- state machine empty → loading → (routed | no-detour), framed map window with
6
- in-iframe route-draw animation, springy controls, toasts, per-device profile.
7
  """
8
  from __future__ import annotations
9
 
10
- import gradio as gr
 
 
 
11
 
12
- from discoverroute import config
13
  from discoverroute.pipeline import plan_route
14
- from discoverroute.ui import design
15
  from discoverroute.ui import map as mapui
 
16
 
17
  N_ALTERNATIVES = 3
18
 
19
-
20
- def _saved_md(profile: dict) -> str:
21
- saved = (profile or {}).get("saved_categories") or []
22
- if not saved:
23
- return "_No saved places yet. Plan a route, then ⭐ save its places._"
24
- from collections import Counter
25
- counts = Counter(saved)
26
- items = ", ".join(f"{c.replace('_',' ')} ×{n}" for c, n in counts.most_common())
27
- return f"**Saved taste:** {items}"
28
 
29
 
30
  def _alt_label(i: int, alt, plain) -> str:
31
- extra = round(alt.discovery.time_min + alt.discovery.dwell_s / 60.0
32
- - plain.time_min)
33
  from collections import Counter
34
  top = Counter(p.category for p in alt.pois).most_common(2)
35
  flavor = ", ".join(c.replace("_", " ") for c, _ in top)
36
- return (f"Option {i+1} · {alt.discovery.distance_m/1000:.1f} km · +{extra} min · "
37
  f"{len(alt.pois)} stops ({flavor})")
38
 
39
 
40
- def on_plan(start, dest, mode, budget, vibe, adventurousness,
41
- prefer_green, prefer_quiet, profile, progress=gr.Progress()):
42
- progress(0.1, desc="Reading your vibe…")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  result = plan_route(
44
  start_query=start, dest_query=dest, mode=mode, budget=budget, vibe=vibe,
45
  adventurousness=adventurousness, prefer_green=prefer_green,
46
- prefer_quiet=prefer_quiet, profile=profile or {}, n_alternatives=N_ALTERNATIVES,
47
  )
48
- progress(0.9, desc="Drawing your wander…")
49
 
50
  if result.error:
51
- gr.Warning(result.error)
52
- return (mapui.empty_map("Hmm — " + result.error.split(".")[0] + "."),
53
- gr.update(visible=False), gr.update(visible=False),
54
- gr.update(choices=[], visible=False),
55
- "", "", "", {"items": []}, [])
56
 
57
  alts = result.alternatives or []
58
- if not alts: # honest no-detour (or budget 0 plain route): design's stump state
59
- html = mapui.render_routes(plain=result.plain, start=result.start, end=result.end)
60
- return (html,
61
- gr.update(visible=True), gr.update(visible=True),
62
- gr.update(choices=[], visible=False),
63
- result.summary_md, result.interpretation_md, result.itinerary_md,
64
- {"items": []}, [])
65
-
66
- items, choices = [], []
 
 
 
 
 
 
67
  for i, alt in enumerate(alts):
68
- html = mapui.render_routes(plain=result.plain, discovery=alt.discovery,
69
- pois=alt.pois, start=result.start, end=result.end)
70
- choices.append(_alt_label(i, alt, result.plain))
71
- items.append({"map": html, "summary": alt.summary_md,
72
- "itinerary": alt.itinerary_md})
73
-
74
- first = items[0]
75
- return (first["map"],
76
- gr.update(visible=True), gr.update(visible=False),
77
- gr.update(choices=choices, value=choices[0], visible=len(choices) > 1),
78
- first["summary"], result.interpretation_md, first["itinerary"],
79
- {"choices": choices, "items": items},
80
- [p.category for p in alts[0].pois])
81
-
82
-
83
- def on_select_alt(choice, state):
84
- items = (state or {}).get("items") or []
85
- choices = (state or {}).get("choices") or []
86
- if not items or choice not in choices:
87
- return gr.update(), gr.update(), gr.update()
88
- it = items[choices.index(choice)]
89
- return it["map"], it["summary"], it["itinerary"]
90
-
91
 
92
- def on_save_places(profile, last_cats):
93
- profile = dict(profile or {"standing_text": "", "saved_categories": []})
94
- if not last_cats:
95
- gr.Warning("Plan a route first — then I can save its places to your taste.")
96
- return profile, _saved_md(profile)
97
- profile["saved_categories"] = (profile.get("saved_categories") or []) + list(last_cats)
98
- gr.Info("Saved this route's places to your taste ✨")
99
- return profile, _saved_md(profile)
100
 
101
 
102
- def on_save_prefs(standing_text, profile):
103
- profile = dict(profile or {"standing_text": "", "saved_categories": []})
104
- profile["standing_text"] = standing_text or ""
105
- gr.Info("Saved your standing preferences ✨")
106
- return profile, _saved_md(profile)
107
 
108
 
109
- def on_clear_profile():
110
- gr.Info("Profile cleared 🧹")
111
- empty = {"standing_text": "", "saved_categories": []}
112
- return empty, "", _saved_md(empty)
113
 
114
 
115
- def on_suggest(evt: gr.KeyUpData):
116
- """Autocomplete a Start/Destination field from the local POI-name index."""
117
- from discoverroute.routing.geocode import suggest
118
- typed = evt.input_value or ""
119
- matches = list(suggest(typed))
120
- # keep what the user typed selectable on top; never clobber their text
121
- choices = ([typed] if typed and typed not in matches else []) + matches
122
- return gr.update(choices=choices or [typed])
123
-
124
-
125
- def show_loading():
126
- """Instant feedback the moment Plan is clicked (the .then chain computes)."""
127
- return (design.LOADING_HTML, gr.update(visible=False),
128
- gr.update(visible=False), gr.update(visible=False))
129
-
130
-
131
- def build_ui() -> gr.Blocks:
132
- with gr.Blocks(title="DiscoverRoute · Paris") as demo:
133
- profile = gr.BrowserState(
134
- {"standing_text": "", "saved_categories": []},
135
- storage_key="discoverroute_profile",
136
- )
137
- alts_state = gr.State({"items": []})
138
- last_cats = gr.State([])
139
-
140
- gr.HTML(design.DR_HERO, elem_id="dr-hero")
141
-
142
- with gr.Row(equal_height=False):
143
- # ---- LEFT: controls --------------------------------------------
144
- with gr.Column(scale=4, min_width=340):
145
- with gr.Group():
146
- start = gr.Dropdown(
147
- label="Start", elem_id="dr-start", elem_classes="dr-field",
148
- info="Type a Paris place — suggestions appear as you type",
149
- value="Place de la République, Paris",
150
- choices=["Place de la République, Paris"],
151
- allow_custom_value=True, filterable=True)
152
- dest = gr.Dropdown(
153
- label="Destination", elem_id="dr-dest", elem_classes="dr-field",
154
- value="Jardin du Luxembourg, Paris",
155
- choices=["Jardin du Luxembourg, Paris"],
156
- allow_custom_value=True, filterable=True)
157
- vibe = gr.Textbox(label="Vibe (free text)", elem_id="dr-vibe",
158
- elem_classes="dr-field",
159
- info="e.g. 'quiet green wander' or 'lively café crawl'")
160
- mode = gr.Radio(choices=["walk", "bike"], value=config.DEFAULT_MODE,
161
- label="Mode", elem_id="dr-mode", elem_classes="dr-seg")
162
- budget = gr.Slider(0.0, config.MAX_BUDGET, value=config.DEFAULT_BUDGET,
163
- step=0.1, label="Detour budget",
164
- elem_id="dr-budget", elem_classes="dr-slider",
165
- info="Extra time vs. the direct trip — 0 = straight there, 1 = up to 2× longer")
166
- adventurousness = gr.Slider(
167
- 0.0, 1.0, value=config.DEFAULT_ADVENTUROUSNESS, step=0.05,
168
- label="Adventurousness", elem_id="dr-adv", elem_classes="dr-slider",
169
- info="Low = well-known places · high = hidden gems")
170
- with gr.Accordion("Manual taste (used only when Vibe and saved profile are both empty)",
171
- open=False, elem_id="dr-manual", elem_classes="dr-collapse"):
172
- prefer_green = gr.Slider(0.0, 1.0, value=0.5, step=0.05,
173
- label="Prefer green", elem_id="dr-green",
174
- elem_classes=["dr-slider", "green"])
175
- prefer_quiet = gr.Slider(0.0, 1.0, value=0.5, step=0.05,
176
- label="Prefer quiet", elem_id="dr-quiet",
177
- elem_classes=["dr-slider", "green"])
178
- go = gr.Button("Plan route", variant="primary", size="lg",
179
- elem_id="dr-plan")
180
- with gr.Accordion("⭐ My taste profile (saved on this device)",
181
- open=False, elem_id="dr-profile", elem_classes="dr-collapse"):
182
- standing = gr.Textbox(
183
- label="Standing preferences", lines=3, elem_id="dr-profile-text",
184
- info="e.g. 'I always love bookshops, gardens, and old churches'")
185
- with gr.Row():
186
- save_prefs_btn = gr.Button("Save", size="sm", elem_id="dr-save")
187
- clear_btn = gr.Button("Clear", size="sm", variant="secondary",
188
- elem_id="dr-clear")
189
- saved_display = gr.Markdown(_saved_md({}), elem_id="dr-saved",
190
- elem_classes="dr-note")
191
- save_places_btn = gr.Button("⭐ Save this route's places",
192
- elem_id="dr-save-places",
193
- elem_classes="dr-star")
194
-
195
- # ---- RIGHT: results --------------------------------------------
196
- with gr.Column(scale=7, min_width=440, elem_id="dr-results"):
197
- alt_radio = gr.Radio(choices=[], label="Route options", visible=False,
198
- elem_id="dr-options", elem_classes="dr-cards")
199
- map_out = gr.HTML(mapui.empty_map(), elem_id="dr-map")
200
- with gr.Group(visible=False) as results_grp:
201
- summary_out = gr.Markdown(elem_id="dr-summary")
202
- interpretation_out = gr.Markdown(elem_id="dr-interp",
203
- elem_classes="dr-tags")
204
- itinerary_out = gr.Markdown(elem_id="dr-itin")
205
- nodetour_html = gr.HTML(design.NO_DETOUR_HTML, visible=False,
206
- elem_id="dr-nodetour")
207
-
208
- # Cosmetic map-press bounce. Gradio feeds an event's `js` return value
209
- # back as the inputs to its `fn`; a side-effect-only js (returns
210
- # undefined) attached to the data event corrupts on_plan's inputs and
211
- # white-screens the frontend. So keep the animation on its own fn-less
212
- # listener, where its return value is harmless.
213
- go.click(None, js=design.DR_CELEBRATE)
214
- # Loading state first (instant), then the actual planning overwrites it.
215
- go.click(
216
- show_loading,
217
- outputs=[map_out, results_grp, nodetour_html, alt_radio],
218
- show_progress="hidden",
219
- ).then(
220
- on_plan,
221
- inputs=[start, dest, mode, budget, vibe, adventurousness,
222
- prefer_green, prefer_quiet, profile],
223
- outputs=[map_out, results_grp, nodetour_html, alt_radio,
224
- summary_out, interpretation_out, itinerary_out,
225
- alts_state, last_cats],
226
- show_progress="minimal",
227
- )
228
- # Autocomplete Start/Destination from the local POI-name index.
229
- for _field in (start, dest):
230
- _field.key_up(on_suggest, outputs=[_field], show_progress="hidden")
231
- alt_radio.change(on_select_alt, inputs=[alt_radio, alts_state],
232
- outputs=[map_out, summary_out, itinerary_out])
233
- save_places_btn.click(on_save_places, inputs=[profile, last_cats],
234
- outputs=[profile, saved_display])
235
- save_prefs_btn.click(on_save_prefs, inputs=[standing, profile],
236
- outputs=[profile, saved_display])
237
- clear_btn.click(on_clear_profile, outputs=[profile, standing, saved_display])
238
-
239
- demo.load(lambda p: (p.get("standing_text", ""), _saved_md(p)),
240
- inputs=[profile], outputs=[standing, saved_display])
241
- return demo
242
-
243
-
244
- def warmup():
245
- """Preload graph + CSR + POIs + the vibe embedder at boot so the first
246
- request is fast (~1s) instead of paying the torch/model load lazily."""
247
  try:
248
  from discoverroute.routing import graph as g
249
  from discoverroute.routing import pois as poimod
@@ -255,7 +124,7 @@ def warmup():
255
  print(f"[warmup] graph FAILED: {exc}", flush=True)
256
  try:
257
  from discoverroute.interpret import embed
258
- embed.vibe_to_affinity("quiet green wander") # loads model + gloss cache
259
  print("[warmup] vibe embedder ready", flush=True)
260
  except Exception as exc: # noqa: BLE001
261
  print(f"[warmup] embedder skipped: {exc}", flush=True)
@@ -263,8 +132,7 @@ def warmup():
263
 
264
  if __name__ == "__main__":
265
  warmup()
266
- build_ui().queue(default_concurrency_limit=4).launch(
267
- theme=design.build_theme(),
268
- css=design.DR_CSS,
269
- head=design.DR_HEAD,
270
- )
 
1
+ """DiscoverRoute — gradio.Server entrypoint (Hugging Face Space app_file).
2
+
3
+ Off-Brand custom frontend: instead of Gradio's default column layout, this serves
4
+ a hand-built app-shell (ui/shell.py) from ``app.get("/")`` and exposes the planner
5
+ as ``@app.api`` endpoints called from the browser via ``@gradio/client`` (the
6
+ required path for ZeroGPU).
7
 
8
  Run locally: python app.py
 
 
 
9
  """
10
  from __future__ import annotations
11
 
12
+ import json
13
+
14
+ from fastapi.responses import HTMLResponse
15
+ from gradio import Server
16
 
 
17
  from discoverroute.pipeline import plan_route
 
18
  from discoverroute.ui import map as mapui
19
+ from discoverroute.ui import shell
20
 
21
  N_ALTERNATIVES = 3
22
 
23
+ app = Server()
 
 
 
 
 
 
 
 
24
 
25
 
26
  def _alt_label(i: int, alt, plain) -> str:
27
+ extra = round(alt.discovery.time_min + alt.discovery.dwell_s / 60.0 - plain.time_min)
 
28
  from collections import Counter
29
  top = Counter(p.category for p in alt.pois).most_common(2)
30
  flavor = ", ".join(c.replace("_", " ") for c, _ in top)
31
+ return (f"Option {i + 1} · {alt.discovery.distance_m / 1000:.1f} km · +{extra} min · "
32
  f"{len(alt.pois)} stops ({flavor})")
33
 
34
 
35
+ @app.api(name="suggest")
36
+ def suggest(query: str = "") -> list:
37
+ """Autocomplete Start/Destination from the local POI-name index."""
38
+ from discoverroute.routing.geocode import suggest as _suggest
39
+ typed = (query or "").strip()
40
+ matches = list(_suggest(typed))
41
+ if typed and typed not in matches:
42
+ matches = [typed] + matches
43
+ return matches or ([typed] if typed else [])
44
+
45
+
46
+ @app.api(name="plan")
47
+ def plan(start: str, dest: str, mode: str = "walk", budget: float = 0.5,
48
+ vibe: str = "", adventurousness: float = 0.3,
49
+ prefer_green: float = 0.0, prefer_quiet: float = 0.0,
50
+ profile: str = "") -> dict:
51
+ """Plan a route and return everything the custom frontend renders."""
52
+ try:
53
+ profile_obj = json.loads(profile) if profile else {}
54
+ except Exception: # noqa: BLE001
55
+ profile_obj = {}
56
+
57
  result = plan_route(
58
  start_query=start, dest_query=dest, mode=mode, budget=budget, vibe=vibe,
59
  adventurousness=adventurousness, prefer_green=prefer_green,
60
+ prefer_quiet=prefer_quiet, profile=profile_obj, n_alternatives=N_ALTERNATIVES,
61
  )
 
62
 
63
  if result.error:
64
+ return {
65
+ "error": result.error,
66
+ "map_html": mapui.empty_map("Hmm — " + result.error.split(".")[0] + "."),
67
+ }
 
68
 
69
  alts = result.alternatives or []
70
+ if not alts: # honest no-detour (or budget 0): plain route + stump state
71
+ return {
72
+ "error": None,
73
+ "no_detour": True,
74
+ "map_html": mapui.render_routes(
75
+ plain=result.plain, start=result.start, end=result.end),
76
+ "summary_md": result.summary_md,
77
+ "interpretation_md": result.interpretation_md,
78
+ "itinerary_md": result.itinerary_md,
79
+ "nodetour_html": _nodetour_html(),
80
+ "alternatives": [],
81
+ "last_cats": [],
82
+ }
83
+
84
+ alternatives = []
85
  for i, alt in enumerate(alts):
86
+ alternatives.append({
87
+ "label": _alt_label(i, alt, result.plain),
88
+ "map_html": mapui.render_routes(
89
+ plain=result.plain, discovery=alt.discovery, pois=alt.pois,
90
+ start=result.start, end=result.end),
91
+ "summary_md": alt.summary_md,
92
+ "itinerary_md": alt.itinerary_md,
93
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
+ return {
96
+ "error": None,
97
+ "no_detour": False,
98
+ "interpretation_md": result.interpretation_md,
99
+ "alternatives": alternatives,
100
+ "last_cats": [p.category for p in alts[0].pois],
101
+ }
 
102
 
103
 
104
+ def _nodetour_html() -> str:
105
+ from discoverroute.ui import design
106
+ return design.NO_DETOUR_HTML
 
 
107
 
108
 
109
+ @app.get("/", response_class=HTMLResponse)
110
+ def homepage() -> str:
111
+ return shell.index_html()
 
112
 
113
 
114
+ def warmup() -> None:
115
+ """Preload graph + CSR + POIs + the vibe embedder so the first request is fast."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  try:
117
  from discoverroute.routing import graph as g
118
  from discoverroute.routing import pois as poimod
 
124
  print(f"[warmup] graph FAILED: {exc}", flush=True)
125
  try:
126
  from discoverroute.interpret import embed
127
+ embed.vibe_to_affinity("quiet green wander")
128
  print("[warmup] vibe embedder ready", flush=True)
129
  except Exception as exc: # noqa: BLE001
130
  print(f"[warmup] embedder skipped: {exc}", flush=True)
 
132
 
133
  if __name__ == "__main__":
134
  warmup()
135
+ # _frontend=False: do NOT mount Gradio's default SPA at "/", so our custom
136
+ # app-shell route (@app.get("/")) wins. The @app.api endpoints + @gradio/client
137
+ # queue still work (that's the API engine, independent of the default UI).
138
+ app.launch(show_error=True, _frontend=False)
 
requirements.txt CHANGED
@@ -10,8 +10,8 @@ scikit-learn==1.9.0
10
  pyarrow==24.0.0
11
  folium==0.20.0
12
 
13
- # UI
14
- gradio==6.17.3
15
 
16
  # Vibe interpretation (Brick 4) — fastembed (ONNX, no torch) is the primary
17
  # backend; sentence-transformers remains as fallback.
 
10
  pyarrow==24.0.0
11
  folium==0.20.0
12
 
13
+ # UI — 6.18 introduces gradio.Server (custom-frontend app-shell; see app.py)
14
+ gradio==6.18.0
15
 
16
  # Vibe interpretation (Brick 4) — fastembed (ONNX, no torch) is the primary
17
  # backend; sentence-transformers remains as fallback.
src/discoverroute/config.py CHANGED
@@ -4,6 +4,7 @@ Everything tunable lives here so behaviour is inspectable, not scattered.
4
  """
5
  from __future__ import annotations
6
 
 
7
  from pathlib import Path
8
 
9
  # --- Paths -------------------------------------------------------------------
@@ -66,8 +67,20 @@ def corridor_halfwidth_m(budget: float) -> float:
66
  EMBED_MODEL = "BAAI/bge-small-en-v1.5"
67
  # bge-v1.5 retrieval instruction, prepended to the query (the vibe) only.
68
  EMBED_QUERY_INSTRUCTION = "Represent this sentence for searching relevant passages: "
69
- # Generative model for posture interpretation + narration (optional; ≤32B, ZeroGPU).
70
- LLM_MODEL = "Qwen/Qwen3.5-9B"
 
 
 
 
 
 
 
 
 
 
 
 
71
  # Affinity floor: the least-matching category still keeps this much interest so
72
  # the route can explore a little; the best-matching category maps to 1.0.
73
  AFFINITY_FLOOR = 0.15
 
4
  """
5
  from __future__ import annotations
6
 
7
+ import os
8
  from pathlib import Path
9
 
10
  # --- Paths -------------------------------------------------------------------
 
67
  EMBED_MODEL = "BAAI/bge-small-en-v1.5"
68
  # bge-v1.5 retrieval instruction, prepended to the query (the vibe) only.
69
  EMBED_QUERY_INSTRUCTION = "Represent this sentence for searching relevant passages: "
70
+ # Generative model for vibe→weights extraction + narration. A 1B in-Space model
71
+ # (Tiny Titan ≤4B; weights pulled from the Hub and run on ZeroGPU). Standard
72
+ # LlamaForCausalLM architecture — no custom kernels.
73
+ LLM_MODEL = "openbmb/MiniCPM5-1B"
74
+
75
+ # --- Trace logging (Open Trace) ----------------------------------------------
76
+ # Every inference call logs a row locally to logs/traces.jsonl; when a write
77
+ # token is present, rows are ALSO pushed (async, non-blocking) to TRACE_REPO.
78
+ # No token => local-only (graceful stub; nothing blocks).
79
+ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
80
+ TRACE_REPO = os.environ.get(
81
+ "DISCOVERROUTE_TRACE_REPO", "build-small-hackathon/discoverroute-traces"
82
+ )
83
+
84
  # Affinity floor: the least-matching category still keeps this much interest so
85
  # the route can explore a little; the best-matching category maps to 1.0.
86
  AFFINITY_FLOOR = 0.15
src/discoverroute/enrich/__init__.py DELETED
@@ -1 +0,0 @@
1
- """Pluggable live-enrichment sources (spec P2-2). Optional; never required."""
 
 
src/discoverroute/enrich/google_places.py DELETED
@@ -1,87 +0,0 @@
1
- """Optional live verification of the FINAL selected stops via Google Places.
2
-
3
- Design constraints (and why this module looks the way it does):
4
- * Google's ToS prohibit storing Places content (only place_id is cacheable),
5
- so this runs per-request on the ~8 chosen stops only and nothing is
6
- persisted. OSM remains the storable base for candidates and routing.
7
- * Opening-hours fields bill at the Enterprise SKU (1,000 free events/month
8
- => ~125 free routes). Verifying only the final stops keeps cost bounded.
9
- * Entirely optional: without ``GOOGLE_MAPS_API_KEY`` in the environment this
10
- module is a silent no-op and the app stays fully offline.
11
-
12
- Each verified POI gains ``.live_status`` (True=open now, False=closed) and
13
- ``.live_rating`` — consumed by the narration template's badges.
14
- """
15
- from __future__ import annotations
16
-
17
- import json
18
- import logging
19
- import os
20
- import urllib.request
21
- from concurrent.futures import ThreadPoolExecutor
22
-
23
- logger = logging.getLogger("discoverroute")
24
-
25
- _ENDPOINT = "https://places.googleapis.com/v1/places:searchText"
26
- _FIELDS = ("places.id,places.displayName,places.businessStatus,"
27
- "places.currentOpeningHours.openNow,places.rating")
28
- _TIMEOUT_S = 3.0
29
- _MAX_STOPS = 12
30
-
31
-
32
- def api_key() -> str | None:
33
- return os.environ.get("GOOGLE_MAPS_API_KEY") or None
34
-
35
-
36
- def _verify_one(poi, key: str) -> None:
37
- """Look up one POI by name near its coordinates; annotate it in place."""
38
- name = getattr(poi, "name", None)
39
- if not name:
40
- return
41
- body = json.dumps({
42
- "textQuery": name,
43
- "locationBias": {"circle": {
44
- "center": {"latitude": poi.lat, "longitude": poi.lon},
45
- "radius": 150.0,
46
- }},
47
- "maxResultCount": 1,
48
- }).encode()
49
- req = urllib.request.Request(
50
- _ENDPOINT, data=body, method="POST",
51
- headers={
52
- "Content-Type": "application/json",
53
- "X-Goog-Api-Key": key,
54
- "X-Goog-FieldMask": _FIELDS,
55
- },
56
- )
57
- try:
58
- with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp:
59
- data = json.loads(resp.read().decode())
60
- except Exception as exc: # noqa: BLE001 - enrichment must never break a route
61
- logger.warning("google verify failed for %r: %s: %s",
62
- name, type(exc).__name__, exc)
63
- return
64
- places = data.get("places") or []
65
- if not places:
66
- return
67
- place = places[0]
68
- if place.get("businessStatus") == "CLOSED_PERMANENTLY":
69
- poi.live_status = False
70
- return
71
- open_now = (place.get("currentOpeningHours") or {}).get("openNow")
72
- if open_now is not None:
73
- poi.live_status = bool(open_now)
74
- rating = place.get("rating")
75
- if rating is not None:
76
- poi.live_rating = float(rating)
77
-
78
-
79
- def verify_stops(pois: list) -> bool:
80
- """Live-verify up to _MAX_STOPS POIs in parallel. Returns True if any ran."""
81
- key = api_key()
82
- if not key or not pois:
83
- return False
84
- batch = pois[:_MAX_STOPS]
85
- with ThreadPoolExecutor(max_workers=4) as pool:
86
- list(pool.map(lambda p: _verify_one(p, key), batch))
87
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/discoverroute/interpret/affinity.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve a free-text vibe into category affinity, with graceful fallbacks.
2
+
3
+ Single entry point for "vibe → affinity over the taxonomy". Tiers, best first:
4
+
5
+ 1. MiniCPM5-1B Call 1 (``llm_vibe.extract``) — when a GPU is present in-Space.
6
+ 2. bge-small embeddings (``embed.vibe_to_affinity``) — CPU, no model download
7
+ beyond the small encoder; the strictly-better-than-keywords middle tier.
8
+ 3. Keyword matcher (``keywords.keyword_affinity``) — instant, model-free net.
9
+ 4. Neutral — equal interest in everything.
10
+
11
+ Every tier returns the same floored ``{category: affinity}`` shape, so the
12
+ routing engine (and the ``Weights`` struct) is oblivious to which one ran.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+
18
+ from discoverroute.data import taxonomy
19
+
20
+
21
+ def _neutral() -> dict[str, float]:
22
+ return {c: 1.0 for c in taxonomy.CATEGORIES}
23
+
24
+
25
+ @functools.lru_cache(maxsize=256)
26
+ def resolve_affinity(vibe: str) -> tuple[dict[str, float], str]:
27
+ """Return ``(affinity, source)`` where source ∈ {llm, embed, keyword, neutral}."""
28
+ vibe = (vibe or "").strip()
29
+ if not vibe:
30
+ return _neutral(), "neutral"
31
+
32
+ # tier 1 — LLM extraction (in-Space GPU only)
33
+ from discoverroute.interpret import llm_vibe
34
+ result = llm_vibe.extract(vibe)
35
+ if result:
36
+ return result["affinity"], "llm"
37
+
38
+ # tier 2 — sentence embeddings (CPU)
39
+ try:
40
+ from discoverroute.interpret import embed
41
+ return embed.vibe_to_affinity(vibe), "embed"
42
+ except Exception: # noqa: BLE001 - encoder unavailable → keyword net
43
+ pass
44
+
45
+ # tier 3 — keyword matcher (model-free)
46
+ from discoverroute.interpret import keywords
47
+ kw = keywords.keyword_affinity(vibe)
48
+ if kw:
49
+ return kw, "keyword"
50
+
51
+ return _neutral(), "neutral"
52
+
53
+
54
+ def affinity_only(vibe: str) -> dict[str, float]:
55
+ return resolve_affinity(vibe)[0]
56
+
57
+
58
+ def source_of(vibe: str) -> str:
59
+ return resolve_affinity(vibe)[1]
src/discoverroute/interpret/keywords.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model-free keyword vibe matcher — the always-available safety net.
2
+
3
+ When Call 1 (the LLM vibe→weights extractor) is unavailable or returns malformed
4
+ JSON, this runs instantly with no model: it scans the vibe text for known cues,
5
+ unions and averages the matched brief-key weight dicts, and hands the result to
6
+ :func:`mapping.brief_scores_to_affinity`. Returns ``None`` when nothing matches,
7
+ so the caller can fall through to a neutral (equal-interest) reading.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from discoverroute.interpret import mapping
12
+
13
+ # substring cue -> brief-key scores (category keys + quiet/green/busy modifiers).
14
+ KEYWORD_WEIGHTS: dict[str, dict[str, float]] = {
15
+ "quiet": {"quiet": 0.9, "busy": 0.1, "park": 0.6},
16
+ "calm": {"quiet": 0.85, "park": 0.5},
17
+ "peace": {"quiet": 0.85, "park": 0.5},
18
+ "coffee": {"cafe": 0.9, "bakery": 0.7},
19
+ "café": {"cafe": 0.9, "bakery": 0.6},
20
+ "cafe": {"cafe": 0.9, "bakery": 0.6},
21
+ "espresso": {"cafe": 0.9},
22
+ "book": {"bookshop": 0.95},
23
+ "librair": {"bookshop": 0.8}, # libraire / librairie
24
+ "read": {"bookshop": 0.7, "quiet": 0.5},
25
+ "green": {"park": 0.9, "green": 0.85},
26
+ "park": {"park": 0.9, "green": 0.8},
27
+ "garden": {"park": 0.9, "green": 0.85},
28
+ "nature": {"park": 0.85, "green": 0.8},
29
+ "water": {"park": 0.6, "viewpoint": 0.6, "green": 0.4},
30
+ "river": {"viewpoint": 0.7, "green": 0.4},
31
+ "canal": {"viewpoint": 0.7, "green": 0.4},
32
+ "histor": {"museum": 0.7, "historic": 0.9},
33
+ "herit": {"historic": 0.9},
34
+ "old": {"historic": 0.7},
35
+ "church": {"historic": 0.8, "quiet": 0.6},
36
+ "museum": {"museum": 0.9, "historic": 0.5},
37
+ "art": {"museum": 0.85},
38
+ "galler": {"museum": 0.85},
39
+ "view": {"viewpoint": 0.9},
40
+ "panoram": {"viewpoint": 0.9},
41
+ "scenic": {"viewpoint": 0.8, "green": 0.5},
42
+ "food": {"restaurant": 0.8, "market": 0.7, "bakery": 0.6},
43
+ "eat": {"restaurant": 0.8, "bakery": 0.5},
44
+ "lunch": {"restaurant": 0.8, "cafe": 0.5},
45
+ "dinner": {"restaurant": 0.85},
46
+ "bakery": {"bakery": 0.9},
47
+ "pastr": {"bakery": 0.9},
48
+ "market": {"market": 0.9},
49
+ "shop": {"market": 0.7},
50
+ "bar": {"bar": 0.85, "busy": 0.5},
51
+ "pub": {"bar": 0.85, "busy": 0.5},
52
+ "drink": {"bar": 0.8},
53
+ "wine": {"bar": 0.8},
54
+ "lively": {"busy": 0.9, "bar": 0.6, "market": 0.6},
55
+ "busy": {"busy": 0.9, "market": 0.6},
56
+ "bustl": {"busy": 0.85, "market": 0.7},
57
+ }
58
+
59
+
60
+ def keyword_scores(vibe: str) -> dict[str, float] | None:
61
+ """Union + average the brief-key scores of every cue found in ``vibe``."""
62
+ text = (vibe or "").lower()
63
+ if not text:
64
+ return None
65
+ sums: dict[str, float] = {}
66
+ counts: dict[str, int] = {}
67
+ matched = False
68
+ for cue, weights in KEYWORD_WEIGHTS.items():
69
+ if cue in text:
70
+ matched = True
71
+ for key, val in weights.items():
72
+ sums[key] = sums.get(key, 0.0) + val
73
+ counts[key] = counts.get(key, 0) + 1
74
+ if not matched:
75
+ return None
76
+ return {key: sums[key] / counts[key] for key in sums}
77
+
78
+
79
+ def keyword_affinity(vibe: str) -> dict[str, float] | None:
80
+ """Keyword scores mapped to a taxonomy affinity dict, or ``None``."""
81
+ scores = keyword_scores(vibe)
82
+ if not scores:
83
+ return None
84
+ return mapping.brief_scores_to_affinity(scores)
src/discoverroute/interpret/llm_vibe.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Call 1 — vibe → routing weights via MiniCPM5-1B (JSON-only, lean).
2
+
3
+ Asks the in-Space 1B model for a small fixed-schema JSON of preference weights,
4
+ validates it hard, and maps it onto the taxonomy affinity the router consumes.
5
+ Any failure — model absent (off-GPU), invalid JSON, or missing keys — returns
6
+ ``None`` so the caller falls back to the keyword matcher. Every attempt is
7
+ traced (``used_fallback=True`` when the model's output is rejected).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import json
13
+ import re
14
+ import time
15
+
16
+ from discoverroute.interpret import mapping
17
+
18
+ SYSTEM_PROMPT = (
19
+ "You are a routing preference extractor.\n"
20
+ "Output ONLY valid JSON. No prose, no explanation, no markdown.\n"
21
+ 'Schema: {"cafe": 0.0-1.0, "park": 0.0-1.0, "bookshop": 0.0-1.0, '
22
+ '"museum": 0.0-1.0, "bakery": 0.0-1.0, "restaurant": 0.0-1.0, '
23
+ '"bar": 0.0-1.0, "viewpoint": 0.0-1.0, "market": 0.0-1.0, '
24
+ '"quiet": 0.0-1.0, "green": 0.0-1.0, "historic": 0.0-1.0, '
25
+ '"busy": 0.0-1.0, "detour_budget_multiplier": 0.5-2.0}\n'
26
+ "All keys required. Values reflect how strongly the vibe matches each."
27
+ )
28
+
29
+ REQUIRED_KEYS = (
30
+ "cafe", "park", "bookshop", "museum", "bakery", "restaurant", "bar",
31
+ "viewpoint", "market", "quiet", "green", "historic", "busy",
32
+ "detour_budget_multiplier",
33
+ )
34
+
35
+
36
+ def _extract_json(text: str) -> dict | None:
37
+ """Pull the first balanced JSON object out of a model response."""
38
+ if not text:
39
+ return None
40
+ # strip code fences if present
41
+ text = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.MULTILINE)
42
+ start = text.find("{")
43
+ if start < 0:
44
+ return None
45
+ depth = 0
46
+ for i in range(start, len(text)):
47
+ if text[i] == "{":
48
+ depth += 1
49
+ elif text[i] == "}":
50
+ depth -= 1
51
+ if depth == 0:
52
+ try:
53
+ return json.loads(text[start:i + 1])
54
+ except Exception: # noqa: BLE001
55
+ return None
56
+ return None
57
+
58
+
59
+ def _validate(obj: dict | None) -> dict | None:
60
+ """Require all keys present and numeric; coerce to floats. Else None."""
61
+ if not isinstance(obj, dict):
62
+ return None
63
+ out: dict[str, float] = {}
64
+ for key in REQUIRED_KEYS:
65
+ if key not in obj:
66
+ return None
67
+ try:
68
+ out[key] = float(obj[key])
69
+ except (TypeError, ValueError):
70
+ return None
71
+ return out
72
+
73
+
74
+ @functools.lru_cache(maxsize=256)
75
+ def extract(vibe: str) -> dict | None:
76
+ """Return ``{"affinity", "budget_multiplier", "raw"}`` or ``None``.
77
+
78
+ ``None`` means "use the fallback": either the model is unavailable
79
+ (off-GPU) or it produced output that failed validation.
80
+ """
81
+ vibe = (vibe or "").strip()
82
+ if not vibe:
83
+ return None
84
+
85
+ from discoverroute.narrate import narrate as _narrate # lazy: llm_available()
86
+ from discoverroute.narrate import trace
87
+
88
+ if not _narrate.llm_available():
89
+ return None # off-GPU: skip straight to the fallback (no trace noise)
90
+
91
+ messages = [
92
+ {"role": "system", "content": SYSTEM_PROMPT},
93
+ {"role": "user", "content": f"Extract routing weights for this vibe: {vibe}"},
94
+ ]
95
+ t0 = time.time()
96
+ try:
97
+ from discoverroute.narrate.llm import run_inference
98
+ raw_text = run_inference(messages, max_new_tokens=160, temperature=0.2)
99
+ except Exception as exc: # noqa: BLE001 - never break interpretation
100
+ latency = int((time.time() - t0) * 1000)
101
+ trace.log_trace("vibe_extraction", {"vibe": vibe},
102
+ {"error": f"{type(exc).__name__}: {exc}"},
103
+ latency, used_fallback=True)
104
+ return None
105
+
106
+ latency = int((time.time() - t0) * 1000)
107
+ parsed = _validate(_extract_json(raw_text))
108
+ if parsed is None:
109
+ trace.log_trace("vibe_extraction", {"vibe": vibe},
110
+ {"raw": raw_text}, latency, used_fallback=True)
111
+ return None
112
+
113
+ affinity = mapping.brief_scores_to_affinity(parsed)
114
+ budget_mult = max(0.5, min(2.0, parsed["detour_budget_multiplier"]))
115
+ trace.log_trace("vibe_extraction", {"vibe": vibe}, parsed,
116
+ latency, used_fallback=False)
117
+ return {"affinity": affinity, "budget_multiplier": budget_mult, "raw": parsed}
src/discoverroute/interpret/mapping.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bridge the LLM/keyword "brief keys" to the taxonomy's category affinity.
2
+
3
+ Call 1 (vibe→weights) and the keyword fallback both speak a small, human set of
4
+ keys — ``cafe, park, bookshop, museum, bakery, restaurant, bar, viewpoint,
5
+ market, historic`` (category keys) plus ``quiet, green, busy`` (modifiers). The
6
+ routing engine, however, scores over the 17-category :mod:`taxonomy` vocabulary
7
+ via the ``Weights.category_affinity`` dict. This module is the single, dependency
8
+ -light translator between the two, so both producers stay consistent.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from discoverroute import config
13
+ from discoverroute.data import taxonomy
14
+
15
+ # brief category-key -> taxonomy categories it directly expresses interest in.
16
+ CATEGORY_KEYS: dict[str, list[str]] = {
17
+ "cafe": ["cafe"],
18
+ "park": ["park_garden"],
19
+ "bookshop": ["bookshop", "library"],
20
+ "museum": ["museum_gallery", "theatre_cinema", "artwork"],
21
+ "bakery": ["bakery_food_shop"],
22
+ "restaurant": ["restaurant"],
23
+ "bar": ["bar_pub"],
24
+ "viewpoint": ["viewpoint", "attraction"],
25
+ "market": ["market", "specialty_shop"],
26
+ "historic": ["monument_historic", "place_of_worship"],
27
+ }
28
+
29
+ # Modifier keys shape every category via its intrinsic priors (not 1:1 to a
30
+ # category): green→greenness, quiet→quietness, busy→liveliness (1−quietness).
31
+ MODIFIER_KEYS = ("green", "quiet", "busy")
32
+
33
+ # Inverted: taxonomy category -> the brief category-keys that target it.
34
+ _KEYS_FOR_CATEGORY: dict[str, list[str]] = {}
35
+ for _key, _cats in CATEGORY_KEYS.items():
36
+ for _c in _cats:
37
+ _KEYS_FOR_CATEGORY.setdefault(_c, []).append(_key)
38
+
39
+ # All keys a producer is expected to emit (category keys + modifiers).
40
+ BRIEF_KEYS: tuple[str, ...] = tuple(CATEGORY_KEYS) + MODIFIER_KEYS
41
+
42
+
43
+ def brief_scores_to_affinity(scores: dict[str, float]) -> dict[str, float]:
44
+ """Map brief-key scores → a floored affinity over every taxonomy category.
45
+
46
+ Each category takes the strongest signal among: its direct category-key
47
+ score, and the modifier contributions (green·greenness, quiet·quietness,
48
+ busy·liveliness). The top category is scaled to 1.0 and the rest floored to
49
+ ``AFFINITY_FLOOR`` — the same shape :func:`embed.vibe_to_affinity` returns,
50
+ so the routing engine cannot tell which producer ran.
51
+ """
52
+ green = float(scores.get("green", 0.0) or 0.0)
53
+ quiet = float(scores.get("quiet", 0.0) or 0.0)
54
+ busy = float(scores.get("busy", 0.0) or 0.0)
55
+
56
+ raw: dict[str, float] = {}
57
+ for cat in taxonomy.CATEGORIES:
58
+ contribs = [0.0]
59
+ for key in _KEYS_FOR_CATEGORY.get(cat, []):
60
+ contribs.append(float(scores.get(key, 0.0) or 0.0))
61
+ contribs.append(green * taxonomy.greenness(cat))
62
+ contribs.append(quiet * taxonomy.quietness(cat))
63
+ contribs.append(busy * (1.0 - taxonomy.quietness(cat)))
64
+ raw[cat] = max(contribs)
65
+
66
+ hi = max(raw.values()) or 1.0
67
+ floor = config.AFFINITY_FLOOR
68
+ return {c: floor + (1.0 - floor) * (v / hi) for c, v in raw.items()}
src/discoverroute/interpret/profile.py CHANGED
@@ -63,8 +63,8 @@ def effective_weights(profile: dict, trip_vibe: str = "",
63
  prof = profile_affinity(profile)
64
  trip = None
65
  if (trip_vibe or "").strip():
66
- from discoverroute.interpret import embed
67
- trip = embed.vibe_to_affinity(trip_vibe)
68
 
69
  if prof is None and trip is None:
70
  affinity = {c: 1.0 for c in taxonomy.CATEGORIES}
 
63
  prof = profile_affinity(profile)
64
  trip = None
65
  if (trip_vibe or "").strip():
66
+ from discoverroute.interpret.affinity import affinity_only
67
+ trip = affinity_only(trip_vibe)
68
 
69
  if prof is None and trip is None:
70
  affinity = {c: 1.0 for c in taxonomy.CATEGORIES}
src/discoverroute/interpret/vibe.py CHANGED
@@ -45,10 +45,10 @@ def _contains(text: str, cues) -> bool:
45
 
46
  def interpret(vibe: str, adventurousness: float = config.DEFAULT_ADVENTUROUSNESS,
47
  budget: float | None = None) -> Interpretation:
48
- from discoverroute.interpret import embed # lazy: avoids loading the model unless used
49
 
50
  text = (vibe or "").strip().lower()
51
- affinity = embed.vibe_to_affinity(vibe)
52
  weights = Weights(category_affinity=affinity, w_category=1.0)
53
 
54
  # (b) posture: start from category defaults, then let the mood tilt it.
 
45
 
46
  def interpret(vibe: str, adventurousness: float = config.DEFAULT_ADVENTUROUSNESS,
47
  budget: float | None = None) -> Interpretation:
48
+ from discoverroute.interpret.affinity import resolve_affinity # lazy: model only if used
49
 
50
  text = (vibe or "").strip().lower()
51
+ affinity, _source = resolve_affinity(vibe)
52
  weights = Weights(category_affinity=affinity, w_category=1.0)
53
 
54
  # (b) posture: start from category defaults, then let the mood tilt it.
src/discoverroute/narrate/llm.py CHANGED
@@ -1,8 +1,10 @@
1
- """Lazy Qwen3.5-9B client for narration/posture (32B, ZeroGPU).
2
 
3
- Loaded only when narration explicitly enables the LLM (GPU present). Runs with
4
- thinking disabled for fast, direct itinerary text. Kept isolated so importing the
5
- rest of the app never pulls in the model.
 
 
6
  """
7
  from __future__ import annotations
8
 
@@ -19,29 +21,33 @@ except Exception: # noqa: BLE001 - not on a Space / package absent
19
 
20
 
21
  @functools.lru_cache(maxsize=1)
22
- def _pipe():
 
23
  import torch
24
  from transformers import AutoModelForCausalLM, AutoTokenizer
25
 
26
- tok = AutoTokenizer.from_pretrained(config.LLM_MODEL)
27
  model = AutoModelForCausalLM.from_pretrained(
28
- config.LLM_MODEL, torch_dtype="auto", device_map="auto"
 
 
 
29
  )
30
  return tok, model
31
 
32
 
33
  @_gpu
34
- def generate(prompt: str, max_new_tokens: int = 320) -> str:
35
- tok, model = _pipe()
36
- messages = [{"role": "user", "content": prompt}]
 
37
  text = tok.apply_chat_template(
38
- messages, tokenize=False, add_generation_prompt=True,
39
- enable_thinking=False, # Qwen3.5: direct answer, no <think> block
40
  )
41
  inputs = tok([text], return_tensors="pt").to(model.device)
42
  out = model.generate(
43
  **inputs, max_new_tokens=max_new_tokens,
44
- temperature=0.7, top_p=0.8, top_k=20, do_sample=True,
45
  )
46
  gen = out[0][inputs.input_ids.shape[1]:]
47
  return tok.decode(gen, skip_special_tokens=True).strip()
 
1
+ """Lazy MiniCPM5-1B client (Tiny Titan4B, in-Space ZeroGPU).
2
 
3
+ One model serves both inference calls vibe→weights extraction and route
4
+ narration so the weights load once and are reused. Standard LlamaForCausalLM
5
+ (no custom kernels). Loaded only when a GPU is present (off-Space the
6
+ ``@spaces.GPU`` decorator is an identity no-op, and callers fall back), so
7
+ importing the rest of the app never pulls in torch/transformers.
8
  """
9
  from __future__ import annotations
10
 
 
21
 
22
 
23
  @functools.lru_cache(maxsize=1)
24
+ def _load():
25
+ """Load (tokenizer, model) once. MiniCPM5-1B → fp16, device_map=auto."""
26
  import torch
27
  from transformers import AutoModelForCausalLM, AutoTokenizer
28
 
29
+ tok = AutoTokenizer.from_pretrained(config.LLM_MODEL, trust_remote_code=True)
30
  model = AutoModelForCausalLM.from_pretrained(
31
+ config.LLM_MODEL,
32
+ torch_dtype=torch.float16,
33
+ device_map="auto",
34
+ trust_remote_code=True,
35
  )
36
  return tok, model
37
 
38
 
39
  @_gpu
40
+ def run_inference(messages: list[dict], max_new_tokens: int = 320,
41
+ temperature: float = 0.7) -> str:
42
+ """Run a chat completion. ``messages`` = [{"role","content"}, ...]."""
43
+ tok, model = _load()
44
  text = tok.apply_chat_template(
45
+ messages, tokenize=False, add_generation_prompt=True
 
46
  )
47
  inputs = tok([text], return_tensors="pt").to(model.device)
48
  out = model.generate(
49
  **inputs, max_new_tokens=max_new_tokens,
50
+ temperature=temperature, top_p=0.8, top_k=20, do_sample=True,
51
  )
52
  gen = out[0][inputs.input_ids.shape[1]:]
53
  return tok.decode(gen, skip_special_tokens=True).strip()
src/discoverroute/narrate/narrate.py CHANGED
@@ -3,7 +3,7 @@
3
  Two generators behind one gate:
4
  * a deterministic **template** — grounded by construction (only ever inserts
5
  real waypoint names + the start/end labels), the safe default; and
6
- * an optional **LLM** (Qwen3.5-9B) that adds voice, used only when it runs
7
  (GPU/Space) and only if its output passes the zero-hallucination gate.
8
 
9
  ``narrate`` always returns text that passes ``verify_grounded`` — the LLM is a
@@ -13,7 +13,6 @@ from __future__ import annotations
13
 
14
  import os
15
 
16
- from discoverroute import config
17
  from discoverroute.narrate import grounding
18
 
19
  # Phrasing per category for the template. Generic (no external facts) so the only
@@ -61,11 +60,8 @@ def template_narration(plain, discovery, pois, vibe, mode, start_label="",
61
  name = p.name or f"a {p.category.replace('_', ' ')}"
62
  reason = _REASON.get(p.category, "a stop worth making")
63
  verb = _verb(posture.get(p.category, "pass"))
64
- state = getattr(p, "open_state", None)
65
- live = getattr(p, "live_status", None) # Google-verified, when present
66
- if live is not None:
67
- badge = " · 🟢 open now ✓live" if live else " · 🔴 closed right now ✓live"
68
- elif state is True:
69
  badge = " · 🟢 open now"
70
  elif state is False:
71
  badge = " · 🔴 closed right now"
@@ -98,7 +94,7 @@ def llm_available() -> bool:
98
 
99
 
100
  def narrate(plain, discovery, pois, vibe="", mode="walk", start_label="",
101
- end_label="", posture=None) -> tuple[str, bool]:
102
  """Return (markdown, used_llm). Output is guaranteed grounded."""
103
  template = template_narration(
104
  plain, discovery, pois, vibe, mode, start_label, end_label, posture
@@ -106,36 +102,73 @@ def narrate(plain, discovery, pois, vibe="", mode="walk", start_label="",
106
  if not llm_available():
107
  return template, False
108
 
 
 
 
 
 
 
109
  try:
110
- text = _llm_narration(plain, discovery, pois, vibe, mode, start_label, end_label)
 
111
  ok, offenders = grounding.verify_grounded(text, pois, start_label, end_label)
 
112
  if ok and text.strip():
 
 
113
  return text, True
 
 
114
  print(f"[narrate] LLM output rejected by grounding gate; offenders={offenders}",
115
  flush=True)
116
  except Exception as exc: # noqa: BLE001 - never let narration break a route
 
 
117
  print(f"[narrate] LLM failed ({type(exc).__name__}): {exc}", flush=True)
118
  return template, False # fail-closed: ship the grounded template
119
 
120
 
121
- def _llm_narration(plain, discovery, pois, vibe, mode, start_label, end_label) -> str:
122
- """Generate narration with Qwen3.5-9B, constrained to the allowed names."""
123
- from discoverroute.narrate.llm import generate
 
 
 
 
 
 
 
 
 
 
124
 
125
  names = [p.name or f"a {p.category.replace('_', ' ')}" for p in pois]
126
  bullet = "\n".join(
127
  f"- {n} ({p.category.replace('_', ' ')})" for n, p in zip(names, pois)
128
  )
129
  extra = round(discovery.time_min - plain.time_min)
130
- prompt = (
131
- "You are a warm local guide writing a short itinerary for a "
132
- f"{mode} through Paris from {start_label} to {end_label}.\n"
133
- f"The traveller's vibe: {vibe or 'open to anything'}.\n"
134
- f"The route adds {extra} minutes to pass these real places, in order:\n"
135
- f"{bullet}\n\n"
136
- "Write 3-5 short sentences. CRITICAL RULES: mention ONLY the place names "
137
- "listed above, spelled exactly. Do NOT invent or name any other place, "
138
- "landmark, street, or neighbourhood. If unsure, refer to a place by its "
139
- "type instead of a name."
 
 
 
 
 
 
 
 
 
 
 
140
  )
141
- return generate(prompt, max_new_tokens=320)
 
 
 
3
  Two generators behind one gate:
4
  * a deterministic **template** — grounded by construction (only ever inserts
5
  real waypoint names + the start/end labels), the safe default; and
6
+ * an optional **LLM** (MiniCPM5-1B) that adds voice, used only when it runs
7
  (GPU/Space) and only if its output passes the zero-hallucination gate.
8
 
9
  ``narrate`` always returns text that passes ``verify_grounded`` — the LLM is a
 
13
 
14
  import os
15
 
 
16
  from discoverroute.narrate import grounding
17
 
18
  # Phrasing per category for the template. Generic (no external facts) so the only
 
60
  name = p.name or f"a {p.category.replace('_', ' ')}"
61
  reason = _REASON.get(p.category, "a stop worth making")
62
  verb = _verb(posture.get(p.category, "pass"))
63
+ state = getattr(p, "open_state", None) # OSM opening_hours, when decidable
64
+ if state is True:
 
 
 
65
  badge = " · 🟢 open now"
66
  elif state is False:
67
  badge = " · 🔴 closed right now"
 
94
 
95
 
96
  def narrate(plain, discovery, pois, vibe="", mode="walk", start_label="",
97
+ end_label="", posture=None, weights=None) -> tuple[str, bool]:
98
  """Return (markdown, used_llm). Output is guaranteed grounded."""
99
  template = template_narration(
100
  plain, discovery, pois, vibe, mode, start_label, end_label, posture
 
102
  if not llm_available():
103
  return template, False
104
 
105
+ import time
106
+
107
+ from discoverroute.narrate import trace
108
+
109
+ meta = {"vibe": vibe, "mode": mode, "n_stops": len(pois)}
110
+ t0 = time.time()
111
  try:
112
+ text = _llm_narration(plain, discovery, pois, vibe, mode,
113
+ start_label, end_label, weights)
114
  ok, offenders = grounding.verify_grounded(text, pois, start_label, end_label)
115
+ latency = int((time.time() - t0) * 1000)
116
  if ok and text.strip():
117
+ trace.log_trace("narration", meta, {"text": text}, latency,
118
+ used_fallback=False)
119
  return text, True
120
+ trace.log_trace("narration", meta, {"text": text, "offenders": offenders},
121
+ latency, used_fallback=True)
122
  print(f"[narrate] LLM output rejected by grounding gate; offenders={offenders}",
123
  flush=True)
124
  except Exception as exc: # noqa: BLE001 - never let narration break a route
125
+ trace.log_trace("narration", meta, {"error": f"{type(exc).__name__}: {exc}"},
126
+ int((time.time() - t0) * 1000), used_fallback=True)
127
  print(f"[narrate] LLM failed ({type(exc).__name__}): {exc}", flush=True)
128
  return template, False # fail-closed: ship the grounded template
129
 
130
 
131
+ def _weights_summary(weights) -> str:
132
+ """Compact 'cafe 0.9, park 0.7' line from the extracted weights, if any."""
133
+ aff = getattr(weights, "category_affinity", None)
134
+ if not aff:
135
+ return ""
136
+ top = sorted(aff, key=aff.get, reverse=True)[:5]
137
+ return ", ".join(f"{c.replace('_', ' ')} {aff[c]:.2f}" for c in top)
138
+
139
+
140
+ def _llm_narration(plain, discovery, pois, vibe, mode, start_label, end_label,
141
+ weights=None) -> str:
142
+ """Generate narration with MiniCPM5-1B, constrained to the allowed names."""
143
+ from discoverroute.narrate.llm import run_inference
144
 
145
  names = [p.name or f"a {p.category.replace('_', ' ')}" for p in pois]
146
  bullet = "\n".join(
147
  f"- {n} ({p.category.replace('_', ' ')})" for n, p in zip(names, pois)
148
  )
149
  extra = round(discovery.time_min - plain.time_min)
150
+ total_min = round(discovery.time_min + getattr(discovery, "dwell_s", 0.0) / 60.0)
151
+ weights_line = _weights_summary(weights)
152
+
153
+ system = (
154
+ "You are a Parisian city guide who knows every street. Write a warm, "
155
+ "specific, first-person itinerary for this walking route. Reference the "
156
+ "user's stated vibe directly. For each stop, explain in one sentence why "
157
+ "it matches what they were looking for. Format as markdown with one "
158
+ "header per stop.\n"
159
+ "CRITICAL: mention ONLY the place names listed in the prompt, spelled "
160
+ "exactly. Never invent or name any other place, street, landmark, or "
161
+ "neighbourhood. If unsure, refer to a place by its type, not a name."
162
+ )
163
+ user = (
164
+ f"Vibe: {vibe or 'open to anything'}\n"
165
+ + (f"Weights extracted: {weights_line}\n" if weights_line else "")
166
+ + f"Mode: {mode} from {start_label or 'the start'} to "
167
+ f"{end_label or 'the destination'}\n"
168
+ f"Ordered stops:\n{bullet}\n"
169
+ f"Total time: {total_min} minutes (about {extra} minutes of discovery)\n\n"
170
+ "Write the itinerary."
171
  )
172
+ messages = [{"role": "system", "content": system},
173
+ {"role": "user", "content": user}]
174
+ return run_inference(messages, max_new_tokens=600)
src/discoverroute/narrate/trace.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference trace logging (Open Trace badge).
2
+
3
+ Every model call — vibe→weights extraction and route narration — emits one JSON
4
+ row. Rows are always appended to ``logs/traces.jsonl`` locally; when a Hugging
5
+ Face write token is present (``HF_TOKEN``), each row is *also* pushed to the
6
+ configured HF Dataset on a daemon thread (non-blocking — the user never waits on
7
+ the write). With no token the push is a silent no-op, so nothing breaks locally.
8
+
9
+ Both successful calls and fallback activations are logged (``used_fallback``),
10
+ so the dataset reflects exactly what the model did vs. what the safety net did.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import threading
16
+ from datetime import datetime, timezone
17
+
18
+ from discoverroute import config
19
+
20
+ LOG_DIR = config.PROJECT_ROOT / "logs"
21
+ TRACES_PATH = LOG_DIR / "traces.jsonl"
22
+ PLANS_PATH = LOG_DIR / "plans.jsonl"
23
+
24
+ # Monotonic, process-local counter so concurrently-written HF filenames never
25
+ # collide (datetime alone can repeat under load).
26
+ _seq_lock = threading.Lock()
27
+ _seq = 0
28
+
29
+
30
+ def _next_seq() -> int:
31
+ global _seq
32
+ with _seq_lock:
33
+ _seq += 1
34
+ return _seq
35
+
36
+
37
+ def _utcnow_iso() -> str:
38
+ return datetime.now(timezone.utc).isoformat()
39
+
40
+
41
+ def _append_local(path, row: dict) -> None:
42
+ try:
43
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
44
+ with open(path, "a", encoding="utf-8") as fh:
45
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
46
+ except Exception: # noqa: BLE001 - logging must never break a route
47
+ pass
48
+
49
+
50
+ def _push_hf_async(row: dict, kind: str) -> None:
51
+ """Best-effort async upload of one row to the trace dataset (token-gated)."""
52
+ token = config.HF_TOKEN
53
+ if not token:
54
+ return # stub: local-only until a token is provided on the Space
55
+ seq = _next_seq()
56
+ stamp = row.get("timestamp", _utcnow_iso()).replace(":", "-")
57
+
58
+ def _push() -> None:
59
+ try:
60
+ from huggingface_hub import HfApi
61
+
62
+ api = HfApi(token=token)
63
+ api.upload_file(
64
+ path_or_fileobj=json.dumps(row, ensure_ascii=False).encode("utf-8"),
65
+ path_in_repo=f"{kind}/{stamp}-{seq:06d}.json",
66
+ repo_id=config.TRACE_REPO,
67
+ repo_type="dataset",
68
+ )
69
+ except Exception: # noqa: BLE001 - never surface trace-push failures
70
+ pass
71
+
72
+ threading.Thread(target=_push, daemon=True).start()
73
+
74
+
75
+ def log_trace(call_type: str, input_data: dict, output_data: dict,
76
+ latency_ms: int, used_fallback: bool = False,
77
+ model: str | None = None) -> dict:
78
+ """Record one inference call (``vibe_extraction`` or ``narration``)."""
79
+ row = {
80
+ "timestamp": _utcnow_iso(),
81
+ "call_type": call_type,
82
+ "model": model or config.LLM_MODEL,
83
+ "input": json.dumps(input_data, ensure_ascii=False),
84
+ "output": json.dumps(output_data, ensure_ascii=False),
85
+ "latency_ms": int(latency_ms),
86
+ "used_fallback": bool(used_fallback),
87
+ }
88
+ _append_local(TRACES_PATH, row)
89
+ _push_hf_async(row, "traces")
90
+ return row
91
+
92
+
93
+ def log_plan(params: dict, summary: dict) -> dict:
94
+ """Record one end-to-end plan_route call (feeds the Field Notes write-up)."""
95
+ row = {"timestamp": _utcnow_iso(), "params": params, "summary": summary}
96
+ _append_local(PLANS_PATH, row)
97
+ _push_hf_async(row, "plans")
98
+ return row
src/discoverroute/pipeline.py CHANGED
@@ -55,6 +55,43 @@ def plan_route(
55
  prefer_quiet: float = 0.0,
56
  profile: dict | None = None,
57
  n_alternatives: int = 1,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  ) -> PlanResult:
59
  """Plan a route. Returns a PlanResult; never raises for user-facing errors."""
60
  try:
@@ -120,13 +157,10 @@ def plan_route(
120
  if discovery is None or not selected:
121
  break
122
  used_ids.update(p.osm_id for p in selected)
123
- # Optional live verification of the chosen stops (no-op without key).
124
- from discoverroute.enrich import google_places
125
- google_places.verify_stops(selected)
126
  itinerary_md, _ = narrate(
127
  plain, discovery, selected, vibe=vibe, mode=mode,
128
  start_label=start_query.strip(), end_label=dest_query.strip(),
129
- posture=posture,
130
  )
131
  alternatives.append(Alternative(
132
  discovery=discovery, pois=selected,
 
55
  prefer_quiet: float = 0.0,
56
  profile: dict | None = None,
57
  n_alternatives: int = 1,
58
+ ) -> PlanResult:
59
+ """Plan a route + log one plan-level trace row. Never raises for user errors."""
60
+ result = _plan_route_impl(
61
+ start_query, dest_query, mode, budget, vibe, adventurousness,
62
+ prefer_green, prefer_quiet, profile, n_alternatives,
63
+ )
64
+ try: # one Open-Trace / Field-Notes row per call; never break a route
65
+ from discoverroute.interpret.affinity import source_of
66
+ from discoverroute.narrate import trace
67
+ trace.log_plan(
68
+ {"start": start_query, "dest": dest_query, "mode": mode,
69
+ "budget": budget, "vibe": vibe, "adventurousness": adventurousness,
70
+ "weights_source": source_of(vibe) if (vibe or "").strip()
71
+ else ("profile" if profile else "manual")},
72
+ {"error": result.error,
73
+ "n_pois_selected": len(result.pois),
74
+ "n_alternatives": len(result.alternatives),
75
+ "plain_min": round(result.plain.time_min, 1) if result.plain else None,
76
+ "discovery_min": (round(result.discovery.time_min, 1)
77
+ if result.discovery else None)},
78
+ )
79
+ except Exception: # noqa: BLE001
80
+ pass
81
+ return result
82
+
83
+
84
+ def _plan_route_impl(
85
+ start_query: str,
86
+ dest_query: str,
87
+ mode: str = config.DEFAULT_MODE,
88
+ budget: float = config.DEFAULT_BUDGET,
89
+ vibe: str = "",
90
+ adventurousness: float = config.DEFAULT_ADVENTUROUSNESS,
91
+ prefer_green: float = 0.0,
92
+ prefer_quiet: float = 0.0,
93
+ profile: dict | None = None,
94
+ n_alternatives: int = 1,
95
  ) -> PlanResult:
96
  """Plan a route. Returns a PlanResult; never raises for user-facing errors."""
97
  try:
 
157
  if discovery is None or not selected:
158
  break
159
  used_ids.update(p.osm_id for p in selected)
 
 
 
160
  itinerary_md, _ = narrate(
161
  plain, discovery, selected, vibe=vibe, mode=mode,
162
  start_label=start_query.strip(), end_label=dest_query.strip(),
163
+ posture=posture, weights=weights,
164
  )
165
  alternatives.append(Alternative(
166
  discovery=discovery, pois=selected,
src/discoverroute/ui/shell.py ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom app-shell frontend for the ``gradio.Server`` backend.
2
+
3
+ Off-Brand: instead of Gradio's default column layout, the page is a hand-built
4
+ HTML/CSS/JS app-shell — fixed left control panel · large map · green route-summary
5
+ bar · scrollable itinerary · mobile bottom-drawer — served from ``app.get("/")``
6
+ and talking to ``@app.api`` endpoints through ``@gradio/client``.
7
+
8
+ Visual identity is preserved by REUSE, not reimplementation: the existing
9
+ :data:`design.DR_CSS` (tokens, sliders, segmented toggle, coral CTA, green summary
10
+ banner, dashed-rail itinerary, framed map window) is injected verbatim and the
11
+ same ``#dr-*`` element ids are kept, so every color/font/treatment carries over.
12
+ Only the spatial layout (``APP_SHELL_CSS``) and the vanilla-JS interactivity
13
+ (autocomplete, localStorage profile, sliders, the 4-step loader) are new.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from discoverroute.ui import design
18
+ from discoverroute.ui import map as mapui
19
+
20
+ # --------------------------------------------------------------- app-shell CSS
21
+ APP_SHELL_CSS = """
22
+ *{ box-sizing:border-box; }
23
+ html,body{ margin:0; height:100%; }
24
+ body{ font-family:'DM Sans',ui-sans-serif,system-ui,sans-serif; color:var(--dr-ink);
25
+ background:radial-gradient(1100px 520px at 88% -8%,#FBEFD6 0%,transparent 60%),var(--dr-cream); }
26
+
27
+ .app-shell{
28
+ display:grid; grid-template-columns:320px 1fr; grid-template-rows:auto 1fr;
29
+ height:100vh; width:100%; background:var(--dr-cream);
30
+ }
31
+
32
+ /* ---- hero (compact ≤120px, content + colors preserved) ---- */
33
+ .hero{ grid-column:1 / -1; grid-row:1; display:flex; align-items:center; gap:18px;
34
+ background:linear-gradient(120deg,#2F5DF4,#5C7DF8); color:#fff;
35
+ padding:12px 26px; max-height:120px; overflow:hidden; position:relative; }
36
+ .hero .hero-body{ flex:1; min-width:0; }
37
+ .hero .loc-pill{ display:inline-flex; align-items:center; gap:7px; background:rgba(255,255,255,.16);
38
+ border-radius:999px; padding:3px 11px; font-family:'Fredoka',sans-serif; font-weight:600;
39
+ font-size:11.5px; letter-spacing:.04em; }
40
+ .hero .loc-pill .dot{ width:7px;height:7px;border-radius:50%;background:#FFC247; }
41
+ .hero h1{ font-family:'Fredoka',sans-serif; font-weight:700; font-size:28px; letter-spacing:-.02em;
42
+ margin:5px 0 3px; line-height:1.05; color:#fff; }
43
+ .hero h1 .accent{ color:#FFE0A0; }
44
+ .hero p{ margin:0 0 7px; max-width:62ch; color:#EAF0FF; font-size:13px; line-height:1.3; }
45
+ .hero .badges{ display:flex; flex-wrap:wrap; gap:7px; }
46
+ .hero .badges span{ background:rgba(255,255,255,.13); border-radius:999px; padding:4px 11px;
47
+ font-size:11.5px; font-family:'Fredoka',sans-serif; font-weight:500; }
48
+ .hero .hero-art{ flex-shrink:0; width:80px; }
49
+ .hero .hero-art svg{ width:80px; height:auto; display:block; }
50
+ .hero .titan-chip{ position:absolute; top:10px; right:18px; background:rgba(255,255,255,.16);
51
+ border-radius:999px; padding:3px 10px; font-size:10.5px; font-family:'Fredoka',sans-serif;
52
+ font-weight:600; letter-spacing:.02em; }
53
+
54
+ /* ---- left control panel (320px, scrollable, sticky CTA) ---- */
55
+ .left-panel{ grid-column:1; grid-row:2; width:320px; height:100%; overflow-y:auto;
56
+ padding:18px 16px 0; border-right:1px solid rgba(0,0,0,.08); background:var(--dr-cream);
57
+ display:flex; flex-direction:column; }
58
+ .dr-control{ margin-bottom:13px; }
59
+ .dr-label{ display:block; font-family:'Fredoka',sans-serif; font-weight:600; font-size:13.5px;
60
+ color:var(--dr-ink); margin-bottom:5px; }
61
+ .dr-help{ font-size:11.5px; color:var(--dr-soft); margin-top:3px; }
62
+ .left-panel input[type=text]{ width:100%; padding:10px 12px; font-size:14px; }
63
+ .combo{ position:relative; }
64
+ .combo-list{ position:absolute; z-index:50; left:0; right:0; top:calc(100% + 3px);
65
+ background:var(--dr-paper); border:1px solid var(--dr-line); border-radius:14px;
66
+ box-shadow:0 14px 30px -14px rgba(43,38,32,.4); overflow:hidden; display:none; }
67
+ .combo-list.open{ display:block; }
68
+ .combo-list div{ padding:9px 12px; font-size:13.5px; cursor:pointer; }
69
+ .combo-list div:hover,.combo-list div.active{ background:#F1FAF4; }
70
+ .dr-slider{ display:flex; align-items:center; gap:10px; }
71
+ .dr-slider input[type=range]{ flex:1; }
72
+ .dr-slider output{ font-family:'JetBrains Mono',monospace; font-size:12.5px; color:var(--dr-soft);
73
+ min-width:30px; text-align:right; }
74
+ details.dr-collapse{ padding:0; margin-bottom:13px; }
75
+ details.dr-collapse summary{ list-style:none; cursor:pointer; padding:11px 14px;
76
+ font-family:'Fredoka',sans-serif; font-weight:600; font-size:13px; color:var(--dr-ink); }
77
+ details.dr-collapse summary::-webkit-details-marker{ display:none; }
78
+ details.dr-collapse[open] summary{ border-bottom:1.5px dashed var(--dr-line); }
79
+ details.dr-collapse .collapse-body{ padding:12px 14px; }
80
+ .dr-cta-wrap{ position:sticky; bottom:0; padding:12px 0; margin-top:auto;
81
+ background:linear-gradient(180deg,rgba(246,236,217,0),var(--dr-cream) 32%); }
82
+ #dr-plan button{ width:100%; padding:13px; cursor:pointer; }
83
+ .dr-note{ font-size:12.5px; color:var(--dr-soft); margin:8px 0; }
84
+ .dr-row{ display:flex; gap:8px; }
85
+ .dr-row button{ flex:1; padding:8px; border-radius:12px; border:1px solid var(--dr-line);
86
+ background:var(--dr-paper); font-family:'Fredoka',sans-serif; font-weight:600; cursor:pointer;
87
+ color:var(--dr-ink); }
88
+ .dr-star{ width:100%; margin-top:8px; padding:9px; border-radius:12px; border:1px solid var(--dr-line);
89
+ background:var(--dr-paper); font-family:'Fredoka',sans-serif; font-weight:600; cursor:pointer;
90
+ color:var(--dr-ink); }
91
+
92
+ /* ---- right column: map · summary bar · itinerary ---- */
93
+ .right-col{ grid-column:2; grid-row:2; display:flex; flex-direction:column; height:100%;
94
+ min-width:0; padding:14px 16px 0; gap:12px; overflow:hidden; }
95
+ .map-container{ flex:1 1 0; min-height:480px; position:relative; }
96
+ #dr-map{ height:100%; }
97
+ #dr-map .map-inner{ position:absolute; inset:34px 0 0 0; }
98
+ #dr-map .map-inner > div,
99
+ #dr-map iframe,
100
+ #dr-map .folium-map,
101
+ #dr-map .leaflet-container{ width:100% !important; height:100% !important; }
102
+ #dr-map .map-inner > div > div{ padding-bottom:0 !important; height:100% !important; }
103
+ .route-summary-bar{ flex:0 0 auto; }
104
+ #dr-summary{ margin:0; }
105
+ /* the styled result blocks only exist once a route is planned */
106
+ #dr-summary:empty, #dr-interp:empty, #dr-itin:empty, #dr-nodetour:empty{ display:none; }
107
+ .itinerary-panel{ flex:0 0 auto; max-height:40vh; overflow-y:auto; padding-bottom:14px; }
108
+ #dr-options{ display:flex; flex-wrap:wrap; gap:10px; margin-bottom:12px; }
109
+ #dr-options .opt{ border:2px solid var(--dr-line); border-radius:var(--dr-r); padding:10px 13px;
110
+ background:var(--dr-paper); color:var(--dr-ink); cursor:pointer; font-size:12.5px;
111
+ transition:all .2s var(--dr-spring); }
112
+ #dr-options .opt:hover{ transform:translateY(-3px); }
113
+ #dr-options .opt.selected{ border-color:var(--dr-grass); background:#F1FAF4;
114
+ box-shadow:0 10px 26px -14px rgba(47,164,99,.6); }
115
+
116
+ /* ---- loading: stride + 4-step stepper (State 2) ---- */
117
+ #dr-loading{ position:absolute; inset:34px 0 0 0; z-index:20; display:none;
118
+ place-items:center; border-radius:0 0 26px 26px;
119
+ background:radial-gradient(700px 320px at 50% 0%,#FBEFD6 0%,transparent 70%),#F6ECD9; }
120
+ #dr-loading.on{ display:grid; }
121
+ .stepper{ display:flex; align-items:center; gap:0; margin-top:18px; }
122
+ .stepper .step{ display:flex; flex-direction:column; align-items:center; gap:6px; width:120px;
123
+ opacity:.4; transition:opacity .4s ease; }
124
+ .stepper .step.active{ opacity:1; }
125
+ .stepper .step .pip{ width:14px; height:14px; border-radius:50%; background:#CFE8D8;
126
+ transition:background .4s ease, transform .4s var(--dr-spring); }
127
+ .stepper .step.active .pip{ background:var(--dr-grass); transform:scale(1.15); }
128
+ .stepper .step .lbl{ font-size:11.5px; font-family:'DM Sans',sans-serif; color:var(--dr-ink);
129
+ text-align:center; }
130
+ .stepper .bar{ flex:1; height:3px; min-width:24px; background:#CFE8D8; position:relative; }
131
+ .stepper .bar.fill{ background:var(--dr-grass); }
132
+ /* State 1 — non-Paris graph load (inert for the Paris demo, built per brief) */
133
+ #dr-mapping{ position:absolute; inset:34px 0 0 0; z-index:21; display:none; place-items:center;
134
+ background:#F6ECD9; }
135
+ #dr-mapping.on{ display:grid; }
136
+ #dr-mapping .pulse{ width:18px;height:18px;border-radius:50%;background:var(--dr-grass);
137
+ animation:drPulse 1.1s ease-in-out infinite; margin:0 auto 10px; }
138
+ @keyframes drPulse{ 0%,100%{ transform:scale(.7); opacity:.5; } 50%{ transform:scale(1.1); opacity:1; } }
139
+
140
+ /* no-detour sticker slot */
141
+ #dr-nodetour:empty{ display:none; }
142
+
143
+ /* mobile FAB (hidden on desktop) */
144
+ .fab{ display:none; }
145
+
146
+ /* ---- mobile: left panel → bottom drawer, full-screen map, coral FAB ---- */
147
+ @media (max-width:768px){
148
+ .app-shell{ grid-template-columns:1fr; grid-template-rows:auto 1fr; height:100vh; }
149
+ .hero{ max-height:none; }
150
+ .hero .hero-art{ display:none; }
151
+ .right-col{ grid-column:1; grid-row:2; padding:0; gap:0; }
152
+ .map-container{ min-height:0; }
153
+ #dr-map .map-inner, #dr-loading, #dr-mapping{ inset:0; }
154
+ .itinerary-panel{ max-height:34vh; padding:0 14px 14px; }
155
+ .route-summary-bar{ padding:0 12px; }
156
+ .left-panel{ position:fixed; left:0; right:0; bottom:0; top:auto; z-index:200; width:100%;
157
+ height:auto; max-height:78vh; border-right:none; border-top-left-radius:24px;
158
+ border-top-right-radius:24px; box-shadow:0 -18px 44px -20px rgba(43,38,32,.4);
159
+ transform:translateY(110%); transition:transform .32s var(--dr-spring); }
160
+ .left-panel.open{ transform:translateY(0); }
161
+ .fab{ display:grid; place-items:center; position:fixed; right:18px; bottom:18px; z-index:210;
162
+ width:60px; height:60px; border-radius:50%; border:none; cursor:pointer;
163
+ background:var(--dr-coral); color:#fff; font-size:24px;
164
+ box-shadow:0 10px 24px -8px rgba(255,106,82,.8); }
165
+ .drawer-close{ display:block; }
166
+ }
167
+ .drawer-close{ display:none; width:100%; text-align:center; padding:6px; font-size:22px;
168
+ color:var(--dr-soft); cursor:pointer; }
169
+
170
+ @media (prefers-reduced-motion:reduce){ *{ animation:none !important; transition:none !important; } }
171
+ """
172
+
173
+
174
+ def _compact_hero() -> str:
175
+ """Hero with the exact copy/badges/colors, tightened to ≤120px."""
176
+ return f"""
177
+ <header class="hero">
178
+ <div class="hero-body">
179
+ <span class="loc-pill"><span class="dot"></span>Paris · walkable detours</span>
180
+ <h1>Spend your extra time on <span class="accent">discovery.</span></h1>
181
+ <p>Ordinary navigation minimizes time. DiscoverRoute detours past places that match
182
+ your taste — within a travel-time budget — and tells you why each one is on the path.</p>
183
+ <div class="badges">
184
+ <span>🗺️ OpenStreetMap data</span>
185
+ <span>🥐 A friendly local guide</span>
186
+ <span>✨ Tuned to your vibe</span>
187
+ </div>
188
+ </div>
189
+ <div class="hero-art">{design._HERO_SVG}</div>
190
+ <span class="titan-chip">running on a 1B model, in-Space</span>
191
+ </header>
192
+ """
193
+
194
+
195
+ def _left_panel() -> str:
196
+ return """
197
+ <aside class="left-panel" id="left-panel">
198
+ <div class="drawer-close" id="drawer-close">×</div>
199
+
200
+ <div class="dr-control combo">
201
+ <label class="dr-label" for="dr-start">Start</label>
202
+ <input type="text" id="dr-start" class="dr-field" autocomplete="off"
203
+ value="Place de la République, Paris">
204
+ <div class="combo-list" id="dr-start-list"></div>
205
+ <div class="dr-help">Type a Paris place — suggestions appear as you type</div>
206
+ </div>
207
+
208
+ <div class="dr-control combo">
209
+ <label class="dr-label" for="dr-dest">Destination</label>
210
+ <input type="text" id="dr-dest" class="dr-field" autocomplete="off"
211
+ value="Jardin du Luxembourg, Paris">
212
+ <div class="combo-list" id="dr-dest-list"></div>
213
+ </div>
214
+
215
+ <div class="dr-control">
216
+ <label class="dr-label" for="dr-vibe">Vibe (free text)</label>
217
+ <input type="text" id="dr-vibe" class="dr-field"
218
+ placeholder="e.g. 'quiet green wander' or 'lively café crawl'">
219
+ </div>
220
+
221
+ <div class="dr-control">
222
+ <label class="dr-label">Mode</label>
223
+ <div id="dr-mode" class="dr-seg">
224
+ <div class="wrap">
225
+ <label class="selected" data-v="walk">walk</label>
226
+ <label data-v="bike">bike</label>
227
+ </div>
228
+ </div>
229
+ </div>
230
+
231
+ <div class="dr-control">
232
+ <label class="dr-label" for="dr-budget-i">Detour budget</label>
233
+ <div id="dr-budget" class="dr-slider">
234
+ <input type="range" id="dr-budget-i" min="0" max="2" step="0.1" value="0.5">
235
+ <output id="dr-budget-o">0.5</output>
236
+ </div>
237
+ <div class="dr-help">Extra time vs. the direct trip — 0 = straight there, 1 = up to 2× longer</div>
238
+ </div>
239
+
240
+ <div class="dr-control">
241
+ <label class="dr-label" for="dr-adv-i">Adventurousness</label>
242
+ <div id="dr-adv" class="dr-slider">
243
+ <input type="range" id="dr-adv-i" min="0" max="1" step="0.05" value="0.3">
244
+ <output id="dr-adv-o">0.3</output>
245
+ </div>
246
+ <div class="dr-help">Low = well-known places · high = hidden gems</div>
247
+ </div>
248
+
249
+ <details class="dr-collapse">
250
+ <summary>Manual taste (used only when Vibe and saved profile are both empty)</summary>
251
+ <div class="collapse-body">
252
+ <div class="dr-control">
253
+ <label class="dr-label" for="dr-green-i">Prefer green</label>
254
+ <div id="dr-green" class="dr-slider green">
255
+ <input type="range" id="dr-green-i" min="0" max="1" step="0.05" value="0.5">
256
+ <output id="dr-green-o">0.5</output>
257
+ </div>
258
+ </div>
259
+ <div class="dr-control">
260
+ <label class="dr-label" for="dr-quiet-i">Prefer quiet</label>
261
+ <div id="dr-quiet" class="dr-slider green">
262
+ <input type="range" id="dr-quiet-i" min="0" max="1" step="0.05" value="0.5">
263
+ <output id="dr-quiet-o">0.5</output>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </details>
268
+
269
+ <div class="dr-cta-wrap">
270
+ <div id="dr-plan"><button type="button" id="dr-plan-btn">Plan route</button></div>
271
+ </div>
272
+
273
+ <details class="dr-collapse">
274
+ <summary>⭐ My taste profile (saved on this device)</summary>
275
+ <div class="collapse-body">
276
+ <label class="dr-label" for="dr-profile-text">Standing preferences</label>
277
+ <input type="text" id="dr-profile-text" class="dr-field"
278
+ placeholder="e.g. 'I always love bookshops, gardens, and old churches'">
279
+ <div class="dr-row" style="margin-top:8px;">
280
+ <button type="button" id="dr-save">Save</button>
281
+ <button type="button" id="dr-clear">Clear</button>
282
+ </div>
283
+ <div class="dr-note" id="dr-saved"></div>
284
+ <button type="button" class="dr-star" id="dr-save-places">⭐ Save this route's places</button>
285
+ </div>
286
+ </details>
287
+ </aside>
288
+ """
289
+
290
+
291
+ def index_html() -> str:
292
+ """Assemble the full single-page app served at ``/``."""
293
+ empty = mapui.empty_map()
294
+ return f"""<!DOCTYPE html>
295
+ <html lang="en">
296
+ <head>
297
+ <meta charset="utf-8">
298
+ <meta name="viewport" content="width=device-width, initial-scale=1">
299
+ <title>DiscoverRoute · Paris</title>
300
+ {design.DR_HEAD}
301
+ <style>{design.DR_CSS}</style>
302
+ <style>{APP_SHELL_CSS}</style>
303
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
304
+ </head>
305
+ <body>
306
+ <div class="gradio-container app-shell">
307
+ {_compact_hero()}
308
+ {_left_panel()}
309
+ <main class="right-col">
310
+ <div class="map-container">
311
+ <div id="dr-map"><div class="map-inner">{empty}</div></div>
312
+ <div id="dr-mapping"><div><div class="pulse"></div>
313
+ <span style="font-family:'Fredoka',sans-serif;font-weight:600;">Mapping the streets…</span></div></div>
314
+ <div id="dr-loading">{_loading_inner()}</div>
315
+ </div>
316
+ <div class="route-summary-bar"><div id="dr-summary"></div></div>
317
+ <div class="itinerary-panel">
318
+ <div id="dr-options"></div>
319
+ <div id="dr-nodetour"></div>
320
+ <div id="dr-interp"></div>
321
+ <div id="dr-itin"></div>
322
+ </div>
323
+ </main>
324
+ </div>
325
+ <button class="fab" id="fab" aria-label="Open controls">☰</button>
326
+
327
+ <script type="module">{_app_js()}</script>
328
+ </body>
329
+ </html>"""
330
+
331
+
332
+ def _loading_inner() -> str:
333
+ """Stride mascot (reused) + the 4-step stepper."""
334
+ steps = ["Interpreting vibe", "Scoring places", "Solving route", "Writing itinerary"]
335
+ pips = []
336
+ for i, label in enumerate(steps):
337
+ if i:
338
+ pips.append('<div class="bar" data-bar="%d"></div>' % i)
339
+ pips.append(
340
+ f'<div class="step" data-step="{i}"><div class="pip"></div>'
341
+ f'<div class="lbl">{label}</div></div>'
342
+ )
343
+ stepper = '<div class="stepper">' + "".join(pips) + "</div>"
344
+ # Reuse the existing stride mascot markup from LOADING_HTML's inner SVG block.
345
+ return f"""
346
+ <div style="text-align:center;">
347
+ <div style="font-family:'Fredoka',system-ui,sans-serif;font-weight:600;font-size:19px;
348
+ color:#2B2620;">Scouting your wander…</div>
349
+ {stepper}
350
+ </div>
351
+ <style>
352
+ @keyframes drBounce {{ 0%,100% {{ transform:translateY(0) }} 50% {{ transform:translateY(-9px) }} }}
353
+ </style>
354
+ """
355
+
356
+
357
+ def _app_js() -> str:
358
+ return r"""
359
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
360
+
361
+ const $ = (id) => document.getElementById(id);
362
+ const md = (s) => (window.marked ? window.marked.parse(s || "") : (s || ""));
363
+
364
+ let appClient = null;
365
+ async function client() {
366
+ if (!appClient) appClient = await Client.connect(window.location.origin);
367
+ return appClient;
368
+ }
369
+
370
+ /* ---------- taste profile via localStorage (replaces gr.BrowserState) ---------- */
371
+ const PKEY = "discoverroute_profile";
372
+ function readProfile() {
373
+ try { return JSON.parse(localStorage.getItem(PKEY)) ||
374
+ { standing_text: "", saved_categories: [] }; }
375
+ catch (e) { return { standing_text: "", saved_categories: [] }; }
376
+ }
377
+ function writeProfile(p) { localStorage.setItem(PKEY, JSON.stringify(p)); renderSaved(); }
378
+ function renderSaved() {
379
+ const p = readProfile();
380
+ const cats = p.saved_categories || [];
381
+ let txt = "_No saved places yet. Plan a route, then ⭐ save its places._";
382
+ if (cats.length) {
383
+ const counts = {};
384
+ cats.forEach(c => counts[c] = (counts[c] || 0) + 1);
385
+ txt = "**Saved taste:** " + Object.entries(counts)
386
+ .sort((a, b) => b[1] - a[1])
387
+ .map(([c, n]) => `${c.replace(/_/g, " ")} ×${n}`).join(", ");
388
+ }
389
+ $("dr-saved").innerHTML = md(txt);
390
+ if ($("dr-profile-text") && !$("dr-profile-text").value) $("dr-profile-text").value = p.standing_text || "";
391
+ }
392
+
393
+ /* ---------- toast ---------- */
394
+ function toast(msg) {
395
+ const t = document.createElement("div");
396
+ t.textContent = msg;
397
+ t.style.cssText = "position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:999;" +
398
+ "background:#2B2620;color:#fff;padding:11px 18px;border-radius:14px;font-size:13.5px;" +
399
+ "box-shadow:0 12px 30px -12px rgba(0,0,0,.5);font-family:'DM Sans',sans-serif;";
400
+ document.body.appendChild(t);
401
+ setTimeout(() => t.remove(), 2600);
402
+ }
403
+
404
+ /* ---------- sliders ---------- */
405
+ [["dr-budget-i", "dr-budget-o"], ["dr-adv-i", "dr-adv-o"],
406
+ ["dr-green-i", "dr-green-o"], ["dr-quiet-i", "dr-quiet-o"]].forEach(([i, o]) => {
407
+ const inp = $(i); if (!inp) return;
408
+ const out = $(o);
409
+ inp.addEventListener("input", () => out.textContent = (+inp.value).toFixed(2).replace(/\.?0+$/, "") || "0");
410
+ });
411
+
412
+ /* ---------- mode segmented toggle ---------- */
413
+ let mode = "walk";
414
+ document.querySelectorAll("#dr-mode label").forEach(l => {
415
+ l.addEventListener("click", () => {
416
+ document.querySelectorAll("#dr-mode label").forEach(x => x.classList.remove("selected"));
417
+ l.classList.add("selected"); mode = l.dataset.v;
418
+ });
419
+ });
420
+
421
+ /* ---------- autocomplete combobox (replaces gr.Dropdown) ---------- */
422
+ function wireCombo(inputId, listId) {
423
+ const input = $(inputId), list = $(listId);
424
+ let timer = null, items = [], active = -1;
425
+ const close = () => { list.classList.remove("open"); active = -1; };
426
+ input.addEventListener("input", () => {
427
+ clearTimeout(timer);
428
+ timer = setTimeout(async () => {
429
+ try {
430
+ const c = await client();
431
+ const r = await c.predict("/suggest", { query: input.value });
432
+ items = (r.data && r.data[0]) || [];
433
+ if (!items.length) return close();
434
+ list.innerHTML = items.map((s, i) =>
435
+ `<div data-i="${i}">${s.replace(/</g, "&lt;")}</div>`).join("");
436
+ list.querySelectorAll("div").forEach(d => d.addEventListener("mousedown", (e) => {
437
+ e.preventDefault(); input.value = items[+d.dataset.i]; close();
438
+ }));
439
+ list.classList.add("open");
440
+ } catch (e) { /* suggestions are best-effort */ }
441
+ }, 160);
442
+ });
443
+ input.addEventListener("keydown", (e) => {
444
+ if (!list.classList.contains("open")) return;
445
+ const els = list.querySelectorAll("div");
446
+ if (e.key === "ArrowDown") { active = Math.min(active + 1, els.length - 1); e.preventDefault(); }
447
+ else if (e.key === "ArrowUp") { active = Math.max(active - 1, 0); e.preventDefault(); }
448
+ else if (e.key === "Enter" && active >= 0) { input.value = items[active]; close(); e.preventDefault(); return; }
449
+ else if (e.key === "Escape") { return close(); }
450
+ els.forEach((d, i) => d.classList.toggle("active", i === active));
451
+ });
452
+ input.addEventListener("blur", () => setTimeout(close, 120));
453
+ }
454
+ wireCombo("dr-start", "dr-start-list");
455
+ wireCombo("dr-dest", "dr-dest-list");
456
+
457
+ /* ---------- 4-step loader (paced to the pipeline phases) ---------- */
458
+ let stepTimer = null;
459
+ function setStep(n) {
460
+ document.querySelectorAll(".stepper .step").forEach(s =>
461
+ s.classList.toggle("active", +s.dataset.step <= n));
462
+ document.querySelectorAll(".stepper .bar").forEach(b =>
463
+ b.classList.toggle("fill", +b.dataset.bar <= n));
464
+ }
465
+ function startLoading() {
466
+ $("dr-loading").classList.add("on");
467
+ $("dr-summary").innerHTML = ""; $("dr-itin").innerHTML = "";
468
+ $("dr-interp").innerHTML = ""; $("dr-options").innerHTML = ""; $("dr-nodetour").innerHTML = "";
469
+ let n = 0; setStep(0);
470
+ stepTimer = setInterval(() => { n = Math.min(n + 1, 3); setStep(n); }, 1200);
471
+ }
472
+ function stopLoading() { clearInterval(stepTimer); setStep(3); $("dr-loading").classList.remove("on"); }
473
+
474
+ /* ---------- render ---------- */
475
+ let lastAlts = [], lastCats = [];
476
+ function renderMap(html) { $("dr-map").innerHTML = '<div class="map-inner">' + html + "</div>"; }
477
+ function selectAlt(idx) {
478
+ const a = lastAlts[idx]; if (!a) return;
479
+ renderMap(a.map_html);
480
+ $("dr-summary").innerHTML = md(a.summary_md);
481
+ $("dr-itin").innerHTML = md(a.itinerary_md);
482
+ document.querySelectorAll("#dr-options .opt").forEach((o, i) =>
483
+ o.classList.toggle("selected", i === idx));
484
+ }
485
+ function renderResult(d) {
486
+ if (d.error) { renderMap(d.map_html); toast("Hmm — " + d.error.split(".")[0] + "."); return; }
487
+ $("dr-interp").innerHTML = md(d.interpretation_md);
488
+ if (d.no_detour) {
489
+ renderMap(d.map_html);
490
+ $("dr-summary").innerHTML = md(d.summary_md);
491
+ $("dr-itin").innerHTML = md(d.itinerary_md);
492
+ $("dr-nodetour").innerHTML = d.nodetour_html || "";
493
+ lastAlts = []; lastCats = [];
494
+ return;
495
+ }
496
+ $("dr-nodetour").innerHTML = "";
497
+ lastAlts = d.alternatives || [];
498
+ lastCats = d.last_cats || [];
499
+ if (lastAlts.length > 1) {
500
+ $("dr-options").innerHTML = lastAlts.map((a, i) =>
501
+ `<div class="opt${i === 0 ? " selected" : ""}" data-i="${i}">${a.label}</div>`).join("");
502
+ document.querySelectorAll("#dr-options .opt").forEach(o =>
503
+ o.addEventListener("click", () => selectAlt(+o.dataset.i)));
504
+ } else { $("dr-options").innerHTML = ""; }
505
+ selectAlt(0);
506
+ }
507
+
508
+ /* ---------- plan ---------- */
509
+ async function plan() {
510
+ // map-press bounce (reused micro-interaction)
511
+ const mapEl = $("dr-map");
512
+ if (mapEl) mapEl.animate(
513
+ [{ transform: "scale(1)" }, { transform: "scale(.99)" }, { transform: "scale(1)" }],
514
+ { duration: 260, easing: "cubic-bezier(.34,1.56,.64,1)" });
515
+ startLoading();
516
+ try {
517
+ const c = await client();
518
+ const r = await c.predict("/plan", {
519
+ start: $("dr-start").value, dest: $("dr-dest").value, mode,
520
+ budget: +$("dr-budget-i").value, vibe: $("dr-vibe").value,
521
+ adventurousness: +$("dr-adv-i").value,
522
+ prefer_green: +$("dr-green-i").value, prefer_quiet: +$("dr-quiet-i").value,
523
+ profile: JSON.stringify(readProfile()),
524
+ });
525
+ renderResult((r.data && r.data[0]) || {});
526
+ } catch (e) {
527
+ toast("Something went wrong planning the route.");
528
+ console.error(e);
529
+ } finally { stopLoading(); }
530
+ }
531
+ $("dr-plan-btn").addEventListener("click", plan);
532
+
533
+ /* ---------- profile buttons ---------- */
534
+ $("dr-save").addEventListener("click", () => {
535
+ const p = readProfile(); p.standing_text = $("dr-profile-text").value || "";
536
+ writeProfile(p); toast("Saved your standing preferences ✨");
537
+ });
538
+ $("dr-clear").addEventListener("click", () => {
539
+ writeProfile({ standing_text: "", saved_categories: [] });
540
+ $("dr-profile-text").value = ""; toast("Profile cleared 🧹");
541
+ });
542
+ $("dr-save-places").addEventListener("click", () => {
543
+ if (!lastCats.length) return toast("Plan a route first — then I can save its places.");
544
+ const p = readProfile();
545
+ p.saved_categories = (p.saved_categories || []).concat(lastCats);
546
+ writeProfile(p); toast("Saved this route's places to your taste ✨");
547
+ });
548
+
549
+ /* ---------- mobile drawer ---------- */
550
+ $("fab").addEventListener("click", () => $("left-panel").classList.add("open"));
551
+ $("drawer-close").addEventListener("click", () => $("left-panel").classList.remove("open"));
552
+
553
+ renderSaved();
554
+ """
tests/test_hours.py CHANGED
@@ -69,15 +69,6 @@ def test_demotion_by_posture():
69
  assert unknown.score == 1.0 and unknown.open_state is None
70
 
71
 
72
- def test_google_verify_noop_without_key(monkeypatch):
73
- """Without GOOGLE_MAPS_API_KEY the enrichment must be a silent no-op."""
74
- monkeypatch.delenv("GOOGLE_MAPS_API_KEY", raising=False)
75
- from discoverroute.enrich import google_places
76
- p = P("cafe", None)
77
- assert google_places.verify_stops([p]) is False
78
- assert not hasattr(p, "live_status")
79
-
80
-
81
  def test_holiday_rules_dont_block_weekday_decisions():
82
  # "PH off" must not force abstention on a decidable weekday
83
  assert is_open("Mo-Fr 08:00-18:00; PH off", TUE_15H) is True
 
69
  assert unknown.score == 1.0 and unknown.open_state is None
70
 
71
 
 
 
 
 
 
 
 
 
 
72
  def test_holiday_rules_dont_block_weekday_decisions():
73
  # "PH off" must not force abstention on a decidable weekday
74
  assert is_open("Mo-Fr 08:00-18:00; PH off", TUE_15H) is True
tests/test_interpret_fallback.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fallback vibe-interpretation: keyword matcher, brief→taxonomy mapping, JSON parse.
2
+
3
+ These cover the model-free path that runs whenever the LLM (Call 1) is absent or
4
+ returns malformed output — the path actually exercised off-GPU.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from discoverroute.data import taxonomy
9
+ from discoverroute.interpret import affinity, keywords, llm_vibe, mapping
10
+
11
+
12
+ def _is_floored_affinity(aff: dict) -> bool:
13
+ return (set(aff) == set(taxonomy.CATEGORIES)
14
+ and all(0.0 <= v <= 1.0 for v in aff.values())
15
+ and abs(max(aff.values()) - 1.0) < 1e-9)
16
+
17
+
18
+ def test_keyword_affinity_matches_books_and_green():
19
+ aff = keywords.keyword_affinity("quiet green bookshops")
20
+ assert aff is not None and _is_floored_affinity(aff)
21
+ # the bookish/green categories should outrank a lively bar
22
+ assert aff["bookshop"] > aff["bar_pub"]
23
+ assert aff["park_garden"] > aff["bar_pub"]
24
+
25
+
26
+ def test_keyword_affinity_none_when_no_cue():
27
+ assert keywords.keyword_affinity("zzzz qwerty") is None
28
+ assert keywords.keyword_scores("") is None
29
+
30
+
31
+ def test_brief_scores_to_affinity_shape_and_modifiers():
32
+ # a pure "green" modifier should lift the greenest category to the top
33
+ aff = mapping.brief_scores_to_affinity({"green": 1.0})
34
+ assert _is_floored_affinity(aff)
35
+ assert aff["park_garden"] >= max(aff[c] for c in taxonomy.CATEGORIES)
36
+
37
+
38
+ def test_llm_json_extract_and_validate():
39
+ good = ('{"cafe":0.9,"park":0.1,"bookshop":0.2,"museum":0.3,"bakery":0.4,'
40
+ '"restaurant":0.5,"bar":0.6,"viewpoint":0.7,"market":0.8,"quiet":0.2,'
41
+ '"green":0.3,"historic":0.4,"busy":0.5,"detour_budget_multiplier":1.4}')
42
+ obj = llm_vibe._validate(llm_vibe._extract_json("noise " + good + " trailing"))
43
+ assert obj is not None and obj["cafe"] == 0.9
44
+ # missing a required key -> rejected
45
+ assert llm_vibe._validate(llm_vibe._extract_json('{"cafe":0.9}')) is None
46
+ # not JSON at all -> None
47
+ assert llm_vibe._extract_json("sorry, I cannot do that") is None
48
+
49
+
50
+ def test_resolve_affinity_neutral_on_empty():
51
+ aff, src = affinity.resolve_affinity("")
52
+ assert src == "neutral"
53
+ assert all(abs(v - 1.0) < 1e-9 for v in aff.values())
54
+
55
+
56
+ def test_resolve_affinity_returns_full_taxonomy():
57
+ aff, src = affinity.resolve_affinity("lively cafe crawl")
58
+ assert set(aff) == set(taxonomy.CATEGORIES)
59
+ assert src in {"llm", "embed", "keyword"}