coreprinciple commited on
Commit
1add89c
·
1 Parent(s): c552106

new cities added

Browse files
.gitignore CHANGED
@@ -31,5 +31,16 @@ test_output.log
31
  # They are committed for the Hugging Face Space so it needs no runtime download.
32
  # If they exceed Space size limits, switch to git-lfs (see PROGRESS.md).
33
 
 
 
 
 
 
 
 
 
 
 
 
34
  # Inference trace logs (Open Trace) — written at runtime, pushed to HF Dataset
35
  logs/
 
31
  # They are committed for the Hugging Face Space so it needs no runtime download.
32
  # If they exceed Space size limits, switch to git-lfs (see PROGRESS.md).
33
 
34
+ # Secondary city cores now live in the HF dataset
35
+ # (build-small-hackathon/discoverroute-cities) and are pulled + pre-warmed at
36
+ # boot — so DON'T commit newly baked cores into the repo (keeps the image lean).
37
+ # London/Barcelona/New York predate this and stay tracked; this only ignores new
38
+ # untracked cores (San Francisco, Tokyo, Mumbai, Shanghai, Berlin, …).
39
+ data/cities/*_walk.graphml
40
+ data/cities/*_pois.parquet
41
+ !data/cities/london_*
42
+ !data/cities/barcelona_*
43
+ !data/cities/newyork_*
44
+
45
  # Inference trace logs (Open Trace) — written at runtime, pushed to HF Dataset
46
  logs/
app.py CHANGED
@@ -59,7 +59,7 @@ def suggest(query: str = "") -> list:
59
  def plan(start: str, dest: str, mode: str = "walk", budget: float = 0.5,
60
  vibe: str = "", adventurousness: float = 0.3,
61
  prefer_green: float = 0.0, prefer_quiet: float = 0.0,
62
- profile: str = "") -> dict:
63
  """Plan a route and return everything the custom frontend renders."""
64
  try:
65
  profile_obj = json.loads(profile) if profile else {}
@@ -70,6 +70,7 @@ def plan(start: str, dest: str, mode: str = "walk", budget: float = 0.5,
70
  start_query=start, dest_query=dest, mode=mode, budget=budget, vibe=vibe,
71
  adventurousness=adventurousness, prefer_green=prefer_green,
72
  prefer_quiet=prefer_quiet, profile=profile_obj, n_alternatives=N_ALTERNATIVES,
 
73
  )
74
 
75
  if result.error:
@@ -160,6 +161,18 @@ def warmup() -> None:
160
  print("[warmup] routing graph + POIs ready", flush=True)
161
  except Exception as exc: # noqa: BLE001
162
  print(f"[warmup] graph FAILED: {exc}", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
163
  try:
164
  from discoverroute.interpret import embed
165
  embed.vibe_to_affinity("quiet green wander")
 
59
  def plan(start: str, dest: str, mode: str = "walk", budget: float = 0.5,
60
  vibe: str = "", adventurousness: float = 0.3,
61
  prefer_green: float = 0.0, prefer_quiet: float = 0.0,
62
+ profile: str = "", city: str = "") -> dict:
63
  """Plan a route and return everything the custom frontend renders."""
64
  try:
65
  profile_obj = json.loads(profile) if profile else {}
 
70
  start_query=start, dest_query=dest, mode=mode, budget=budget, vibe=vibe,
71
  adventurousness=adventurousness, prefer_green=prefer_green,
72
  prefer_quiet=prefer_quiet, profile=profile_obj, n_alternatives=N_ALTERNATIVES,
73
+ city=city,
74
  )
75
 
76
  if result.error:
 
161
  print("[warmup] routing graph + POIs ready", flush=True)
162
  except Exception as exc: # noqa: BLE001
163
  print(f"[warmup] graph FAILED: {exc}", flush=True)
164
+
165
+ # Pre-warm the secondary cities: pull each from the HF dataset (if not already
166
+ # local) and load it into memory NOW, at boot, so the first user to pick a city
167
+ # waits 0 s — and request-time stays fully offline (files are local by then).
168
+ from discoverroute import config
169
+ from discoverroute.routing import area as area_mod
170
+ for slug in config.PREWARM_CITIES:
171
+ try:
172
+ area_mod._city_area(slug) # downloads if needed + loads + caches
173
+ print(f"[warmup] city ready: {slug}", flush=True)
174
+ except Exception as exc: # noqa: BLE001 - one bad city must not block boot
175
+ print(f"[warmup] city {slug} skipped: {exc}", flush=True)
176
  try:
177
  from discoverroute.interpret import embed
178
  embed.vibe_to_affinity("quiet green wander")
data/cities/cities_manifest.json CHANGED
@@ -52,5 +52,95 @@
52
  "build_date": "2026-06-13",
53
  "source": "OpenStreetMap",
54
  "license": "ODbL \u2014 \u00a9 OpenStreetMap contributors"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
  }
 
52
  "build_date": "2026-06-13",
53
  "source": "OpenStreetMap",
54
  "license": "ODbL \u2014 \u00a9 OpenStreetMap contributors"
55
+ },
56
+ "sanfrancisco": {
57
+ "label": "San Francisco",
58
+ "center": [
59
+ 37.788,
60
+ -122.4075
61
+ ],
62
+ "bbox": [
63
+ -122.44387424116717,
64
+ 37.75905120318436,
65
+ -122.37112575883283,
66
+ 37.81694879681563
67
+ ],
68
+ "tz": "America/Los_Angeles",
69
+ "poi_count": 3802,
70
+ "build_date": "2026-06-14",
71
+ "source": "OpenStreetMap",
72
+ "license": "ODbL \u2014 \u00a9 OpenStreetMap contributors"
73
+ },
74
+ "tokyo": {
75
+ "label": "Tokyo",
76
+ "center": [
77
+ 35.6762,
78
+ 139.7653
79
+ ],
80
+ "bbox": [
81
+ 139.72991277516684,
82
+ 35.64725120318437,
83
+ 139.80068722483315,
84
+ 35.705148796815635
85
+ ],
86
+ "tz": "Asia/Tokyo",
87
+ "poi_count": 10051,
88
+ "build_date": "2026-06-14",
89
+ "source": "OpenStreetMap",
90
+ "license": "ODbL \u2014 \u00a9 OpenStreetMap contributors"
91
+ },
92
+ "mumbai": {
93
+ "label": "Mumbai",
94
+ "center": [
95
+ 18.922,
96
+ 72.8347
97
+ ],
98
+ "bbox": [
99
+ 72.80431189708523,
100
+ 18.893051203184367,
101
+ 72.86508810291477,
102
+ 18.950948796815634
103
+ ],
104
+ "tz": "Asia/Kolkata",
105
+ "poi_count": 413,
106
+ "build_date": "2026-06-14",
107
+ "source": "OpenStreetMap",
108
+ "license": "ODbL \u2014 \u00a9 OpenStreetMap contributors"
109
+ },
110
+ "shanghai": {
111
+ "label": "Shanghai",
112
+ "center": [
113
+ 31.234,
114
+ 121.481
115
+ ],
116
+ "bbox": [
117
+ 121.4473812266571,
118
+ 31.205051203184368,
119
+ 121.51461877334289,
120
+ 31.262948796815635
121
+ ],
122
+ "tz": "Asia/Shanghai",
123
+ "poi_count": 2071,
124
+ "build_date": "2026-06-14",
125
+ "source": "OpenStreetMap",
126
+ "license": "ODbL \u2014 \u00a9 OpenStreetMap contributors"
127
+ },
128
+ "berlin": {
129
+ "label": "Berlin",
130
+ "center": [
131
+ 52.517,
132
+ 13.3889
133
+ ],
134
+ "bbox": [
135
+ 13.341661332878164,
136
+ 52.48805120318437,
137
+ 13.436138667121835,
138
+ 52.54594879681564
139
+ ],
140
+ "tz": "Europe/Berlin",
141
+ "poi_count": 10810,
142
+ "build_date": "2026-06-14",
143
+ "source": "OpenStreetMap",
144
+ "license": "ODbL \u2014 \u00a9 OpenStreetMap contributors"
145
  }
146
  }
src/discoverroute/config.py CHANGED
@@ -87,11 +87,34 @@ def corridor_halfwidth_m(budget: float) -> float:
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"
 
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
+ "sanfrancisco": {"label": "San Francisco", "center": (37.7880, -122.4075), "radius_m": 3200, "tz": "America/Los_Angeles"},
94
+ "tokyo": {"label": "Tokyo", "center": (35.6762, 139.7653), "radius_m": 3200, "tz": "Asia/Tokyo"},
95
+ "mumbai": {"label": "Mumbai", "center": (18.9220, 72.8347), "radius_m": 3200, "tz": "Asia/Kolkata"},
96
+ "shanghai": {"label": "Shanghai", "center": (31.2340, 121.4810), "radius_m": 3200, "tz": "Asia/Shanghai"},
97
+ "berlin": {"label": "Berlin", "center": (52.5170, 13.3889), "radius_m": 3200, "tz": "Europe/Berlin"},
98
  }
99
 
100
+ # Secondary city cores are hosted as a HF *dataset* (just a folder of files), not
101
+ # committed into this repo — so the Space/app image stays lean and scales past a
102
+ # handful of cities. Each `<slug>_walk.graphml` + `<slug>_pois.parquet` is pulled
103
+ # on demand (public repo => no token needed) and cached into CITY_DATA_DIR, after
104
+ # which the normal on-disk path (city_graph_path/city_pois_path) just works.
105
+ CITIES_DATASET_REPO = os.environ.get(
106
+ "DISCOVERROUTE_CITIES_REPO", "build-small-hackathon/discoverroute-cities"
107
+ )
108
+ # Cities to download + load into memory at boot ("pre-warm") so the first user to
109
+ # pick one waits 0 s. Default: every configured city. Boot cost is paid once,
110
+ # before any request, and keeps request-time fully offline (files already local).
111
+ # Override with a comma-separated slug list, e.g. "london,newyork,tokyo".
112
+ PREWARM_CITIES = [
113
+ s.strip() for s in os.environ.get(
114
+ "DISCOVERROUTE_PREWARM_CITIES", ",".join(CITIES)
115
+ ).split(",") if s.strip() in CITIES
116
+ ]
117
+
118
 
119
  def city_graph_path(slug: str) -> Path:
120
  return CITY_DATA_DIR / f"{slug}_walk.graphml"
src/discoverroute/pipeline.py CHANGED
@@ -56,11 +56,12 @@ def plan_route(
56
  prefer_quiet: float = 0.0,
57
  profile: dict | None = None,
58
  n_alternatives: int = 1,
 
59
  ) -> PlanResult:
60
  """Plan a route + log one plan-level trace row. Never raises for user errors."""
61
  result = _plan_route_impl(
62
  start_query, dest_query, mode, budget, vibe, adventurousness,
63
- prefer_green, prefer_quiet, profile, n_alternatives,
64
  )
65
  try: # one Open-Trace / Field-Notes row per call; never break a route
66
  from discoverroute.interpret.affinity import source_of
@@ -93,6 +94,7 @@ def _plan_route_impl(
93
  prefer_quiet: float = 0.0,
94
  profile: dict | None = None,
95
  n_alternatives: int = 1,
 
96
  ) -> PlanResult:
97
  """Plan a route. Returns a PlanResult; never raises for user-facing errors."""
98
  # Validate & clamp inputs up front (an unknown mode would silently route at
@@ -110,7 +112,7 @@ def _plan_route_impl(
110
  # Pick the area: Paris is pre-baked/instant; any other city is fetched
111
  # live from OSM (only the box spanning the two endpoints).
112
  area = area_mod.resolve_area(
113
- start, end, label=_city_label(start_query, dest_query))
114
  graph = area.graph
115
  plain = g.plain_route(graph, *start, *end, mode=mode)
116
  except RouteError as exc:
 
56
  prefer_quiet: float = 0.0,
57
  profile: dict | None = None,
58
  n_alternatives: int = 1,
59
+ city: str = "",
60
  ) -> PlanResult:
61
  """Plan a route + log one plan-level trace row. Never raises for user errors."""
62
  result = _plan_route_impl(
63
  start_query, dest_query, mode, budget, vibe, adventurousness,
64
+ prefer_green, prefer_quiet, profile, n_alternatives, city,
65
  )
66
  try: # one Open-Trace / Field-Notes row per call; never break a route
67
  from discoverroute.interpret.affinity import source_of
 
94
  prefer_quiet: float = 0.0,
95
  profile: dict | None = None,
96
  n_alternatives: int = 1,
97
+ city: str = "",
98
  ) -> PlanResult:
99
  """Plan a route. Returns a PlanResult; never raises for user-facing errors."""
100
  # Validate & clamp inputs up front (an unknown mode would silently route at
 
112
  # Pick the area: Paris is pre-baked/instant; any other city is fetched
113
  # live from OSM (only the box spanning the two endpoints).
114
  area = area_mod.resolve_area(
115
+ start, end, label=_city_label(start_query, dest_query), city=city)
116
  graph = area.graph
117
  plain = g.plain_route(graph, *start, *end, mode=mode)
118
  except RouteError as exc:
src/discoverroute/routing/area.py CHANGED
@@ -173,17 +173,41 @@ def _in_bbox(pt, bbox) -> bool:
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"])
@@ -244,15 +268,26 @@ def _build_ondemand(bbox, label: str) -> Area:
244
 
245
 
246
  def resolve_area(start: tuple[float, float], end: tuple[float, float],
247
- label: str = "") -> Area:
248
  """Pick the right area for an A→B request.
249
 
250
- Both endpoints inside Paris => the instant pre-baked area. Otherwise fetch a
251
- just-big-enough box around the two points live from OSM. Endpoints too far
252
- apart for an on-demand box are rejected with a friendly RouteError.
 
 
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()
 
173
 
174
 
175
  def available_cities() -> list[str]:
176
+ """Slugs of pre-baked cities whose data is present on disk (incl. pulled)."""
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
+ def ensure_city_files(slug: str) -> None:
182
+ """Make a city's graph + POI parquet exist locally, pulling from the HF
183
+ dataset if absent. Downloads into CITY_DATA_DIR so every existing on-disk
184
+ code path (city_graph_path/available_cities) then works unchanged. The repo
185
+ is public, so no token is needed; the file is cached after the first pull.
186
+ """
187
+ targets = [config.city_graph_path(slug), config.city_pois_path(slug)]
188
+ if all(p.exists() for p in targets):
189
+ return
190
+ from huggingface_hub import hf_hub_download
191
+ config.CITY_DATA_DIR.mkdir(parents=True, exist_ok=True)
192
+ for path in targets:
193
+ if path.exists():
194
+ continue
195
+ t0 = time.time()
196
+ hf_hub_download(
197
+ repo_id=config.CITIES_DATASET_REPO, repo_type="dataset",
198
+ filename=path.name, local_dir=str(config.CITY_DATA_DIR),
199
+ )
200
+ logger.info("pulled %s from %s in %.1fs",
201
+ path.name, config.CITIES_DATASET_REPO, time.time() - t0)
202
+
203
+
204
  @functools.lru_cache(maxsize=8)
205
  def _city_area(slug: str) -> Area:
206
+ """Load a pre-baked city as an offline Area, pulling its files if needed."""
207
  from discoverroute.routing import graph as g
208
 
209
  spec = config.CITIES[slug]
210
+ ensure_city_files(slug)
211
  graph = ox.load_graphml(config.city_graph_path(slug))
212
  df = pd.read_parquet(config.city_pois_path(slug))
213
  origin = tuple(spec["center"])
 
268
 
269
 
270
  def resolve_area(start: tuple[float, float], end: tuple[float, float],
271
+ label: str = "", city: str = "") -> Area:
272
  """Pick the right area for an A→B request.
273
 
274
+ An explicit ``city`` slug (from the UI picker) wins outright the pre-baked
275
+ core is loaded (pulled from the dataset if needed). Otherwise both endpoints
276
+ inside Paris => the pre-baked Paris area; both inside one cached city core =>
277
+ that core; anything else is fetched just-big-enough live from OSM. Endpoints
278
+ too far apart for an on-demand box are rejected with a friendly RouteError.
279
  """
280
  from discoverroute.routing.graph import RouteError
281
 
282
+ # 0) Explicit city pick (the UI dropdown) — load that core, full stop.
283
+ slug = (city or "").strip().lower()
284
+ if slug:
285
+ if slug == "paris":
286
+ return _paris_area()
287
+ if slug in config.CITIES:
288
+ return _city_area(slug)
289
+ # Unknown slug => ignore and fall through to inference (never crash a route).
290
+
291
  # 1) Paris — full pre-baked city (instant, offline).
292
  if config.in_paris(*start) and config.in_paris(*end):
293
  return _paris_area()
src/discoverroute/ui/shell.py CHANGED
@@ -82,6 +82,7 @@ body{ font-family:'DM Sans',ui-sans-serif,system-ui,sans-serif; color:var(--dr-i
82
  /* a leading pin/flag glyph for the start & destination fields */
83
  .field-wrap{ position:relative; }
84
  .field-wrap > input[type=text]{ padding-left:34px; }
 
85
  .field-wrap::before{ content:attr(data-glyph); position:absolute; left:12px; top:50%;
86
  transform:translateY(-50%); font-size:14px; pointer-events:none; z-index:2; line-height:1; }
87
 
@@ -314,6 +315,21 @@ def _vibe_chips() -> str:
314
  return f'<div class="vibe-chips" id="dr-vibe-chips">{chips}</div>'
315
 
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  def _left_panel() -> str:
318
  return f"""
319
  <aside class="left-panel" id="left-panel">
@@ -337,6 +353,15 @@ def _left_panel() -> str:
337
  {_vibe_chips()}
338
  </div>
339
 
 
 
 
 
 
 
 
 
 
340
  <div class="dr-control combo">
341
  <label class="dr-label" for="dr-start">Start</label>
342
  <div class="field-wrap" data-glyph="📍">
@@ -344,8 +369,7 @@ def _left_panel() -> str:
344
  value="Place de la République, Paris">
345
  </div>
346
  <div class="combo-list" id="dr-start-list"></div>
347
- <div class="dr-help">📍 Paris · London · Barcelona · New York — try a landmark
348
- (e.g. "British Museum, London"), not a street address.</div>
349
  </div>
350
 
351
  <div class="dr-control combo">
@@ -781,6 +805,7 @@ async function plan() {
781
  adventurousness: +$("dr-adv-i").value,
782
  prefer_green: +$("dr-green-i").value, prefer_quiet: +$("dr-quiet-i").value,
783
  profile: JSON.stringify(readProfile()),
 
784
  });
785
  renderResult((r.data && r.data[0]) || {});
786
  } catch (e) {
 
82
  /* a leading pin/flag glyph for the start & destination fields */
83
  .field-wrap{ position:relative; }
84
  .field-wrap > input[type=text]{ padding-left:34px; }
85
+ .field-wrap > select.dr-field{ padding-left:34px; cursor:pointer; }
86
  .field-wrap::before{ content:attr(data-glyph); position:absolute; left:12px; top:50%;
87
  transform:translateY(-50%); font-size:14px; pointer-events:none; z-index:2; line-height:1; }
88
 
 
315
  return f'<div class="vibe-chips" id="dr-vibe-chips">{chips}</div>'
316
 
317
 
318
+ # (slug, label) for the city picker: Paris (the always-on default) then every
319
+ # configured core. The chosen slug is sent to /plan and wins area resolution.
320
+ _CITY_CHOICES = [("paris", "Paris")] + [
321
+ (slug, spec["label"]) for slug, spec in config.CITIES.items()
322
+ ]
323
+
324
+
325
+ def _city_options() -> str:
326
+ opts = "".join(
327
+ f'<option value="{slug}"{" selected" if slug == "paris" else ""}>{label}</option>'
328
+ for slug, label in _CITY_CHOICES
329
+ )
330
+ return opts
331
+
332
+
333
  def _left_panel() -> str:
334
  return f"""
335
  <aside class="left-panel" id="left-panel">
 
353
  {_vibe_chips()}
354
  </div>
355
 
356
+ <div class="dr-control">
357
+ <label class="dr-label" for="dr-city">City</label>
358
+ <div class="field-wrap" data-glyph="🌍">
359
+ <select id="dr-city" class="dr-field">{_city_options()}</select>
360
+ </div>
361
+ <div class="dr-help">Pick the city to explore — Start &amp; Destination should be
362
+ places within it.</div>
363
+ </div>
364
+
365
  <div class="dr-control combo">
366
  <label class="dr-label" for="dr-start">Start</label>
367
  <div class="field-wrap" data-glyph="📍">
 
369
  value="Place de la République, Paris">
370
  </div>
371
  <div class="combo-list" id="dr-start-list"></div>
372
+ <div class="dr-help">Try a landmark (e.g. "British Museum"), not a street address.</div>
 
373
  </div>
374
 
375
  <div class="dr-control combo">
 
805
  adventurousness: +$("dr-adv-i").value,
806
  prefer_green: +$("dr-green-i").value, prefer_quiet: +$("dr-quiet-i").value,
807
  profile: JSON.stringify(readProfile()),
808
+ city: $("dr-city").value,
809
  });
810
  renderResult((r.data && r.data[0]) || {});
811
  } catch (e) {