Spaces:
Running on Zero
Running on Zero
Commit ·
2c5a5d1
1
Parent(s): a82c73b
fixed ui
Browse files- app.py +29 -2
- data/build_manifest.json +8 -0
- src/discoverroute/config.py +21 -2
- src/discoverroute/data/build_pois.py +18 -0
- src/discoverroute/data/taxonomy.py +42 -0
- src/discoverroute/interpret/vibe.py +6 -0
- src/discoverroute/narrate/narrate.py +54 -20
- src/discoverroute/pipeline.py +13 -4
- src/discoverroute/routing/geocode.py +24 -0
- src/discoverroute/routing/graph.py +6 -1
- src/discoverroute/routing/scoring.py +15 -1
- src/discoverroute/ui/map.py +6 -2
- src/discoverroute/ui/shell.py +173 -97
- tests/test_robustness.py +89 -0
app.py
CHANGED
|
@@ -28,8 +28,9 @@ def _alt_label(i: int, alt, plain) -> str:
|
|
| 28 |
from collections import Counter
|
| 29 |
top = Counter(p.category for p in alt.pois).most_common(2)
|
| 30 |
flavor = ", ".join(c.replace("_", " ") for c, _ in top)
|
|
|
|
| 31 |
return (f"Option {i + 1} · {alt.discovery.distance_m / 1000:.1f} km · +{extra} min · "
|
| 32 |
-
f"{
|
| 33 |
|
| 34 |
|
| 35 |
@app.api(name="suggest")
|
|
@@ -63,9 +64,16 @@ def plan(start: str, dest: str, mode: str = "walk", budget: float = 0.5,
|
|
| 63 |
if result.error:
|
| 64 |
return {
|
| 65 |
"error": result.error,
|
| 66 |
-
"map_html": mapui.empty_map("Hmm — " + result.error.split(".")[0] + "."),
|
| 67 |
}
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
alts = result.alternatives or []
|
| 70 |
if not alts: # honest no-detour (or budget 0): plain route + stump state
|
| 71 |
return {
|
|
@@ -79,6 +87,8 @@ def plan(start: str, dest: str, mode: str = "walk", budget: float = 0.5,
|
|
| 79 |
"nodetour_html": _nodetour_html(),
|
| 80 |
"alternatives": [],
|
| 81 |
"last_cats": [],
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
|
| 84 |
alternatives = []
|
|
@@ -90,6 +100,7 @@ def plan(start: str, dest: str, mode: str = "walk", budget: float = 0.5,
|
|
| 90 |
start=result.start, end=result.end),
|
| 91 |
"summary_md": alt.summary_md,
|
| 92 |
"itinerary_md": alt.itinerary_md,
|
|
|
|
| 93 |
})
|
| 94 |
|
| 95 |
return {
|
|
@@ -98,6 +109,22 @@ def plan(start: str, dest: str, mode: str = "walk", budget: float = 0.5,
|
|
| 98 |
"interpretation_md": result.interpretation_md,
|
| 99 |
"alternatives": alternatives,
|
| 100 |
"last_cats": [p.category for p in alts[0].pois],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
|
|
|
|
| 28 |
from collections import Counter
|
| 29 |
top = Counter(p.category for p in alt.pois).most_common(2)
|
| 30 |
flavor = ", ".join(c.replace("_", " ") for c, _ in top)
|
| 31 |
+
n = len(alt.pois)
|
| 32 |
return (f"Option {i + 1} · {alt.discovery.distance_m / 1000:.1f} km · +{extra} min · "
|
| 33 |
+
f"{n} place{'' if n == 1 else 's'} ({flavor})")
|
| 34 |
|
| 35 |
|
| 36 |
@app.api(name="suggest")
|
|
|
|
| 64 |
if result.error:
|
| 65 |
return {
|
| 66 |
"error": result.error,
|
| 67 |
+
"map_html": mapui.empty_map("Hmm — " + result.error.split(". ")[0].rstrip(".") + "."),
|
| 68 |
}
|
| 69 |
|
| 70 |
+
geo = {
|
| 71 |
+
"start": list(result.start) if result.start else None,
|
| 72 |
+
"end": list(result.end) if result.end else None,
|
| 73 |
+
"mode": (mode or "walk").lower(),
|
| 74 |
+
"start_label": start, "end_label": dest,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
alts = result.alternatives or []
|
| 78 |
if not alts: # honest no-detour (or budget 0): plain route + stump state
|
| 79 |
return {
|
|
|
|
| 87 |
"nodetour_html": _nodetour_html(),
|
| 88 |
"alternatives": [],
|
| 89 |
"last_cats": [],
|
| 90 |
+
"export": _export_data(result.plain, []),
|
| 91 |
+
**geo,
|
| 92 |
}
|
| 93 |
|
| 94 |
alternatives = []
|
|
|
|
| 100 |
start=result.start, end=result.end),
|
| 101 |
"summary_md": alt.summary_md,
|
| 102 |
"itinerary_md": alt.itinerary_md,
|
| 103 |
+
"export": _export_data(alt.discovery, alt.pois),
|
| 104 |
})
|
| 105 |
|
| 106 |
return {
|
|
|
|
| 109 |
"interpretation_md": result.interpretation_md,
|
| 110 |
"alternatives": alternatives,
|
| 111 |
"last_cats": [p.category for p in alts[0].pois],
|
| 112 |
+
**geo,
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _export_data(route, pois) -> dict:
|
| 117 |
+
"""Lat/lon waypoints + the real polyline, so the browser can build a Google/
|
| 118 |
+
Apple Maps link or a GPX file without re-planning."""
|
| 119 |
+
from discoverroute.data import taxonomy
|
| 120 |
+
return {
|
| 121 |
+
"waypoints": [
|
| 122 |
+
{"lat": round(p.lat, 6), "lon": round(p.lon, 6),
|
| 123 |
+
"name": taxonomy.display_label(p)}
|
| 124 |
+
for p in (pois or [])
|
| 125 |
+
],
|
| 126 |
+
"coords": [[round(c[0], 6), round(c[1], 6)]
|
| 127 |
+
for c in (getattr(route, "coords", None) or [])],
|
| 128 |
}
|
| 129 |
|
| 130 |
|
data/build_manifest.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"source": "OpenStreetMap",
|
| 3 |
+
"license": "ODbL \u2014 \u00a9 OpenStreetMap contributors",
|
| 4 |
+
"city": "Paris",
|
| 5 |
+
"build_date": "2026-06-12",
|
| 6 |
+
"poi_count": 30589,
|
| 7 |
+
"opening_hours_coverage_pct": 30.9
|
| 8 |
+
}
|
src/discoverroute/config.py
CHANGED
|
@@ -4,6 +4,7 @@ Everything tunable lives here so behaviour is inspectable, not scattered.
|
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
|
|
|
|
| 7 |
import os
|
| 8 |
from pathlib import Path
|
| 9 |
|
|
@@ -19,6 +20,22 @@ DATA_DIR.mkdir(exist_ok=True)
|
|
| 19 |
GRAPH_WALK_PATH = DATA_DIR / "paris_walk.graphml"
|
| 20 |
POIS_PATH = DATA_DIR / "paris_pois.parquet"
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# --- Geographic scope --------------------------------------------------------
|
| 23 |
# Paris proper (the 20 arrondissements). Used to bound the OSM download and to
|
| 24 |
# reject out-of-area requests.
|
|
@@ -85,8 +102,10 @@ TRACE_REPO = os.environ.get(
|
|
| 85 |
# the route can explore a little; the best-matching category maps to 1.0.
|
| 86 |
AFFINITY_FLOOR = 0.15
|
| 87 |
# Below this cosine-similarity span across categories, a vibe is treated as
|
| 88 |
-
# off-domain/neutral rather than amplified into false preferences.
|
| 89 |
-
|
|
|
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
# --- Adventurousness ---------------------------------------------------------
|
|
|
|
| 4 |
"""
|
| 5 |
from __future__ import annotations
|
| 6 |
|
| 7 |
+
import json
|
| 8 |
import os
|
| 9 |
from pathlib import Path
|
| 10 |
|
|
|
|
| 20 |
GRAPH_WALK_PATH = DATA_DIR / "paris_walk.graphml"
|
| 21 |
POIS_PATH = DATA_DIR / "paris_pois.parquet"
|
| 22 |
|
| 23 |
+
# --- Data provenance / freshness --------------------------------------------
|
| 24 |
+
# The app runs on a static OSM snapshot; this manifest (written by build_pois.py)
|
| 25 |
+
# records when it was built so the UI can show an honest "as of <date>" line.
|
| 26 |
+
DATA_MANIFEST_PATH = DATA_DIR / "build_manifest.json"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _load_manifest() -> dict:
|
| 30 |
+
try:
|
| 31 |
+
return json.loads(DATA_MANIFEST_PATH.read_text(encoding="utf-8"))
|
| 32 |
+
except Exception: # noqa: BLE001 - missing/invalid manifest is non-fatal
|
| 33 |
+
return {}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
DATA_MANIFEST = _load_manifest()
|
| 37 |
+
DATA_BUILD_DATE = DATA_MANIFEST.get("build_date", "") # ISO 'YYYY-MM-DD' or ''
|
| 38 |
+
|
| 39 |
# --- Geographic scope --------------------------------------------------------
|
| 40 |
# Paris proper (the 20 arrondissements). Used to bound the OSM download and to
|
| 41 |
# reject out-of-area requests.
|
|
|
|
| 102 |
# the route can explore a little; the best-matching category maps to 1.0.
|
| 103 |
AFFINITY_FLOOR = 0.15
|
| 104 |
# Below this cosine-similarity span across categories, a vibe is treated as
|
| 105 |
+
# off-domain/neutral rather than amplified into false preferences. Measured:
|
| 106 |
+
# off-domain text (gibberish, "quantum physics") spans ~0.06-0.14, real vibes
|
| 107 |
+
# ~0.30+, so the threshold sits between — at 0.04 the guard never fired.
|
| 108 |
+
MIN_AFFINITY_SPAN = 0.18
|
| 109 |
|
| 110 |
|
| 111 |
# --- Adventurousness ---------------------------------------------------------
|
src/discoverroute/data/build_pois.py
CHANGED
|
@@ -116,11 +116,29 @@ def build_pois(place: str = config.PARIS_PLACE) -> pd.DataFrame:
|
|
| 116 |
|
| 117 |
|
| 118 |
def main() -> None:
|
|
|
|
|
|
|
|
|
|
| 119 |
df = build_pois()
|
| 120 |
config.POIS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 121 |
df.to_parquet(config.POIS_PATH, index=False)
|
| 122 |
print(f"[build_pois] saved {len(df):,} POIs to {config.POIS_PATH}")
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
if __name__ == "__main__":
|
| 126 |
main()
|
|
|
|
| 116 |
|
| 117 |
|
| 118 |
def main() -> None:
|
| 119 |
+
import json
|
| 120 |
+
from datetime import datetime, timezone
|
| 121 |
+
|
| 122 |
df = build_pois()
|
| 123 |
config.POIS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 124 |
df.to_parquet(config.POIS_PATH, index=False)
|
| 125 |
print(f"[build_pois] saved {len(df):,} POIs to {config.POIS_PATH}")
|
| 126 |
|
| 127 |
+
# Provenance manifest so the app can show an honest "data as of <date>" line.
|
| 128 |
+
n = max(1, len(df))
|
| 129 |
+
hours = int(df["opening_hours"].notna().sum())
|
| 130 |
+
manifest = {
|
| 131 |
+
"source": "OpenStreetMap",
|
| 132 |
+
"license": "ODbL — © OpenStreetMap contributors",
|
| 133 |
+
"city": "Paris",
|
| 134 |
+
"build_date": datetime.now(timezone.utc).date().isoformat(),
|
| 135 |
+
"poi_count": int(len(df)),
|
| 136 |
+
"opening_hours_coverage_pct": round(hours / n * 100, 1),
|
| 137 |
+
}
|
| 138 |
+
config.DATA_MANIFEST_PATH.write_text(
|
| 139 |
+
json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
| 140 |
+
print(f"[build_pois] wrote manifest {config.DATA_MANIFEST_PATH}")
|
| 141 |
+
|
| 142 |
|
| 143 |
if __name__ == "__main__":
|
| 144 |
main()
|
src/discoverroute/data/taxonomy.py
CHANGED
|
@@ -108,6 +108,48 @@ DWELL_TIME_SEC: dict[str, float] = {
|
|
| 108 |
}
|
| 109 |
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
def posture(category: str) -> str:
|
| 112 |
return POSTURE_DEFAULT.get(category, "pass")
|
| 113 |
|
|
|
|
| 108 |
}
|
| 109 |
|
| 110 |
|
| 111 |
+
# Warm, natural noun per category for POIs that have no OSM name — so an unnamed
|
| 112 |
+
# place reads as "a quiet garden", not the clunky "a park garden" / raw snake_case.
|
| 113 |
+
PRETTY_CATEGORY: dict[str, str] = {
|
| 114 |
+
"park_garden": "garden",
|
| 115 |
+
"water_feature": "fountain",
|
| 116 |
+
"viewpoint": "viewpoint",
|
| 117 |
+
"monument_historic": "historic landmark",
|
| 118 |
+
"museum_gallery": "museum",
|
| 119 |
+
"artwork": "piece of public art",
|
| 120 |
+
"place_of_worship": "church",
|
| 121 |
+
"library": "library",
|
| 122 |
+
"bookshop": "bookshop",
|
| 123 |
+
"theatre_cinema": "theatre",
|
| 124 |
+
"cafe": "café",
|
| 125 |
+
"bakery_food_shop": "bakery",
|
| 126 |
+
"restaurant": "restaurant",
|
| 127 |
+
"bar_pub": "bar",
|
| 128 |
+
"market": "market",
|
| 129 |
+
"specialty_shop": "shop",
|
| 130 |
+
"attraction": "landmark",
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def pretty_category(category: str) -> str:
|
| 135 |
+
"""Human noun for a category, e.g. 'park_garden' -> 'garden'."""
|
| 136 |
+
return PRETTY_CATEGORY.get(category) or (category or "place").replace("_", " ")
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def display_label(poi) -> str:
|
| 140 |
+
"""The single source of truth for naming a POI in any UI surface.
|
| 141 |
+
|
| 142 |
+
The real OSM name when present; otherwise a natural 'a/an <noun>' phrase with
|
| 143 |
+
the correct article (never a raw 'a artwork' or snake_case category).
|
| 144 |
+
"""
|
| 145 |
+
name = getattr(poi, "name", None)
|
| 146 |
+
if name is not None and str(name).strip():
|
| 147 |
+
return str(name).strip()
|
| 148 |
+
noun = pretty_category(getattr(poi, "category", "") or "")
|
| 149 |
+
article = "an" if noun[:1].lower() in "aeiou" else "a"
|
| 150 |
+
return f"{article} {noun}"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
def posture(category: str) -> str:
|
| 154 |
return POSTURE_DEFAULT.get(category, "pass")
|
| 155 |
|
src/discoverroute/interpret/vibe.py
CHANGED
|
@@ -75,6 +75,12 @@ def interpret(vibe: str, adventurousness: float = config.DEFAULT_ADVENTUROUSNESS
|
|
| 75 |
def _explain(vibe, top, affinity, posture, budget_hint) -> str:
|
| 76 |
if not (vibe or "").strip():
|
| 77 |
return "_No vibe given — every kind of place is weighted equally._"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
lines = [f"**Reading “{vibe.strip()}” as:**"]
|
| 79 |
for c in top:
|
| 80 |
nice = c.replace("_", " ")
|
|
|
|
| 75 |
def _explain(vibe, top, affinity, posture, budget_hint) -> str:
|
| 76 |
if not (vibe or "").strip():
|
| 77 |
return "_No vibe given — every kind of place is weighted equally._"
|
| 78 |
+
# Off-domain / unreadable vibe: the interpreter degraded to neutral (all
|
| 79 |
+
# categories equal). Say so honestly instead of inventing a confident top-4.
|
| 80 |
+
vals = list(affinity.values())
|
| 81 |
+
if vals and (max(vals) - min(vals)) < 1e-6:
|
| 82 |
+
return (f"_I couldn't read a clear taste from “{vibe.strip()}” — "
|
| 83 |
+
f"weighting every kind of place equally._")
|
| 84 |
lines = [f"**Reading “{vibe.strip()}” as:**"]
|
| 85 |
for c in top:
|
| 86 |
nice = c.replace("_", " ")
|
src/discoverroute/narrate/narrate.py
CHANGED
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
| 13 |
|
| 14 |
import os
|
| 15 |
|
|
|
|
| 16 |
from discoverroute.narrate import grounding
|
| 17 |
|
| 18 |
# Phrasing per category for the template. Generic (no external facts) so the only
|
|
@@ -42,32 +43,54 @@ def _verb(posture: str) -> str:
|
|
| 42 |
return "Pause at" if posture == "stop" else "Pass by"
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
| 46 |
-
end_label="", posture=None) -> str:
|
| 47 |
posture = posture or {}
|
|
|
|
| 48 |
extra = round(discovery.time_min + getattr(discovery, "dwell_s", 0.0) / 60.0
|
| 49 |
- plain.time_min)
|
| 50 |
unit = "minute" if extra == 1 else "minutes"
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
lead += (
|
| 54 |
f"Spending **{extra} extra {unit}**{vibe_clause}, your {mode} threads "
|
| 55 |
-
f"{
|
| 56 |
f"{end_label or 'the destination'}:\n"
|
| 57 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
lines = [lead]
|
|
|
|
| 59 |
for i, p in enumerate(pois, 1):
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
state = getattr(p, "open_state", None) # OSM opening_hours, when decidable
|
| 64 |
-
if state is True:
|
| 65 |
-
badge = " · 🟢 open now"
|
| 66 |
-
elif state is False:
|
| 67 |
-
badge = " · 🔴 closed right now"
|
| 68 |
else:
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
lines.append(
|
| 72 |
f"\nThen on to {end_label or 'your destination'}. Every place above is a "
|
| 73 |
f"real spot on your route — nothing invented."
|
|
@@ -76,18 +99,29 @@ def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
|
| 76 |
|
| 77 |
|
| 78 |
def llm_available() -> bool:
|
| 79 |
-
"""True
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
return False
|
| 82 |
try:
|
| 83 |
import torch # noqa: F401
|
| 84 |
import transformers # noqa: F401
|
| 85 |
except Exception:
|
| 86 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
try:
|
| 88 |
import torch
|
| 89 |
-
if os.environ.get("DISCOVERROUTE_USE_LLM", "auto").lower() in ("1", "true", "on"):
|
| 90 |
-
return True
|
| 91 |
return bool(torch.cuda.is_available())
|
| 92 |
except Exception:
|
| 93 |
return False
|
|
@@ -97,7 +131,7 @@ def narrate(plain, discovery, pois, vibe="", mode="walk", start_label="",
|
|
| 97 |
end_label="", posture=None, weights=None) -> tuple[str, bool]:
|
| 98 |
"""Return (markdown, used_llm). Output is guaranteed grounded."""
|
| 99 |
template = template_narration(
|
| 100 |
-
plain, discovery, pois, vibe, mode, start_label, end_label, posture
|
| 101 |
)
|
| 102 |
if not llm_available():
|
| 103 |
return template, False
|
|
@@ -142,7 +176,7 @@ def _llm_narration(plain, discovery, pois, vibe, mode, start_label, end_label,
|
|
| 142 |
"""Generate narration with MiniCPM5-1B, constrained to the allowed names."""
|
| 143 |
from discoverroute.narrate.llm import run_inference
|
| 144 |
|
| 145 |
-
names = [
|
| 146 |
bullet = "\n".join(
|
| 147 |
f"- {n} ({p.category.replace('_', ' ')})" for n, p in zip(names, pois)
|
| 148 |
)
|
|
|
|
| 13 |
|
| 14 |
import os
|
| 15 |
|
| 16 |
+
from discoverroute.data import taxonomy
|
| 17 |
from discoverroute.narrate import grounding
|
| 18 |
|
| 19 |
# Phrasing per category for the template. Generic (no external facts) so the only
|
|
|
|
| 43 |
return "Pause at" if posture == "stop" else "Pass by"
|
| 44 |
|
| 45 |
|
| 46 |
+
def _hours_badge(poi, posture_val: str) -> str:
|
| 47 |
+
"""Honest open/closed badge. Unknown hours are flagged only for places you'd
|
| 48 |
+
enter (stops); a park you stroll past needs no 'hours unverified' caveat."""
|
| 49 |
+
state = getattr(poi, "open_state", None) # OSM opening_hours, when decidable
|
| 50 |
+
if state is True:
|
| 51 |
+
return " · 🟢 open now"
|
| 52 |
+
if state is False:
|
| 53 |
+
return " · 🔴 closed right now"
|
| 54 |
+
if posture_val == "stop":
|
| 55 |
+
return " · ⚪ hours unverified"
|
| 56 |
+
return ""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
def template_narration(plain, discovery, pois, vibe, mode, start_label="",
|
| 60 |
+
end_label="", posture=None, weights=None) -> str:
|
| 61 |
posture = posture or {}
|
| 62 |
+
n = len(pois)
|
| 63 |
extra = round(discovery.time_min + getattr(discovery, "dwell_s", 0.0) / 60.0
|
| 64 |
- plain.time_min)
|
| 65 |
unit = "minute" if extra == 1 else "minutes"
|
| 66 |
+
place_word = "place" if n == 1 else "places"
|
| 67 |
+
v = (vibe or "").strip()
|
| 68 |
+
vibe_clause = f" to match your *{v}* mood" if v else ""
|
| 69 |
+
lead = "### Why this route\n"
|
| 70 |
lead += (
|
| 71 |
f"Spending **{extra} extra {unit}**{vibe_clause}, your {mode} threads "
|
| 72 |
+
f"**{n} {place_word}** between {start_label or 'the start'} and "
|
| 73 |
f"{end_label or 'the destination'}:\n"
|
| 74 |
)
|
| 75 |
+
# The categories the vibe leans on most — used to tie a stop back to the vibe.
|
| 76 |
+
top_cats: set[str] = set()
|
| 77 |
+
aff = getattr(weights, "category_affinity", None)
|
| 78 |
+
if v and aff:
|
| 79 |
+
top_cats = set(sorted(aff, key=aff.get, reverse=True)[:3])
|
| 80 |
+
|
| 81 |
lines = [lead]
|
| 82 |
+
prev_cat = None
|
| 83 |
for i, p in enumerate(pois, 1):
|
| 84 |
+
label = taxonomy.display_label(p)
|
| 85 |
+
if p.category == prev_cat: # avoid 3 identical reason lines in a row
|
| 86 |
+
reason = f"another {taxonomy.pretty_category(p.category)} along the way"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
else:
|
| 88 |
+
reason = _REASON.get(p.category, "a spot worth a look")
|
| 89 |
+
prev_cat = p.category
|
| 90 |
+
verb = _verb(posture.get(p.category, "pass"))
|
| 91 |
+
tie = " — a match for your vibe" if (v and p.category in top_cats) else ""
|
| 92 |
+
badge = _hours_badge(p, posture.get(p.category, "pass"))
|
| 93 |
+
lines.append(f"{i}. **{label}** — {verb.lower()} for {reason}{tie}.{badge}")
|
| 94 |
lines.append(
|
| 95 |
f"\nThen on to {end_label or 'your destination'}. Every place above is a "
|
| 96 |
f"real spot on your route — nothing invented."
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
def llm_available() -> bool:
|
| 102 |
+
"""True when the generative narrator (MiniCPM5-1B) should run.
|
| 103 |
+
|
| 104 |
+
ZeroGPU gotcha: on a ZeroGPU Space the GPU is allocated on demand *inside* an
|
| 105 |
+
``@spaces.GPU`` call, so ``torch.cuda.is_available()`` is False here at gate
|
| 106 |
+
time — gating on it would silently force the template forever. We instead
|
| 107 |
+
trust the ZeroGPU/Space environment (and an explicit override). The narrator
|
| 108 |
+
still fails closed to the template if the model errors or fails grounding.
|
| 109 |
+
"""
|
| 110 |
+
flag = os.environ.get("DISCOVERROUTE_USE_LLM", "auto").lower()
|
| 111 |
+
if flag in ("0", "false", "off"):
|
| 112 |
return False
|
| 113 |
try:
|
| 114 |
import torch # noqa: F401
|
| 115 |
import transformers # noqa: F401
|
| 116 |
except Exception:
|
| 117 |
return False
|
| 118 |
+
if flag in ("1", "true", "on"):
|
| 119 |
+
return True
|
| 120 |
+
# ZeroGPU Space: CUDA isn't visible outside @spaces.GPU — enable by environment.
|
| 121 |
+
if os.environ.get("SPACES_ZERO_GPU") or os.environ.get("SPACES_ZERO_GPU_V2"):
|
| 122 |
+
return True
|
| 123 |
try:
|
| 124 |
import torch
|
|
|
|
|
|
|
| 125 |
return bool(torch.cuda.is_available())
|
| 126 |
except Exception:
|
| 127 |
return False
|
|
|
|
| 131 |
end_label="", posture=None, weights=None) -> tuple[str, bool]:
|
| 132 |
"""Return (markdown, used_llm). Output is guaranteed grounded."""
|
| 133 |
template = template_narration(
|
| 134 |
+
plain, discovery, pois, vibe, mode, start_label, end_label, posture, weights
|
| 135 |
)
|
| 136 |
if not llm_available():
|
| 137 |
return template, False
|
|
|
|
| 176 |
"""Generate narration with MiniCPM5-1B, constrained to the allowed names."""
|
| 177 |
from discoverroute.narrate.llm import run_inference
|
| 178 |
|
| 179 |
+
names = [taxonomy.display_label(p) for p in pois]
|
| 180 |
bullet = "\n".join(
|
| 181 |
f"- {n} ({p.category.replace('_', ' ')})" for n, p in zip(names, pois)
|
| 182 |
)
|
src/discoverroute/pipeline.py
CHANGED
|
@@ -94,6 +94,15 @@ def _plan_route_impl(
|
|
| 94 |
n_alternatives: int = 1,
|
| 95 |
) -> PlanResult:
|
| 96 |
"""Plan a route. Returns a PlanResult; never raises for user-facing errors."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
try:
|
| 98 |
graph = g.load_graph()
|
| 99 |
start = g.geocode_point(start_query)
|
|
@@ -119,10 +128,10 @@ def _plan_route_impl(
|
|
| 119 |
interp = interpret(vibe, adventurousness, budget)
|
| 120 |
posture = interp.posture
|
| 121 |
interp_md = interp.explanation
|
| 122 |
-
# An explicit pace word in the vibe ("quick", "all day")
|
| 123 |
-
#
|
| 124 |
-
# route
|
| 125 |
-
if interp.budget_hint is not None:
|
| 126 |
budget = interp.budget_hint
|
| 127 |
if has_profile:
|
| 128 |
interp_md += "\n\n_Blended with your saved taste profile._"
|
|
|
|
| 94 |
n_alternatives: int = 1,
|
| 95 |
) -> PlanResult:
|
| 96 |
"""Plan a route. Returns a PlanResult; never raises for user-facing errors."""
|
| 97 |
+
# Validate & clamp inputs up front (an unknown mode would silently route at
|
| 98 |
+
# walking speed then mislabel itself; out-of-range budget breaks invariants).
|
| 99 |
+
mode = (mode or "").strip().lower()
|
| 100 |
+
if mode not in config.TRAVEL_SPEEDS_KMH:
|
| 101 |
+
return PlanResult(None, None, [], None, None, "", "",
|
| 102 |
+
error="Mode must be 'walk' or 'bike'.")
|
| 103 |
+
slider_budget = max(0.0, min(float(budget), config.MAX_BUDGET))
|
| 104 |
+
budget = slider_budget
|
| 105 |
+
adventurousness = max(0.0, min(float(adventurousness), 1.0))
|
| 106 |
try:
|
| 107 |
graph = g.load_graph()
|
| 108 |
start = g.geocode_point(start_query)
|
|
|
|
| 128 |
interp = interpret(vibe, adventurousness, budget)
|
| 129 |
posture = interp.posture
|
| 130 |
interp_md = interp.explanation
|
| 131 |
+
# An explicit pace word in the vibe ("quick", "all day") nudges the
|
| 132 |
+
# budget — BUT never resurrects a detour the user explicitly disabled
|
| 133 |
+
# by zeroing the slider (P0-3: budget 0 == plain route, slider wins).
|
| 134 |
+
if interp.budget_hint is not None and slider_budget > 0:
|
| 135 |
budget = interp.budget_hint
|
| 136 |
if has_profile:
|
| 137 |
interp_md += "\n\n_Blended with your saved taste profile._"
|
src/discoverroute/routing/geocode.py
CHANGED
|
@@ -50,6 +50,30 @@ def _strip_trailing_geo(norm: str) -> str:
|
|
| 50 |
return " ".join(tokens)
|
| 51 |
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
@functools.lru_cache(maxsize=1)
|
| 54 |
def _index() -> tuple[dict[str, _Entry], list[_Entry]]:
|
| 55 |
"""Lazy name index: exact normalised-name map + full entry list.
|
|
|
|
| 50 |
return " ".join(tokens)
|
| 51 |
|
| 52 |
|
| 53 |
+
# Obvious non-Paris places that would otherwise namesake-match a Paris POI
|
| 54 |
+
# (e.g. a restaurant literally named "Tokyo"), silently producing a fake route.
|
| 55 |
+
# Checked before name matching so they fail with an honest "Paris only" message.
|
| 56 |
+
WORLD_PLACES = frozenset({
|
| 57 |
+
"london", "tokyo", "new york", "newyork", "berlin", "rome", "madrid",
|
| 58 |
+
"barcelona", "amsterdam", "brussels", "lisbon", "vienna", "prague",
|
| 59 |
+
"budapest", "moscow", "beijing", "shanghai", "hong kong", "seoul",
|
| 60 |
+
"bangkok", "singapore", "sydney", "melbourne", "dubai", "mumbai", "delhi",
|
| 61 |
+
"new delhi", "cairo", "istanbul", "athens", "dublin", "edinburgh",
|
| 62 |
+
"manchester", "los angeles", "san francisco", "chicago", "boston", "miami",
|
| 63 |
+
"toronto", "montreal", "mexico city", "rio de janeiro", "sao paulo",
|
| 64 |
+
"buenos aires", "kyoto", "osaka", "milan", "venice", "florence", "naples",
|
| 65 |
+
"munich", "frankfurt", "hamburg", "zurich", "geneva", "oslo", "stockholm",
|
| 66 |
+
"copenhagen", "helsinki", "warsaw", "kyiv", "kiev",
|
| 67 |
+
"china", "japan", "america", "usa", "england", "germany", "italy", "spain",
|
| 68 |
+
"russia", "india", "europe", "france",
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def is_world_place(query: str) -> bool:
|
| 73 |
+
"""True if the query is plainly a non-Paris city/country (denylist)."""
|
| 74 |
+
return _strip_trailing_geo(_normalize(query or "")) in WORLD_PLACES
|
| 75 |
+
|
| 76 |
+
|
| 77 |
@functools.lru_cache(maxsize=1)
|
| 78 |
def _index() -> tuple[dict[str, _Entry], list[_Entry]]:
|
| 79 |
"""Lazy name index: exact normalised-name map + full entry list.
|
src/discoverroute/routing/graph.py
CHANGED
|
@@ -101,8 +101,13 @@ def geocode_point(query: str) -> tuple[float, float]:
|
|
| 101 |
# Try "lat, lon" first, then the offline POI-name index (no network needed).
|
| 102 |
latlon = _try_parse_latlon(query)
|
| 103 |
if latlon is None:
|
| 104 |
-
from discoverroute.routing.geocode import local_geocode
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
latlon = local_geocode(query)
|
| 107 |
if latlon is None:
|
| 108 |
if os.environ.get(config.OFFLINE_ENV_VAR) == "1":
|
|
|
|
| 101 |
# Try "lat, lon" first, then the offline POI-name index (no network needed).
|
| 102 |
latlon = _try_parse_latlon(query)
|
| 103 |
if latlon is None:
|
| 104 |
+
from discoverroute.routing.geocode import is_world_place, local_geocode
|
| 105 |
|
| 106 |
+
if is_world_place(query):
|
| 107 |
+
raise RouteError(
|
| 108 |
+
f"{query!r} looks like a place outside Paris — DiscoverRoute "
|
| 109 |
+
"covers Paris only. Try a Paris landmark (e.g. 'Louvre')."
|
| 110 |
+
)
|
| 111 |
latlon = local_geocode(query)
|
| 112 |
if latlon is None:
|
| 113 |
if os.environ.get(config.OFFLINE_ENV_VAR) == "1":
|
src/discoverroute/routing/scoring.py
CHANGED
|
@@ -20,6 +20,12 @@ from discoverroute.data import taxonomy
|
|
| 20 |
# ranks contribute score * decay**rank (rank 0 = full). 0.5 => 1, 0.5, 0.25, ...
|
| 21 |
DIVERSITY_DECAY = 0.5
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
@dataclass
|
| 25 |
class Weights:
|
|
@@ -77,7 +83,15 @@ def base_score(poi, weights: Weights, adventurousness: float) -> float:
|
|
| 77 |
adv = min(1.0, max(0.0, adventurousness))
|
| 78 |
confidence_factor = poi.confidence ** (1.0 - adv)
|
| 79 |
serendipity = 1.0 + adv * (1.0 - poi.confidence)
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
def score_pois(pois: list, weights: Weights, adventurousness: float) -> list:
|
|
|
|
| 20 |
# ranks contribute score * decay**rank (rank 0 = full). 0.5 => 1, 0.5, 0.25, ...
|
| 21 |
DIVERSITY_DECAY = 0.5
|
| 22 |
|
| 23 |
+
# Anonymous POIs (no OSM name) are demoted by this factor — applied BEFORE the
|
| 24 |
+
# adventurousness serendipity boost, so high adventurousness surfaces *named*
|
| 25 |
+
# hidden gems instead of flooding the route with un-findable "a piece of public
|
| 26 |
+
# art" entries. (Parks/fountains/art are 40-83% unnamed in OSM.)
|
| 27 |
+
UNNAMED_SCORE_FACTOR = 0.45
|
| 28 |
+
|
| 29 |
|
| 30 |
@dataclass
|
| 31 |
class Weights:
|
|
|
|
| 83 |
adv = min(1.0, max(0.0, adventurousness))
|
| 84 |
confidence_factor = poi.confidence ** (1.0 - adv)
|
| 85 |
serendipity = 1.0 + adv * (1.0 - poi.confidence)
|
| 86 |
+
# Name-aware demotion (adv-independent): only penalise POIs that explicitly
|
| 87 |
+
# carry an empty/None name. POIs with no ``name`` attribute at all (synthetic
|
| 88 |
+
# test objects) are treated as named, so this never perturbs unit tests.
|
| 89 |
+
name_factor = 1.0
|
| 90 |
+
if hasattr(poi, "name"):
|
| 91 |
+
_name = getattr(poi, "name")
|
| 92 |
+
if _name is None or (isinstance(_name, str) and not _name.strip()):
|
| 93 |
+
name_factor = UNNAMED_SCORE_FACTOR
|
| 94 |
+
return raw * name_factor * confidence_factor * serendipity
|
| 95 |
|
| 96 |
|
| 97 |
def score_pois(pois: list, weights: Weights, adventurousness: float) -> list:
|
src/discoverroute/ui/map.py
CHANGED
|
@@ -110,11 +110,15 @@ def render_routes(
|
|
| 110 |
all_coords.extend(discovery.coords)
|
| 111 |
|
| 112 |
if pois:
|
|
|
|
| 113 |
for i, poi in enumerate(pois):
|
| 114 |
-
name = getattr(poi, "name", None)
|
| 115 |
cat = getattr(poi, "category", "")
|
| 116 |
icon = markers.poi_icon(cat, index=i)
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
| 118 |
if icon is not None:
|
| 119 |
folium.Marker([poi.lat, poi.lon], icon=icon,
|
| 120 |
tooltip=tooltip).add_to(fmap)
|
|
|
|
| 110 |
all_coords.extend(discovery.coords)
|
| 111 |
|
| 112 |
if pois:
|
| 113 |
+
from discoverroute.data import taxonomy
|
| 114 |
for i, poi in enumerate(pois):
|
| 115 |
+
name = getattr(poi, "name", None)
|
| 116 |
cat = getattr(poi, "category", "")
|
| 117 |
icon = markers.poi_icon(cat, index=i)
|
| 118 |
+
if name and str(name).strip():
|
| 119 |
+
tooltip = f"{name} · {taxonomy.pretty_category(cat)}" if cat else str(name)
|
| 120 |
+
else: # unnamed → a single natural label, never raw snake_case
|
| 121 |
+
tooltip = taxonomy.display_label(poi)
|
| 122 |
if icon is not None:
|
| 123 |
folium.Marker([poi.lat, poi.lon], icon=icon,
|
| 124 |
tooltip=tooltip).add_to(fmap)
|
src/discoverroute/ui/shell.py
CHANGED
|
@@ -14,9 +14,27 @@ Only the spatial layout (``APP_SHELL_CSS``) and the vanilla-JS interactivity
|
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
|
|
|
|
| 17 |
from discoverroute.ui import design
|
| 18 |
from discoverroute.ui import map as mapui
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
# --------------------------------------------------------------- app-shell CSS
|
| 21 |
APP_SHELL_CSS = """
|
| 22 |
*{ box-sizing:border-box; }
|
|
@@ -25,45 +43,26 @@ body{ font-family:'DM Sans',ui-sans-serif,system-ui,sans-serif; color:var(--dr-i
|
|
| 25 |
background:radial-gradient(1100px 520px at 88% -8%,#FBEFD6 0%,transparent 60%),var(--dr-cream); }
|
| 26 |
|
| 27 |
.app-shell{
|
| 28 |
-
display:grid; grid-template-columns:340px 1fr; grid-template-rows:
|
| 29 |
-
height:100vh; width:100%; background:var(--dr-cream);
|
| 30 |
}
|
| 31 |
|
| 32 |
-
/* ----
|
| 33 |
-
.
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
.
|
| 41 |
-
border-radius:999px; padding:4px
|
| 42 |
-
font-size:
|
| 43 |
-
.
|
| 44 |
-
box-shadow:0 0
|
| 45 |
-
@keyframes drBeacon{ 0%{ box-shadow:0 0 0 0 rgba(255,194,71,.6); }
|
| 46 |
-
70%,100%{ box-shadow:0 0 0 7px rgba(255,194,71,0); } }
|
| 47 |
-
.hero h1{ font-family:'Fredoka',sans-serif; font-weight:700; font-size:30px; letter-spacing:-.025em;
|
| 48 |
-
margin:7px 0 4px; line-height:1.04; color:#fff; }
|
| 49 |
-
.hero h1 .accent{ color:#FFE0A0; }
|
| 50 |
-
.hero p{ margin:0 0 9px; max-width:64ch; color:#EAF0FF; font-size:13px; line-height:1.35; }
|
| 51 |
-
.hero .badges{ display:flex; flex-wrap:wrap; gap:8px; }
|
| 52 |
-
.hero .badges span{ background:rgba(255,255,255,.14); border-radius:999px; padding:5px 12px;
|
| 53 |
-
font-size:11.5px; font-family:'Fredoka',sans-serif; font-weight:500; }
|
| 54 |
-
.hero .hero-art{ flex-shrink:0; width:84px; position:relative; z-index:1;
|
| 55 |
-
animation:drFloat 6s ease-in-out infinite; }
|
| 56 |
-
.hero .hero-art svg{ width:84px; height:auto; display:block; }
|
| 57 |
-
@keyframes drFloat{ 0%,100%{ transform:translateY(0); } 50%{ transform:translateY(-7px); } }
|
| 58 |
-
.hero .titan-chip{ position:absolute; top:12px; right:20px; z-index:2;
|
| 59 |
-
display:inline-flex; align-items:center; gap:6px; background:rgba(255,255,255,.18);
|
| 60 |
-
border-radius:999px; padding:4px 12px; font-size:10.5px; font-family:'Fredoka',sans-serif;
|
| 61 |
-
font-weight:600; letter-spacing:.02em; }
|
| 62 |
-
.hero .titan-chip::before{ content:''; width:6px; height:6px; border-radius:50%;
|
| 63 |
-
background:#9BF0BE; box-shadow:0 0 6px #6FE39C; }
|
| 64 |
|
| 65 |
/* ---- left control panel (340px, scrollable, sticky CTA) ---- */
|
| 66 |
-
.left-panel{ grid-column:1; grid-row:
|
| 67 |
border-right:1px solid var(--dr-line);
|
| 68 |
background:linear-gradient(180deg,#FBF3E2,var(--dr-cream) 140px);
|
| 69 |
display:flex; flex-direction:column; }
|
|
@@ -152,26 +151,59 @@ details.dr-collapse .collapse-body{ padding:13px 15px; }
|
|
| 152 |
color:var(--dr-ink); transition:all .16s var(--dr-spring); }
|
| 153 |
.dr-star:hover{ transform:translateY(-2px); border-color:var(--dr-sun); background:#FFF8E8; }
|
| 154 |
|
| 155 |
-
/* ---- right column: map
|
| 156 |
-
.right-col{ grid-column:2; grid-row:
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
#dr-map{ height:100%;
|
| 160 |
-
|
|
|
|
|
|
|
| 161 |
#dr-map .map-inner > div,
|
| 162 |
#dr-map iframe,
|
| 163 |
#dr-map .folium-map,
|
| 164 |
#dr-map .leaflet-container{ width:100% !important; height:100% !important; }
|
| 165 |
#dr-map .map-inner > div > div{ padding-bottom:0 !important; height:100% !important; }
|
| 166 |
-
|
| 167 |
-
#dr-summary{ margin:0; }
|
| 168 |
/* the styled result blocks only exist once a route is planned */
|
| 169 |
#dr-summary:empty, #dr-interp:empty, #dr-itin:empty, #dr-nodetour:empty{ display:none; }
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
scrollbar-width:thin; scrollbar-color:var(--dr-line) transparent; }
|
| 172 |
-
.
|
| 173 |
-
.
|
| 174 |
border:3px solid transparent; background-clip:content-box; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
#dr-options{ display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
|
| 176 |
#dr-options:empty{ display:none; }
|
| 177 |
#dr-options .opt{ border:2px solid var(--dr-line); border-radius:var(--dr-r); padding:11px 14px;
|
|
@@ -191,8 +223,8 @@ details.dr-collapse .collapse-body{ padding:13px 15px; }
|
|
| 191 |
.onboard p{ margin:0; font-size:12.5px; color:var(--dr-soft); line-height:1.5; }
|
| 192 |
|
| 193 |
/* ---- loading: stride + 4-step stepper (State 2) ---- */
|
| 194 |
-
#dr-loading{ position:absolute; inset:
|
| 195 |
-
place-items:center;
|
| 196 |
background:radial-gradient(700px 320px at 50% 0%,#FBEFD6 0%,transparent 70%),#F6ECD9; }
|
| 197 |
#dr-loading.on{ display:grid; animation:drFade .25s ease; }
|
| 198 |
@keyframes drFade{ from{ opacity:0; } to{ opacity:1; } }
|
|
@@ -213,7 +245,7 @@ details.dr-collapse .collapse-body{ padding:13px 15px; }
|
|
| 213 |
border-radius:3px; transition:background .4s ease; }
|
| 214 |
.stepper .bar.fill{ background:var(--dr-grass); }
|
| 215 |
/* State 1 — non-Paris graph load (inert for the Paris demo, built per brief) */
|
| 216 |
-
#dr-mapping{ position:absolute; inset:
|
| 217 |
background:#F6ECD9; }
|
| 218 |
#dr-mapping.on{ display:grid; }
|
| 219 |
#dr-mapping .pulse{ width:18px;height:18px;border-radius:50%;background:var(--dr-grass);
|
|
@@ -229,17 +261,11 @@ details.dr-collapse .collapse-body{ padding:13px 15px; }
|
|
| 229 |
|
| 230 |
/* ---- mobile: left panel → bottom drawer, full-screen map, coral FAB ---- */
|
| 231 |
@media (max-width:768px){
|
| 232 |
-
.app-shell{ grid-template-columns:1fr; grid-template-rows:
|
| 233 |
-
.
|
| 234 |
-
.
|
| 235 |
-
.hero .titan-chip{ position:static; margin-top:8px; align-self:flex-start; }
|
| 236 |
-
.right-col{ grid-column:1; grid-row:2; padding:0; gap:0; }
|
| 237 |
-
.map-container{ min-height:0; }
|
| 238 |
-
#dr-map .map-inner, #dr-loading, #dr-mapping{ inset:0; }
|
| 239 |
-
.itinerary-panel{ max-height:34vh; padding:0 14px 14px; }
|
| 240 |
-
.route-summary-bar{ padding:0 12px; }
|
| 241 |
.left-panel{ position:fixed; left:0; right:0; bottom:0; top:auto; z-index:200; width:100%;
|
| 242 |
-
height:auto; max-height:
|
| 243 |
border-top-right-radius:26px; box-shadow:0 -18px 44px -20px rgba(43,38,32,.4);
|
| 244 |
transform:translateY(110%); transition:transform .32s var(--dr-spring);
|
| 245 |
padding-top:8px; }
|
|
@@ -263,27 +289,6 @@ details.dr-collapse .collapse-body{ padding:13px 15px; }
|
|
| 263 |
"""
|
| 264 |
|
| 265 |
|
| 266 |
-
def _compact_hero() -> str:
|
| 267 |
-
"""Hero with the exact copy/badges/colors, tightened to ≤120px."""
|
| 268 |
-
return f"""
|
| 269 |
-
<header class="hero">
|
| 270 |
-
<div class="hero-body">
|
| 271 |
-
<span class="loc-pill"><span class="dot"></span>Paris · walkable detours</span>
|
| 272 |
-
<h1>Spend your extra time on <span class="accent">discovery.</span></h1>
|
| 273 |
-
<p>Ordinary navigation minimizes time. DiscoverRoute detours past places that match
|
| 274 |
-
your taste — within a travel-time budget — and tells you why each one is on the path.</p>
|
| 275 |
-
<div class="badges">
|
| 276 |
-
<span>🗺️ OpenStreetMap data</span>
|
| 277 |
-
<span>🥐 A friendly local guide</span>
|
| 278 |
-
<span>✨ Tuned to your vibe</span>
|
| 279 |
-
</div>
|
| 280 |
-
</div>
|
| 281 |
-
<div class="hero-art">{design._HERO_SVG}</div>
|
| 282 |
-
<span class="titan-chip">running on a 1B model, in-Space</span>
|
| 283 |
-
</header>
|
| 284 |
-
"""
|
| 285 |
-
|
| 286 |
-
|
| 287 |
_VIBE_PRESETS = [
|
| 288 |
"quiet green wander",
|
| 289 |
"lively café crawl",
|
|
@@ -310,6 +315,11 @@ def _left_panel() -> str:
|
|
| 310 |
aria-label="Close controls">×</div>
|
| 311 |
|
| 312 |
<div class="panel-scroll">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
<div class="vibe-block">
|
| 314 |
<div class="dr-control combo" style="margin-bottom:0;">
|
| 315 |
<label class="dr-label" for="dr-vibe">What's your vibe today?</label>
|
|
@@ -433,7 +443,6 @@ def index_html() -> str:
|
|
| 433 |
</head>
|
| 434 |
<body>
|
| 435 |
<div class="gradio-container app-shell">
|
| 436 |
-
{_compact_hero()}
|
| 437 |
{_left_panel()}
|
| 438 |
<main class="right-col">
|
| 439 |
<div class="map-container">
|
|
@@ -442,22 +451,27 @@ def index_html() -> str:
|
|
| 442 |
<span style="font-family:'Fredoka',sans-serif;font-weight:600;">Mapping the streets…</span></div></div>
|
| 443 |
<div id="dr-loading">{_loading_inner()}</div>
|
| 444 |
</div>
|
| 445 |
-
<
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
<
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
</div>
|
| 456 |
-
|
| 457 |
-
<div id="dr-nodetour"></div>
|
| 458 |
-
<div id="dr-interp"></div>
|
| 459 |
-
<div id="dr-itin"></div>
|
| 460 |
-
</div>
|
| 461 |
</main>
|
| 462 |
</div>
|
| 463 |
<button class="fab" id="fab" aria-label="Open route controls">Plan</button>
|
|
@@ -626,6 +640,7 @@ function setStep(n) {
|
|
| 626 |
function hideOnboard() { const o = $("dr-onboard"); if (o) o.style.display = "none"; }
|
| 627 |
function startLoading() {
|
| 628 |
hideOnboard();
|
|
|
|
| 629 |
$("dr-loading").classList.add("on");
|
| 630 |
$("dr-summary").innerHTML = ""; $("dr-itin").innerHTML = "";
|
| 631 |
$("dr-interp").innerHTML = ""; $("dr-options").innerHTML = ""; $("dr-nodetour").innerHTML = "";
|
|
@@ -636,8 +651,56 @@ function stopLoading() { clearInterval(stepTimer); setStep(3); $("dr-loading").c
|
|
| 636 |
|
| 637 |
/* ---------- render ---------- */
|
| 638 |
const REDUCE = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
| 639 |
-
let lastAlts = [], lastCats = [];
|
| 640 |
function renderMap(html) { $("dr-map").innerHTML = '<div class="map-inner">' + html + "</div>"; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
function enter(el) {
|
| 642 |
if (!el || REDUCE) return;
|
| 643 |
try { el.animate([{ transform: "translateY(10px)", opacity: 0.001 }, { transform: "none", opacity: 1 }],
|
|
@@ -661,10 +724,13 @@ function selectAlt(idx) {
|
|
| 661 |
enter($("dr-summary")); enter($("dr-itin"));
|
| 662 |
document.querySelectorAll("#dr-options .opt").forEach((o, i) =>
|
| 663 |
o.classList.toggle("selected", i === idx));
|
|
|
|
| 664 |
}
|
| 665 |
function renderResult(d) {
|
| 666 |
hideOnboard();
|
| 667 |
-
if (d.error) { renderMap(d.map_html); toast(
|
|
|
|
|
|
|
| 668 |
$("dr-interp").innerHTML = md(d.interpretation_md);
|
| 669 |
enter($("dr-interp"));
|
| 670 |
if (d.no_detour) {
|
|
@@ -673,6 +739,8 @@ function renderResult(d) {
|
|
| 673 |
$("dr-itin").innerHTML = md(d.itinerary_md);
|
| 674 |
$("dr-nodetour").innerHTML = d.nodetour_html || "";
|
| 675 |
lastAlts = []; lastCats = [];
|
|
|
|
|
|
|
| 676 |
return;
|
| 677 |
}
|
| 678 |
$("dr-nodetour").innerHTML = "";
|
|
@@ -685,10 +753,12 @@ function renderResult(d) {
|
|
| 685 |
o.addEventListener("click", () => selectAlt(+o.dataset.i)));
|
| 686 |
} else { $("dr-options").innerHTML = ""; }
|
| 687 |
selectAlt(0);
|
|
|
|
| 688 |
}
|
| 689 |
|
| 690 |
/* ---------- plan ---------- */
|
| 691 |
async function plan() {
|
|
|
|
| 692 |
// map-press bounce (reused micro-interaction)
|
| 693 |
const mapEl = $("dr-map");
|
| 694 |
if (mapEl) mapEl.animate(
|
|
@@ -734,6 +804,12 @@ const closeDrawer = () => $("left-panel").classList.remove("open");
|
|
| 734 |
$("fab").addEventListener("click", openDrawer);
|
| 735 |
$("drawer-close").addEventListener("click", closeDrawer);
|
| 736 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 737 |
/* ---------- organic bounce on interaction (delegated, capture phase) ---------- */
|
| 738 |
document.addEventListener("click", (e) => {
|
| 739 |
const el = e.target.closest(
|
|
|
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
+
from discoverroute import config
|
| 18 |
from discoverroute.ui import design
|
| 19 |
from discoverroute.ui import map as mapui
|
| 20 |
|
| 21 |
+
_MONTHS = ["", "January", "February", "March", "April", "May", "June", "July",
|
| 22 |
+
"August", "September", "October", "November", "December"]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _provenance_line() -> str:
|
| 26 |
+
"""Honest data-freshness + attribution line for the results sheet footer."""
|
| 27 |
+
when = ""
|
| 28 |
+
d = config.DATA_BUILD_DATE
|
| 29 |
+
if d:
|
| 30 |
+
try:
|
| 31 |
+
y, m, _ = d.split("-")
|
| 32 |
+
when = f" · snapshot {_MONTHS[int(m)]} {y}"
|
| 33 |
+
except Exception: # noqa: BLE001
|
| 34 |
+
when = f" · snapshot {d}"
|
| 35 |
+
return (f"Places from OpenStreetMap{when} · open/close times are best-effort "
|
| 36 |
+
f"(often unlisted) · © OpenStreetMap contributors (ODbL)")
|
| 37 |
+
|
| 38 |
# --------------------------------------------------------------- app-shell CSS
|
| 39 |
APP_SHELL_CSS = """
|
| 40 |
*{ box-sizing:border-box; }
|
|
|
|
| 43 |
background:radial-gradient(1100px 520px at 88% -8%,#FBEFD6 0%,transparent 60%),var(--dr-cream); }
|
| 44 |
|
| 45 |
.app-shell{
|
| 46 |
+
display:grid; grid-template-columns:340px 1fr; grid-template-rows:1fr;
|
| 47 |
+
height:100vh; height:100dvh; width:100%; background:var(--dr-cream);
|
| 48 |
}
|
| 49 |
|
| 50 |
+
/* ---- brand header (top of the left panel — replaces the old blue ribbon) ---- */
|
| 51 |
+
.brand{ display:flex; align-items:center; gap:10px; padding:0 0 14px; margin-bottom:6px;
|
| 52 |
+
border-bottom:1px solid var(--dr-line); }
|
| 53 |
+
.brand .logo{ width:34px; height:34px; border-radius:11px; flex-shrink:0; display:grid;
|
| 54 |
+
place-items:center; font-size:18px; background:linear-gradient(135deg,#2F5DF4,#5C7DF8);
|
| 55 |
+
box-shadow:0 6px 14px -6px rgba(47,93,244,.7); }
|
| 56 |
+
.brand .bname{ font-family:'Fredoka',sans-serif; font-weight:700; font-size:19px;
|
| 57 |
+
letter-spacing:-.01em; color:var(--dr-ink); line-height:1; }
|
| 58 |
+
.brand .titan-chip{ margin-left:auto; display:inline-flex; align-items:center; gap:6px;
|
| 59 |
+
background:#EAF6EF; color:var(--dr-grass-d); border-radius:999px; padding:4px 10px;
|
| 60 |
+
font-size:10px; font-family:'Fredoka',sans-serif; font-weight:600; white-space:nowrap; }
|
| 61 |
+
.brand .titan-chip::before{ content:''; width:6px; height:6px; border-radius:50%;
|
| 62 |
+
background:var(--dr-grass); box-shadow:0 0 6px var(--dr-grass); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
/* ---- left control panel (340px, scrollable, sticky CTA) ---- */
|
| 65 |
+
.left-panel{ grid-column:1; grid-row:1; width:340px; height:100%; overflow:hidden;
|
| 66 |
border-right:1px solid var(--dr-line);
|
| 67 |
background:linear-gradient(180deg,#FBF3E2,var(--dr-cream) 140px);
|
| 68 |
display:flex; flex-direction:column; }
|
|
|
|
| 151 |
color:var(--dr-ink); transition:all .16s var(--dr-spring); }
|
| 152 |
.dr-star:hover{ transform:translateY(-2px); border-color:var(--dr-sun); background:#FFF8E8; }
|
| 153 |
|
| 154 |
+
/* ---- right column: full-bleed map (the hero element) ---- */
|
| 155 |
+
.right-col{ grid-column:2; grid-row:1; position:relative; height:100%; min-width:0; overflow:hidden; }
|
| 156 |
+
.map-container{ position:absolute; inset:0; }
|
| 157 |
+
/* full-bleed: strip the framed-window chrome that DR_CSS gives #dr-map */
|
| 158 |
+
#dr-map{ height:100%; border:none !important; border-radius:0 !important;
|
| 159 |
+
box-shadow:none !important; background:#F6ECD9; }
|
| 160 |
+
#dr-map::before{ display:none !important; }
|
| 161 |
+
#dr-map .map-inner{ position:absolute; inset:0; }
|
| 162 |
#dr-map .map-inner > div,
|
| 163 |
#dr-map iframe,
|
| 164 |
#dr-map .folium-map,
|
| 165 |
#dr-map .leaflet-container{ width:100% !important; height:100% !important; }
|
| 166 |
#dr-map .map-inner > div > div{ padding-bottom:0 !important; height:100% !important; }
|
| 167 |
+
|
|
|
|
| 168 |
/* the styled result blocks only exist once a route is planned */
|
| 169 |
#dr-summary:empty, #dr-interp:empty, #dr-itin:empty, #dr-nodetour:empty{ display:none; }
|
| 170 |
+
|
| 171 |
+
/* ---- floating results sheet: overlays the map, never resizes it ---- */
|
| 172 |
+
.results-sheet{ position:absolute; left:16px; right:16px; bottom:16px; z-index:30;
|
| 173 |
+
display:flex; flex-direction:column; max-height:46%;
|
| 174 |
+
background:var(--dr-paper); border:1px solid var(--dr-line); border-radius:22px;
|
| 175 |
+
box-shadow:0 26px 64px -26px rgba(43,38,32,.62); overflow:hidden;
|
| 176 |
+
animation:drSheetIn .34s var(--dr-spring); }
|
| 177 |
+
@keyframes drSheetIn{ from{ transform:translateY(16px); opacity:.4; } to{ transform:none; opacity:1; } }
|
| 178 |
+
.results-sheet[hidden]{ display:none; }
|
| 179 |
+
.sheet-head{ flex:0 0 auto; display:flex; align-items:stretch; cursor:pointer; }
|
| 180 |
+
.sheet-head #dr-summary{ flex:1 1 auto; margin:0 !important; border-radius:0 !important; }
|
| 181 |
+
.sheet-toggle{ flex:0 0 auto; width:46px; border:none; cursor:pointer; color:#fff;
|
| 182 |
+
background:var(--dr-grass-d); font-size:16px; display:grid; place-items:center;
|
| 183 |
+
transition:background .15s, transform .25s var(--dr-spring); }
|
| 184 |
+
.sheet-toggle:hover{ background:#1A6B3E; }
|
| 185 |
+
.results-sheet.collapsed .sheet-toggle{ transform:rotate(180deg); }
|
| 186 |
+
.sheet-body{ flex:1 1 auto; min-height:0; overflow-y:auto; padding:14px 18px 16px;
|
| 187 |
scrollbar-width:thin; scrollbar-color:var(--dr-line) transparent; }
|
| 188 |
+
.sheet-body::-webkit-scrollbar{ width:9px; }
|
| 189 |
+
.sheet-body::-webkit-scrollbar-thumb{ background:var(--dr-line); border-radius:9px;
|
| 190 |
border:3px solid transparent; background-clip:content-box; }
|
| 191 |
+
.results-sheet.collapsed .sheet-body{ display:none; }
|
| 192 |
+
|
| 193 |
+
/* export-to-maps row inside the results sheet */
|
| 194 |
+
.export-row{ display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-bottom:14px;
|
| 195 |
+
padding-bottom:14px; border-bottom:1px dashed var(--dr-line); }
|
| 196 |
+
.export-row[hidden]{ display:none; }
|
| 197 |
+
.export-label{ font-family:'Fredoka',sans-serif; font-weight:600; font-size:12px;
|
| 198 |
+
color:var(--dr-soft); margin-right:2px; }
|
| 199 |
+
.ex-btn{ display:inline-flex; align-items:center; gap:5px; font-family:'DM Sans',sans-serif;
|
| 200 |
+
font-size:12.5px; font-weight:600; color:var(--dr-ink); text-decoration:none; cursor:pointer;
|
| 201 |
+
border:1.5px solid var(--dr-line); background:#FFFDF8; border-radius:999px; padding:6px 12px;
|
| 202 |
+
transition:transform .16s var(--dr-spring), border-color .16s, background .16s; }
|
| 203 |
+
.ex-btn:hover{ transform:translateY(-2px); border-color:var(--dr-cobalt); background:#F0F4FF; }
|
| 204 |
+
.ex-note{ flex-basis:100%; font-size:11px; color:var(--dr-soft); line-height:1.4; }
|
| 205 |
+
.data-note{ margin-top:14px; padding-top:12px; border-top:1px dashed var(--dr-line);
|
| 206 |
+
font-size:10.5px; color:var(--dr-soft); line-height:1.45; }
|
| 207 |
#dr-options{ display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
|
| 208 |
#dr-options:empty{ display:none; }
|
| 209 |
#dr-options .opt{ border:2px solid var(--dr-line); border-radius:var(--dr-r); padding:11px 14px;
|
|
|
|
| 223 |
.onboard p{ margin:0; font-size:12.5px; color:var(--dr-soft); line-height:1.5; }
|
| 224 |
|
| 225 |
/* ---- loading: stride + 4-step stepper (State 2) ---- */
|
| 226 |
+
#dr-loading{ position:absolute; inset:0; z-index:40; display:none;
|
| 227 |
+
place-items:center;
|
| 228 |
background:radial-gradient(700px 320px at 50% 0%,#FBEFD6 0%,transparent 70%),#F6ECD9; }
|
| 229 |
#dr-loading.on{ display:grid; animation:drFade .25s ease; }
|
| 230 |
@keyframes drFade{ from{ opacity:0; } to{ opacity:1; } }
|
|
|
|
| 245 |
border-radius:3px; transition:background .4s ease; }
|
| 246 |
.stepper .bar.fill{ background:var(--dr-grass); }
|
| 247 |
/* State 1 — non-Paris graph load (inert for the Paris demo, built per brief) */
|
| 248 |
+
#dr-mapping{ position:absolute; inset:0; z-index:41; display:none; place-items:center;
|
| 249 |
background:#F6ECD9; }
|
| 250 |
#dr-mapping.on{ display:grid; }
|
| 251 |
#dr-mapping .pulse{ width:18px;height:18px;border-radius:50%;background:var(--dr-grass);
|
|
|
|
| 261 |
|
| 262 |
/* ---- mobile: left panel → bottom drawer, full-screen map, coral FAB ---- */
|
| 263 |
@media (max-width:768px){
|
| 264 |
+
.app-shell{ grid-template-columns:1fr; grid-template-rows:1fr; }
|
| 265 |
+
.right-col{ grid-column:1; grid-row:1; }
|
| 266 |
+
.results-sheet{ left:10px; right:10px; bottom:10px; max-height:56%; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
.left-panel{ position:fixed; left:0; right:0; bottom:0; top:auto; z-index:200; width:100%;
|
| 268 |
+
height:auto; max-height:86vh; max-height:86dvh; border-right:none; border-top-left-radius:26px;
|
| 269 |
border-top-right-radius:26px; box-shadow:0 -18px 44px -20px rgba(43,38,32,.4);
|
| 270 |
transform:translateY(110%); transition:transform .32s var(--dr-spring);
|
| 271 |
padding-top:8px; }
|
|
|
|
| 289 |
"""
|
| 290 |
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
_VIBE_PRESETS = [
|
| 293 |
"quiet green wander",
|
| 294 |
"lively café crawl",
|
|
|
|
| 315 |
aria-label="Close controls">×</div>
|
| 316 |
|
| 317 |
<div class="panel-scroll">
|
| 318 |
+
<div class="brand">
|
| 319 |
+
<span class="logo">🗺️</span>
|
| 320 |
+
<span class="bname">WanderLust</span>
|
| 321 |
+
<span class="titan-chip">1B · in-Space</span>
|
| 322 |
+
</div>
|
| 323 |
<div class="vibe-block">
|
| 324 |
<div class="dr-control combo" style="margin-bottom:0;">
|
| 325 |
<label class="dr-label" for="dr-vibe">What's your vibe today?</label>
|
|
|
|
| 443 |
</head>
|
| 444 |
<body>
|
| 445 |
<div class="gradio-container app-shell">
|
|
|
|
| 446 |
{_left_panel()}
|
| 447 |
<main class="right-col">
|
| 448 |
<div class="map-container">
|
|
|
|
| 451 |
<span style="font-family:'Fredoka',sans-serif;font-weight:600;">Mapping the streets…</span></div></div>
|
| 452 |
<div id="dr-loading">{_loading_inner()}</div>
|
| 453 |
</div>
|
| 454 |
+
<section class="results-sheet" id="results-sheet" hidden aria-label="Route details">
|
| 455 |
+
<div class="sheet-head" id="sheet-head">
|
| 456 |
+
<div id="dr-summary"></div>
|
| 457 |
+
<button class="sheet-toggle" id="sheet-toggle" aria-label="Collapse or expand details"
|
| 458 |
+
title="Collapse / expand">▾</button>
|
| 459 |
+
</div>
|
| 460 |
+
<div class="sheet-body">
|
| 461 |
+
<div class="export-row" id="export-row" hidden>
|
| 462 |
+
<span class="export-label">Take it with you</span>
|
| 463 |
+
<a id="ex-gmaps" class="ex-btn" target="_blank" rel="noopener noreferrer">🗺️ Google Maps</a>
|
| 464 |
+
<a id="ex-apple" class="ex-btn" target="_blank" rel="noopener noreferrer">🍎 Apple Maps</a>
|
| 465 |
+
<button id="ex-gpx" class="ex-btn" type="button">⬇️ GPX file</button>
|
| 466 |
+
<span class="ex-note" id="ex-note"></span>
|
| 467 |
</div>
|
| 468 |
+
<div id="dr-options"></div>
|
| 469 |
+
<div id="dr-nodetour"></div>
|
| 470 |
+
<div id="dr-interp"></div>
|
| 471 |
+
<div id="dr-itin"></div>
|
| 472 |
+
<div class="data-note">{_provenance_line()}</div>
|
| 473 |
</div>
|
| 474 |
+
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
</main>
|
| 476 |
</div>
|
| 477 |
<button class="fab" id="fab" aria-label="Open route controls">Plan</button>
|
|
|
|
| 640 |
function hideOnboard() { const o = $("dr-onboard"); if (o) o.style.display = "none"; }
|
| 641 |
function startLoading() {
|
| 642 |
hideOnboard();
|
| 643 |
+
hideSheet();
|
| 644 |
$("dr-loading").classList.add("on");
|
| 645 |
$("dr-summary").innerHTML = ""; $("dr-itin").innerHTML = "";
|
| 646 |
$("dr-interp").innerHTML = ""; $("dr-options").innerHTML = ""; $("dr-nodetour").innerHTML = "";
|
|
|
|
| 651 |
|
| 652 |
/* ---------- render ---------- */
|
| 653 |
const REDUCE = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
| 654 |
+
let lastAlts = [], lastCats = [], geo = null, curExport = null;
|
| 655 |
function renderMap(html) { $("dr-map").innerHTML = '<div class="map-inner">' + html + "</div>"; }
|
| 656 |
+
|
| 657 |
+
/* ---------- export to maps (client-side; data already in the /plan payload) ---------- */
|
| 658 |
+
function _ll(p) { return (+p[0]).toFixed(6) + "," + (+p[1]).toFixed(6); }
|
| 659 |
+
function buildGmaps(start, end, wps, mode) {
|
| 660 |
+
const travel = mode === "bike" ? "bicycling" : "walking";
|
| 661 |
+
let u = "https://www.google.com/maps/dir/?api=1&origin=" + _ll(start) +
|
| 662 |
+
"&destination=" + _ll(end) + "&travelmode=" + travel;
|
| 663 |
+
const w = (wps || []).slice(0, 9).map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6));
|
| 664 |
+
if (w.length) u += "&waypoints=" + encodeURIComponent(w.join("|"));
|
| 665 |
+
return u;
|
| 666 |
+
}
|
| 667 |
+
function buildApple(start, end) {
|
| 668 |
+
return "https://maps.apple.com/?saddr=" + _ll(start) + "&daddr=" + _ll(end) + "&dirflg=w";
|
| 669 |
+
}
|
| 670 |
+
function _xml(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
| 671 |
+
function buildGpx(name, start, end, wps, coords) {
|
| 672 |
+
const L = ['<?xml version="1.0" encoding="UTF-8"?>',
|
| 673 |
+
'<gpx version="1.1" creator="WanderLust" xmlns="http://www.topografix.com/GPX/1/1">',
|
| 674 |
+
"<metadata><name>" + _xml(name) + "</name></metadata>"];
|
| 675 |
+
if (start) L.push('<wpt lat="' + start[0].toFixed(6) + '" lon="' + start[1].toFixed(6) + '"><name>Start</name></wpt>');
|
| 676 |
+
(wps || []).forEach(p => L.push('<wpt lat="' + p.lat.toFixed(6) + '" lon="' + p.lon.toFixed(6) + '"><name>' + _xml(p.name) + "</name></wpt>"));
|
| 677 |
+
if (end) L.push('<wpt lat="' + end[0].toFixed(6) + '" lon="' + end[1].toFixed(6) + '"><name>Destination</name></wpt>');
|
| 678 |
+
L.push("<trk><name>" + _xml(name) + "</name><trkseg>");
|
| 679 |
+
(coords || []).forEach(c => L.push('<trkpt lat="' + (+c[0]).toFixed(6) + '" lon="' + (+c[1]).toFixed(6) + '"/>'));
|
| 680 |
+
L.push("</trkseg></trk></gpx>");
|
| 681 |
+
return L.join("\n");
|
| 682 |
+
}
|
| 683 |
+
function refreshExport() {
|
| 684 |
+
const row = $("export-row");
|
| 685 |
+
if (!row) return;
|
| 686 |
+
if (!geo || !geo.start || !geo.end || !curExport) { row.hidden = true; return; }
|
| 687 |
+
row.hidden = false;
|
| 688 |
+
const wps = curExport.waypoints || [];
|
| 689 |
+
$("ex-gmaps").href = buildGmaps(geo.start, geo.end, wps, geo.mode);
|
| 690 |
+
$("ex-apple").href = buildApple(geo.start, geo.end);
|
| 691 |
+
$("ex-note").textContent = (wps.length > 9
|
| 692 |
+
? "Google Maps fits 9 stops — the GPX keeps all " + wps.length + ". " : "") +
|
| 693 |
+
"Apple Maps shows start → end only; the GPX keeps every stop.";
|
| 694 |
+
}
|
| 695 |
+
function downloadGpx() {
|
| 696 |
+
if (!geo || !curExport) return;
|
| 697 |
+
const gpx = buildGpx("WanderLust route", geo.start, geo.end, curExport.waypoints, curExport.coords);
|
| 698 |
+
const blob = new Blob([gpx], { type: "application/gpx+xml" });
|
| 699 |
+
const a = document.createElement("a");
|
| 700 |
+
a.href = URL.createObjectURL(blob); a.download = "wanderlust-route.gpx";
|
| 701 |
+
document.body.appendChild(a); a.click(); a.remove();
|
| 702 |
+
setTimeout(() => URL.revokeObjectURL(a.href), 1500);
|
| 703 |
+
}
|
| 704 |
function enter(el) {
|
| 705 |
if (!el || REDUCE) return;
|
| 706 |
try { el.animate([{ transform: "translateY(10px)", opacity: 0.001 }, { transform: "none", opacity: 1 }],
|
|
|
|
| 724 |
enter($("dr-summary")); enter($("dr-itin"));
|
| 725 |
document.querySelectorAll("#dr-options .opt").forEach((o, i) =>
|
| 726 |
o.classList.toggle("selected", i === idx));
|
| 727 |
+
curExport = a.export || null; refreshExport();
|
| 728 |
}
|
| 729 |
function renderResult(d) {
|
| 730 |
hideOnboard();
|
| 731 |
+
if (d.error) { hideSheet(); renderMap(d.map_html); toast(d.error); return; }
|
| 732 |
+
geo = { start: d.start, end: d.end, mode: d.mode,
|
| 733 |
+
start_label: d.start_label, end_label: d.end_label };
|
| 734 |
$("dr-interp").innerHTML = md(d.interpretation_md);
|
| 735 |
enter($("dr-interp"));
|
| 736 |
if (d.no_detour) {
|
|
|
|
| 739 |
$("dr-itin").innerHTML = md(d.itinerary_md);
|
| 740 |
$("dr-nodetour").innerHTML = d.nodetour_html || "";
|
| 741 |
lastAlts = []; lastCats = [];
|
| 742 |
+
curExport = d.export || null; refreshExport();
|
| 743 |
+
showSheet();
|
| 744 |
return;
|
| 745 |
}
|
| 746 |
$("dr-nodetour").innerHTML = "";
|
|
|
|
| 753 |
o.addEventListener("click", () => selectAlt(+o.dataset.i)));
|
| 754 |
} else { $("dr-options").innerHTML = ""; }
|
| 755 |
selectAlt(0);
|
| 756 |
+
showSheet();
|
| 757 |
}
|
| 758 |
|
| 759 |
/* ---------- plan ---------- */
|
| 760 |
async function plan() {
|
| 761 |
+
closeDrawer(); // mobile: reveal the full-screen map + route
|
| 762 |
// map-press bounce (reused micro-interaction)
|
| 763 |
const mapEl = $("dr-map");
|
| 764 |
if (mapEl) mapEl.animate(
|
|
|
|
| 804 |
$("fab").addEventListener("click", openDrawer);
|
| 805 |
$("drawer-close").addEventListener("click", closeDrawer);
|
| 806 |
|
| 807 |
+
/* ---------- floating results sheet (collapsible overlay) ---------- */
|
| 808 |
+
function showSheet() { const s = $("results-sheet"); if (s) { s.hidden = false; s.classList.remove("collapsed"); } }
|
| 809 |
+
function hideSheet() { const s = $("results-sheet"); if (s) s.hidden = true; }
|
| 810 |
+
$("sheet-head").addEventListener("click", () => $("results-sheet").classList.toggle("collapsed"));
|
| 811 |
+
$("ex-gpx").addEventListener("click", downloadGpx);
|
| 812 |
+
|
| 813 |
/* ---------- organic bounce on interaction (delegated, capture phase) ---------- */
|
| 814 |
document.addEventListener("click", (e) => {
|
| 815 |
const el = e.target.closest(
|
tests/test_robustness.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for the adversarial-review fixes (input robustness + clarity).
|
| 2 |
+
|
| 3 |
+
These lock in invariants that were previously violated: mode validation, the
|
| 4 |
+
budget=0 == plain-route guarantee, non-Paris rejection, nonsense-vibe → neutral,
|
| 5 |
+
the unnamed-POI demotion, and place-count consistency across UI surfaces.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import app
|
| 10 |
+
from discoverroute.data import taxonomy
|
| 11 |
+
from discoverroute.interpret.affinity import resolve_affinity
|
| 12 |
+
from discoverroute.pipeline import plan_route
|
| 13 |
+
from discoverroute.routing import scoring
|
| 14 |
+
|
| 15 |
+
S = "Place de la République, Paris"
|
| 16 |
+
D = "Jardin du Luxembourg, Paris"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---- input validation ------------------------------------------------------
|
| 20 |
+
def test_invalid_mode_rejected():
|
| 21 |
+
assert plan_route(S, D, mode="car").error
|
| 22 |
+
assert plan_route(S, D, mode="xyz").error
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_uppercase_mode_normalized():
|
| 26 |
+
assert plan_route(S, D, mode="WALK").error is None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_world_city_rejected_not_namesake_routed():
|
| 30 |
+
err = plan_route("London", "Tokyo", vibe="quiet").error
|
| 31 |
+
assert err and "Paris" in err
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_budget_zero_is_plain_route_even_with_pace_word():
|
| 35 |
+
# P0-3: an explicit 0 slider must win over a vibe pace hint ("all day").
|
| 36 |
+
r = plan_route(S, D, budget=0.0, vibe="I want to spend all day exploring")
|
| 37 |
+
assert r.discovery is None and not r.pois
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ---- vibe interpretation ---------------------------------------------------
|
| 41 |
+
def test_nonsense_vibe_degrades_to_neutral():
|
| 42 |
+
aff, _ = resolve_affinity("quantum physics asdfgh")
|
| 43 |
+
spread = max(aff.values()) - min(aff.values())
|
| 44 |
+
assert spread < 1e-6 # all categories equal → neutral
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_real_vibe_is_confident():
|
| 48 |
+
aff, _ = resolve_affinity("quiet green park")
|
| 49 |
+
assert (max(aff.values()) - min(aff.values())) > 0.2
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ---- unnamed-POI demotion --------------------------------------------------
|
| 53 |
+
class _POI:
|
| 54 |
+
def __init__(self, name, category="park_garden", confidence=0.5):
|
| 55 |
+
self.name, self.category, self.confidence = name, category, confidence
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_unnamed_poi_scored_below_named():
|
| 59 |
+
w = scoring.Weights(category_affinity={"park_garden": 1.0})
|
| 60 |
+
named = scoring.base_score(_POI("Jardin X"), w, adventurousness=1.0)
|
| 61 |
+
unnamed = scoring.base_score(_POI(None), w, adventurousness=1.0)
|
| 62 |
+
assert unnamed < named # demoted even at max adventurousness
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_high_adventurousness_not_mostly_unnamed():
|
| 66 |
+
r = plan_route(S, D, vibe="hidden gems off the beaten path",
|
| 67 |
+
budget=0.7, adventurousness=1.0)
|
| 68 |
+
if r.pois:
|
| 69 |
+
unnamed = sum(1 for p in r.pois
|
| 70 |
+
if not (getattr(p, "name", None) and str(p.name).strip()))
|
| 71 |
+
assert unnamed / len(r.pois) <= 0.5
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---- labels & count consistency --------------------------------------------
|
| 75 |
+
def test_display_label_article_and_no_snake_case():
|
| 76 |
+
assert taxonomy.display_label(_POI(None, "artwork")) == "a piece of public art"
|
| 77 |
+
assert taxonomy.display_label(_POI(None, "attraction")) == "a landmark"
|
| 78 |
+
assert "_" not in taxonomy.display_label(_POI(None, "monument_historic"))
|
| 79 |
+
assert taxonomy.display_label(_POI("Louvre")) == "Louvre"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_place_count_consistent_across_surfaces():
|
| 83 |
+
r = plan_route(S, D, vibe="quiet green bookshops", budget=0.5, n_alternatives=1)
|
| 84 |
+
if r.alternatives:
|
| 85 |
+
a = r.alternatives[0]
|
| 86 |
+
n = len(a.pois)
|
| 87 |
+
assert f"{n} place" in a.summary_md
|
| 88 |
+
assert f"{n} place" in app._alt_label(0, a, r.plain)
|
| 89 |
+
assert f"{n} place" in a.itinerary_md
|