Spaces:
Sleeping
Sleeping
File size: 10,474 Bytes
f00d669 2c5a5d1 10cf611 f00d669 2c5a5d1 f00d669 6881784 1add89c 6881784 1add89c 6881784 1a502dd f00d669 10cf611 f00d669 a4c45c1 c16ccf9 f00d669 6c11e60 f00d669 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | """Central configuration: paths, Paris bounds, travel constants, defaults.
Everything tunable lives here so behaviour is inspectable, not scattered.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
# --- Paths -------------------------------------------------------------------
PKG_ROOT = Path(__file__).resolve().parent
PROJECT_ROOT = PKG_ROOT.parent.parent
CACHE_DIR = PROJECT_ROOT / "cache"
DATA_DIR = PROJECT_ROOT / "data"
CACHE_DIR.mkdir(exist_ok=True)
DATA_DIR.mkdir(exist_ok=True)
# Cached offline artifacts (committed for the Space; built by data/build_graph.py).
GRAPH_WALK_PATH = DATA_DIR / "paris_walk.graphml"
POIS_PATH = DATA_DIR / "paris_pois.parquet"
# --- Data provenance / freshness --------------------------------------------
# The app runs on a static OSM snapshot; this manifest (written by build_pois.py)
# records when it was built so the UI can show an honest "as of <date>" line.
DATA_MANIFEST_PATH = DATA_DIR / "build_manifest.json"
def _load_manifest() -> dict:
try:
return json.loads(DATA_MANIFEST_PATH.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001 - missing/invalid manifest is non-fatal
return {}
DATA_MANIFEST = _load_manifest()
DATA_BUILD_DATE = DATA_MANIFEST.get("build_date", "") # ISO 'YYYY-MM-DD' or ''
# --- Geographic scope --------------------------------------------------------
# Paris proper (the 20 arrondissements). Used to bound the OSM download and to
# reject out-of-area requests.
PARIS_PLACE = "Paris, Île-de-France, France"
# Bounding box (south, west, north, east) — a coarse rejection gate for inputs.
# Slightly padded beyond the périphérique.
PARIS_BBOX = (48.8156, 2.2241, 48.9022, 2.4699) # (lat_min, lon_min, lat_max, lon_max)
PARIS_CENTER = (48.8566, 2.3522)
# --- Offline mode --------------------------------------------------------------
# When this env var is "1", geocoding never falls back to Nominatim (network):
# only the local POI-name index and 'lat, lon' inputs are accepted.
OFFLINE_ENV_VAR = "DISCOVERROUTE_OFFLINE"
# --- Travel model ------------------------------------------------------------
# Used to convert edge length (metres) into travel time (seconds).
TRAVEL_SPEEDS_KMH = {
"walk": 4.8,
"bike": 15.0,
}
DEFAULT_MODE = "walk"
# --- Detour budget defaults --------------------------------------------------
# budget is a fraction of direct-route time the user is willing to add.
# 0.0 => route equals the plain route. 1.0 => allow up to 2x the direct time.
DEFAULT_BUDGET = 0.5
MAX_BUDGET = 2.0
# --- Corridor (candidate gathering) ------------------------------------------
# Half-width of the search corridor around the direct route, in metres. Grows
# with the detour budget: more budget => look further off the direct line.
# (Heuristic for spec open-question §12; tuned in Brick 2.)
CORRIDOR_BASE_M = 250.0
CORRIDOR_BUDGET_M = 500.0 # added per unit of budget
MAX_CANDIDATES = 600 # corridor cap (keep nearest-to-route; scoring is cheap)
SOLVER_CANDIDATES = 40 # shortlist (top-scoring) for the real travel matrix
MAX_DETOUR_STOPS = 12 # max POIs the orienteering route may include
def corridor_halfwidth_m(budget: float) -> float:
return CORRIDOR_BASE_M + CORRIDOR_BUDGET_M * max(0.0, budget)
# --- Pre-baked extra cities (offline, keeps "Off the Grid") ------------------
# Paris ships full-city (above). These additional cities are baked as a bounded
# walkable core (centre + radius) by data/build_city.py and committed, so they
# route fully offline — no live OSM at request time. Add a city here, run
# `python -m discoverroute.data.build_city <slug>`, commit the data.
CITY_DATA_DIR = DATA_DIR / "cities"
CITIES_MANIFEST_PATH = CITY_DATA_DIR / "cities_manifest.json"
CITIES = {
"london": {"label": "London", "center": (51.5118, -0.1230), "radius_m": 3200, "tz": "Europe/London"},
"barcelona": {"label": "Barcelona", "center": (41.3870, 2.1700), "radius_m": 3200, "tz": "Europe/Madrid"},
"newyork": {"label": "New York", "center": (40.7560, -73.9845), "radius_m": 3200, "tz": "America/New_York"},
"sanfrancisco": {"label": "San Francisco", "center": (37.7880, -122.4075), "radius_m": 3200, "tz": "America/Los_Angeles"},
"tokyo": {"label": "Tokyo", "center": (35.6762, 139.7653), "radius_m": 3200, "tz": "Asia/Tokyo"},
"mumbai": {"label": "Mumbai", "center": (18.9220, 72.8347), "radius_m": 3200, "tz": "Asia/Kolkata"},
"shanghai": {"label": "Shanghai", "center": (31.2340, 121.4810), "radius_m": 3200, "tz": "Asia/Shanghai"},
"berlin": {"label": "Berlin", "center": (52.5170, 13.3889), "radius_m": 3200, "tz": "Europe/Berlin"},
}
# Secondary city cores are hosted as a HF *dataset* (just a folder of files), not
# committed into this repo — so the Space/app image stays lean and scales past a
# handful of cities. Each `<slug>_walk.graphml` + `<slug>_pois.parquet` is pulled
# on demand (public repo => no token needed) and cached into CITY_DATA_DIR, after
# which the normal on-disk path (city_graph_path/city_pois_path) just works.
CITIES_DATASET_REPO = os.environ.get(
"DISCOVERROUTE_CITIES_REPO", "build-small-hackathon/discoverroute-cities"
)
# Cities to download + load into memory at boot ("pre-warm") so the first user to
# pick one waits 0 s. Default: every configured city. Boot cost is paid once,
# before any request, and keeps request-time fully offline (files already local).
# Override with a comma-separated slug list, e.g. "london,newyork,tokyo".
PREWARM_CITIES = [
s.strip() for s in os.environ.get(
"DISCOVERROUTE_PREWARM_CITIES", ",".join(CITIES)
).split(",") if s.strip() in CITIES
]
def city_graph_path(slug: str) -> Path:
return CITY_DATA_DIR / f"{slug}_walk.graphml"
def city_pois_path(slug: str) -> Path:
return CITY_DATA_DIR / f"{slug}_pois.parquet"
# --- Other cities (on-demand) ------------------------------------------------
# Paris ships pre-baked (instant, offline). Any other city is fetched live from
# OpenStreetMap at request time: we download only the bounding box spanning the
# two endpoints (plus a margin), not the whole metropolis — turning a multi-GB
# city download into a few-MB box that builds in seconds.
ON_DEMAND_MARGIN_M = 900.0 # padding added around the A→B bbox (corridor room)
# Reject on-demand requests whose endpoints are absurdly far apart: a giant bbox
# would overrun the public OSM servers and the worker's memory. Paris (cached)
# is exempt from this cap.
MAX_ENDPOINT_DISTANCE_M = 25_000.0
AREA_CACHE_SIZE = 4 # how many on-demand city areas to keep in memory
# Time budget for a single on-demand OSM fetch (graph or one feature key).
ON_DEMAND_FETCH_TIMEOUT = 60
# --- Models (Brick 4 / 6) ----------------------------------------------------
# Small text encoder for vibe -> category affinity (CPU-friendly, offline).
EMBED_MODEL = "BAAI/bge-small-en-v1.5"
# bge-v1.5 retrieval instruction, prepended to the query (the vibe) only.
EMBED_QUERY_INSTRUCTION = "Represent this sentence for searching relevant passages: "
# Generative model for vibe→weights extraction + narration. A 1B in-Space model
# (Tiny Titan ≤4B; weights pulled from the Hub and run on ZeroGPU). Standard
# LlamaForCausalLM architecture — no custom kernels.
LLM_MODEL = "openbmb/MiniCPM5-1B"
# --- Trace logging (Open Trace) ----------------------------------------------
# Every inference call logs a row locally to logs/traces.jsonl; when a write
# token is present, rows are ALSO pushed (async, non-blocking) to TRACE_REPO.
# No token => local-only (graceful stub; nothing blocks).
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
TRACE_REPO = os.environ.get(
"DISCOVERROUTE_TRACE_REPO", "build-small-hackathon/discoverroute-traces"
)
# Affinity floor: the least-matching category still keeps this much interest so
# the route can explore a little; the best-matching category maps to 1.0.
AFFINITY_FLOOR = 0.15
# Only the top-N matched categories drive a vibe route; the rest are zeroed so
# the long tail (ranks N+1..17) can't silently backfill stops with off-vibe
# filler (the adversarial review found the same statues/churches bleeding into
# 10+ unrelated routes via the floor). Sparse routes then end honestly short.
TOP_AFFINITY_CATEGORIES = 6
# A vibe whose BEST raw cosine to any category gloss is below this is a weak/
# out-of-vocabulary match (measured: real vibes peak 0.66-0.85; "brutalist
# architecture" 0.51, nonsense ~0.49). We still route, but the narration says so
# honestly instead of claiming "a match for your vibe".
WEAK_MATCH_SIMILARITY = 0.55
# For "hidden gems"-style vibes, exclude well-documented (famous) POIs: a place
# this richly tagged isn't off the beaten path. Confidence is the tag-richness
# proxy; Notre Dame etc. sit at ~1.0. (Only applied when a discovery cue fires.)
FAMOUS_CONFIDENCE = 0.85
# Below this cosine-similarity span across categories, a vibe is treated as
# off-domain/neutral rather than amplified into false preferences. Measured
# (bge-small, 16-vibe battery): gibberish "asdfqwer" spans 0.081; the LOWEST
# real vibe ("romantic evening stroll") spans 0.143; "take me somewhere
# beautiful" 0.152, "brutalist architecture" 0.148. So 0.18 (the prior value)
# wrongly neutralised genuine evocative vibes — collapsing them to an identical
# generic grab-bag. 0.10 sits just above gibberish, rescuing real vibes while
# still catching nonsense. (Abstract vibes have NO clean separation from
# nonsense by span alone — "quantum physics lecture" also spans 0.143 — but a
# weakly-themed route for them beats a deceptive default route.)
MIN_AFFINITY_SPAN = 0.10
# --- Adventurousness ---------------------------------------------------------
# 0.0 => only high-confidence, well-documented POIs.
# 1.0 => admit low-confidence / under-documented POIs (serendipity).
DEFAULT_ADVENTUROUSNESS = 0.3
def speed_ms(mode: str) -> float:
"""Travel speed in metres/second for the given mode."""
kmh = TRAVEL_SPEEDS_KMH.get(mode, TRAVEL_SPEEDS_KMH[DEFAULT_MODE])
return kmh * 1000.0 / 3600.0
def in_paris(lat: float, lon: float) -> bool:
"""True if a point falls inside the padded Paris bounding box."""
lat_min, lon_min, lat_max, lon_max = PARIS_BBOX
return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max
|