Spaces:
Running on Zero
Running on Zero
Commit ·
10cf611
1
Parent(s): b34c92a
v2 with model changed and using gradio server func
Browse files- .gitignore +3 -0
- DEPLOY.md +18 -15
- HACKATHON_DEPLOYMENT.md +6 -4
- README.md +3 -2
- app.py +84 -216
- requirements.txt +2 -2
- src/discoverroute/config.py +15 -2
- src/discoverroute/enrich/__init__.py +0 -1
- src/discoverroute/enrich/google_places.py +0 -87
- src/discoverroute/interpret/affinity.py +59 -0
- src/discoverroute/interpret/keywords.py +84 -0
- src/discoverroute/interpret/llm_vibe.py +117 -0
- src/discoverroute/interpret/mapping.py +68 -0
- src/discoverroute/interpret/profile.py +2 -2
- src/discoverroute/interpret/vibe.py +2 -2
- src/discoverroute/narrate/llm.py +19 -13
- src/discoverroute/narrate/narrate.py +56 -23
- src/discoverroute/narrate/trace.py +98 -0
- src/discoverroute/pipeline.py +38 -4
- src/discoverroute/ui/shell.py +554 -0
- tests/test_hours.py +0 -9
- tests/test_interpret_fallback.py +59 -0
.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
|
| 44 |
-
|
| 45 |
|
| 46 |
1. In the Space **Settings → Hardware**, select a **ZeroGPU** tier.
|
| 47 |
-
2. The `@spaces.GPU` decorator on `narrate/llm.py::
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
| 51 |
|
| 52 |
To force the LLM on/off regardless of hardware, set the Space variable
|
| 53 |
`DISCOVERROUTE_USE_LLM` to `1` / `0`.
|
| 54 |
|
| 55 |
-
##
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
| 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
|
| 63 |
-
|
| 64 |
-
-
|
|
|
|
|
|
|
| 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
|
| 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.
|
| 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
|
|
|
|
| 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 —
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 47 |
)
|
| 48 |
-
progress(0.9, desc="Drawing your wander…")
|
| 49 |
|
| 50 |
if result.error:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
"", "", "", {"items": []}, [])
|
| 56 |
|
| 57 |
alts = result.alternatives or []
|
| 58 |
-
if not alts: # honest no-detour (or budget 0
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
result.
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
for i, alt in enumerate(alts):
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 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 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
return profile, _saved_md(profile)
|
| 100 |
|
| 101 |
|
| 102 |
-
def
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
gr.Info("Saved your standing preferences ✨")
|
| 106 |
-
return profile, _saved_md(profile)
|
| 107 |
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
return empty, "", _saved_md(empty)
|
| 113 |
|
| 114 |
|
| 115 |
-
def
|
| 116 |
-
"""
|
| 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")
|
| 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 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 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.
|
| 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
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 67 |
-
trip =
|
| 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
|
| 49 |
|
| 50 |
text = (vibe or "").strip().lower()
|
| 51 |
-
affinity =
|
| 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
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 29 |
)
|
| 30 |
return tok, model
|
| 31 |
|
| 32 |
|
| 33 |
@_gpu
|
| 34 |
-
def
|
| 35 |
-
|
| 36 |
-
messages = [{"role"
|
|
|
|
| 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=
|
| 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 Titan ≤4B, 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** (
|
| 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 |
-
|
| 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,
|
|
|
|
| 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
|
| 122 |
-
"""
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
"
|
| 137 |
-
"
|
| 138 |
-
"
|
| 139 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
)
|
| 141 |
-
|
|
|
|
|
|
|
|
|
| 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, "<")}</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"}
|