Tristan Leduc Claude Opus 4.8 (1M context) commited on
Commit
6881784
·
1 Parent(s): d34ffe8

Pre-baked offline cities (London, Barcelona, New York) + endpoint-stop fix

Browse files

Keeps the "Off the Grid" badge while going multi-city: each extra city is baked
as a bounded walkable core (centre + radius) committed via Git LFS, so it routes
fully offline — no live OSM at request time.

- data/build_city.py + config.CITIES registry; data/cities/<slug>_{walk.graphml,
pois.parquet} for london/barcelona/newyork (+ cities_manifest.json).
- routing/area.py: resolve_area now picks Paris → a pre-baked city (both points
in its core) → (online only) on-demand fetch. In offline mode, anywhere else
is refused with an honest "WanderLust covers Paris, London, …" message.
- routing/geocode.py: offline name index now spans Paris + every pre-baked city,
with city-hint disambiguation ("…, London") so a landmark resolves to the
right city; suggest()/local_geocode() are city-aware.
- pipeline.py: drop candidate POIs within 80 m of the start/destination so an
endpoint (e.g. the square you start from) is never offered as a "discovery
stop" — it isn't a detour and read as a bug.

Verified offline: London/Barcelona/NYC geocode with no network and route on
their own graphs; Paris unchanged; uncovered locations refused gracefully.
17 geocode/routing/pois tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

data/cities/barcelona_pois.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fd2cf7db57860905475a21d74ac5f2529e361201f9d640f3a813beea38c7f696
3
+ size 467125
data/cities/barcelona_walk.graphml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7d915d218b8672f34a6ae6e8a28dc81e44357feda1032f99cc5e6797d2828056
3
+ size 51051619
data/cities/cities_manifest.json ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "london": {
3
+ "label": "London",
4
+ "center": [
5
+ 51.5118,
6
+ -0.123
7
+ ],
8
+ "bbox": [
9
+ -0.16918911934351633,
10
+ 51.48285120318437,
11
+ -0.07681088065648367,
12
+ 51.540748796815635
13
+ ],
14
+ "tz": "Europe/London",
15
+ "poi_count": 10852,
16
+ "build_date": "2026-06-13",
17
+ "source": "OpenStreetMap",
18
+ "license": "ODbL \u2014 \u00a9 OpenStreetMap contributors"
19
+ },
20
+ "barcelona": {
21
+ "label": "Barcelona",
22
+ "center": [
23
+ 41.387,
24
+ 2.17
25
+ ],
26
+ "bbox": [
27
+ 2.131685394983051,
28
+ 41.35805120318437,
29
+ 2.2083146050169487,
30
+ 41.415948796815634
31
+ ],
32
+ "tz": "Europe/Madrid",
33
+ "poi_count": 11198,
34
+ "build_date": "2026-06-13",
35
+ "source": "OpenStreetMap",
36
+ "license": "ODbL \u2014 \u00a9 OpenStreetMap contributors"
37
+ },
38
+ "newyork": {
39
+ "label": "New York",
40
+ "center": [
41
+ 40.756,
42
+ -73.9845
43
+ ],
44
+ "bbox": [
45
+ -74.02244862803626,
46
+ 40.72705120318437,
47
+ -73.94655137196374,
48
+ 40.784948796815634
49
+ ],
50
+ "tz": "America/New_York",
51
+ "poi_count": 7416,
52
+ "build_date": "2026-06-13",
53
+ "source": "OpenStreetMap",
54
+ "license": "ODbL \u2014 \u00a9 OpenStreetMap contributors"
55
+ }
56
+ }
data/cities/london_pois.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cea6dead717a364c54f17c27f08083f7d47c7ad3c1c36d539a324777de3eb092
3
+ size 469067
data/cities/london_walk.graphml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:29788fdbdc48a9859c30350a2dcb33dae04aae47e5e5f38fc6b79f8ecd60c77f
3
+ size 51874171
data/cities/newyork_pois.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a46cc3449673d4faac43e6456d26bb3d2a74effdbf0d436c0921495e275a5a75
3
+ size 320167
data/cities/newyork_walk.graphml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3fb8c2f6c51fc2d29c30d345c6e2628553dd80cb2db43242a5a4865f94a71fea
3
+ size 18354729
src/discoverroute/config.py CHANGED
@@ -79,6 +79,28 @@ def corridor_halfwidth_m(budget: float) -> float:
79
  return CORRIDOR_BASE_M + CORRIDOR_BUDGET_M * max(0.0, budget)
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  # --- Other cities (on-demand) ------------------------------------------------
83
  # Paris ships pre-baked (instant, offline). Any other city is fetched live from
84
  # OpenStreetMap at request time: we download only the bounding box spanning the
 
79
  return CORRIDOR_BASE_M + CORRIDOR_BUDGET_M * max(0.0, budget)
80
 
81
 
82
+ # --- Pre-baked extra cities (offline, keeps "Off the Grid") ------------------
83
+ # Paris ships full-city (above). These additional cities are baked as a bounded
84
+ # walkable core (centre + radius) by data/build_city.py and committed, so they
85
+ # route fully offline — no live OSM at request time. Add a city here, run
86
+ # `python -m discoverroute.data.build_city <slug>`, commit the data.
87
+ CITY_DATA_DIR = DATA_DIR / "cities"
88
+ CITIES_MANIFEST_PATH = CITY_DATA_DIR / "cities_manifest.json"
89
+ CITIES = {
90
+ "london": {"label": "London", "center": (51.5118, -0.1230), "radius_m": 3200, "tz": "Europe/London"},
91
+ "barcelona": {"label": "Barcelona", "center": (41.3870, 2.1700), "radius_m": 3200, "tz": "Europe/Madrid"},
92
+ "newyork": {"label": "New York", "center": (40.7560, -73.9845), "radius_m": 3200, "tz": "America/New_York"},
93
+ }
94
+
95
+
96
+ def city_graph_path(slug: str) -> Path:
97
+ return CITY_DATA_DIR / f"{slug}_walk.graphml"
98
+
99
+
100
+ def city_pois_path(slug: str) -> Path:
101
+ return CITY_DATA_DIR / f"{slug}_pois.parquet"
102
+
103
+
104
  # --- Other cities (on-demand) ------------------------------------------------
105
  # Paris ships pre-baked (instant, offline). Any other city is fetched live from
106
  # OpenStreetMap at request time: we download only the bounding box spanning the
src/discoverroute/data/build_city.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pre-bake a walkable *city core* (graph + POIs) for offline routing.
2
+
3
+ Paris ships full-city; additional cities are baked as a bounded core (a box
4
+ around the centre) so the data stays small and the build is quick, while still
5
+ covering the walkable, demo-worthy heart of the city. Running this writes
6
+ ``data/cities/<slug>_walk.graphml`` + ``data/cities/<slug>_pois.parquet`` so the
7
+ city routes **fully offline** at request time — no live OSM calls, preserving the
8
+ "Off the Grid" badge while still being multi-city.
9
+
10
+ python -m discoverroute.data.build_city london
11
+ python -m discoverroute.data.build_city # all cities in config.CITIES
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ import sys
18
+ import time
19
+ from datetime import datetime, timezone
20
+
21
+ import osmnx as ox
22
+
23
+ from discoverroute import config
24
+ from discoverroute.routing.area import _fetch_pois # reuse the tested POI fetch
25
+
26
+
27
+ def _bbox_from_center(center, radius_m):
28
+ """(left, bottom, right, top) = (W, S, E, N) box around a centre point."""
29
+ lat, lon = center
30
+ dlat = radius_m / 110_540.0
31
+ dlon = radius_m / (111_320.0 * max(0.1, math.cos(math.radians(lat))))
32
+ return (lon - dlon, lat - dlat, lon + dlon, lat + dlat)
33
+
34
+
35
+ def build_city(slug: str) -> None:
36
+ spec = config.CITIES.get(slug)
37
+ if spec is None:
38
+ raise SystemExit(f"Unknown city {slug!r}. Known: {', '.join(config.CITIES)}")
39
+ label = spec["label"]
40
+ bbox = _bbox_from_center(spec["center"], spec["radius_m"])
41
+
42
+ ox.settings.use_cache = True
43
+ ox.settings.log_console = False
44
+ ox.settings.requests_timeout = 180
45
+
46
+ config.CITY_DATA_DIR.mkdir(parents=True, exist_ok=True)
47
+ t0 = time.time()
48
+ print(f"[build_city] {label}: downloading walk graph for bbox {bbox}")
49
+ graph = ox.graph_from_bbox(bbox, network_type="walk")
50
+ graph = ox.truncate.largest_component(graph, strongly=True)
51
+ gpath = config.city_graph_path(slug)
52
+ ox.save_graphml(graph, gpath)
53
+ print(f"[build_city] {label}: {graph.number_of_nodes():,} nodes -> {gpath.name}")
54
+
55
+ df = _fetch_pois(bbox)
56
+ if not len(df):
57
+ raise SystemExit(f"[build_city] {label}: no POIs found — aborting.")
58
+ ppath = config.city_pois_path(slug)
59
+ df.to_parquet(ppath, index=False)
60
+ print(f"[build_city] {label}: {len(df):,} POIs -> {ppath.name} "
61
+ f"(by category:\n{df['category'].value_counts().to_string()})")
62
+
63
+ # Per-city provenance, appended to the cities manifest.
64
+ manifest = {}
65
+ if config.CITIES_MANIFEST_PATH.exists():
66
+ manifest = json.loads(config.CITIES_MANIFEST_PATH.read_text("utf-8"))
67
+ manifest[slug] = {
68
+ "label": label,
69
+ "center": list(spec["center"]),
70
+ "bbox": list(bbox),
71
+ "tz": spec["tz"],
72
+ "poi_count": int(len(df)),
73
+ "build_date": datetime.now(timezone.utc).date().isoformat(),
74
+ "source": "OpenStreetMap", "license": "ODbL — © OpenStreetMap contributors",
75
+ }
76
+ config.CITIES_MANIFEST_PATH.write_text(json.dumps(manifest, indent=2) + "\n", "utf-8")
77
+ print(f"[build_city] {label}: done in {time.time() - t0:.0f}s")
78
+
79
+
80
+ def main() -> None:
81
+ slugs = sys.argv[1:] or list(config.CITIES)
82
+ for s in slugs:
83
+ build_city(s)
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
src/discoverroute/pipeline.py CHANGED
@@ -245,6 +245,13 @@ def _prepare_discovery(graph, start, end, plain, mode, budget, weights, adventur
245
  table = area.table if area is not None else None
246
  origin = area.origin if area is not None else None
247
  candidates = poimod.corridor_pois(plain.coords, budget, table=table, origin=origin)
 
 
 
 
 
 
 
248
  if not candidates:
249
  return None, None, None
250
  # Discovery vibes ("hidden gems"): drop famous, well-documented sights so the
@@ -324,6 +331,19 @@ def _solve_one(graph, start, end, plain, mode, budget, shortlist, matrix, time_f
324
  return discovery, result.ordered_pois
325
 
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  def _city_label(start_query: str, dest_query: str) -> str:
328
  """A friendly area name for on-demand fetch logs/messages.
329
 
 
245
  table = area.table if area is not None else None
246
  origin = area.origin if area is not None else None
247
  candidates = poimod.corridor_pois(plain.coords, budget, table=table, origin=origin)
248
+ if not candidates:
249
+ return None, None, None
250
+ # Never offer the start or destination itself as a "discovery stop" — a POI
251
+ # sitting on an endpoint (e.g. the square you're starting from) is not a
252
+ # detour and reads as a bug. Drop anything within ENDPOINT_EXCLUSION_M.
253
+ candidates = [p for p in candidates
254
+ if not _near_endpoint(p.lat, p.lon, start, end)]
255
  if not candidates:
256
  return None, None, None
257
  # Discovery vibes ("hidden gems"): drop famous, well-documented sights so the
 
331
  return discovery, result.ordered_pois
332
 
333
 
334
+ def _near_endpoint(lat: float, lon: float, start, end, thresh_m: float = 80.0) -> bool:
335
+ """True if (lat, lon) is within thresh_m of the start or destination point."""
336
+ import math
337
+
338
+ def d(a, b):
339
+ (la1, lo1), (la2, lo2) = a, b
340
+ p1, p2 = math.radians(la1), math.radians(la2)
341
+ dp, dl = math.radians(la2 - la1), math.radians(lo2 - lo1)
342
+ h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
343
+ return 2 * 6_371_000.0 * math.asin(math.sqrt(h))
344
+ return d((lat, lon), start) < thresh_m or d((lat, lon), end) < thresh_m
345
+
346
+
347
  def _city_label(start_query: str, dest_query: str) -> str:
348
  """A friendly area name for on-demand fetch logs/messages.
349
 
src/discoverroute/routing/area.py CHANGED
@@ -15,6 +15,7 @@ from __future__ import annotations
15
  import functools
16
  import logging
17
  import math
 
18
  import time
19
  from dataclasses import dataclass
20
  from zoneinfo import ZoneInfo
@@ -157,6 +158,42 @@ def _paris_area() -> Area:
157
  )
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  # Manual bounded LRU of on-demand areas (graphs are heavy — keep only a few).
161
  _ondemand_cache: dict[str, Area] = {}
162
  _ondemand_order: list[str] = []
@@ -216,9 +253,25 @@ def resolve_area(start: tuple[float, float], end: tuple[float, float],
216
  """
217
  from discoverroute.routing.graph import RouteError
218
 
 
219
  if config.in_paris(*start) and config.in_paris(*end):
220
  return _paris_area()
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  dist = _haversine_m(start, end)
223
  if dist > config.MAX_ENDPOINT_DISTANCE_M:
224
  raise RouteError(
 
15
  import functools
16
  import logging
17
  import math
18
+ import os
19
  import time
20
  from dataclasses import dataclass
21
  from zoneinfo import ZoneInfo
 
158
  )
159
 
160
 
161
+ def city_bbox(slug: str):
162
+ """(left, bottom, right, top) box for a pre-baked city — matches build_city."""
163
+ spec = config.CITIES[slug]
164
+ lat, lon = spec["center"]
165
+ r = spec["radius_m"]
166
+ dlat = r / 110_540.0
167
+ dlon = r / (111_320.0 * max(0.1, math.cos(math.radians(lat))))
168
+ return (lon - dlon, lat - dlat, lon + dlon, lat + dlat)
169
+
170
+
171
+ def _in_bbox(pt, bbox) -> bool:
172
+ return bbox[0] <= pt[1] <= bbox[2] and bbox[1] <= pt[0] <= bbox[3]
173
+
174
+
175
+ def available_cities() -> list[str]:
176
+ """Slugs of pre-baked cities whose data is actually present on disk."""
177
+ return [s for s in config.CITIES
178
+ if config.city_graph_path(s).exists() and config.city_pois_path(s).exists()]
179
+
180
+
181
+ @functools.lru_cache(maxsize=8)
182
+ def _city_area(slug: str) -> Area:
183
+ """Load a pre-baked city (committed graph + parquet) as an offline Area."""
184
+ from discoverroute.routing import graph as g
185
+
186
+ spec = config.CITIES[slug]
187
+ graph = ox.load_graphml(config.city_graph_path(slug))
188
+ df = pd.read_parquet(config.city_pois_path(slug))
189
+ origin = tuple(spec["center"])
190
+ return Area(
191
+ key=slug, label=spec["label"], graph=graph,
192
+ table=poimod.index_table(df, origin), csr=g.build_csr(graph),
193
+ origin=origin, tz=ZoneInfo(spec["tz"]), source="prebaked",
194
+ )
195
+
196
+
197
  # Manual bounded LRU of on-demand areas (graphs are heavy — keep only a few).
198
  _ondemand_cache: dict[str, Area] = {}
199
  _ondemand_order: list[str] = []
 
253
  """
254
  from discoverroute.routing.graph import RouteError
255
 
256
+ # 1) Paris — full pre-baked city (instant, offline).
257
  if config.in_paris(*start) and config.in_paris(*end):
258
  return _paris_area()
259
 
260
+ # 2) Other pre-baked cities (instant, offline) — both points in one city core.
261
+ for slug in available_cities():
262
+ bbox = city_bbox(slug)
263
+ if _in_bbox(start, bbox) and _in_bbox(end, bbox):
264
+ return _city_area(slug)
265
+
266
+ # 3) Anywhere else. Offline mode (the "Off the Grid" deploy config) only knows
267
+ # the pre-baked cities, so say so honestly. Online mode fetches live from OSM.
268
+ if os.environ.get(config.OFFLINE_ENV_VAR) == "1":
269
+ covered = ", ".join(["Paris"] + [config.CITIES[s]["label"] for s in available_cities()])
270
+ raise RouteError(
271
+ f"WanderLust covers {covered}. Pick a start and destination within one "
272
+ "of these cities (this build runs fully offline)."
273
+ )
274
+
275
  dist = _haversine_m(start, end)
276
  if dist > config.MAX_ENDPOINT_DISTANCE_M:
277
  raise RouteError(
src/discoverroute/routing/geocode.py CHANGED
@@ -31,6 +31,7 @@ class _Entry(NamedTuple):
31
  n_tags: int
32
  display: str # original POI name, for autocomplete suggestions
33
  category: str
 
34
 
35
 
36
  def _normalize(text: str) -> str:
@@ -44,10 +45,36 @@ def _normalize(text: str) -> str:
44
 
45
  def _strip_trailing_geo(norm: str) -> str:
46
  """Drop trailing 'paris' / 'france' qualifiers (but never the whole query)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  tokens = norm.split()
48
- while len(tokens) > 1 and tokens[-1] in _TRAILING_TOKENS:
 
 
 
49
  tokens.pop()
50
- return " ".join(tokens)
51
 
52
 
53
  # Obvious non-Paris places that would otherwise namesake-match a Paris POI
@@ -76,35 +103,43 @@ def is_world_place(query: str) -> bool:
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.
80
 
81
- For duplicate names (e.g. chain shops) the exact map keeps the entry with
82
- the highest (confidence, n_tags) the best-documented bearer of the name.
 
83
  """
 
 
 
 
84
  from discoverroute.routing.pois import load_pois
85
 
86
- df = load_pois()
87
- named = df[df["name"].notna()]
 
 
 
 
 
88
  exact: dict[str, _Entry] = {}
89
  entries: list[_Entry] = []
90
- for row in named.itertuples(index=False):
91
- norm = _normalize(row.name)
92
- if not norm:
93
- continue
94
- entry = _Entry(
95
- norm=norm,
96
- tokens=frozenset(norm.split()),
97
- lat=float(row.lat),
98
- lon=float(row.lon),
99
- confidence=float(row.confidence),
100
- n_tags=int(row.n_tags),
101
- display=str(row.name),
102
- category=str(row.category),
103
- )
104
- entries.append(entry)
105
- best = exact.get(norm)
106
- if best is None or (entry.confidence, entry.n_tags) > (best.confidence, best.n_tags):
107
- exact[norm] = entry
108
  return exact, entries
109
 
110
 
@@ -116,11 +151,16 @@ def local_geocode(query: str) -> tuple[float, float] | None:
116
  tokens present in the POI name), ranked by substring match, confidence,
117
  tag count, and name brevity. Returns None when nothing matches confidently.
118
  """
119
- norm = _strip_trailing_geo(_normalize(query or ""))
120
  if not norm:
121
  return None
122
  exact, entries = _index()
123
 
 
 
 
 
 
124
  hit = exact.get(norm)
125
  if hit is not None:
126
  return hit.lat, hit.lon
@@ -130,6 +170,10 @@ def local_geocode(query: str) -> tuple[float, float] | None:
130
  return None # only short fragments — too ambiguous to trust
131
  q_set = frozenset(q_tokens)
132
  candidates = [e for e in entries if q_set <= e.tokens]
 
 
 
 
133
  if not candidates:
134
  return None
135
  best = max(
@@ -148,10 +192,12 @@ def suggest(query: str, limit: int = 8) -> tuple[str, ...]:
148
  display name. Pure local index — no network. Returns () for short/ambiguous
149
  input rather than guessing.
150
  """
151
- norm = _strip_trailing_geo(_normalize(query or ""))
152
  if len(norm) < 3:
153
  return ()
154
  _, entries = _index()
 
 
155
  toks = norm.split()
156
  head, last = frozenset(toks[:-1]), toks[-1]
157
 
 
31
  n_tags: int
32
  display: str # original POI name, for autocomplete suggestions
33
  category: str
34
+ city: str # "paris" or a pre-baked city slug — for disambiguation
35
 
36
 
37
  def _normalize(text: str) -> str:
 
45
 
46
  def _strip_trailing_geo(norm: str) -> str:
47
  """Drop trailing 'paris' / 'france' qualifiers (but never the whole query)."""
48
+ return _split_geo(norm)[0]
49
+
50
+
51
+ @functools.lru_cache(maxsize=1)
52
+ def _geo_hints() -> dict[str, str]:
53
+ """Normalised city/qualifier word -> city slug (for trailing-token hints)."""
54
+ from discoverroute import config
55
+
56
+ hints = {"paris": "paris", "france": "paris"}
57
+ for slug, spec in config.CITIES.items():
58
+ hints[slug] = slug
59
+ for tok in _normalize(spec["label"]).split():
60
+ hints[tok] = slug
61
+ return hints
62
+
63
+
64
+ def _split_geo(norm: str) -> tuple[str, str | None]:
65
+ """Split a normalised query into (core, city_hint_slug).
66
+
67
+ Pops trailing geography qualifiers ("…, london", "…, paris, france") and
68
+ returns which city they point at, so a landmark that exists in two cities can
69
+ be disambiguated by the city the user named.
70
+ """
71
  tokens = norm.split()
72
+ hints = _geo_hints()
73
+ hint = None
74
+ while len(tokens) > 1 and tokens[-1] in hints:
75
+ hint = hints[tokens[-1]]
76
  tokens.pop()
77
+ return " ".join(tokens), hint
78
 
79
 
80
  # Obvious non-Paris places that would otherwise namesake-match a Paris POI
 
103
 
104
  @functools.lru_cache(maxsize=1)
105
  def _index() -> tuple[dict[str, _Entry], list[_Entry]]:
106
+ """Lazy name index over Paris + every pre-baked city's POI names.
107
 
108
+ Exact normalised-name map + full entry list. For duplicate names the exact
109
+ map keeps the entry with the highest (confidence, n_tags); the per-entry city
110
+ tag lets a city-hinted query ("…, London") prefer the right city.
111
  """
112
+ import pandas as pd
113
+
114
+ from discoverroute import config
115
+ from discoverroute.routing import area as area_mod
116
  from discoverroute.routing.pois import load_pois
117
 
118
+ sources = [("paris", load_pois())]
119
+ for slug in area_mod.available_cities():
120
+ try:
121
+ sources.append((slug, pd.read_parquet(config.city_pois_path(slug))))
122
+ except Exception: # noqa: BLE001 - a missing/partial city is non-fatal
123
+ continue
124
+
125
  exact: dict[str, _Entry] = {}
126
  entries: list[_Entry] = []
127
+ for city, df in sources:
128
+ named = df[df["name"].notna()]
129
+ for row in named.itertuples(index=False):
130
+ norm = _normalize(row.name)
131
+ if not norm:
132
+ continue
133
+ entry = _Entry(
134
+ norm=norm, tokens=frozenset(norm.split()),
135
+ lat=float(row.lat), lon=float(row.lon),
136
+ confidence=float(row.confidence), n_tags=int(row.n_tags),
137
+ display=str(row.name), category=str(row.category), city=city,
138
+ )
139
+ entries.append(entry)
140
+ best = exact.get(norm)
141
+ if best is None or (entry.confidence, entry.n_tags) > (best.confidence, best.n_tags):
142
+ exact[norm] = entry
 
 
143
  return exact, entries
144
 
145
 
 
151
  tokens present in the POI name), ranked by substring match, confidence,
152
  tag count, and name brevity. Returns None when nothing matches confidently.
153
  """
154
+ norm, hint = _split_geo(_normalize(query or ""))
155
  if not norm:
156
  return None
157
  exact, entries = _index()
158
 
159
+ # Exact name match — prefer the hinted city when the query named one.
160
+ if hint:
161
+ for e in entries:
162
+ if e.norm == norm and e.city == hint:
163
+ return e.lat, e.lon
164
  hit = exact.get(norm)
165
  if hit is not None:
166
  return hit.lat, hit.lon
 
170
  return None # only short fragments — too ambiguous to trust
171
  q_set = frozenset(q_tokens)
172
  candidates = [e for e in entries if q_set <= e.tokens]
173
+ if hint:
174
+ hinted = [e for e in candidates if e.city == hint]
175
+ if hinted:
176
+ candidates = hinted
177
  if not candidates:
178
  return None
179
  best = max(
 
192
  display name. Pure local index — no network. Returns () for short/ambiguous
193
  input rather than guessing.
194
  """
195
+ norm, hint = _split_geo(_normalize(query or ""))
196
  if len(norm) < 3:
197
  return ()
198
  _, entries = _index()
199
+ if hint:
200
+ entries = [e for e in entries if e.city == hint] or entries
201
  toks = norm.split()
202
  head, last = frozenset(toks[:-1]), toks[-1]
203