Spaces:
Running on Zero
Design system, live freshness, autocomplete, and clay-pin markers
Browse filesUI/UX (from the design handoff):
- Clay-sticker theme, hero, framed Voyager map with warm grade, loading
state ("Scouting your wander…"), route-draw + staggered marker pop-in
- 14-piece clay-pin marker family (vendored SVGs) with color-by-meaning
mapping for all 17 categories, start/dest pins, legend in the family's
color language
- Start/Destination autocomplete over the local 30k POI-name index
- Force-light mode (design is light-only; Gradio dark vars made text
unreadable), per-event js fixed to pass inputs through
Freshness:
- Open-now awareness from OSM opening_hours (9,461 POIs): conservative
evaluator (abstains on exotic grammar, per-rule PH/SH handling),
plan-time demotion of closed places, night realism for unknown-hours
daytime categories, 🟢/🔴 itinerary badges
- Optional Google Places live-verify of final stops (GOOGLE_MAPS_API_KEY;
ToS-clean: per-request, never stored)
- Embedder switched to fastembed/ONNX (no torch in the request path;
warms in ~9s), boot warmup for graph+POIs+embedder
Routing/quality:
- P1-2 single-pot budgeting: stops cost travel + dwell against one
(1+budget)×direct cap; honest "+N min incl. lingering" accounting
- Gloss surgery: "specialty coffee tour" no longer routes to attractions
("tour"→"tourist" false-friend); coffee shops/roasters rank first
- Corridor STRtree + matrix reuse across alternatives (~1.3s warm plans)
- Hardened grounding gate (appended-qualifier hallucinations), Nominatim
timeout/UA, geocode cache, graceful degradation on solver errors
Tests: hours (12), geocode/autocomplete (3), property-based vibe fit;
suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- .gitignore +8 -1
- DEPLOY.md +21 -0
- FIELD_NOTES.md +69 -0
- PROGRESS.md +61 -0
- app.py +52 -12
- data/paris_pois.parquet +2 -2
- pyproject.toml +1 -0
- requirements.txt +3 -1
- src/discoverroute/data/build_pois.py +2 -0
- src/discoverroute/data/taxonomy.py +3 -3
- src/discoverroute/enrich/__init__.py +1 -0
- src/discoverroute/enrich/google_places.py +87 -0
- src/discoverroute/interpret/embed.py +35 -9
- src/discoverroute/narrate/narrate.py +13 -2
- src/discoverroute/pipeline.py +20 -10
- src/discoverroute/routing/geocode.py +43 -0
- src/discoverroute/routing/graph.py +1 -0
- src/discoverroute/routing/hours.py +171 -0
- src/discoverroute/routing/orienteering.py +19 -21
- src/discoverroute/routing/pois.py +5 -0
- src/discoverroute/ui/design.py +126 -25
- src/discoverroute/ui/icons/marker-bakery.svg +7 -0
- src/discoverroute/ui/icons/marker-bookshop.svg +7 -0
- src/discoverroute/ui/icons/marker-cafe.svg +7 -0
- src/discoverroute/ui/icons/marker-canal.svg +7 -0
- src/discoverroute/ui/icons/marker-dest.svg +7 -0
- src/discoverroute/ui/icons/marker-fountain.svg +7 -0
- src/discoverroute/ui/icons/marker-library.svg +7 -0
- src/discoverroute/ui/icons/marker-market.svg +7 -0
- src/discoverroute/ui/icons/marker-museum.svg +7 -0
- src/discoverroute/ui/icons/marker-park.svg +7 -0
- src/discoverroute/ui/icons/marker-square.svg +7 -0
- src/discoverroute/ui/icons/marker-star.svg +7 -0
- src/discoverroute/ui/icons/marker-start.svg +7 -0
- src/discoverroute/ui/icons/marker-viewpoint.svg +7 -0
- src/discoverroute/ui/map.py +63 -20
- src/discoverroute/ui/markers.py +88 -0
- tests/test_geocode.py +20 -110
- tests/test_hours.py +98 -0
- tests/test_interpret.py +19 -8
- tests/test_pipeline.py +4 -1
|
@@ -16,9 +16,16 @@ flagged/
|
|
| 16 |
# OS
|
| 17 |
.DS_Store
|
| 18 |
|
| 19 |
-
# Design handoff assets (build-time reference only — not shipped to the Space
|
|
|
|
| 20 |
ux app.zip
|
| 21 |
ux-design/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# NOTE: data/*.graphml and data/*.parquet are the offline build artifacts.
|
| 24 |
# They are committed for the Hugging Face Space so it needs no runtime download.
|
|
|
|
| 16 |
# OS
|
| 17 |
.DS_Store
|
| 18 |
|
| 19 |
+
# Design handoff assets (build-time reference only — not shipped to the Space;
|
| 20 |
+
# the marker SVGs are vendored into src/discoverroute/ui/icons/)
|
| 21 |
ux app.zip
|
| 22 |
ux-design/
|
| 23 |
+
design_handoff_discoverroute/
|
| 24 |
+
design_handoff_discoverroute copy/
|
| 25 |
+
|
| 26 |
+
# Local dev tooling + scratch test output
|
| 27 |
+
.claude/
|
| 28 |
+
test_output.log
|
| 29 |
|
| 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.
|
|
@@ -52,6 +52,27 @@ the Qwen3.5-9B generative narration:
|
|
| 52 |
To force the LLM on/off regardless of hardware, set the Space variable
|
| 53 |
`DISCOVERROUTE_USE_LLM` to `1` / `0`.
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
## Notes
|
| 56 |
- First boot loads the 90 MB graph (~9 s, one-time); warm requests are ~1 s.
|
| 57 |
- If you ever rebuild the data: `python -m discoverroute.data.build_graph` then
|
|
|
|
| 52 |
To force the LLM on/off regardless of hardware, set the Space variable
|
| 53 |
`DISCOVERROUTE_USE_LLM` to `1` / `0`.
|
| 54 |
|
| 55 |
+
## Optional: live Google verification of the final stops
|
| 56 |
+
|
| 57 |
+
Set the Space **secret** `GOOGLE_MAPS_API_KEY` (Google Cloud → APIs → Places API
|
| 58 |
+
(New) enabled, billing on) and each planned route live-verifies its ~8 chosen
|
| 59 |
+
stops: permanently-closed detection, open-right-now, rating. Notes:
|
| 60 |
+
- Hours fields bill at the Enterprise SKU → ~1,000 free lookups/month ≈ 125
|
| 61 |
+
routes; ~$0.15/route beyond. Only the final stops are queried, never stored
|
| 62 |
+
(Google ToS), and OSM remains the routing/candidate base.
|
| 63 |
+
- Without the key the app is fully offline: open-now still works from OSM
|
| 64 |
+
`opening_hours` tags (~31% of POIs carry them).
|
| 65 |
+
|
| 66 |
+
## Refreshing the data snapshot
|
| 67 |
+
|
| 68 |
+
Place data is a build-time snapshot. To refresh it (e.g. before a demo):
|
| 69 |
+
```bash
|
| 70 |
+
rm -rf cache/ # drop the Overpass HTTP cache to force a fresh download
|
| 71 |
+
.venv/bin/python -m discoverroute.data.build_graph # ~3 min
|
| 72 |
+
.venv/bin/python -m discoverroute.data.build_pois # ~12 min
|
| 73 |
+
```
|
| 74 |
+
OSM edits typically reach Overpass within minutes, so a rebuild is near-live.
|
| 75 |
+
|
| 76 |
## Notes
|
| 77 |
- First boot loads the 90 MB graph (~9 s, one-time); warm requests are ~1 s.
|
| 78 |
- If you ever rebuild the data: `python -m discoverroute.data.build_graph` then
|
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Field Notes: Building DiscoverRoute
|
| 2 |
+
|
| 3 |
+
> **Draft.** Written from the build log (`PROGRESS.md`) and the Space card (`README.md`) for the Build Small Hackathon's *Field Notes* badge. Review, personalize, and add your own voice before publishing.
|
| 4 |
+
|
| 5 |
+
## The inversion
|
| 6 |
+
|
| 7 |
+
Every navigation app I've used solves the same problem: get me there in the minimum time. DiscoverRoute starts from the opposite premise. If you're walking across Paris and you'd happily spend fifteen extra minutes, that surplus is a *budget* — and the interesting question is what to spend it on.
|
| 8 |
+
|
| 9 |
+
So the app takes a start, a destination, a free-text vibe, and an "adventurousness" level, and returns a walkable or bikeable route that deliberately detours past places matching your taste — within a hard travel-time budget — plus a narrated itinerary explaining why each place is on the path. The route never exceeds `(1 + budget) ×` the direct time, and budget 0 simply gives you the plain route. It runs on small (≤32B) open models and OpenStreetMap data, single city: Paris.
|
| 10 |
+
|
| 11 |
+
## Walking skeleton first, AI last
|
| 12 |
+
|
| 13 |
+
The strategy I committed to in the build log's first line: *walking skeleton first; scariest plumbing early; AI added only after a manual-weight router already works.* Each brick had a definition-of-done and a test, and wasn't left until green.
|
| 14 |
+
|
| 15 |
+
That meant the first four bricks contained no AI at all.
|
| 16 |
+
|
| 17 |
+
**Brick 0** was the boring-but-scary plumbing: download the Paris walk network via OSMnx into a graph of **77,454 nodes and 221,688 edges** (a 90 MB GraphML file), geocode inputs, run Dijkstra, render the polyline on a Folium map inside a Gradio shell. Seven tests, including a sanity check that République→Luxembourg is at least 2 km and that bike beats walk (travel time is derived per mode from one graph: walk 4.8 km/h, bike 15 km/h).
|
| 18 |
+
|
| 19 |
+
**Brick 1** built the POI layer: **30,589 Paris POIs across 17 curated categories**, cached to a 1.2 MB parquet file, each with greenness/quietness priors and a *confidence* score derived from OSM tag richness.
|
| 20 |
+
|
| 21 |
+
**Bricks 2–3** were the heart of the system, and they're classical algorithms: a budgeted **submodular orienteering solver**. The key modeling insight is diversity by design. A naive scorer that just sums point values will happily route you past five cafés, because cafés are everywhere and score fine individually. The submodular reward applies *diminishing returns per category*: the first café is worth full value, the second much less, the third almost nothing. So a park + a viewpoint + a bookshop beats five cafés — not because of a hand-tuned penalty, but because the objective itself says variety is worth more than repetition. The solver is a better-of-two greedy (picking by raw gain *and* by reward-per-added-time, keeping the better result), tested against a known-optimal synthetic instance, with an explicit diversity-beats-repetition test.
|
| 22 |
+
|
| 23 |
+
Only once that manual-weight router produced real discovery routes on a real map did any model enter the picture.
|
| 24 |
+
|
| 25 |
+
## What the small models actually do
|
| 26 |
+
|
| 27 |
+
Two models, both deliberately small, each load-bearing in exactly one place:
|
| 28 |
+
|
| 29 |
+
- **`BAAI/bge-small-en-v1.5` (~33M parameters, CPU-only)** turns your free-text vibe into category affinities. Each of the 17 categories has a short gloss; the vibe is embedded and matched by cosine similarity to those glosses, min-max rescaled into a usable weight range. "Quiet green wander" and a contrasting vibe produce measurably different waypoint sets on the same A/B pair — that's an end-to-end test, not a hope.
|
| 30 |
+
- **`Qwen/Qwen3.5-9B` (Apache 2.0, thinking mode off)** is an *optional* narration enhancer. The default itinerary text comes from a deterministic template that is grounded by construction; the LLM only rewrites it, on GPU (ZeroGPU on the Space), and is gated by a verifier (more on that below). bf16 at ~18 GB it sits comfortably on ZeroGPU. I considered the whole ≤32B ladder — Qwen3.5-4B (lighter), 9B (chosen), 27B (needs quantization on 40 GB) — and excluded Qwen3.5-35B-A3B because 35B breaks the hackathon's 32B cap.
|
| 31 |
+
|
| 32 |
+
Small-is-the-point here, not a constraint to grumble about. Routing is pure classical algorithms; the model is load-bearing only in interpretation and narration. The skeleton runs CPU-only and offline with the rule-based fallback — which means the template-narration mode runs on nothing but the 33M embedder. Your taste profile never needs to leave your device (it's persisted per-device via browser state, no accounts), and the whole interpretation stack fits on a laptop.
|
| 33 |
+
|
| 34 |
+
## The zero-hallucination gate
|
| 35 |
+
|
| 36 |
+
LLMs narrating a route is exactly the place hallucination hurts most: the model will cheerfully invent a charming bistro that doesn't exist. So narration sits behind a **fail-closed grounding verifier**: it extracts capitalized place-name spans from the generated text (handling multi-word names and French "de la" chains) and passes only if *every* mention maps to an allowed name — the route's waypoints, the start/end, or "Paris". Any violation, and the system silently falls back to the deterministic template. The release-gate test plants a hallucinated "Eiffel Tower" in narration and verifies the gate catches it; the final end-to-end test asserts the shipped narration is grounded.
|
| 37 |
+
|
| 38 |
+
Then the adversarial review pass found a real hole in it. The old check accepted an allowed name being a *substring* of a longer mention — so if "Café de la Paix" was a real waypoint, the invented "Café de la Paix sur Seine" sailed through. The fix inverts the containment: strip common words from a mention, then require the *core* to be a substring of an allowed name, not the reverse. The same pass also fixed mention extraction, which had been gluing "République, Paris and Jardin…" into one span by treating "and"/"et" as name-internal. Both attack shapes — appended qualifier and shortened reference — now have regression tests.
|
| 39 |
+
|
| 40 |
+
## War stories
|
| 41 |
+
|
| 42 |
+
A few things that broke, and what fixing them taught me:
|
| 43 |
+
|
| 44 |
+
**Overpass timed out.** The combined POI query for all of Paris was too much for the Overpass API. Fix: fetch one tag key at a time with a 300s timeout — amenity (77k raw), shop (29k), tourism (7.5k), leisure (6k), historic (2.5k), natural — then filter down to the 30,589 classified POIs.
|
| 45 |
+
|
| 46 |
+
**Per-pair routing was the first latency wall.** Computing travel times between candidate POIs pair-by-pair put warm requests at **8–14 seconds**. Replacing it with **SciPy multi-source Dijkstra** — one C call over a cached CSR adjacency matrix — brought warm per-request latency to **~1 second**.
|
| 47 |
+
|
| 48 |
+
**The 635ms hiding in a loop.** During the adversarial review, the performance reviewer reported alarming numbers — which turned out to be a red herring: their machine was thrashing. Re-measured on a clean machine, the suggested fix (porting route-stitching to CSR) was needless — stitch was only 59ms. The real bottleneck was `build_matrix` at **~635ms, recomputed three times** inside the alternatives loop. Hoisting the corridor selection and matrix out of the loop (compute once, reuse) dropped `n_alternatives=3` from **~2.1s to ~1.3s** — the same cost as a single route. An STRtree took corridor selection from 87ms to ~5ms for good measure. Lesson: measure on a quiet machine before believing a profile, and look for repeated work before clever work.
|
| 49 |
+
|
| 50 |
+
**The review fixed product lies, too.** The alternatives label showed *total* minutes where it claimed to show detour minutes; the vibe's budget hint was displayed but silently discarded; off-domain vibes ("tax deadline") manufactured false preferences until a minimum-similarity-span guard mapped them to neutral. And error handling got hardened so disconnected nodes or a corrupt parquet degrade to the plain route instead of a traceback. After the pass: 42 tests passing.
|
| 51 |
+
|
| 52 |
+
## The design handoff port
|
| 53 |
+
|
| 54 |
+
The default Gradio look got replaced wholesale from a design handoff: a low-poly "clay sticker" aesthetic — cream paper background, cobalt/grass/coral/sun palette, Fredoka display type. The port landed as a theme plus CSS (sticker cards, a coral CTA that visibly depresses, springy sliders, a framed map window with a titlebar), a results bounce-in observer, and a friendly empty/no-detour state.
|
| 55 |
+
|
| 56 |
+
The fiddliest part: the map is a Folium iframe, and **an iframe can't be animated from the parent page**. So the route draw-on and the staggered POI marker pops are injected as JavaScript *inside* the iframe's own document, keyed off CSS class names attached to the polylines and markers at render time. Reduced-motion and AA focus rings are respected throughout.
|
| 57 |
+
|
| 58 |
+
## Honest limitations
|
| 59 |
+
|
| 60 |
+
- **Bike uses the walk graph.** One mode-agnostic graph, with per-mode speeds. Routing bikes on the pedestrian network is a documented v1 approximation; a separate bike graph is on the deferred list.
|
| 61 |
+
- **Confidence is OSM tag richness, nothing more.** A well-tagged tourist trap looks "confident"; a beloved hole-in-the-wall with two tags looks risky. The adventurousness slider leans into this honestly — it both fades the confidence penalty and *boosts* under-documented POIs — but the signal itself is just tag count.
|
| 62 |
+
- **Paris only.** The graph and POI table are built offline for one city. The pipeline generalizes; the data doesn't, yet.
|
| 63 |
+
- Cold boot pays ~8.6s to load the 90 MB graph (plus 0.2s for the CSR cache); a faster serialization is a known, deferred debt.
|
| 64 |
+
|
| 65 |
+
## What's next
|
| 66 |
+
|
| 67 |
+
The P2 list, in rough order: live navigation (today it's plan-then-walk), external enrichment beyond OSM tags so "confidence" can mean more than tag richness, and place embeddings so taste matching can operate on places themselves rather than on 17 categories. Nearer-term housekeeping from the log: a real bike graph, faster graph serialization, and per-place removal in the profile UI.
|
| 68 |
+
|
| 69 |
+
The submission itself is the last brick: push to a Space under the hackathon org, record the demo, write the post — and decide whether DiscoverRoute is Backyard AI (a real problem, really used) or a resident of the Thousand Token Wood.
|
|
@@ -206,6 +206,67 @@ aesthetic: cream paper, cobalt/grass/coral/sun, Fredoka display type.
|
|
| 206 |
- Assets: inline-SVG placeholders shipped; 6 clay illustrations to
|
| 207 |
generate/commission later per the kit's asset checklist (style spec saved).
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
---
|
| 210 |
|
| 211 |
## Decisions (made with user, 2026-06-08)
|
|
|
|
| 206 |
- Assets: inline-SVG placeholders shipped; 6 clay illustrations to
|
| 207 |
generate/commission later per the kit's asset checklist (style spec saved).
|
| 208 |
|
| 209 |
+
### Live browser testing pass (2026-06-10/11, via Chrome extension + computer-use)
|
| 210 |
+
Found & fixed — none of these were catchable headlessly:
|
| 211 |
+
1. **Plan click hung forever**: per-event `js=` (DR_CELEBRATE) didn't pass
|
| 212 |
+
Gradio's input values through → all inputs nulled AND completion chain broken.
|
| 213 |
+
Fix: `(...args) => {…; return args;}`.
|
| 214 |
+
2. **Dark-mode unreadability**: OS dark mode flips Gradio vars to near-white text
|
| 215 |
+
on our forced-cream cards. Fix: head-script strips the `dark` class
|
| 216 |
+
(debounced, childList-only observer — a hot attribute observer livelocked the
|
| 217 |
+
renderer) + `.dark` CSS var overrides as backstop. Design is light-only.
|
| 218 |
+
3. **First-request freeze (minutes)**: lazy `import torch` (~1 GB dylibs) in the
|
| 219 |
+
request path. Fix: **switched the embedder to fastembed/ONNX** (same
|
| 220 |
+
bge-small; warms in ~9 s incl. download, no torch) with sentence-transformers
|
| 221 |
+
fallback; warmup() also pre-warms the embedder + POIs at boot.
|
| 222 |
+
4. Cosmetics: input text + map-titlebar colors were theme-washed; unselected
|
| 223 |
+
mode-segment and route-option cards were dark-on-dark; accordion labels faint;
|
| 224 |
+
`launch(js=…)` silently never executes (moved enhancer into `head=`).
|
| 225 |
+
|
| 226 |
+
**Programmatic E2E (gradio_client against the live app)**: vibe plan 2.6 s warm
|
| 227 |
+
with 3 labeled options ("+7 min · 8 stops (artwork, park garden)"); contrasting
|
| 228 |
+
vibe → different itinerary, cafe top-ranked; budget 0 → no discovery polyline,
|
| 229 |
+
plain messaging, no-detour state visible; head script + CSS + fonts delivered;
|
| 230 |
+
profile save round-trips. All green.
|
| 231 |
+
|
| 232 |
+
### Freshness stack (2026-06-11) — open-now + optional Google live-verify
|
| 233 |
+
- **Map face-lift**: CARTO Voyager tiles + warm grade; POI markers colored by
|
| 234 |
+
category family (grass nature / cobalt culture / sun food / coral art) with a
|
| 235 |
+
matching legend; autocomplete dropdowns over the local 30k-name index
|
| 236 |
+
(`geocode.suggest`, key_up); "Scouting your wander…" loading state via a
|
| 237 |
+
.then() chain.
|
| 238 |
+
- **Open-now from OSM (free, offline)**: `opening_hours` stored at build (9,461
|
| 239 |
+
POIs, 31%); `routing/hours.py` conservative evaluator (abstains on exotic
|
| 240 |
+
grammar; PH/SH rules dropped per-rule, not whole-spec); plan-time demotion
|
| 241 |
+
closed-stop ×0.2 / closed-pass ×0.7; unknown-hours daytime categories ×0.5 at
|
| 242 |
+
night; 🟢/🔴 badges in the itinerary. Verified live at 23 h: route picks only
|
| 243 |
+
open bars/cafés.
|
| 244 |
+
- **P1-2 single-pot budgeting fix**: a stop now costs added-travel + dwell
|
| 245 |
+
against the ONE (1+budget)×direct cap (the old separate 40 % dwell pot made
|
| 246 |
+
"bar hopping" unable to afford a single 15-min bar). Summary/labels/narration
|
| 247 |
+
count dwell ("+26 min incl. ~25 min lingering").
|
| 248 |
+
- **Google Places live-verify (optional)**: `enrich/google_places.py` — with
|
| 249 |
+
GOOGLE_MAPS_API_KEY set, the final stops get businessStatus/openNow/rating at
|
| 250 |
+
display time (ToS-clean: never stored; ~125 free routes/month, Enterprise SKU).
|
| 251 |
+
Silent no-op without the key. DEPLOY.md documents key setup + data-refresh.
|
| 252 |
+
- Tests: 12 hours/no-key tests added; suite green (orienteering/pipeline 25/25).
|
| 253 |
+
|
| 254 |
+
### Vibe quality + clay-pin markers (2026-06-12)
|
| 255 |
+
- **"specialty coffee tour" bug**: the token "tour" lit up the attraction gloss
|
| 256 |
+
("notable TOURist attraction") at 1.0, beating cafe — routes went to escape
|
| 257 |
+
rooms. Gloss surgery: attraction → "a famous landmark or major sight worth
|
| 258 |
+
seeing"; cafe gloss gains "specialty coffee shop, espresso"; bakery gloss
|
| 259 |
+
gains "coffee roaster" (OSM shop=coffee lands there). Now cafe 1.0 /
|
| 260 |
+
bakery .97 / attraction .79, and "famous landmarks tour" still → attraction.
|
| 261 |
+
18 interpret/profile/narration tests green.
|
| 262 |
+
- **Designed marker family integrated** (user's icons/ handoff): 14 clay-pin
|
| 263 |
+
SVGs copied to `ui/markers.py` + `ui/icons/`; 17 categories mapped to the 14
|
| 264 |
+
kinds (color-by-meaning: cobalt water/wayfinding · grass green space · coral
|
| 265 |
+
culture · sun cozy stops); Leaflet DivIcons with tip-anchored pins, cast
|
| 266 |
+
shadow, springy hover, staggered pop-in (reduced-motion gated); start/dest
|
| 267 |
+
clay pins replace stock folium markers; legend re-labeled to the family's
|
| 268 |
+
color language. Verified live: pins + pop-in CSS + endpoint pins in map HTML.
|
| 269 |
+
|
| 270 |
---
|
| 271 |
|
| 272 |
## Decisions (made with user, 2026-06-08)
|
|
@@ -28,7 +28,8 @@ def _saved_md(profile: dict) -> str:
|
|
| 28 |
|
| 29 |
|
| 30 |
def _alt_label(i: int, alt, plain) -> str:
|
| 31 |
-
extra = round(alt.discovery.time_min
|
|
|
|
| 32 |
from collections import Counter
|
| 33 |
top = Counter(p.category for p in alt.pois).most_common(2)
|
| 34 |
flavor = ", ".join(c.replace("_", " ") for c, _ in top)
|
|
@@ -111,6 +112,22 @@ def on_clear_profile():
|
|
| 111 |
return empty, "", _saved_md(empty)
|
| 112 |
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
def build_ui() -> gr.Blocks:
|
| 115 |
with gr.Blocks(title="DiscoverRoute · Paris") as demo:
|
| 116 |
profile = gr.BrowserState(
|
|
@@ -126,13 +143,17 @@ def build_ui() -> gr.Blocks:
|
|
| 126 |
# ---- LEFT: controls --------------------------------------------
|
| 127 |
with gr.Column(scale=4, min_width=340):
|
| 128 |
with gr.Group():
|
| 129 |
-
start = gr.
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
vibe = gr.Textbox(label="Vibe (free text)", elem_id="dr-vibe",
|
| 137 |
elem_classes="dr-field",
|
| 138 |
info="e.g. 'quiet green wander' or 'lively café crawl'")
|
|
@@ -184,7 +205,18 @@ def build_ui() -> gr.Blocks:
|
|
| 184 |
nodetour_html = gr.HTML(design.NO_DETOUR_HTML, visible=False,
|
| 185 |
elem_id="dr-nodetour")
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
go.click(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
on_plan,
|
| 189 |
inputs=[start, dest, mode, budget, vibe, adventurousness,
|
| 190 |
prefer_green, prefer_quiet, profile],
|
|
@@ -192,8 +224,10 @@ def build_ui() -> gr.Blocks:
|
|
| 192 |
summary_out, interpretation_out, itinerary_out,
|
| 193 |
alts_state, last_cats],
|
| 194 |
show_progress="minimal",
|
| 195 |
-
js=design.DR_CELEBRATE,
|
| 196 |
)
|
|
|
|
|
|
|
|
|
|
| 197 |
alt_radio.change(on_select_alt, inputs=[alt_radio, alts_state],
|
| 198 |
outputs=[map_out, summary_out, itinerary_out])
|
| 199 |
save_places_btn.click(on_save_places, inputs=[profile, last_cats],
|
|
@@ -208,7 +242,8 @@ def build_ui() -> gr.Blocks:
|
|
| 208 |
|
| 209 |
|
| 210 |
def warmup():
|
| 211 |
-
"""Preload graph + CSR + POIs at boot so the first
|
|
|
|
| 212 |
try:
|
| 213 |
from discoverroute.routing import graph as g
|
| 214 |
from discoverroute.routing import pois as poimod
|
|
@@ -217,7 +252,13 @@ def warmup():
|
|
| 217 |
poimod.load_pois()
|
| 218 |
print("[warmup] routing graph + POIs ready", flush=True)
|
| 219 |
except Exception as exc: # noqa: BLE001
|
| 220 |
-
print(f"[warmup] FAILED: {exc}", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
|
| 223 |
if __name__ == "__main__":
|
|
@@ -226,5 +267,4 @@ if __name__ == "__main__":
|
|
| 226 |
theme=design.build_theme(),
|
| 227 |
css=design.DR_CSS,
|
| 228 |
head=design.DR_HEAD,
|
| 229 |
-
js=design.DR_JS,
|
| 230 |
)
|
|
|
|
| 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)
|
|
|
|
| 112 |
return empty, "", _saved_md(empty)
|
| 113 |
|
| 114 |
|
| 115 |
+
def on_suggest(evt: gr.KeyUpData):
|
| 116 |
+
"""Autocomplete a Start/Destination field from the local POI-name index."""
|
| 117 |
+
from discoverroute.routing.geocode import suggest
|
| 118 |
+
typed = evt.input_value or ""
|
| 119 |
+
matches = list(suggest(typed))
|
| 120 |
+
# keep what the user typed selectable on top; never clobber their text
|
| 121 |
+
choices = ([typed] if typed and typed not in matches else []) + matches
|
| 122 |
+
return gr.update(choices=choices or [typed])
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def show_loading():
|
| 126 |
+
"""Instant feedback the moment Plan is clicked (the .then chain computes)."""
|
| 127 |
+
return (design.LOADING_HTML, gr.update(visible=False),
|
| 128 |
+
gr.update(visible=False), gr.update(visible=False))
|
| 129 |
+
|
| 130 |
+
|
| 131 |
def build_ui() -> gr.Blocks:
|
| 132 |
with gr.Blocks(title="DiscoverRoute · Paris") as demo:
|
| 133 |
profile = gr.BrowserState(
|
|
|
|
| 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'")
|
|
|
|
| 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],
|
|
|
|
| 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],
|
|
|
|
| 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
|
|
|
|
| 252 |
poimod.load_pois()
|
| 253 |
print("[warmup] routing graph + POIs ready", flush=True)
|
| 254 |
except Exception as exc: # noqa: BLE001
|
| 255 |
+
print(f"[warmup] graph FAILED: {exc}", flush=True)
|
| 256 |
+
try:
|
| 257 |
+
from discoverroute.interpret import embed
|
| 258 |
+
embed.vibe_to_affinity("quiet green wander") # loads model + gloss cache
|
| 259 |
+
print("[warmup] vibe embedder ready", flush=True)
|
| 260 |
+
except Exception as exc: # noqa: BLE001
|
| 261 |
+
print(f"[warmup] embedder skipped: {exc}", flush=True)
|
| 262 |
|
| 263 |
|
| 264 |
if __name__ == "__main__":
|
|
|
|
| 267 |
theme=design.build_theme(),
|
| 268 |
css=design.DR_CSS,
|
| 269 |
head=design.DR_HEAD,
|
|
|
|
| 270 |
)
|
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ee83e69ffff3971cf41042d10ea891ce853dc440e09dd275a8cd821d8751e26a
|
| 3 |
+
size 1333331
|
|
@@ -20,6 +20,7 @@ dependencies = [
|
|
| 20 |
# Brick 4/6: vibe interpretation + narration. Kept optional so the
|
| 21 |
# walking skeleton (Bricks 0-3) installs without ML weights.
|
| 22 |
ml = [
|
|
|
|
| 23 |
"sentence-transformers>=3.0",
|
| 24 |
"transformers>=4.45",
|
| 25 |
"accelerate>=1.0",
|
|
|
|
| 20 |
# Brick 4/6: vibe interpretation + narration. Kept optional so the
|
| 21 |
# walking skeleton (Bricks 0-3) installs without ML weights.
|
| 22 |
ml = [
|
| 23 |
+
"fastembed>=0.4",
|
| 24 |
"sentence-transformers>=3.0",
|
| 25 |
"transformers>=4.45",
|
| 26 |
"accelerate>=1.0",
|
|
@@ -13,7 +13,9 @@ folium==0.20.0
|
|
| 13 |
# UI
|
| 14 |
gradio==6.17.3
|
| 15 |
|
| 16 |
-
# Vibe interpretation (Brick 4)
|
|
|
|
|
|
|
| 17 |
sentence-transformers==5.5.1
|
| 18 |
|
| 19 |
# Narration LLM (Brick 6) — Qwen3.5-9B on ZeroGPU
|
|
|
|
| 13 |
# UI
|
| 14 |
gradio==6.17.3
|
| 15 |
|
| 16 |
+
# Vibe interpretation (Brick 4) — fastembed (ONNX, no torch) is the primary
|
| 17 |
+
# backend; sentence-transformers remains as fallback.
|
| 18 |
+
fastembed>=0.4
|
| 19 |
sentence-transformers==5.5.1
|
| 20 |
|
| 21 |
# Narration LLM (Brick 6) — Qwen3.5-9B on ZeroGPU
|
|
@@ -90,6 +90,7 @@ def build_pois(place: str = config.PARIS_PLACE) -> pd.DataFrame:
|
|
| 90 |
continue
|
| 91 |
osm_type, osm_id = (idx if isinstance(idx, tuple) else ("node", idx))
|
| 92 |
name = tags.get("name")
|
|
|
|
| 93 |
records.append(
|
| 94 |
{
|
| 95 |
"osm_type": str(osm_type),
|
|
@@ -102,6 +103,7 @@ def build_pois(place: str = config.PARIS_PLACE) -> pd.DataFrame:
|
|
| 102 |
"quietness": taxonomy.quietness(category),
|
| 103 |
"confidence": round(taxonomy.confidence(tags), 4),
|
| 104 |
"n_tags": len(tags),
|
|
|
|
| 105 |
}
|
| 106 |
)
|
| 107 |
|
|
|
|
| 90 |
continue
|
| 91 |
osm_type, osm_id = (idx if isinstance(idx, tuple) else ("node", idx))
|
| 92 |
name = tags.get("name")
|
| 93 |
+
hours = tags.get("opening_hours")
|
| 94 |
records.append(
|
| 95 |
{
|
| 96 |
"osm_type": str(osm_type),
|
|
|
|
| 103 |
"quietness": taxonomy.quietness(category),
|
| 104 |
"confidence": round(taxonomy.confidence(tags), 4),
|
| 105 |
"n_tags": len(tags),
|
| 106 |
+
"opening_hours": str(hours) if hours is not None else None,
|
| 107 |
}
|
| 108 |
)
|
| 109 |
|
|
@@ -53,13 +53,13 @@ CATEGORY_GLOSS: dict[str, str] = {
|
|
| 53 |
"library": "a library, quiet reading and books",
|
| 54 |
"bookshop": "an independent bookshop, browsing books",
|
| 55 |
"theatre_cinema": "a theatre or cinema, performance and film",
|
| 56 |
-
"cafe": "a cosy cafe
|
| 57 |
-
"bakery_food_shop": "a bakery, patisserie, chocolate or fine-food shop",
|
| 58 |
"restaurant": "a restaurant for a proper meal",
|
| 59 |
"bar_pub": "a lively bar, pub or wine bar, drinks and atmosphere",
|
| 60 |
"market": "a bustling open-air or covered market, food and stalls",
|
| 61 |
"specialty_shop": "a characterful specialty shop — antiques, art, design",
|
| 62 |
-
"attraction": "a
|
| 63 |
}
|
| 64 |
|
| 65 |
|
|
|
|
| 53 |
"library": "a library, quiet reading and books",
|
| 54 |
"bookshop": "an independent bookshop, browsing books",
|
| 55 |
"theatre_cinema": "a theatre or cinema, performance and film",
|
| 56 |
+
"cafe": "a cosy cafe or specialty coffee shop, espresso and a pause",
|
| 57 |
+
"bakery_food_shop": "a bakery, patisserie, coffee roaster, chocolate or fine-food shop",
|
| 58 |
"restaurant": "a restaurant for a proper meal",
|
| 59 |
"bar_pub": "a lively bar, pub or wine bar, drinks and atmosphere",
|
| 60 |
"market": "a bustling open-air or covered market, food and stalls",
|
| 61 |
"specialty_shop": "a characterful specialty shop — antiques, art, design",
|
| 62 |
+
"attraction": "a famous landmark or major sight worth seeing",
|
| 63 |
}
|
| 64 |
|
| 65 |
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Pluggable live-enrichment sources (spec P2-2). Optional; never required."""
|
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -5,22 +5,46 @@ user's vibe to affinities over the *finite* OSM category vocabulary by cosine
|
|
| 5 |
similarity to each category's human-readable gloss. The output is interpretable
|
| 6 |
weights — the scoring path downstream stays a transparent weighted sum.
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import functools
|
| 14 |
|
|
|
|
|
|
|
| 15 |
from discoverroute import config
|
| 16 |
from discoverroute.data import taxonomy
|
| 17 |
|
| 18 |
|
| 19 |
@functools.lru_cache(maxsize=1)
|
| 20 |
-
def
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
|
| 26 |
@functools.lru_cache(maxsize=1)
|
|
@@ -28,24 +52,26 @@ def _gloss_matrix():
|
|
| 28 |
"""(categories, normalized gloss embedding matrix) computed once."""
|
| 29 |
cats = list(taxonomy.CATEGORY_GLOSS.keys())
|
| 30 |
glosses = [taxonomy.CATEGORY_GLOSS[c] for c in cats]
|
| 31 |
-
|
| 32 |
-
return cats,
|
| 33 |
|
| 34 |
|
|
|
|
| 35 |
def vibe_to_affinity(vibe: str) -> dict[str, float]:
|
| 36 |
"""Map a free-text vibe to a {category: affinity in [floor, 1]} dict.
|
| 37 |
|
| 38 |
Cosine similarities are min-max rescaled across categories so the best match
|
| 39 |
is 1.0 and the weakest is the configured floor — guaranteeing measurable
|
| 40 |
contrast between different vibes while keeping a little exploration room.
|
|
|
|
| 41 |
"""
|
| 42 |
vibe = (vibe or "").strip()
|
| 43 |
cats, gloss_emb = _gloss_matrix()
|
| 44 |
if not vibe:
|
| 45 |
return {c: 1.0 for c in cats} # neutral: equal interest
|
| 46 |
|
| 47 |
-
|
| 48 |
-
q =
|
| 49 |
sims = gloss_emb @ q # cosine (both normalized)
|
| 50 |
|
| 51 |
lo, hi = float(sims.min()), float(sims.max())
|
|
|
|
| 5 |
similarity to each category's human-readable gloss. The output is interpretable
|
| 6 |
weights — the scoring path downstream stays a transparent weighted sum.
|
| 7 |
|
| 8 |
+
Backends (same bge-small model either way):
|
| 9 |
+
1. **fastembed / ONNXRuntime** (preferred) — no torch import. Torch is ~1 GB of
|
| 10 |
+
shared libraries; loading it lazily mid-request froze the app for minutes on
|
| 11 |
+
a memory-pressured laptop and would slow Space cold-starts too.
|
| 12 |
+
2. **sentence-transformers** (fallback) — used only if fastembed is absent.
|
| 13 |
+
|
| 14 |
+
Everything loads lazily and is cached; gloss embeddings are computed once.
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
import functools
|
| 19 |
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
from discoverroute import config
|
| 23 |
from discoverroute.data import taxonomy
|
| 24 |
|
| 25 |
|
| 26 |
@functools.lru_cache(maxsize=1)
|
| 27 |
+
def _encoder():
|
| 28 |
+
"""Return (name, encode_fn) where encode_fn(list[str]) -> normalized ndarray."""
|
| 29 |
+
try:
|
| 30 |
+
from fastembed import TextEmbedding
|
| 31 |
+
|
| 32 |
+
model = TextEmbedding(model_name=config.EMBED_MODEL)
|
| 33 |
+
|
| 34 |
+
def encode(texts: list[str]) -> np.ndarray:
|
| 35 |
+
vecs = np.stack(list(model.embed(texts)))
|
| 36 |
+
return vecs / np.linalg.norm(vecs, axis=1, keepdims=True)
|
| 37 |
+
|
| 38 |
+
return "fastembed", encode
|
| 39 |
+
except Exception: # noqa: BLE001 - fall back to the torch stack
|
| 40 |
+
from sentence_transformers import SentenceTransformer
|
| 41 |
+
|
| 42 |
+
model = SentenceTransformer(config.EMBED_MODEL)
|
| 43 |
+
|
| 44 |
+
def encode(texts: list[str]) -> np.ndarray:
|
| 45 |
+
return model.encode(texts, normalize_embeddings=True)
|
| 46 |
|
| 47 |
+
return "sentence-transformers", encode
|
| 48 |
|
| 49 |
|
| 50 |
@functools.lru_cache(maxsize=1)
|
|
|
|
| 52 |
"""(categories, normalized gloss embedding matrix) computed once."""
|
| 53 |
cats = list(taxonomy.CATEGORY_GLOSS.keys())
|
| 54 |
glosses = [taxonomy.CATEGORY_GLOSS[c] for c in cats]
|
| 55 |
+
_, encode = _encoder()
|
| 56 |
+
return cats, encode(glosses)
|
| 57 |
|
| 58 |
|
| 59 |
+
@functools.lru_cache(maxsize=256)
|
| 60 |
def vibe_to_affinity(vibe: str) -> dict[str, float]:
|
| 61 |
"""Map a free-text vibe to a {category: affinity in [floor, 1]} dict.
|
| 62 |
|
| 63 |
Cosine similarities are min-max rescaled across categories so the best match
|
| 64 |
is 1.0 and the weakest is the configured floor — guaranteeing measurable
|
| 65 |
contrast between different vibes while keeping a little exploration room.
|
| 66 |
+
Cached per vibe text (repeated demo prompts don't re-encode).
|
| 67 |
"""
|
| 68 |
vibe = (vibe or "").strip()
|
| 69 |
cats, gloss_emb = _gloss_matrix()
|
| 70 |
if not vibe:
|
| 71 |
return {c: 1.0 for c in cats} # neutral: equal interest
|
| 72 |
|
| 73 |
+
_, encode = _encoder()
|
| 74 |
+
q = encode([config.EMBED_QUERY_INSTRUCTION + vibe])[0]
|
| 75 |
sims = gloss_emb @ q # cosine (both normalized)
|
| 76 |
|
| 77 |
lo, hi = float(sims.min()), float(sims.max())
|
|
@@ -46,7 +46,8 @@ def _verb(posture: str) -> str:
|
|
| 46 |
def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
| 47 |
end_label="", posture=None) -> str:
|
| 48 |
posture = posture or {}
|
| 49 |
-
extra = round(discovery.time_min
|
|
|
|
| 50 |
unit = "minute" if extra == 1 else "minutes"
|
| 51 |
lead = f"### Why this route\n"
|
| 52 |
vibe_clause = f" for a *{vibe.strip()}*" if (vibe or "").strip() else ""
|
|
@@ -60,7 +61,17 @@ def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
lines.append(
|
| 65 |
f"\nThen on to {end_label or 'your destination'}. Every place above is a "
|
| 66 |
f"real spot on your route — nothing invented."
|
|
|
|
| 46 |
def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
| 47 |
end_label="", posture=None) -> str:
|
| 48 |
posture = posture or {}
|
| 49 |
+
extra = round(discovery.time_min + getattr(discovery, "dwell_s", 0.0) / 60.0
|
| 50 |
+
- plain.time_min)
|
| 51 |
unit = "minute" if extra == 1 else "minutes"
|
| 52 |
lead = f"### Why this route\n"
|
| 53 |
vibe_clause = f" for a *{vibe.strip()}*" if (vibe or "").strip() else ""
|
|
|
|
| 61 |
name = p.name or f"a {p.category.replace('_', ' ')}"
|
| 62 |
reason = _REASON.get(p.category, "a stop worth making")
|
| 63 |
verb = _verb(posture.get(p.category, "pass"))
|
| 64 |
+
state = getattr(p, "open_state", None)
|
| 65 |
+
live = getattr(p, "live_status", None) # Google-verified, when present
|
| 66 |
+
if live is not None:
|
| 67 |
+
badge = " · 🟢 open now ✓live" if live else " · 🔴 closed right now ✓live"
|
| 68 |
+
elif state is True:
|
| 69 |
+
badge = " · 🟢 open now"
|
| 70 |
+
elif state is False:
|
| 71 |
+
badge = " · 🔴 closed right now"
|
| 72 |
+
else:
|
| 73 |
+
badge = ""
|
| 74 |
+
lines.append(f"{i}. **{name}** — {verb.lower()} for {reason}.{badge}")
|
| 75 |
lines.append(
|
| 76 |
f"\nThen on to {end_label or 'your destination'}. Every place above is a "
|
| 77 |
f"real spot on your route — nothing invented."
|
|
@@ -109,7 +109,8 @@ def plan_route(
|
|
| 109 |
used_ids: set[int] = set()
|
| 110 |
try:
|
| 111 |
shortlist, matrix, time_fn = _prepare_discovery(
|
| 112 |
-
graph, start, end, plain, mode, budget, weights, adventurousness
|
|
|
|
| 113 |
for _ in range(max(1, n_alternatives)):
|
| 114 |
if shortlist is None:
|
| 115 |
break
|
|
@@ -119,6 +120,9 @@ def plan_route(
|
|
| 119 |
if discovery is None or not selected:
|
| 120 |
break
|
| 121 |
used_ids.update(p.osm_id for p in selected)
|
|
|
|
|
|
|
|
|
|
| 122 |
itinerary_md, _ = narrate(
|
| 123 |
plain, discovery, selected, vibe=vibe, mode=mode,
|
| 124 |
start_label=start_query.strip(), end_label=dest_query.strip(),
|
|
@@ -159,7 +163,8 @@ def plan_route(
|
|
| 159 |
)
|
| 160 |
|
| 161 |
|
| 162 |
-
def _prepare_discovery(graph, start, end, plain, mode, budget, weights, adventurousness
|
|
|
|
| 163 |
"""Corridor → score → shortlist → real travel matrix. Done ONCE per request.
|
| 164 |
|
| 165 |
The expensive step is the matrix (cutoff-bounded multi-source Dijkstra), so we
|
|
@@ -171,6 +176,10 @@ def _prepare_discovery(graph, start, end, plain, mode, budget, weights, adventur
|
|
| 171 |
if not candidates:
|
| 172 |
return None, None, None
|
| 173 |
scoring.score_pois(candidates, weights, adventurousness)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
shortlist = sorted((p for p in candidates if p.score > 0),
|
| 175 |
key=lambda p: p.score, reverse=True)[: config.SOLVER_CANDIDATES]
|
| 176 |
if not shortlist:
|
|
@@ -190,12 +199,11 @@ def _solve_one(graph, start, end, plain, mode, budget, shortlist, matrix, time_f
|
|
| 190 |
return None, []
|
| 191 |
budget_s = (1.0 + budget) * plain.time_s
|
| 192 |
|
| 193 |
-
# P1-2
|
| 194 |
-
#
|
| 195 |
-
|
| 196 |
posture_dict = posture or {}
|
| 197 |
|
| 198 |
-
# posture_fn returns dwell time in seconds for a POI.
|
| 199 |
def posture_fn(poi):
|
| 200 |
from discoverroute.data import taxonomy
|
| 201 |
poi_category = getattr(poi, "category", "attraction")
|
|
@@ -206,7 +214,6 @@ def _solve_one(graph, start, end, plain, mode, budget, shortlist, matrix, time_f
|
|
| 206 |
|
| 207 |
result = ot.solve(start, end, pool, budget_s, time_fn,
|
| 208 |
max_pois=config.MAX_DETOUR_STOPS,
|
| 209 |
-
dwell_budget_s=dwell_budget_sec,
|
| 210 |
posture_fn=posture_fn)
|
| 211 |
if not result.ordered_pois:
|
| 212 |
return None, []
|
|
@@ -216,6 +223,7 @@ def _solve_one(graph, start, end, plain, mode, budget, shortlist, matrix, time_f
|
|
| 216 |
+ [matrix.node_for(end)]
|
| 217 |
)
|
| 218 |
discovery = g.stitch_route(graph, waypoint_nodes, mode, result.ordered_pois)
|
|
|
|
| 219 |
return discovery, result.ordered_pois
|
| 220 |
|
| 221 |
|
|
@@ -230,10 +238,12 @@ def _summary(plain: Route, discovery: Route | None, mode: str) -> str:
|
|
| 230 |
line = f"**Plain route** · {plain.distance_m/1000:.2f} km · {plain.time_min:.0f} min"
|
| 231 |
if discovery is None:
|
| 232 |
return line + f" ({mode})"
|
| 233 |
-
|
|
|
|
|
|
|
| 234 |
return (
|
| 235 |
f"**Discovery route** · {discovery.distance_m/1000:.2f} km · "
|
| 236 |
-
f"{discovery.time_min:.0f} min · **+{extra:.0f} min** of
|
| 237 |
-
f"past {len(discovery.waypoint_pois)} places\n\n"
|
| 238 |
f"{line} ({mode}) — shown for comparison"
|
| 239 |
)
|
|
|
|
| 109 |
used_ids: set[int] = set()
|
| 110 |
try:
|
| 111 |
shortlist, matrix, time_fn = _prepare_discovery(
|
| 112 |
+
graph, start, end, plain, mode, budget, weights, adventurousness,
|
| 113 |
+
posture=posture)
|
| 114 |
for _ in range(max(1, n_alternatives)):
|
| 115 |
if shortlist is None:
|
| 116 |
break
|
|
|
|
| 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(),
|
|
|
|
| 163 |
)
|
| 164 |
|
| 165 |
|
| 166 |
+
def _prepare_discovery(graph, start, end, plain, mode, budget, weights, adventurousness,
|
| 167 |
+
posture=None):
|
| 168 |
"""Corridor → score → shortlist → real travel matrix. Done ONCE per request.
|
| 169 |
|
| 170 |
The expensive step is the matrix (cutoff-bounded multi-source Dijkstra), so we
|
|
|
|
| 176 |
if not candidates:
|
| 177 |
return None, None, None
|
| 178 |
scoring.score_pois(candidates, weights, adventurousness)
|
| 179 |
+
# Open-now awareness: demote places that are closed right now (heavily for
|
| 180 |
+
# stop-at categories, mildly for pass-by; unknown hours left untouched).
|
| 181 |
+
from discoverroute.routing import hours
|
| 182 |
+
hours.apply_open_now(candidates, posture)
|
| 183 |
shortlist = sorted((p for p in candidates if p.score > 0),
|
| 184 |
key=lambda p: p.score, reverse=True)[: config.SOLVER_CANDIDATES]
|
| 185 |
if not shortlist:
|
|
|
|
| 199 |
return None, []
|
| 200 |
budget_s = (1.0 + budget) * plain.time_s
|
| 201 |
|
| 202 |
+
# P1-2, single shared pot: a stop's cost = added travel + dwell; a pass-by
|
| 203 |
+
# costs travel only. The solver enforces everything against budget_s, so the
|
| 204 |
+
# total trip (walking + lingering) never exceeds (1+budget) × direct.
|
| 205 |
posture_dict = posture or {}
|
| 206 |
|
|
|
|
| 207 |
def posture_fn(poi):
|
| 208 |
from discoverroute.data import taxonomy
|
| 209 |
poi_category = getattr(poi, "category", "attraction")
|
|
|
|
| 214 |
|
| 215 |
result = ot.solve(start, end, pool, budget_s, time_fn,
|
| 216 |
max_pois=config.MAX_DETOUR_STOPS,
|
|
|
|
| 217 |
posture_fn=posture_fn)
|
| 218 |
if not result.ordered_pois:
|
| 219 |
return None, []
|
|
|
|
| 223 |
+ [matrix.node_for(end)]
|
| 224 |
)
|
| 225 |
discovery = g.stitch_route(graph, waypoint_nodes, mode, result.ordered_pois)
|
| 226 |
+
discovery.dwell_s = result.dwell_time_s
|
| 227 |
return discovery, result.ordered_pois
|
| 228 |
|
| 229 |
|
|
|
|
| 238 |
line = f"**Plain route** · {plain.distance_m/1000:.2f} km · {plain.time_min:.0f} min"
|
| 239 |
if discovery is None:
|
| 240 |
return line + f" ({mode})"
|
| 241 |
+
dwell_min = discovery.dwell_s / 60.0
|
| 242 |
+
extra = discovery.time_min + dwell_min - plain.time_min
|
| 243 |
+
dwell_note = f" (incl. ~{dwell_min:.0f} min lingering)" if dwell_min >= 1 else ""
|
| 244 |
return (
|
| 245 |
f"**Discovery route** · {discovery.distance_m/1000:.2f} km · "
|
| 246 |
+
f"{discovery.time_min + dwell_min:.0f} min · **+{extra:.0f} min** of "
|
| 247 |
+
f"discovery{dwell_note} past {len(discovery.waypoint_pois)} places\n\n"
|
| 248 |
f"{line} ({mode}) — shown for comparison"
|
| 249 |
)
|
|
@@ -29,6 +29,8 @@ class _Entry(NamedTuple):
|
|
| 29 |
lon: float
|
| 30 |
confidence: float
|
| 31 |
n_tags: int
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def _normalize(text: str) -> str:
|
|
@@ -72,6 +74,8 @@ def _index() -> tuple[dict[str, _Entry], list[_Entry]]:
|
|
| 72 |
lon=float(row.lon),
|
| 73 |
confidence=float(row.confidence),
|
| 74 |
n_tags=int(row.n_tags),
|
|
|
|
|
|
|
| 75 |
)
|
| 76 |
entries.append(entry)
|
| 77 |
best = exact.get(norm)
|
|
@@ -109,3 +113,42 @@ def local_geocode(query: str) -> tuple[float, float] | None:
|
|
| 109 |
key=lambda e: (norm in e.norm, e.confidence, e.n_tags, -len(e.norm)),
|
| 110 |
)
|
| 111 |
return best.lat, best.lon
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
lon: float
|
| 30 |
confidence: float
|
| 31 |
n_tags: int
|
| 32 |
+
display: str # original POI name, for autocomplete suggestions
|
| 33 |
+
category: str
|
| 34 |
|
| 35 |
|
| 36 |
def _normalize(text: str) -> str:
|
|
|
|
| 74 |
lon=float(row.lon),
|
| 75 |
confidence=float(row.confidence),
|
| 76 |
n_tags=int(row.n_tags),
|
| 77 |
+
display=str(row.name),
|
| 78 |
+
category=str(row.category),
|
| 79 |
)
|
| 80 |
entries.append(entry)
|
| 81 |
best = exact.get(norm)
|
|
|
|
| 113 |
key=lambda e: (norm in e.norm, e.confidence, e.n_tags, -len(e.norm)),
|
| 114 |
)
|
| 115 |
return best.lat, best.lon
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@functools.lru_cache(maxsize=1024)
|
| 119 |
+
def suggest(query: str, limit: int = 8) -> tuple[str, ...]:
|
| 120 |
+
"""Autocomplete: Paris place names matching a partial query, best first.
|
| 121 |
+
|
| 122 |
+
Matches treat the last token as a prefix (the user is mid-word). Ranked by
|
| 123 |
+
(substring match, confidence, tag richness, name brevity); deduplicated by
|
| 124 |
+
display name. Pure local index — no network. Returns () for short/ambiguous
|
| 125 |
+
input rather than guessing.
|
| 126 |
+
"""
|
| 127 |
+
norm = _strip_trailing_geo(_normalize(query or ""))
|
| 128 |
+
if len(norm) < 3:
|
| 129 |
+
return ()
|
| 130 |
+
_, entries = _index()
|
| 131 |
+
toks = norm.split()
|
| 132 |
+
head, last = frozenset(toks[:-1]), toks[-1]
|
| 133 |
+
|
| 134 |
+
scored: list[tuple[tuple, _Entry]] = []
|
| 135 |
+
for e in entries:
|
| 136 |
+
if norm in e.norm:
|
| 137 |
+
rank = 2 # full query appears verbatim in the name
|
| 138 |
+
elif head <= e.tokens and any(t.startswith(last) for t in e.tokens):
|
| 139 |
+
rank = 1 # all complete tokens present, last token a prefix
|
| 140 |
+
else:
|
| 141 |
+
continue
|
| 142 |
+
scored.append(((rank, e.confidence, e.n_tags, -len(e.norm)), e))
|
| 143 |
+
|
| 144 |
+
scored.sort(key=lambda t: t[0], reverse=True)
|
| 145 |
+
out: list[str] = []
|
| 146 |
+
seen: set[str] = set()
|
| 147 |
+
for _, e in scored:
|
| 148 |
+
if e.display in seen:
|
| 149 |
+
continue
|
| 150 |
+
seen.add(e.display)
|
| 151 |
+
out.append(e.display)
|
| 152 |
+
if len(out) >= limit:
|
| 153 |
+
break
|
| 154 |
+
return tuple(out)
|
|
@@ -39,6 +39,7 @@ class Route:
|
|
| 39 |
distance_m: float
|
| 40 |
mode: str
|
| 41 |
waypoint_pois: list = field(default_factory=list) # filled by later bricks
|
|
|
|
| 42 |
|
| 43 |
@property
|
| 44 |
def time_s(self) -> float:
|
|
|
|
| 39 |
distance_m: float
|
| 40 |
mode: str
|
| 41 |
waypoint_pois: list = field(default_factory=list) # filled by later bricks
|
| 42 |
+
dwell_s: float = 0.0 # planned lingering time at stops (P1-2)
|
| 43 |
|
| 44 |
@property
|
| 45 |
def time_s(self) -> float:
|
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Conservative OSM ``opening_hours`` evaluation — open / closed / unknown.
|
| 2 |
+
|
| 3 |
+
The OSM opening-hours grammar is large; this parses only the unambiguous,
|
| 4 |
+
overwhelmingly common patterns and returns ``None`` (unknown) for everything
|
| 5 |
+
else — the app must never demote a place on a misread rule. Handled:
|
| 6 |
+
|
| 7 |
+
24/7 · "Mo-Fr 08:00-18:00" · day lists "Mo,We,Fr" · ranges across multiple
|
| 8 |
+
rules "Tu-Su 09:00-18:00; Mo off" · several time spans "09:00-12:00,14:00-18:00"
|
| 9 |
+
· plain daily times "10:00-19:00" · overnight spans "18:00-02:00" · "off"/"closed"
|
| 10 |
+
|
| 11 |
+
Abstained: PH/SH (holidays), sunrise/sunset, months/weeks, "+" open-ends, etc.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
from datetime import datetime, timedelta
|
| 17 |
+
from zoneinfo import ZoneInfo
|
| 18 |
+
|
| 19 |
+
PARIS_TZ = ZoneInfo("Europe/Paris")
|
| 20 |
+
|
| 21 |
+
_DAYS = ["mo", "tu", "we", "th", "fr", "sa", "su"]
|
| 22 |
+
_DAY_RE = r"(?:mo|tu|we|th|fr|sa|su)"
|
| 23 |
+
_TIME_RE = re.compile(r"^\d{1,2}:\d{2}$")
|
| 24 |
+
# tokens that mean "too complex — abstain". PH/SH (public/school holidays) are
|
| 25 |
+
# NOT here: holiday rules are dropped per-rule instead, since the regular-day
|
| 26 |
+
# part of "Mo-Fr 08:00-18:00; PH off" is perfectly decidable.
|
| 27 |
+
_ABSTAIN = re.compile(
|
| 28 |
+
r"sunrise|sunset|dawn|dusk|week|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|easter|\+|\|\|"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _parse_days(spec: str) -> set[int] | None:
|
| 33 |
+
"""'mo-fr' / 'mo,we,fr' / 'tu-su,mo' -> weekday indices, or None if invalid.
|
| 34 |
+
|
| 35 |
+
PH/SH tokens inside a list ("PH,Sa,Su 10:00-18:00") are ignored: they extend
|
| 36 |
+
the rule to holidays, which doesn't change what regular weekdays it covers.
|
| 37 |
+
"""
|
| 38 |
+
days: set[int] = set()
|
| 39 |
+
saw_any = False
|
| 40 |
+
for part in spec.split(","):
|
| 41 |
+
part = part.strip()
|
| 42 |
+
if part in ("ph", "sh"):
|
| 43 |
+
continue
|
| 44 |
+
m = re.fullmatch(rf"({_DAY_RE})\s*-\s*({_DAY_RE})", part)
|
| 45 |
+
if m:
|
| 46 |
+
a, b = _DAYS.index(m.group(1)), _DAYS.index(m.group(2))
|
| 47 |
+
if a <= b:
|
| 48 |
+
days.update(range(a, b + 1))
|
| 49 |
+
else: # wrapping range, e.g. fr-mo
|
| 50 |
+
days.update(list(range(a, 7)) + list(range(0, b + 1)))
|
| 51 |
+
saw_any = True
|
| 52 |
+
continue
|
| 53 |
+
if re.fullmatch(_DAY_RE, part):
|
| 54 |
+
days.add(_DAYS.index(part))
|
| 55 |
+
saw_any = True
|
| 56 |
+
continue
|
| 57 |
+
return None
|
| 58 |
+
return days if saw_any else set()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _parse_spans(spec: str) -> list[tuple[int, int]] | None:
|
| 62 |
+
"""'09:00-12:00,14:00-18:00' -> [(540,720),(840,1080)] minutes, or None."""
|
| 63 |
+
spans: list[tuple[int, int]] = []
|
| 64 |
+
for part in spec.split(","):
|
| 65 |
+
part = part.strip()
|
| 66 |
+
m = re.fullmatch(r"(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})", part)
|
| 67 |
+
if not m:
|
| 68 |
+
return None
|
| 69 |
+
h1, m1 = map(int, m.group(1).split(":"))
|
| 70 |
+
h2, m2 = map(int, m.group(2).split(":"))
|
| 71 |
+
if not (0 <= h1 <= 24 and 0 <= h2 <= 24 and m1 < 60 and m2 < 60):
|
| 72 |
+
return None
|
| 73 |
+
spans.append((h1 * 60 + m1, h2 * 60 + m2))
|
| 74 |
+
return spans
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def is_open(opening_hours: str | None, when: datetime | None = None) -> bool | None:
|
| 78 |
+
"""True/False if confidently determinable for ``when`` (Paris time); else None."""
|
| 79 |
+
if not opening_hours or not str(opening_hours).strip():
|
| 80 |
+
return None
|
| 81 |
+
text = " ".join(str(opening_hours).lower().split())
|
| 82 |
+
if text in ("24/7", "24/7;"):
|
| 83 |
+
return True
|
| 84 |
+
if _ABSTAIN.search(text):
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
when = when or datetime.now(PARIS_TZ)
|
| 88 |
+
if when.tzinfo is None:
|
| 89 |
+
when = when.replace(tzinfo=PARIS_TZ)
|
| 90 |
+
weekday = when.weekday()
|
| 91 |
+
minute = when.hour * 60 + when.minute
|
| 92 |
+
# for overnight spans we also need "yesterday evening" rules
|
| 93 |
+
yesterday = (weekday - 1) % 7
|
| 94 |
+
|
| 95 |
+
decided: bool | None = None
|
| 96 |
+
matched_any_rule = False
|
| 97 |
+
day_head_re = re.compile(rf"^(?:(?:{_DAY_RE}|ph|sh)(?:\s*-\s*(?:{_DAY_RE}|ph|sh))?)"
|
| 98 |
+
rf"(?:,(?:{_DAY_RE}|ph|sh)(?:\s*-\s*(?:{_DAY_RE}|ph|sh))?)*$")
|
| 99 |
+
for rule in text.split(";"):
|
| 100 |
+
rule = rule.strip()
|
| 101 |
+
if not rule:
|
| 102 |
+
continue
|
| 103 |
+
head, _, tail = rule.partition(" ")
|
| 104 |
+
if day_head_re.fullmatch(head):
|
| 105 |
+
day_spec, rest = head, tail.strip()
|
| 106 |
+
else:
|
| 107 |
+
day_spec, rest = None, rule
|
| 108 |
+
days = _parse_days(day_spec.replace(" ", "")) if day_spec else set(range(7))
|
| 109 |
+
if days is None:
|
| 110 |
+
return None # unparseable day spec -> abstain entirely
|
| 111 |
+
if not days:
|
| 112 |
+
continue # holiday-only rule (e.g. "PH off") -> no weekday effect
|
| 113 |
+
|
| 114 |
+
if rest in ("off", "closed"):
|
| 115 |
+
if weekday in days:
|
| 116 |
+
decided = False
|
| 117 |
+
matched_any_rule = True
|
| 118 |
+
continue
|
| 119 |
+
spans = _parse_spans(rest)
|
| 120 |
+
if spans is None:
|
| 121 |
+
return None # unparseable times -> abstain entirely
|
| 122 |
+
|
| 123 |
+
matched_any_rule = True
|
| 124 |
+
for lo, hi in spans:
|
| 125 |
+
if hi >= lo: # same-day span
|
| 126 |
+
if weekday in days and lo <= minute < hi:
|
| 127 |
+
decided = True
|
| 128 |
+
else: # overnight span, e.g. 18:00-02:00
|
| 129 |
+
if weekday in days and minute >= lo:
|
| 130 |
+
decided = True
|
| 131 |
+
if yesterday in days and minute < hi:
|
| 132 |
+
decided = True
|
| 133 |
+
|
| 134 |
+
if decided is True:
|
| 135 |
+
return True
|
| 136 |
+
# only claim "closed" when at least one rule parsed and applies to this place
|
| 137 |
+
return False if matched_any_rule else None
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# Demotion factors when a place is closed at plan time: stopping at a closed
|
| 141 |
+
# café is pointless (heavy demotion); passing a closed monument still has
|
| 142 |
+
# exterior value (mild demotion). Unknown hours are left untouched — except for
|
| 143 |
+
# typically-daytime categories late at night, which get a mild realism demotion
|
| 144 |
+
# (a café with unlisted hours is a poor bet at midnight).
|
| 145 |
+
CLOSED_STOP_FACTOR = 0.2
|
| 146 |
+
CLOSED_PASS_FACTOR = 0.7
|
| 147 |
+
NIGHT_UNKNOWN_FACTOR = 0.5
|
| 148 |
+
_NIGHT_START_H, _NIGHT_END_H = 21, 6
|
| 149 |
+
_DAYTIME_CATEGORIES = {
|
| 150 |
+
"cafe", "bakery_food_shop", "market", "museum_gallery", "library",
|
| 151 |
+
"bookshop", "specialty_shop",
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def apply_open_now(pois: list, posture: dict[str, str] | None,
|
| 156 |
+
when: datetime | None = None) -> list:
|
| 157 |
+
"""Annotate each POI with ``.open_state`` and demote closed ones in place."""
|
| 158 |
+
posture = posture or {}
|
| 159 |
+
when = when or datetime.now(PARIS_TZ)
|
| 160 |
+
is_night = when.hour >= _NIGHT_START_H or when.hour < _NIGHT_END_H
|
| 161 |
+
for p in pois:
|
| 162 |
+
state = is_open(getattr(p, "opening_hours", None), when)
|
| 163 |
+
p.open_state = state # True / False / None(unknown)
|
| 164 |
+
if state is False:
|
| 165 |
+
factor = (CLOSED_STOP_FACTOR
|
| 166 |
+
if posture.get(p.category, "pass") == "stop"
|
| 167 |
+
else CLOSED_PASS_FACTOR)
|
| 168 |
+
p.score *= factor
|
| 169 |
+
elif state is None and is_night and p.category in _DAYTIME_CATEGORIES:
|
| 170 |
+
p.score *= NIGHT_UNKNOWN_FACTOR
|
| 171 |
+
return pois
|
|
@@ -53,9 +53,11 @@ def _greedy(start, end, pool, budget_s, time_fn, decay, max_pois, by_ratio, gain
|
|
| 53 |
``max_pois`` is reached. The floor stops the route padding its remaining budget
|
| 54 |
with negligible-value detours.
|
| 55 |
|
| 56 |
-
P1-2:
|
| 57 |
-
dwell time
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
"""
|
| 60 |
seq: list[Point] = [start, end]
|
| 61 |
selected: list = []
|
|
@@ -64,40 +66,36 @@ def _greedy(start, end, pool, budget_s, time_fn, decay, max_pois, by_ratio, gain
|
|
| 64 |
cur_detour_dist = 0.0
|
| 65 |
|
| 66 |
while len(selected) < max_pois:
|
| 67 |
-
best = None # (key, added, idx, poi)
|
| 68 |
for p in pool:
|
| 69 |
if p in selected:
|
| 70 |
continue
|
| 71 |
gain = scoring.marginal_gain(selected, p, decay)
|
| 72 |
if gain < gain_floor:
|
| 73 |
continue
|
|
|
|
|
|
|
|
|
|
| 74 |
ppt = (p.lat, p.lon)
|
| 75 |
for i in range(1, len(seq)):
|
| 76 |
added = (time_fn(seq[i - 1], ppt) + time_fn(ppt, seq[i])
|
| 77 |
- time_fn(seq[i - 1], seq[i]))
|
| 78 |
-
|
|
|
|
| 79 |
continue
|
| 80 |
-
|
| 81 |
-
#
|
| 82 |
-
|
| 83 |
-
poi_dwell = posture_fn(p)
|
| 84 |
-
if cur_dwell + poi_dwell > dwell_budget_s:
|
| 85 |
-
continue
|
| 86 |
-
|
| 87 |
-
key = gain / max(added, _EPS) if by_ratio else gain
|
| 88 |
-
# tie-break toward the cheaper detour
|
| 89 |
-
cand = (key, -added)
|
| 90 |
if best is None or cand > best[0]:
|
| 91 |
-
best = (cand, added, i, p)
|
| 92 |
if best is None:
|
| 93 |
break
|
| 94 |
-
_, added, idx, poi = best
|
| 95 |
seq.insert(idx, (poi.lat, poi.lon))
|
| 96 |
selected.insert(idx - 1, poi)
|
| 97 |
-
cur_time += added
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
cur_detour_dist += added
|
| 101 |
|
| 102 |
return OrienteeringResult(
|
| 103 |
selected, cur_time, scoring.set_reward(selected, decay),
|
|
|
|
| 53 |
``max_pois`` is reached. The floor stops the route padding its remaining budget
|
| 54 |
with negligible-value detours.
|
| 55 |
|
| 56 |
+
P1-2 (single shared pot): a stop's full cost is added travel time PLUS its
|
| 57 |
+
dwell time (from ``posture_fn``); a pass-by costs travel only. Everything is
|
| 58 |
+
enforced against the one ``budget_s`` cap, so the user-facing promise —
|
| 59 |
+
total trip ≤ (1+budget) × direct — holds whether time is spent walking or
|
| 60 |
+
lingering. ``dwell_budget_s`` optionally adds a separate dwell-only cap.
|
| 61 |
"""
|
| 62 |
seq: list[Point] = [start, end]
|
| 63 |
selected: list = []
|
|
|
|
| 66 |
cur_detour_dist = 0.0
|
| 67 |
|
| 68 |
while len(selected) < max_pois:
|
| 69 |
+
best = None # (key, added, idx, poi, dwell)
|
| 70 |
for p in pool:
|
| 71 |
if p in selected:
|
| 72 |
continue
|
| 73 |
gain = scoring.marginal_gain(selected, p, decay)
|
| 74 |
if gain < gain_floor:
|
| 75 |
continue
|
| 76 |
+
poi_dwell = posture_fn(p) if posture_fn is not None else 0.0
|
| 77 |
+
if dwell_budget_s is not None and cur_dwell + poi_dwell > dwell_budget_s:
|
| 78 |
+
continue
|
| 79 |
ppt = (p.lat, p.lon)
|
| 80 |
for i in range(1, len(seq)):
|
| 81 |
added = (time_fn(seq[i - 1], ppt) + time_fn(ppt, seq[i])
|
| 82 |
- time_fn(seq[i - 1], seq[i]))
|
| 83 |
+
cost = added + poi_dwell # stops pay travel + dwell, passes travel
|
| 84 |
+
if cur_time + cost > budget_s:
|
| 85 |
continue
|
| 86 |
+
key = gain / max(cost, _EPS) if by_ratio else gain
|
| 87 |
+
# tie-break toward the cheaper insertion
|
| 88 |
+
cand = (key, -cost)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
if best is None or cand > best[0]:
|
| 90 |
+
best = (cand, added, i, p, poi_dwell)
|
| 91 |
if best is None:
|
| 92 |
break
|
| 93 |
+
_, added, idx, poi, poi_dwell = best
|
| 94 |
seq.insert(idx, (poi.lat, poi.lon))
|
| 95 |
selected.insert(idx - 1, poi)
|
| 96 |
+
cur_time += added + poi_dwell
|
| 97 |
+
cur_dwell += poi_dwell
|
| 98 |
+
cur_detour_dist += added
|
|
|
|
| 99 |
|
| 100 |
return OrienteeringResult(
|
| 101 |
selected, cur_time, scoring.set_reward(selected, decay),
|
|
@@ -35,8 +35,11 @@ class POI:
|
|
| 35 |
quietness: float
|
| 36 |
confidence: float
|
| 37 |
n_tags: int
|
|
|
|
| 38 |
# filled by the scorer (Brick 2):
|
| 39 |
score: float = 0.0
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
def _to_metres(lat, lon):
|
|
@@ -111,6 +114,8 @@ def corridor_pois(
|
|
| 111 |
quietness=float(r.quietness),
|
| 112 |
confidence=float(r.confidence),
|
| 113 |
n_tags=int(r.n_tags),
|
|
|
|
|
|
|
| 114 |
)
|
| 115 |
for r in sel.itertuples(index=False)
|
| 116 |
]
|
|
|
|
| 35 |
quietness: float
|
| 36 |
confidence: float
|
| 37 |
n_tags: int
|
| 38 |
+
opening_hours: str | None = None
|
| 39 |
# filled by the scorer (Brick 2):
|
| 40 |
score: float = 0.0
|
| 41 |
+
# filled by hours.apply_open_now: True / False / None (unknown)
|
| 42 |
+
open_state: bool | None = None
|
| 43 |
|
| 44 |
|
| 45 |
def _to_metres(lat, lon):
|
|
|
|
| 114 |
quietness=float(r.quietness),
|
| 115 |
confidence=float(r.confidence),
|
| 116 |
n_tags=int(r.n_tags),
|
| 117 |
+
opening_hours=(None if pd.isna(getattr(r, "opening_hours", None))
|
| 118 |
+
else str(r.opening_hours)),
|
| 119 |
)
|
| 120 |
for r in sel.itertuples(index=False)
|
| 121 |
]
|
|
@@ -60,10 +60,14 @@ DR_CSS = """
|
|
| 60 |
box-shadow:0 18px 44px -20px rgba(43,38,32,.32) !important; background:var(--dr-paper) !important;
|
| 61 |
}
|
| 62 |
|
| 63 |
-
/* inputs */
|
| 64 |
.gradio-container input[type=text], .gradio-container textarea{
|
| 65 |
border:2px solid var(--dr-line) !important; border-radius:var(--dr-r) !important;
|
| 66 |
-
background:#FFFEFB !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
.gradio-container input[type=text]:focus, .gradio-container textarea:focus{
|
| 69 |
border-color:var(--dr-cobalt) !important; box-shadow:0 0 0 4px rgba(47,93,244,.14) !important;
|
|
@@ -84,8 +88,10 @@ DR_CSS = """
|
|
| 84 |
/* mode toggle as a segmented control */
|
| 85 |
#dr-mode .wrap{ background:#F0E3CC; padding:5px; border-radius:var(--dr-r); gap:6px; }
|
| 86 |
#dr-mode label{ flex:1; justify-content:center; border:none !important;
|
|
|
|
| 87 |
border-radius:13px !important; transition:all .2s var(--dr-spring); }
|
| 88 |
-
#dr-mode label
|
|
|
|
| 89 |
box-shadow:0 4px 12px -4px rgba(43,38,32,.25); transform:translateY(-1px); }
|
| 90 |
|
| 91 |
/* springy sliders — per-slider accents (budget coral · adventurousness sun ·
|
|
@@ -102,14 +108,18 @@ DR_CSS = """
|
|
| 102 |
#dr-adv input[type=range]::-webkit-slider-thumb{ border-color:var(--dr-sun); }
|
| 103 |
#dr-green input[type=range], #dr-quiet input[type=range]{ accent-color:var(--dr-grass); }
|
| 104 |
|
| 105 |
-
/* collapsibles -> dashed taste cards */
|
| 106 |
.dr-collapse{ border:1.5px dashed var(--dr-line) !important; border-radius:var(--dr-r) !important;
|
| 107 |
background:#FFFDF8 !important; box-shadow:none !important; }
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
/* route-options radio -> selectable cards */
|
| 110 |
#dr-options .wrap{ display:grid; grid-template-columns:repeat(3,1fr); gap:11px; }
|
| 111 |
#dr-options label{ border:2px solid var(--dr-line) !important; border-radius:var(--dr-r) !important;
|
| 112 |
-
padding:14px !important;
|
|
|
|
|
|
|
| 113 |
#dr-options label:hover{ transform:translateY(-3px); }
|
| 114 |
#dr-options label.selected{ border-color:var(--dr-grass) !important; background:#F1FAF4 !important;
|
| 115 |
box-shadow:0 10px 26px -14px rgba(47,164,99,.6) !important; }
|
|
@@ -118,7 +128,7 @@ DR_CSS = """
|
|
| 118 |
#dr-map{ border-radius:26px !important; overflow:hidden; border:1px solid var(--dr-line);
|
| 119 |
box-shadow:0 18px 44px -20px rgba(43,38,32,.34); position:relative; background:var(--dr-paper); }
|
| 120 |
#dr-map::before{ content:'Paris — live map'; display:block; font-family:'Fredoka',sans-serif;
|
| 121 |
-
font-weight:600; font-size:13.5px; padding:11px 16px 11px 64px;
|
| 122 |
border-bottom:1px solid var(--dr-line);
|
| 123 |
background-image:radial-gradient(circle at 20px 50%,#FF6A52 5px,transparent 5px),
|
| 124 |
radial-gradient(circle at 36px 50%,#FFC247 5px,transparent 5px),
|
|
@@ -174,6 +184,13 @@ footer{ visibility:hidden; }
|
|
| 174 |
#dr-map::before{ font-size:12px; background-image:linear-gradient(180deg,#FFF,#FBF4E6); }
|
| 175 |
}
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
/* respect reduced motion */
|
| 178 |
@media (prefers-reduced-motion:reduce){
|
| 179 |
.gradio-container *{ animation:none !important; transition:none !important; } }
|
|
@@ -182,40 +199,71 @@ footer{ visibility:hidden; }
|
|
| 182 |
"""
|
| 183 |
|
| 184 |
# ---------------------------------------------------------------- head (§4)
|
|
|
|
|
|
|
| 185 |
DR_HEAD = """
|
| 186 |
<link rel='preconnect' href='https://fonts.googleapis.com'>
|
| 187 |
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>
|
| 188 |
<link href='https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap' rel='stylesheet'>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
"""
|
| 190 |
|
| 191 |
# ---------------------------------------------------------------- js (§4/§6)
|
| 192 |
# Outer-page enhancer: bounce results in when they (re)appear. The map's own
|
| 193 |
# route-draw / marker-pop animations run INSIDE the folium iframe (ui/map.py
|
| 194 |
# injects MAP_ANIMATION_JS there — an iframe can't be reached reliably from here).
|
| 195 |
-
|
| 196 |
-
()
|
| 197 |
-
function celebrate(el){
|
| 198 |
-
if(!el || el.dataset.shown) return; el.dataset.shown='1';
|
| 199 |
-
el.animate([{opacity:0,transform:'translateY(14px)'},{opacity:1,transform:'none'}],
|
| 200 |
-
{duration:480,easing:'cubic-bezier(.34,1.56,.64,1)',fill:'forwards'});
|
| 201 |
-
}
|
| 202 |
-
const obs = new MutationObserver(()=>{
|
| 203 |
-
['#dr-summary','#dr-interp','#dr-itin','#dr-options'].forEach(s=>{
|
| 204 |
-
const el=document.querySelector(s);
|
| 205 |
-
if(el && el.textContent.trim()) celebrate(el);
|
| 206 |
-
});
|
| 207 |
-
});
|
| 208 |
-
obs.observe(document.body,{childList:true,subtree:true});
|
| 209 |
-
}
|
| 210 |
-
"""
|
| 211 |
|
| 212 |
-
# Map-press bounce the instant Plan is clicked (per-event js).
|
|
|
|
|
|
|
| 213 |
DR_CELEBRATE = """
|
| 214 |
-
() => {
|
| 215 |
const map = document.querySelector('#dr-map');
|
| 216 |
if (map) map.animate(
|
| 217 |
[{transform:'scale(1)'},{transform:'scale(.99)'},{transform:'scale(1)'}],
|
| 218 |
{duration:260, easing:'cubic-bezier(.34,1.56,.64,1)'});
|
|
|
|
| 219 |
}
|
| 220 |
"""
|
| 221 |
|
|
@@ -336,3 +384,56 @@ NO_DETOUR_HTML = """
|
|
| 336 |
|
| 337 |
# Empty-map overlay message (rendered by ui/map.py inside the map frame).
|
| 338 |
EMPTY_STATE_LABEL = "Where shall we wander?"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
box-shadow:0 18px 44px -20px rgba(43,38,32,.32) !important; background:var(--dr-paper) !important;
|
| 61 |
}
|
| 62 |
|
| 63 |
+
/* inputs — explicit ink color (theme override otherwise leaves them too pale) */
|
| 64 |
.gradio-container input[type=text], .gradio-container textarea{
|
| 65 |
border:2px solid var(--dr-line) !important; border-radius:var(--dr-r) !important;
|
| 66 |
+
background:#FFFEFB !important; color:var(--dr-ink) !important;
|
| 67 |
+
transition:border-color .18s, box-shadow .18s !important;
|
| 68 |
+
}
|
| 69 |
+
.gradio-container input[type=text]::placeholder, .gradio-container textarea::placeholder{
|
| 70 |
+
color:#B9AE99 !important; opacity:1;
|
| 71 |
}
|
| 72 |
.gradio-container input[type=text]:focus, .gradio-container textarea:focus{
|
| 73 |
border-color:var(--dr-cobalt) !important; box-shadow:0 0 0 4px rgba(47,93,244,.14) !important;
|
|
|
|
| 88 |
/* mode toggle as a segmented control */
|
| 89 |
#dr-mode .wrap{ background:#F0E3CC; padding:5px; border-radius:var(--dr-r); gap:6px; }
|
| 90 |
#dr-mode label{ flex:1; justify-content:center; border:none !important;
|
| 91 |
+
background:transparent !important; color:var(--dr-soft) !important;
|
| 92 |
border-radius:13px !important; transition:all .2s var(--dr-spring); }
|
| 93 |
+
#dr-mode label span{ color:inherit !important; }
|
| 94 |
+
#dr-mode label.selected{ background:var(--dr-paper) !important; color:var(--dr-cobalt) !important;
|
| 95 |
box-shadow:0 4px 12px -4px rgba(43,38,32,.25); transform:translateY(-1px); }
|
| 96 |
|
| 97 |
/* springy sliders — per-slider accents (budget coral · adventurousness sun ·
|
|
|
|
| 108 |
#dr-adv input[type=range]::-webkit-slider-thumb{ border-color:var(--dr-sun); }
|
| 109 |
#dr-green input[type=range], #dr-quiet input[type=range]{ accent-color:var(--dr-grass); }
|
| 110 |
|
| 111 |
+
/* collapsibles -> dashed taste cards (labels at full ink for readability) */
|
| 112 |
.dr-collapse{ border:1.5px dashed var(--dr-line) !important; border-radius:var(--dr-r) !important;
|
| 113 |
background:#FFFDF8 !important; box-shadow:none !important; }
|
| 114 |
+
.dr-collapse .label-wrap span, .dr-collapse button span, .dr-collapse > button{
|
| 115 |
+
color:var(--dr-ink) !important; opacity:1 !important; }
|
| 116 |
|
| 117 |
+
/* route-options radio -> selectable cards (paper bg + ink text, both states) */
|
| 118 |
#dr-options .wrap{ display:grid; grid-template-columns:repeat(3,1fr); gap:11px; }
|
| 119 |
#dr-options label{ border:2px solid var(--dr-line) !important; border-radius:var(--dr-r) !important;
|
| 120 |
+
padding:14px !important; background:var(--dr-paper) !important; color:var(--dr-ink) !important;
|
| 121 |
+
transition:all .2s var(--dr-spring); }
|
| 122 |
+
#dr-options label span, #dr-options label *{ color:var(--dr-ink) !important; }
|
| 123 |
#dr-options label:hover{ transform:translateY(-3px); }
|
| 124 |
#dr-options label.selected{ border-color:var(--dr-grass) !important; background:#F1FAF4 !important;
|
| 125 |
box-shadow:0 10px 26px -14px rgba(47,164,99,.6) !important; }
|
|
|
|
| 128 |
#dr-map{ border-radius:26px !important; overflow:hidden; border:1px solid var(--dr-line);
|
| 129 |
box-shadow:0 18px 44px -20px rgba(43,38,32,.34); position:relative; background:var(--dr-paper); }
|
| 130 |
#dr-map::before{ content:'Paris — live map'; display:block; font-family:'Fredoka',sans-serif;
|
| 131 |
+
font-weight:600; font-size:13.5px; padding:11px 16px 11px 64px; color:var(--dr-ink);
|
| 132 |
border-bottom:1px solid var(--dr-line);
|
| 133 |
background-image:radial-gradient(circle at 20px 50%,#FF6A52 5px,transparent 5px),
|
| 134 |
radial-gradient(circle at 36px 50%,#FFC247 5px,transparent 5px),
|
|
|
|
| 184 |
#dr-map::before{ font-size:12px; background-image:linear-gradient(180deg,#FFF,#FBF4E6); }
|
| 185 |
}
|
| 186 |
|
| 187 |
+
/* dark-mode belt-and-suspenders: even if the dark class sticks, keep the
|
| 188 |
+
design's ink-on-cream readable (the head script also strips the class) */
|
| 189 |
+
.dark .gradio-container, .dark .gradio-container .prose,
|
| 190 |
+
.dark .gradio-container .prose *, .dark .gradio-container label span,
|
| 191 |
+
.dark .gradio-container p, .dark .gradio-container li{
|
| 192 |
+
color:var(--dr-ink) !important; }
|
| 193 |
+
|
| 194 |
/* respect reduced motion */
|
| 195 |
@media (prefers-reduced-motion:reduce){
|
| 196 |
.gradio-container *{ animation:none !important; transition:none !important; } }
|
|
|
|
| 199 |
"""
|
| 200 |
|
| 201 |
# ---------------------------------------------------------------- head (§4)
|
| 202 |
+
# Includes the page enhancer as a real <script> — Gradio 6's launch(js=...)
|
| 203 |
+
# proved unreliable, while head= injection always executes.
|
| 204 |
DR_HEAD = """
|
| 205 |
<link rel='preconnect' href='https://fonts.googleapis.com'>
|
| 206 |
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>
|
| 207 |
<link href='https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap' rel='stylesheet'>
|
| 208 |
+
<script>
|
| 209 |
+
(function(){
|
| 210 |
+
// The clay design is light-only; Gradio applies a `dark` class from the OS
|
| 211 |
+
// preference, flipping text vars to near-white on our cream cards. Strip it
|
| 212 |
+
// and keep it stripped.
|
| 213 |
+
var forceLight = function(){
|
| 214 |
+
document.documentElement.classList.remove('dark');
|
| 215 |
+
if (document.body) document.body.classList.remove('dark');
|
| 216 |
+
};
|
| 217 |
+
// Transform-only entrance (no opacity: Gradio morphs the DOM mid-animation
|
| 218 |
+
// and an interrupted opacity animation would freeze content half-faded).
|
| 219 |
+
var celebrate = function(el){
|
| 220 |
+
if(!el || el.dataset.shown) return; el.dataset.shown='1';
|
| 221 |
+
try{ el.animate([{transform:'translateY(14px)'},{transform:'none'}],
|
| 222 |
+
{duration:480,easing:'cubic-bezier(.34,1.56,.64,1)'}); }catch(e){}
|
| 223 |
+
};
|
| 224 |
+
var arm = function(){
|
| 225 |
+
forceLight();
|
| 226 |
+
// Debounced via rAF and childList-only: observing class attributes fires on
|
| 227 |
+
// every Svelte class toggle (constantly) and can livelock a slow renderer.
|
| 228 |
+
// Dark-class re-adds are covered by the .dark CSS overrides as backstop.
|
| 229 |
+
var scheduled = false;
|
| 230 |
+
var tick = function(){
|
| 231 |
+
scheduled = false;
|
| 232 |
+
forceLight();
|
| 233 |
+
['#dr-summary','#dr-interp','#dr-itin','#dr-options'].forEach(function(s){
|
| 234 |
+
var el = document.querySelector(s);
|
| 235 |
+
if (el && el.textContent.trim()) celebrate(el);
|
| 236 |
+
});
|
| 237 |
+
};
|
| 238 |
+
var obs = new MutationObserver(function(){
|
| 239 |
+
if (!scheduled){ scheduled = true; requestAnimationFrame(tick); }
|
| 240 |
+
});
|
| 241 |
+
obs.observe(document.body, {childList:true, subtree:true});
|
| 242 |
+
};
|
| 243 |
+
if (document.readyState === 'loading')
|
| 244 |
+
document.addEventListener('DOMContentLoaded', arm);
|
| 245 |
+
else arm();
|
| 246 |
+
})();
|
| 247 |
+
</script>
|
| 248 |
"""
|
| 249 |
|
| 250 |
# ---------------------------------------------------------------- js (§4/§6)
|
| 251 |
# Outer-page enhancer: bounce results in when they (re)appear. The map's own
|
| 252 |
# route-draw / marker-pop animations run INSIDE the folium iframe (ui/map.py
|
| 253 |
# injects MAP_ANIMATION_JS there — an iframe can't be reached reliably from here).
|
| 254 |
+
# NOTE: the page enhancer (force-light + entrance animation) lives in DR_HEAD as
|
| 255 |
+
# a real <script>; Gradio 6's launch(js=...) silently failed to execute it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
+
# Map-press bounce the instant Plan is clicked (per-event js). NOTE: an event's
|
| 258 |
+
# js function receives the input values and its RETURN VALUE REPLACES them — it
|
| 259 |
+
# must pass the args through unchanged or every input reaches Python as null.
|
| 260 |
DR_CELEBRATE = """
|
| 261 |
+
(...args) => {
|
| 262 |
const map = document.querySelector('#dr-map');
|
| 263 |
if (map) map.animate(
|
| 264 |
[{transform:'scale(1)'},{transform:'scale(.99)'},{transform:'scale(1)'}],
|
| 265 |
{duration:260, easing:'cubic-bezier(.34,1.56,.64,1)'});
|
| 266 |
+
return args;
|
| 267 |
}
|
| 268 |
"""
|
| 269 |
|
|
|
|
| 384 |
|
| 385 |
# Empty-map overlay message (rendered by ui/map.py inside the map frame).
|
| 386 |
EMPTY_STATE_LABEL = "Where shall we wander?"
|
| 387 |
+
|
| 388 |
+
# ------------------------------------------------------- loading state (§C)
|
| 389 |
+
# Shown in the map slot the instant Plan is clicked (via a .then() chain): a
|
| 390 |
+
# little local mid-stride + bouncing dots, per the kit's loading-state spec.
|
| 391 |
+
LOADING_HTML = """
|
| 392 |
+
<div style="height:520px; display:grid; place-items:center; background:
|
| 393 |
+
radial-gradient(700px 320px at 50% 0%,#FBEFD6 0%,transparent 70%), #F6ECD9;">
|
| 394 |
+
<div style="text-align:center;">
|
| 395 |
+
<svg width="120" height="110" viewBox="0 0 120 110" fill="none" aria-hidden="true"
|
| 396 |
+
style="animation:drStride .9s ease-in-out infinite alternate;">
|
| 397 |
+
<ellipse cx="60" cy="98" rx="34" ry="7" fill="#2B2620" opacity=".12"/>
|
| 398 |
+
<rect x="18" y="88" width="84" height="6" rx="3" fill="#E7DAC0"/>
|
| 399 |
+
<circle cx="30" cy="91" r="2.5" fill="#FFFCF5"/>
|
| 400 |
+
<circle cx="60" cy="91" r="2.5" fill="#FFFCF5"/>
|
| 401 |
+
<circle cx="90" cy="91" r="2.5" fill="#FFFCF5"/>
|
| 402 |
+
<circle cx="62" cy="28" r="13" fill="#FFC9A3"/>
|
| 403 |
+
<path d="M50 24 a13 13 0 0 1 24 -3 l-5 2 a 9 9 0 0 0 -14 2z" fill="#8A5A33"/>
|
| 404 |
+
<rect x="52" y="40" width="20" height="30" rx="9" fill="#2F5DF4"/>
|
| 405 |
+
<rect x="47" y="44" width="9" height="22" rx="4.5" fill="#2F5DF4"
|
| 406 |
+
transform="rotate(18 51 55)"/>
|
| 407 |
+
<rect x="68" y="44" width="9" height="22" rx="4.5" fill="#214AD0"
|
| 408 |
+
transform="rotate(-26 72 55)"/>
|
| 409 |
+
<rect x="54" y="66" width="8" height="26" rx="4" fill="#2B2620"
|
| 410 |
+
transform="rotate(14 58 79)"/>
|
| 411 |
+
<rect x="62" y="66" width="8" height="26" rx="4" fill="#2B2620"
|
| 412 |
+
transform="rotate(-22 66 79)"/>
|
| 413 |
+
<ellipse cx="52" cy="94" rx="7" ry="3.5" fill="#E14D37"/>
|
| 414 |
+
<ellipse cx="74" cy="90" rx="7" ry="3.5" fill="#E14D37"
|
| 415 |
+
transform="rotate(-14 74 90)"/>
|
| 416 |
+
<path d="M88 36 c0,-7 11,-7 11,0 c0,5.5 -5.5,8 -5.5,12.5 c0,-4.5 -5.5,-7 -5.5,-12.5z"
|
| 417 |
+
fill="#FF6A52"/>
|
| 418 |
+
<circle cx="93.5" cy="36" r="2.6" fill="#FFFCF5"/>
|
| 419 |
+
</svg>
|
| 420 |
+
<div style="font-family:'Fredoka',system-ui,sans-serif; font-weight:600;
|
| 421 |
+
font-size:19px; color:#2B2620; margin-top:10px;">Scouting your wander…</div>
|
| 422 |
+
<div style="margin-top:12px; display:flex; gap:8px; justify-content:center;">
|
| 423 |
+
<span style="width:11px;height:11px;border-radius:50%;background:#FF6A52;
|
| 424 |
+
animation:drBounce 1s ease-in-out infinite;"></span>
|
| 425 |
+
<span style="width:11px;height:11px;border-radius:50%;background:#FFC247;
|
| 426 |
+
animation:drBounce 1s ease-in-out .15s infinite;"></span>
|
| 427 |
+
<span style="width:11px;height:11px;border-radius:50%;background:#2FA463;
|
| 428 |
+
animation:drBounce 1s ease-in-out .3s infinite;"></span>
|
| 429 |
+
</div>
|
| 430 |
+
<div style="font-family:'DM Sans',system-ui,sans-serif; font-size:13px;
|
| 431 |
+
color:#6B6256; margin-top:12px;">reading your vibe · scoring 30,000 places · threading the detour</div>
|
| 432 |
+
</div>
|
| 433 |
+
<style>
|
| 434 |
+
@keyframes drBounce { 0%,100% { transform:translateY(0) } 50% { transform:translateY(-9px) } }
|
| 435 |
+
@keyframes drStride { 0% { transform:translateX(-7px) } 100% { transform:translateX(7px) } }
|
| 436 |
+
@media (prefers-reduced-motion:reduce){ *{ animation:none !important; } }
|
| 437 |
+
</style>
|
| 438 |
+
</div>
|
| 439 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -11,12 +11,31 @@ from branca.element import Element
|
|
| 11 |
|
| 12 |
from discoverroute import config
|
| 13 |
from discoverroute.routing.graph import Route
|
| 14 |
-
from discoverroute.ui import design
|
| 15 |
|
| 16 |
PLAIN_COLOR = "#2F5DF4" # cobalt — the plain/fastest route
|
| 17 |
DISCOVERY_COLOR = "#2FA463" # grass — the discovery route
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
_LEGEND_HTML = """
|
| 22 |
<div style="position:absolute; bottom:18px; left:12px; z-index:9999;
|
|
@@ -28,10 +47,25 @@ _LEGEND_HTML = """
|
|
| 28 |
<span style="display:inline-block;width:18px;height:4px;border-radius:2px;
|
| 29 |
background:#2F5DF4;vertical-align:middle;margin-right:7px;"></span>Fastest route<br>
|
| 30 |
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;
|
| 31 |
-
background:#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
</div>
|
| 33 |
"""
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
def _fit_bounds(fmap: folium.Map, coords: list[tuple[float, float]]) -> None:
|
| 37 |
if not coords:
|
|
@@ -50,7 +84,8 @@ def render_routes(
|
|
| 50 |
) -> str:
|
| 51 |
"""Render routes + markers and return the map as standalone HTML."""
|
| 52 |
center = start or config.PARIS_CENTER
|
| 53 |
-
fmap = folium.Map(location=list(center), zoom_start=14,
|
|
|
|
| 54 |
|
| 55 |
all_coords: list[tuple[float, float]] = []
|
| 56 |
|
|
@@ -75,38 +110,45 @@ def render_routes(
|
|
| 75 |
all_coords.extend(discovery.coords)
|
| 76 |
|
| 77 |
if pois:
|
| 78 |
-
for poi in pois:
|
| 79 |
name = getattr(poi, "name", None) or getattr(poi, "category", "POI")
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
|
| 92 |
if start is not None:
|
|
|
|
| 93 |
folium.Marker(list(start), tooltip="Start",
|
| 94 |
-
icon=folium.Icon(color="blue", icon="play")).add_to(fmap)
|
| 95 |
if end is not None:
|
|
|
|
| 96 |
folium.Marker(list(end), tooltip="Destination",
|
| 97 |
-
icon=folium.Icon(color="red", icon="flag")).add_to(fmap)
|
| 98 |
|
| 99 |
_fit_bounds(fmap, all_coords or [c for c in (start, end) if c])
|
| 100 |
|
| 101 |
root = fmap.get_root()
|
| 102 |
root.html.add_child(Element(_LEGEND_HTML))
|
|
|
|
|
|
|
| 103 |
root.html.add_child(Element(design.MAP_ANIMATION_JS))
|
| 104 |
return fmap._repr_html_()
|
| 105 |
|
| 106 |
|
| 107 |
def empty_map(message: str = design.EMPTY_STATE_LABEL) -> str:
|
| 108 |
"""A blank Paris map with a friendly sticker overlay (empty/error state)."""
|
| 109 |
-
fmap = folium.Map(location=list(config.PARIS_CENTER), zoom_start=12,
|
|
|
|
| 110 |
overlay = f"""
|
| 111 |
<div style="position:absolute; inset:0; z-index:9999; display:grid; place-items:center;
|
| 112 |
pointer-events:none; background:rgba(246,236,217,.45);">
|
|
@@ -127,5 +169,6 @@ def empty_map(message: str = design.EMPTY_STATE_LABEL) -> str:
|
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
"""
|
|
|
|
| 130 |
fmap.get_root().html.add_child(Element(overlay))
|
| 131 |
return fmap._repr_html_()
|
|
|
|
| 11 |
|
| 12 |
from discoverroute import config
|
| 13 |
from discoverroute.routing.graph import Route
|
| 14 |
+
from discoverroute.ui import design, markers
|
| 15 |
|
| 16 |
PLAIN_COLOR = "#2F5DF4" # cobalt — the plain/fastest route
|
| 17 |
DISCOVERY_COLOR = "#2FA463" # grass — the discovery route
|
| 18 |
+
|
| 19 |
+
# Warmer, livelier basemap than the pale Positron — CARTO Voyager (keyless).
|
| 20 |
+
_TILE_URL = "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
|
| 21 |
+
_TILE_ATTR = ('© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> '
|
| 22 |
+
'contributors © <a href="https://carto.com/attributions">CARTO</a>')
|
| 23 |
+
|
| 24 |
+
# POI marker color by category family (design palette).
|
| 25 |
+
_CATEGORY_COLORS = {
|
| 26 |
+
# nature & calm — grass
|
| 27 |
+
"park_garden": "#2FA463", "water_feature": "#2FA463", "viewpoint": "#2FA463",
|
| 28 |
+
# culture & history — cobalt
|
| 29 |
+
"monument_historic": "#2F5DF4", "museum_gallery": "#2F5DF4",
|
| 30 |
+
"place_of_worship": "#2F5DF4", "library": "#2F5DF4", "theatre_cinema": "#2F5DF4",
|
| 31 |
+
"attraction": "#2F5DF4",
|
| 32 |
+
# food & drink — sun
|
| 33 |
+
"cafe": "#E89E1C", "bakery_food_shop": "#E89E1C", "restaurant": "#E89E1C",
|
| 34 |
+
"bar_pub": "#E89E1C", "market": "#E89E1C",
|
| 35 |
+
# art & finds — coral
|
| 36 |
+
"artwork": "#FF6A52", "bookshop": "#FF6A52", "specialty_shop": "#FF6A52",
|
| 37 |
+
}
|
| 38 |
+
_DEFAULT_POI_COLOR = "#FF6A52"
|
| 39 |
|
| 40 |
_LEGEND_HTML = """
|
| 41 |
<div style="position:absolute; bottom:18px; left:12px; z-index:9999;
|
|
|
|
| 47 |
<span style="display:inline-block;width:18px;height:4px;border-radius:2px;
|
| 48 |
background:#2F5DF4;vertical-align:middle;margin-right:7px;"></span>Fastest route<br>
|
| 49 |
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;
|
| 50 |
+
background:#2FA463;vertical-align:middle;margin-right:7px;margin-left:4px;"></span>Green space
|
| 51 |
+
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;
|
| 52 |
+
background:#2F5DF4;vertical-align:middle;margin-right:7px;margin-left:10px;"></span>Water & wayfinding<br>
|
| 53 |
+
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;
|
| 54 |
+
background:#FF6A52;vertical-align:middle;margin-right:7px;margin-left:4px;"></span>Culture
|
| 55 |
+
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;
|
| 56 |
+
background:#E89E1C;vertical-align:middle;margin-right:7px;margin-left:10px;"></span>Cozy stops
|
| 57 |
</div>
|
| 58 |
"""
|
| 59 |
|
| 60 |
+
# Gentle warm grade on the tiles so the basemap sits inside the cream design
|
| 61 |
+
# instead of fighting it (applies inside the folium iframe).
|
| 62 |
+
_TILE_WARMTH_CSS = """
|
| 63 |
+
<style>
|
| 64 |
+
.leaflet-tile-pane{ filter: saturate(1.12) sepia(0.10) brightness(1.02); }
|
| 65 |
+
.leaflet-container{ background:#F6ECD9; }
|
| 66 |
+
</style>
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
|
| 70 |
def _fit_bounds(fmap: folium.Map, coords: list[tuple[float, float]]) -> None:
|
| 71 |
if not coords:
|
|
|
|
| 84 |
) -> str:
|
| 85 |
"""Render routes + markers and return the map as standalone HTML."""
|
| 86 |
center = start or config.PARIS_CENTER
|
| 87 |
+
fmap = folium.Map(location=list(center), zoom_start=14,
|
| 88 |
+
tiles=_TILE_URL, attr=_TILE_ATTR)
|
| 89 |
|
| 90 |
all_coords: list[tuple[float, float]] = []
|
| 91 |
|
|
|
|
| 110 |
all_coords.extend(discovery.coords)
|
| 111 |
|
| 112 |
if pois:
|
| 113 |
+
for i, poi in enumerate(pois):
|
| 114 |
name = getattr(poi, "name", None) or getattr(poi, "category", "POI")
|
| 115 |
+
cat = getattr(poi, "category", "")
|
| 116 |
+
icon = markers.poi_icon(cat, index=i)
|
| 117 |
+
tooltip = f"{name} · {cat.replace('_', ' ')}" if cat else str(name)
|
| 118 |
+
if icon is not None:
|
| 119 |
+
folium.Marker([poi.lat, poi.lon], icon=icon,
|
| 120 |
+
tooltip=tooltip).add_to(fmap)
|
| 121 |
+
else: # icon file missing — fall back to a colored dot
|
| 122 |
+
folium.CircleMarker(
|
| 123 |
+
location=[poi.lat, poi.lon], radius=7, color="#FFFCF5",
|
| 124 |
+
weight=2, fill=True, fill_opacity=1.0,
|
| 125 |
+
fill_color=_CATEGORY_COLORS.get(cat, _DEFAULT_POI_COLOR),
|
| 126 |
+
class_name="dr-poi", tooltip=tooltip,
|
| 127 |
+
).add_to(fmap)
|
| 128 |
|
| 129 |
if start is not None:
|
| 130 |
+
icon = markers.endpoint_icon("start")
|
| 131 |
folium.Marker(list(start), tooltip="Start",
|
| 132 |
+
icon=icon or folium.Icon(color="blue", icon="play")).add_to(fmap)
|
| 133 |
if end is not None:
|
| 134 |
+
icon = markers.endpoint_icon("dest")
|
| 135 |
folium.Marker(list(end), tooltip="Destination",
|
| 136 |
+
icon=icon or folium.Icon(color="red", icon="flag")).add_to(fmap)
|
| 137 |
|
| 138 |
_fit_bounds(fmap, all_coords or [c for c in (start, end) if c])
|
| 139 |
|
| 140 |
root = fmap.get_root()
|
| 141 |
root.html.add_child(Element(_LEGEND_HTML))
|
| 142 |
+
root.html.add_child(Element(_TILE_WARMTH_CSS))
|
| 143 |
+
root.html.add_child(Element(markers.MARKER_CSS))
|
| 144 |
root.html.add_child(Element(design.MAP_ANIMATION_JS))
|
| 145 |
return fmap._repr_html_()
|
| 146 |
|
| 147 |
|
| 148 |
def empty_map(message: str = design.EMPTY_STATE_LABEL) -> str:
|
| 149 |
"""A blank Paris map with a friendly sticker overlay (empty/error state)."""
|
| 150 |
+
fmap = folium.Map(location=list(config.PARIS_CENTER), zoom_start=12,
|
| 151 |
+
tiles=_TILE_URL, attr=_TILE_ATTR)
|
| 152 |
overlay = f"""
|
| 153 |
<div style="position:absolute; inset:0; z-index:9999; display:grid; place-items:center;
|
| 154 |
pointer-events:none; background:rgba(246,236,217,.45);">
|
|
|
|
| 169 |
</div>
|
| 170 |
</div>
|
| 171 |
"""
|
| 172 |
+
fmap.get_root().html.add_child(Element(_TILE_WARMTH_CSS))
|
| 173 |
fmap.get_root().html.add_child(Element(overlay))
|
| 174 |
return fmap._repr_html_()
|
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Clay-pin map markers — the designed 14-piece marker family.
|
| 2 |
+
|
| 3 |
+
SVGs live in ``ui/icons/`` (source of truth: the design handoff's
|
| 4 |
+
``icons/markers-spec.md``). Each marker is inlined into a Leaflet ``DivIcon``
|
| 5 |
+
(no extra HTTP requests inside the map iframe), sized per spec with the pin tip
|
| 6 |
+
anchored on the coordinate, plus the spec's cast shadow, springy hover, and
|
| 7 |
+
staggered pop-in — all gated behind ``prefers-reduced-motion``.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import functools
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import folium
|
| 15 |
+
|
| 16 |
+
_ICON_DIR = Path(__file__).resolve().parent / "icons"
|
| 17 |
+
|
| 18 |
+
# Our 17 OSM categories -> the 14 designed marker kinds (color-by-meaning:
|
| 19 |
+
# cobalt water/wayfinding · grass green space · coral culture · sun cozy stops).
|
| 20 |
+
CATEGORY_TO_KIND = {
|
| 21 |
+
"park_garden": "park",
|
| 22 |
+
"water_feature": "fountain",
|
| 23 |
+
"viewpoint": "viewpoint",
|
| 24 |
+
"monument_historic": "museum",
|
| 25 |
+
"museum_gallery": "museum",
|
| 26 |
+
"artwork": "star", # public art = a highlight find
|
| 27 |
+
"place_of_worship": "museum", # columns glyph reads classical/temple
|
| 28 |
+
"library": "library",
|
| 29 |
+
"bookshop": "bookshop",
|
| 30 |
+
"theatre_cinema": "museum",
|
| 31 |
+
"cafe": "cafe",
|
| 32 |
+
"bakery_food_shop": "bakery",
|
| 33 |
+
"restaurant": "cafe",
|
| 34 |
+
"bar_pub": "cafe",
|
| 35 |
+
"market": "market",
|
| 36 |
+
"specialty_shop": "market",
|
| 37 |
+
"attraction": "star",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
_W = 40 # marker width px (spec: 28 / 40 / 56)
|
| 41 |
+
_H = round(_W * 84 / 64) # 52 — height keeps the 64x84 viewBox ratio
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@functools.lru_cache(maxsize=32)
|
| 45 |
+
def _svg(kind: str) -> str:
|
| 46 |
+
path = _ICON_DIR / f"marker-{kind}.svg"
|
| 47 |
+
try:
|
| 48 |
+
return path.read_text()
|
| 49 |
+
except OSError:
|
| 50 |
+
return ""
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def marker_icon(kind: str, index: int = 0, width: int = _W) -> folium.DivIcon | None:
|
| 54 |
+
"""A DivIcon for one pin; ``index`` staggers the pop-in (~150 ms per spec)."""
|
| 55 |
+
svg = _svg(kind)
|
| 56 |
+
if not svg:
|
| 57 |
+
return None
|
| 58 |
+
h = round(width * 84 / 64)
|
| 59 |
+
html = (f'<div class="dr-pin" style="animation-delay:{600 + index * 150}ms;'
|
| 60 |
+
f'width:{width}px;height:{h}px;">{svg}</div>')
|
| 61 |
+
return folium.DivIcon(html=html, icon_size=(width, h),
|
| 62 |
+
icon_anchor=(width // 2, h), class_name="dr-pin-wrap")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def poi_icon(category: str, index: int = 0) -> folium.DivIcon | None:
|
| 66 |
+
return marker_icon(CATEGORY_TO_KIND.get(category, "star"), index)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def endpoint_icon(which: str) -> folium.DivIcon | None:
|
| 70 |
+
"""'start' (cobalt arrow) or 'dest' (coral flag), slightly larger."""
|
| 71 |
+
return marker_icon(which, index=-2, width=46)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# Injected once per map (ui/map.py): shadow + hover spring + pop-in, per spec.
|
| 75 |
+
MARKER_CSS = """
|
| 76 |
+
<style>
|
| 77 |
+
.dr-pin-wrap{ background:transparent; border:none; }
|
| 78 |
+
.dr-pin{ filter: drop-shadow(0 6px 5px rgba(43,38,32,.28));
|
| 79 |
+
transition: transform .25s cubic-bezier(.34,1.56,.64,1);
|
| 80 |
+
transform-origin: 50% 100%;
|
| 81 |
+
animation: drPinPop .55s cubic-bezier(.34,1.56,.64,1) backwards; }
|
| 82 |
+
.dr-pin svg{ width:100%; height:100%; display:block; }
|
| 83 |
+
.dr-pin:hover{ transform: translateY(-8px) scale(1.05); }
|
| 84 |
+
@keyframes drPinPop{ 0%{ transform:scale(.3); } 60%{ transform:scale(1.12); }
|
| 85 |
+
100%{ transform:scale(1); } }
|
| 86 |
+
@media (prefers-reduced-motion: reduce){ .dr-pin{ animation:none; } }
|
| 87 |
+
</style>
|
| 88 |
+
"""
|
|
@@ -1,123 +1,33 @@
|
|
| 1 |
-
"""Offline
|
| 2 |
-
|
| 3 |
-
Real names are picked from the parquet at test time (never hardcoded guesses),
|
| 4 |
-
except the app's two default inputs, which must resolve locally by contract.
|
| 5 |
-
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
-
import math
|
| 9 |
-
|
| 10 |
-
import pandas as pd
|
| 11 |
import pytest
|
| 12 |
|
| 13 |
from discoverroute import config
|
| 14 |
-
from discoverroute.routing import geocode as gc
|
| 15 |
-
from discoverroute.routing.graph import RouteError, geocode_point
|
| 16 |
|
| 17 |
-
|
| 18 |
-
not config.POIS_PATH.exists(),
|
| 19 |
-
reason="POI table not built (run: python -m discoverroute.data.build_pois)",
|
| 20 |
)
|
| 21 |
|
| 22 |
-
# Known coordinates of the app's two default inputs.
|
| 23 |
-
REPUBLIQUE = (48.8674, 2.3636)
|
| 24 |
-
LUXEMBOURG = (48.8462, 2.3372)
|
| 25 |
-
|
| 26 |
-
GIBBERISH = "zzqx flurbington nonexistovia 9999"
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def _named_pois() -> pd.DataFrame:
|
| 30 |
-
from discoverroute.routing.pois import load_pois
|
| 31 |
-
|
| 32 |
-
df = load_pois()
|
| 33 |
-
return df[df["name"].notna()]
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def _pick_name(require_accent: bool = False) -> str:
|
| 37 |
-
"""A real, distinctive POI name from the table (best-documented first)."""
|
| 38 |
-
df = _named_pois().sort_values(["confidence", "n_tags"], ascending=False)
|
| 39 |
-
for name in df["name"]:
|
| 40 |
-
norm = gc._normalize(name)
|
| 41 |
-
tokens = norm.split()
|
| 42 |
-
if len(tokens) < 2 or not any(len(t) >= 4 for t in tokens):
|
| 43 |
-
continue # too short/ambiguous to be a fair test query
|
| 44 |
-
if tokens[-1] in ("paris", "france"):
|
| 45 |
-
continue # would interact with suffix stripping; pick another
|
| 46 |
-
if require_accent and all(ord(c) < 128 for c in name):
|
| 47 |
-
continue
|
| 48 |
-
return name
|
| 49 |
-
pytest.skip("no suitable POI name found in the table")
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def _coords_for_name(name: str) -> set[tuple[float, float]]:
|
| 53 |
-
"""All (lat, lon) rows whose normalised name equals the query's."""
|
| 54 |
-
df = _named_pois()
|
| 55 |
-
norm = gc._normalize(name)
|
| 56 |
-
mask = df["name"].map(lambda n: gc._normalize(n) == norm)
|
| 57 |
-
return {(float(r.lat), float(r.lon)) for r in df[mask].itertuples()}
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
assert
|
| 70 |
-
assert
|
| 71 |
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
def test_paris_suffix_stripped():
|
| 83 |
-
name = _pick_name()
|
| 84 |
-
expected = gc.local_geocode(name)
|
| 85 |
-
assert expected is not None
|
| 86 |
-
assert gc.local_geocode(f"{name}, Paris") == expected
|
| 87 |
-
assert gc.local_geocode(f"{name} Paris") == expected
|
| 88 |
-
assert gc.local_geocode(f"{name}, Paris, France") == expected
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def test_no_match_returns_none():
|
| 92 |
-
assert gc.local_geocode(GIBBERISH) is None
|
| 93 |
-
assert gc.local_geocode("") is None
|
| 94 |
-
assert gc.local_geocode("de la") is None # short fragments: too ambiguous
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def test_offline_mode_raises_for_unmatchable(monkeypatch):
|
| 98 |
-
monkeypatch.setenv(config.OFFLINE_ENV_VAR, "1")
|
| 99 |
-
with pytest.raises(RouteError, match="offline"):
|
| 100 |
-
geocode_point(GIBBERISH)
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
def test_latlon_path_unaffected_offline(monkeypatch):
|
| 104 |
-
monkeypatch.setenv(config.OFFLINE_ENV_VAR, "1")
|
| 105 |
-
assert geocode_point("48.8674, 2.3636") == (48.8674, 2.3636)
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
@pytest.mark.parametrize(
|
| 109 |
-
"query,known",
|
| 110 |
-
[
|
| 111 |
-
("Place de la République, Paris", REPUBLIQUE),
|
| 112 |
-
("Jardin du Luxembourg, Paris", LUXEMBOURG),
|
| 113 |
-
],
|
| 114 |
-
)
|
| 115 |
-
def test_app_defaults_resolve_locally(query, known, monkeypatch):
|
| 116 |
-
# Pure offline path: must work with the Nominatim fallback disabled.
|
| 117 |
-
monkeypatch.setenv(config.OFFLINE_ENV_VAR, "1")
|
| 118 |
-
local = gc.local_geocode(query)
|
| 119 |
-
assert local is not None, f"default input {query!r} not in local index"
|
| 120 |
-
assert config.in_paris(*local)
|
| 121 |
-
assert _dist_m(local, known) < 1500, f"{query!r} resolved far away: {local}"
|
| 122 |
-
# And the full geocode_point pipeline (bounds check included) agrees.
|
| 123 |
-
assert geocode_point(query) == local
|
|
|
|
| 1 |
+
"""Offline geocoder + autocomplete suggestions (local POI-name index)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
|
|
|
|
|
|
| 4 |
import pytest
|
| 5 |
|
| 6 |
from discoverroute import config
|
|
|
|
|
|
|
| 7 |
|
| 8 |
+
pois_available = pytest.mark.skipif(
|
| 9 |
+
not config.POIS_PATH.exists(), reason="POI table not built"
|
|
|
|
| 10 |
)
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
@pois_available
|
| 14 |
+
def test_suggest_finds_landmarks():
|
| 15 |
+
from discoverroute.routing.geocode import suggest
|
| 16 |
+
assert "Jardin du Luxembourg" in suggest("jardin du lux")
|
| 17 |
+
assert any("Eiffel" in s for s in suggest("eiffel"))
|
| 18 |
|
| 19 |
|
| 20 |
+
@pois_available
|
| 21 |
+
def test_suggest_abstains_on_noise():
|
| 22 |
+
from discoverroute.routing.geocode import suggest
|
| 23 |
+
assert suggest("xq") == ()
|
| 24 |
+
assert suggest("") == ()
|
| 25 |
|
| 26 |
|
| 27 |
+
@pois_available
|
| 28 |
+
def test_suggestion_round_trips_to_geocode():
|
| 29 |
+
"""Every suggestion must be resolvable by the local geocoder (in Paris)."""
|
| 30 |
+
from discoverroute.routing.geocode import local_geocode, suggest
|
| 31 |
+
for name in suggest("jardin du lux")[:3]:
|
| 32 |
+
pt = local_geocode(name)
|
| 33 |
+
assert pt is not None and config.in_paris(*pt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Open-now awareness: OSM opening_hours parsing + plan-time demotion."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
from discoverroute.routing.hours import (PARIS_TZ, apply_open_now, is_open)
|
| 7 |
+
|
| 8 |
+
TUE_15H = datetime(2026, 6, 9, 15, 0, tzinfo=PARIS_TZ) # Tuesday 15:00
|
| 9 |
+
TUE_23H = datetime(2026, 6, 9, 23, 30, tzinfo=PARIS_TZ) # Tuesday 23:30
|
| 10 |
+
MON_15H = datetime(2026, 6, 8, 15, 0, tzinfo=PARIS_TZ) # Monday 15:00
|
| 11 |
+
WED_01H = datetime(2026, 6, 10, 1, 0, tzinfo=PARIS_TZ) # Wednesday 01:00
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_always_open():
|
| 15 |
+
assert is_open("24/7", TUE_15H) is True
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_simple_weekday_range():
|
| 19 |
+
assert is_open("Mo-Fr 08:00-18:00", TUE_15H) is True
|
| 20 |
+
assert is_open("Mo-Fr 08:00-18:00", TUE_23H) is False
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_multi_rule_with_off_day():
|
| 24 |
+
spec = "Tu-Su 10:00-18:00; Mo off"
|
| 25 |
+
assert is_open(spec, TUE_15H) is True
|
| 26 |
+
assert is_open(spec, MON_15H) is False
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_lunch_split_spans():
|
| 30 |
+
spec = "Mo-Fr 09:00-12:00,14:00-18:00"
|
| 31 |
+
assert is_open(spec, TUE_15H) is True
|
| 32 |
+
assert is_open(spec, datetime(2026, 6, 9, 13, 0, tzinfo=PARIS_TZ)) is False
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_daily_no_days():
|
| 36 |
+
assert is_open("07:00-23:30", TUE_15H) is True
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_overnight_span():
|
| 40 |
+
spec = "Tu 18:00-02:00"
|
| 41 |
+
assert is_open(spec, TUE_23H) is True
|
| 42 |
+
assert is_open(spec, WED_01H) is True # spills past midnight into Wednesday
|
| 43 |
+
assert is_open(spec, TUE_15H) is False
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_abstains_on_complex():
|
| 47 |
+
assert is_open("sunrise-sunset", TUE_15H) is None
|
| 48 |
+
assert is_open("Mar-Oct 08:00-20:00", TUE_15H) is None
|
| 49 |
+
assert is_open(None, TUE_15H) is None
|
| 50 |
+
assert is_open("", TUE_15H) is None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class P:
|
| 54 |
+
def __init__(self, category, hours, score=1.0):
|
| 55 |
+
self.category, self.opening_hours, self.score = category, hours, score
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_demotion_by_posture():
|
| 59 |
+
closed_cafe = P("cafe", "Mo-Fr 08:00-12:00") # closed Tue 15:00
|
| 60 |
+
closed_monument = P("monument_historic", "Mo-Fr 08:00-12:00")
|
| 61 |
+
open_cafe = P("cafe", "Mo-Fr 08:00-18:00")
|
| 62 |
+
unknown = P("cafe", None)
|
| 63 |
+
posture = {"cafe": "stop", "monument_historic": "pass"}
|
| 64 |
+
apply_open_now([closed_cafe, closed_monument, open_cafe, unknown],
|
| 65 |
+
posture, when=TUE_15H)
|
| 66 |
+
assert closed_cafe.score == 0.2 # stop-at closed -> heavy demotion
|
| 67 |
+
assert closed_monument.score == 0.7 # pass-by closed -> mild demotion
|
| 68 |
+
assert open_cafe.score == 1.0 and open_cafe.open_state is True
|
| 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
|
| 84 |
+
assert is_open("Mo-Fr 08:00-18:00; PH off", TUE_23H) is False
|
| 85 |
+
# PH inside a day list extends to holidays; weekdays still decidable
|
| 86 |
+
assert is_open("PH,Sa,Su 10:00-18:00; Mo-Fr 08:30-17:00", TUE_15H) is True
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_night_demotes_unknown_daytime_categories():
|
| 90 |
+
night = datetime(2026, 6, 9, 23, 30, tzinfo=PARIS_TZ)
|
| 91 |
+
day = datetime(2026, 6, 9, 15, 0, tzinfo=PARIS_TZ)
|
| 92 |
+
cafe_n, bar_n = P("cafe", None), P("bar_pub", None)
|
| 93 |
+
apply_open_now([cafe_n, bar_n], {}, when=night)
|
| 94 |
+
assert cafe_n.score == 0.5 # unknown café at 23:30 -> poor bet
|
| 95 |
+
assert bar_n.score == 1.0 # unknown bar at night -> plausible
|
| 96 |
+
cafe_d = P("cafe", None)
|
| 97 |
+
apply_open_now([cafe_d], {}, when=day)
|
| 98 |
+
assert cafe_d.score == 1.0 # daytime unknown untouched
|
|
@@ -53,18 +53,29 @@ def test_budget_and_posture_hints():
|
|
| 53 |
|
| 54 |
@data_ready
|
| 55 |
def test_vibe_changes_route_categories():
|
| 56 |
-
"""Same A/B, contrasting vibes ->
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
from discoverroute.pipeline import plan_route
|
| 59 |
|
| 60 |
a = "Place de la République, Paris"
|
| 61 |
b = "Jardin du Luxembourg, Paris"
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
|
| 65 |
-
gc = Counter(p.category for p in green.pois)
|
| 66 |
-
lc = Counter(p.category for p in lively.pois)
|
| 67 |
# the two routes should not select an identical set of waypoints
|
| 68 |
assert {p.osm_id for p in green.pois} != {p.osm_id for p in lively.pois}
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
@data_ready
|
| 55 |
def test_vibe_changes_route_categories():
|
| 56 |
+
"""Same A/B, contrasting vibes -> each route serves ITS OWN vibe best (P0-5).
|
| 57 |
+
|
| 58 |
+
Property-based rather than exact category counts (which are brittle across
|
| 59 |
+
embedder backends): scoring each route's categories under a vibe's affinity,
|
| 60 |
+
the route planned FOR that vibe must fit it at least as well as the route
|
| 61 |
+
planned for the contrasting vibe.
|
| 62 |
+
"""
|
| 63 |
+
from discoverroute.interpret.embed import vibe_to_affinity
|
| 64 |
from discoverroute.pipeline import plan_route
|
| 65 |
|
| 66 |
a = "Place de la République, Paris"
|
| 67 |
b = "Jardin du Luxembourg, Paris"
|
| 68 |
+
v_green, v_lively = "quiet green park wander", "lively bar and café crawl"
|
| 69 |
+
green = plan_route(a, b, budget=0.7, vibe=v_green)
|
| 70 |
+
lively = plan_route(a, b, budget=0.7, vibe=v_lively)
|
| 71 |
+
assert green.pois and lively.pois
|
| 72 |
|
|
|
|
|
|
|
| 73 |
# the two routes should not select an identical set of waypoints
|
| 74 |
assert {p.osm_id for p in green.pois} != {p.osm_id for p in lively.pois}
|
| 75 |
+
|
| 76 |
+
def fit(route, affinity): # mean affinity of the route's categories
|
| 77 |
+
return sum(affinity.get(p.category, 0.0) for p in route.pois) / len(route.pois)
|
| 78 |
+
|
| 79 |
+
aff_green, aff_lively = vibe_to_affinity(v_green), vibe_to_affinity(v_lively)
|
| 80 |
+
assert fit(green, aff_green) >= fit(lively, aff_green)
|
| 81 |
+
assert fit(lively, aff_lively) >= fit(green, aff_lively)
|
|
@@ -45,7 +45,10 @@ def test_discovery_respects_budget_and_detours():
|
|
| 45 |
|
| 46 |
@data_ready
|
| 47 |
def test_out_of_bounds_clean_error():
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
assert r.error is not None
|
| 50 |
assert r.discovery is None and r.plain is None
|
| 51 |
|
|
|
|
| 45 |
|
| 46 |
@data_ready
|
| 47 |
def test_out_of_bounds_clean_error():
|
| 48 |
+
# Explicit London coordinates: deterministically outside the Paris bbox.
|
| 49 |
+
# (A *name* like "London" may legitimately resolve to a Paris venue with
|
| 50 |
+
# that name via the offline POI-name geocoder.)
|
| 51 |
+
r = plan_route("51.5074, -0.1278", DEST, budget=0.5)
|
| 52 |
assert r.error is not None
|
| 53 |
assert r.discovery is None and r.plain is None
|
| 54 |
|