coreprinciple commited on
Commit
2c5a5d1
·
1 Parent(s): a82c73b
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"{len(alt.pois)} stops ({flavor})")
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
- MIN_AFFINITY_SPAN = 0.04
 
 
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
- lead = f"### Why this route\n"
52
- vibe_clause = f" for a *{vibe.strip()}*" if (vibe or "").strip() else ""
 
 
53
  lead += (
54
  f"Spending **{extra} extra {unit}**{vibe_clause}, your {mode} threads "
55
- f"{len(pois)} discoveries between {start_label or 'the start'} and "
56
  f"{end_label or 'the destination'}:\n"
57
  )
 
 
 
 
 
 
58
  lines = [lead]
 
59
  for i, p in enumerate(pois, 1):
60
- name = p.name or f"a {p.category.replace('_', ' ')}"
61
- reason = _REASON.get(p.category, "a stop worth making")
62
- verb = _verb(posture.get(p.category, "pass"))
63
- state = getattr(p, "open_state", None) # OSM opening_hours, when decidable
64
- if state is True:
65
- badge = " · 🟢 open now"
66
- elif state is False:
67
- badge = " · 🔴 closed right now"
68
  else:
69
- badge = ""
70
- lines.append(f"{i}. **{name}** — {verb.lower()} for {reason}.{badge}")
 
 
 
 
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 only if explicitly enabled and a GPU + transformers are present."""
80
- if os.environ.get("DISCOVERROUTE_USE_LLM", "auto").lower() in ("0", "false", "off"):
 
 
 
 
 
 
 
 
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 = [p.name or f"a {p.category.replace('_', ' ')}" for p in pois]
146
  bullet = "\n".join(
147
  f"- {n} ({p.category.replace('_', ' ')})" for n, p in zip(names, pois)
148
  )
 
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") overrides the
123
- # sliderotherwise the shown "pace hint budget X" contradicts the
124
- # route actually planned.
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
+ # budgetBUT 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
- return raw * confidence_factor * serendipity
 
 
 
 
 
 
 
 
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) 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)
 
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:auto 1fr;
29
- height:100vh; width:100%; background:var(--dr-cream);
30
  }
31
 
32
- /* ---- hero (compact but roomycontent must never clip) ---- */
33
- .hero{ grid-column:1 / -1; grid-row:1; display:flex; align-items:center; gap:22px;
34
- background:linear-gradient(120deg,#2F5DF4,#5C7DF8); color:#fff;
35
- padding:18px 34px; max-height:180px; overflow:hidden; position:relative;
36
- box-shadow:0 10px 30px -18px rgba(33,74,208,.7); z-index:3; }
37
- .hero::after{ content:''; position:absolute; inset:0; pointer-events:none;
38
- background:radial-gradient(420px 180px at 96% -30%,rgba(255,255,255,.16),transparent 70%); }
39
- .hero .hero-body{ flex:1; min-width:0; position:relative; z-index:1; }
40
- .hero .loc-pill{ display:inline-flex; align-items:center; gap:7px; background:rgba(255,255,255,.18);
41
- border-radius:999px; padding:4px 12px; font-family:'Fredoka',sans-serif; font-weight:600;
42
- font-size:11.5px; letter-spacing:.05em; text-transform:uppercase; }
43
- .hero .loc-pill .dot{ width:7px;height:7px;border-radius:50%;background:#FFC247;
44
- box-shadow:0 0 0 0 rgba(255,194,71,.7); animation:drBeacon 2.4s ease-out infinite; }
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:2; width:340px; height:100%; overflow:hidden;
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 · summary bar · itinerary ---- */
156
- .right-col{ grid-column:2; grid-row:2; display:flex; flex-direction:column; height:100%;
157
- min-width:0; padding:16px 18px 0; gap:14px; overflow:hidden; }
158
- .map-container{ flex:1 1 0; min-height:240px; position:relative; }
159
- #dr-map{ height:100%; }
160
- #dr-map .map-inner{ position:absolute; inset:46px 0 0 0; }
 
 
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
- .route-summary-bar{ flex:0 0 auto; }
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
- .itinerary-panel{ flex:0 1 auto; min-height:0; max-height:46vh; overflow-y:auto; padding-bottom:16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  scrollbar-width:thin; scrollbar-color:var(--dr-line) transparent; }
172
- .itinerary-panel::-webkit-scrollbar{ width:9px; }
173
- .itinerary-panel::-webkit-scrollbar-thumb{ background:var(--dr-line); border-radius:9px;
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:46px 0 0 0; z-index:20; display:none;
195
- place-items:center; border-radius:0 0 26px 26px;
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:46px 0 0 0; z-index:21; display:none; place-items:center;
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:auto 1fr; height:100vh; }
233
- .hero{ max-height:none; }
234
- .hero .hero-art{ display:none; }
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:84vh; border-right:none; border-top-left-radius:26px;
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">&times;</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
- <div class="route-summary-bar"><div id="dr-summary"></div></div>
446
- <div class="itinerary-panel">
447
- <div id="dr-onboard" class="onboard">
448
- <div class="ob-emoji">🥐</div>
449
- <div>
450
- <h3>Ready when you are.</h3>
451
- <p>Pick a vibe (or tap a chip), keep or tweak the Start &amp; Destination, then
452
- hit <strong>Plan my route</strong>. I'll thread a detour past places that match —
453
- and tell you why each one made the cut.</p>
 
 
 
 
454
  </div>
 
 
 
 
 
455
  </div>
456
- <div id="dr-options"></div>
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("Hmm — " + d.error.split(".")[0] + "."); return; }
 
 
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">&times;</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">&#9662;</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&nbsp;Maps</a>
464
+ <a id="ex-apple" class="ex-btn" target="_blank" rel="noopener noreferrer">🍎 Apple&nbsp;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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
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