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

fixed the narration+traces

Browse files
src/discoverroute/narrate/gazetteer.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-city geographic context the narrator is allowed to name.
2
+
3
+ The zero-hallucination gate (:mod:`grounding`) rejects any capitalized place
4
+ name that isn't a selected POI or an endpoint. That guarantee is sound for
5
+ *venues* (you must not be told to visit an invented café), but it also rejected
6
+ every neighbourhood, river and quarter a real city guide naturally mentions —
7
+ so any vivid LLM narration crossing recognizable geography got thrown away and
8
+ the flat template shipped instead.
9
+
10
+ This module supplies a curated, real allowlist of district / quarter / river /
11
+ landmark names per pre-baked city. These are *context* the narrator may
12
+ reference ("as you cross the Marais", "along the Seine") — not stops. They are
13
+ real OSM-scale places, so naming them is grounded, not invented. The generic
14
+ era/architecture adjectives ("Roman", "Gothic") live in :data:`grounding._COMMON`
15
+ instead, since they are city-independent.
16
+
17
+ On-demand (arbitrary) cities have no gazetteer entry: they get only the POIs +
18
+ endpoints, exactly as before, so an un-curated city still fails closed to the
19
+ template rather than risking an ungrounded neighbourhood name.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ # Keyed by ``Area.key`` (see routing/area.py): "paris", "london", "barcelona",
24
+ # "newyork". On-demand areas use a bbox-hash key absent from this map -> no extra
25
+ # context (safe default). Phrases may be multi-word; the gate substring-matches a
26
+ # mention's distinctive core, so "the Latin Quarter" grounds against "Latin
27
+ # Quarter" while "Eiffel Tower" (Eiffel not listed) still correctly fails.
28
+ CITY_GAZETTEER: dict[str, list[str]] = {
29
+ "paris": [
30
+ # river / islands / banks
31
+ "Seine", "Rive Gauche", "Rive Droite", "Left Bank", "Right Bank",
32
+ "Île de la Cité", "Île Saint-Louis", "Canal Saint-Martin",
33
+ # quarters / neighbourhoods
34
+ "Le Marais", "Marais", "Quartier Latin", "Latin Quarter",
35
+ "Saint-Germain", "Saint-Germain-des-Prés", "Montmartre", "Montparnasse",
36
+ "Belleville", "Bastille", "Pigalle", "Oberkampf", "Le Sentier",
37
+ # well-known landmarks/areas a guide name-checks in passing
38
+ "Sorbonne", "Panthéon", "Champs-Élysées", "Tuileries", "Louvre",
39
+ "Notre-Dame", "Île-de-France",
40
+ ],
41
+ "london": [
42
+ "Thames", "South Bank", "Southbank", "North Bank", "the City",
43
+ "Bloomsbury", "Soho", "Covent Garden", "Mayfair", "Westminster",
44
+ "Southwark", "Lambeth", "Vauxhall", "Holborn", "Clerkenwell",
45
+ "Fitzrovia", "Marylebone", "the West End", "the East End", "Shoreditch",
46
+ "Bankside", "Embankment", "Strand", "Piccadilly",
47
+ ],
48
+ "barcelona": [
49
+ "Mediterranean", "Barri Gòtic", "Gothic Quarter", "El Raval", "El Born",
50
+ "La Ribera", "Eixample", "La Rambla", "Las Ramblas", "Barceloneta",
51
+ "Gràcia", "Montjuïc", "Poble Sec", "Ciutat Vella", "Port Vell",
52
+ "Passeig de Gràcia",
53
+ ],
54
+ "newyork": [
55
+ "Manhattan", "Midtown", "Downtown", "Uptown", "SoHo", "NoHo", "Tribeca",
56
+ "Greenwich Village", "the Village", "East Village", "West Village",
57
+ "Chelsea", "the Lower East Side", "the Upper West Side",
58
+ "the Upper East Side", "Times Square", "Central Park", "Hudson",
59
+ "East River", "Broadway", "Fifth Avenue", "the Flatiron",
60
+ ],
61
+ }
62
+
63
+
64
+ def geo_terms(area_key: str, label: str = "") -> list[str]:
65
+ """Allowed geographic-context names for an area (empty for un-curated ones)."""
66
+ terms = list(CITY_GAZETTEER.get((area_key or "").lower(), ()))
67
+ if label and label.strip():
68
+ terms.append(label.strip()) # the city's own name ("London", "Paris")
69
+ return terms
src/discoverroute/narrate/grounding.py CHANGED
@@ -45,6 +45,18 @@ _COMMON = {
45
  "weaving", "dip", "duck", "swing", "loop", "breathe", "slow", "set", "make",
46
  "expect", "stay", "keep", "give", "spend", "spent", "thread", "threaded",
47
  "minute", "hour", "hours", "place", "places", "stops", "option", "options",
 
 
 
 
 
 
 
 
 
 
 
 
48
  }
49
 
50
  _TOKEN_RE = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ0-9'’.\-]*")
@@ -58,11 +70,18 @@ def _norm(s: str) -> str:
58
  return re.sub(r"\s+", " ", s).strip()
59
 
60
 
61
- def allowed_names(pois, start_label: str = "", end_label: str = "") -> list[str]:
 
62
  names = [p.name for p in pois if getattr(p, "name", None)]
63
  for lbl in (start_label, end_label):
64
  if lbl and lbl.strip():
65
  names.append(lbl.strip())
 
 
 
 
 
 
66
  names.append("Paris")
67
  return names
68
 
@@ -139,9 +158,15 @@ def _is_grounded_mention(mention: str, allowed_norm: list[str]) -> bool:
139
  return any(core in a for a in allowed_norm if a)
140
 
141
 
142
- def verify_grounded(text: str, pois, start_label="", end_label="") -> tuple[bool, list[str]]:
143
- """(ok, offenders). ok=True iff every mention maps to an allowed name."""
144
- allowed_norm = [_norm(a) for a in allowed_names(pois, start_label, end_label)]
 
 
 
 
 
 
145
  offenders = [
146
  mention for mention in extract_mentions(text)
147
  if not _is_grounded_mention(mention, allowed_norm)
 
45
  "weaving", "dip", "duck", "swing", "loop", "breathe", "slow", "set", "make",
46
  "expect", "stay", "keep", "give", "spend", "spent", "thread", "threaded",
47
  "minute", "hour", "hours", "place", "places", "stops", "option", "options",
48
+ # city-independent descriptive words a guide uses: era/architecture styles,
49
+ # nationalities, and generic geographic nouns. Never standalone venue names,
50
+ # so admitting them lets prose breathe without opening a hallucination vector
51
+ # (a distinctive token like "Eiffel" still has to match an allowed name).
52
+ "roman", "gothic", "medieval", "renaissance", "baroque", "romanesque",
53
+ "neoclassical", "art", "deco", "nouveau", "modern", "ancient", "classical",
54
+ "victorian", "georgian", "haussmann", "french", "parisian", "english",
55
+ "british", "spanish", "catalan", "american", "european",
56
+ "river", "riverside", "quarter", "district", "neighbourhood", "neighborhood",
57
+ "bank", "island", "hill", "boulevard", "avenue", "street", "lane", "square",
58
+ "park", "garden", "gardens", "bridge", "quay", "embankment", "canal",
59
+ "market", "quartier", "rue", "pont", "jardin", "plaza", "passeig",
60
  }
61
 
62
  _TOKEN_RE = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ0-9'’.\-]*")
 
70
  return re.sub(r"\s+", " ", s).strip()
71
 
72
 
73
+ def allowed_names(pois, start_label: str = "", end_label: str = "",
74
+ extra_allowed=None) -> list[str]:
75
  names = [p.name for p in pois if getattr(p, "name", None)]
76
  for lbl in (start_label, end_label):
77
  if lbl and lbl.strip():
78
  names.append(lbl.strip())
79
+ # Geographic context the narrator may name (districts, river, landmarks) —
80
+ # supplied per city by narrate.gazetteer. Real OSM-scale places, not invented
81
+ # venues, so admitting them keeps the grounding guarantee while letting the
82
+ # LLM write like an actual city guide instead of falling back to the template.
83
+ if extra_allowed:
84
+ names.extend(a for a in extra_allowed if a and a.strip())
85
  names.append("Paris")
86
  return names
87
 
 
158
  return any(core in a for a in allowed_norm if a)
159
 
160
 
161
+ def verify_grounded(text: str, pois, start_label="", end_label="",
162
+ extra_allowed=None) -> tuple[bool, list[str]]:
163
+ """(ok, offenders). ok=True iff every mention maps to an allowed name.
164
+
165
+ ``extra_allowed`` is the per-city geographic gazetteer (districts, river,
166
+ landmarks) the narrator may reference beyond the selected POIs + endpoints.
167
+ """
168
+ allowed_norm = [_norm(a)
169
+ for a in allowed_names(pois, start_label, end_label, extra_allowed)]
170
  offenders = [
171
  mention for mention in extract_mentions(text)
172
  if not _is_grounded_mention(mention, allowed_norm)
src/discoverroute/narrate/llm.py CHANGED
@@ -12,9 +12,17 @@ import functools
12
 
13
  from discoverroute import config
14
 
 
 
 
 
 
 
 
 
15
  try:
16
  import spaces # ZeroGPU; effect-free off-Spaces
17
- _gpu = spaces.GPU(duration=120)
18
  except Exception: # noqa: BLE001 - not on a Space / package absent
19
  def _gpu(fn):
20
  return fn
@@ -41,13 +49,18 @@ def run_inference(messages: list[dict], max_new_tokens: int = 320,
41
  temperature: float = 0.7) -> str:
42
  """Run a chat completion. ``messages`` = [{"role","content"}, ...]."""
43
  tok, model = _load()
 
 
 
 
44
  text = tok.apply_chat_template(
45
- messages, tokenize=False, add_generation_prompt=True
46
  )
47
  inputs = tok([text], return_tensors="pt").to(model.device)
 
48
  out = model.generate(
49
  **inputs, max_new_tokens=max_new_tokens,
50
- temperature=temperature, top_p=0.8, top_k=20, do_sample=True,
51
  )
52
  gen = out[0][inputs.input_ids.shape[1]:]
53
  return tok.decode(gen, skip_special_tokens=True).strip()
 
12
 
13
  from discoverroute import config
14
 
15
+ # ZeroGPU reserves the requested ``duration`` seconds of quota *per call*, so a
16
+ # fat slice drains a day's allowance in a dozen requests (the live Space was
17
+ # erroring "180s requested vs. 170s left" then falling back to the template). A
18
+ # 1B model loading from cache + generating ≤480 tokens on an A10G finishes well
19
+ # inside 45s, so request that — ~3× more calls per day, with headroom over the
20
+ # first-call weight-load cost.
21
+ GPU_DURATION_S = 45
22
+
23
  try:
24
  import spaces # ZeroGPU; effect-free off-Spaces
25
+ _gpu = spaces.GPU(duration=GPU_DURATION_S)
26
  except Exception: # noqa: BLE001 - not on a Space / package absent
27
  def _gpu(fn):
28
  return fn
 
49
  temperature: float = 0.7) -> str:
50
  """Run a chat completion. ``messages`` = [{"role","content"}, ...]."""
51
  tok, model = _load()
52
+ # MiniCPM5 is a hybrid-reasoning model: with thinking on it emits a
53
+ # <think>...</think> block first, which would devour the JSON/narration token
54
+ # budget and leave nothing usable. We want the fast direct answer, so disable
55
+ # it explicitly (the kwarg is ignored harmlessly by templates that lack it).
56
  text = tok.apply_chat_template(
57
+ messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
58
  )
59
  inputs = tok([text], return_tensors="pt").to(model.device)
60
+ # MiniCPM5 "no-think" recommended sampling: temperature 0.7, top_p 0.95.
61
  out = model.generate(
62
  **inputs, max_new_tokens=max_new_tokens,
63
+ temperature=temperature, top_p=0.95, top_k=20, do_sample=True,
64
  )
65
  gen = out[0][inputs.input_ids.shape[1]:]
66
  return tok.decode(gen, skip_special_tokens=True).strip()
src/discoverroute/narrate/narrate.py CHANGED
@@ -135,8 +135,14 @@ def llm_available() -> bool:
135
 
136
 
137
  def narrate(plain, discovery, pois, vibe="", mode="walk", start_label="",
138
- end_label="", posture=None, weights=None, weak=False) -> tuple[str, bool]:
139
- """Return (markdown, used_llm). Output is guaranteed grounded."""
 
 
 
 
 
 
140
  template = template_narration(
141
  plain, discovery, pois, vibe, mode, start_label, end_label, posture, weights, weak
142
  )
@@ -151,8 +157,9 @@ def narrate(plain, discovery, pois, vibe="", mode="walk", start_label="",
151
  t0 = time.time()
152
  try:
153
  text = _llm_narration(plain, discovery, pois, vibe, mode,
154
- start_label, end_label, weights)
155
- ok, offenders = grounding.verify_grounded(text, pois, start_label, end_label)
 
156
  latency = int((time.time() - t0) * 1000)
157
  if ok and text.strip():
158
  trace.log_trace("narration", meta, {"text": text}, latency,
@@ -179,8 +186,14 @@ def _weights_summary(weights) -> str:
179
 
180
 
181
  def _llm_narration(plain, discovery, pois, vibe, mode, start_label, end_label,
182
- weights=None) -> str:
183
- """Generate narration with MiniCPM5-1B, constrained to the allowed names."""
 
 
 
 
 
 
184
  from discoverroute.narrate.llm import run_inference
185
 
186
  names = [taxonomy.display_label(p) for p in pois]
@@ -190,26 +203,36 @@ def _llm_narration(plain, discovery, pois, vibe, mode, start_label, end_label,
190
  extra = round(discovery.time_min - plain.time_min)
191
  total_min = round(discovery.time_min + getattr(discovery, "dwell_s", 0.0) / 60.0)
192
  weights_line = _weights_summary(weights)
 
 
193
 
194
  system = (
195
- "You are a Parisian city guide who knows every street. Write a warm, "
196
- "specific, first-person itinerary for this walking route. Reference the "
197
- "user's stated vibe directly. For each stop, explain in one sentence why "
198
- "it matches what they were looking for. Format as markdown with one "
199
- "header per stop.\n"
200
- "CRITICAL: mention ONLY the place names listed in the prompt, spelled "
201
- "exactly. Never invent or name any other place, street, landmark, or "
202
- "neighbourhood. If unsure, refer to a place by its type, not a name."
 
 
 
 
203
  )
204
  user = (
205
  f"Vibe: {vibe or 'open to anything'}\n"
206
  + (f"Weights extracted: {weights_line}\n" if weights_line else "")
207
  + f"Mode: {mode} from {start_label or 'the start'} to "
208
  f"{end_label or 'the destination'}\n"
209
- f"Ordered stops:\n{bullet}\n"
 
 
210
  f"Total time: {total_min} minutes (about {extra} minutes of discovery)\n\n"
211
  "Write the itinerary."
212
  )
213
  messages = [{"role": "system", "content": system},
214
  {"role": "user", "content": user}]
215
- return run_inference(messages, max_new_tokens=600)
 
 
 
135
 
136
 
137
  def narrate(plain, discovery, pois, vibe="", mode="walk", start_label="",
138
+ end_label="", posture=None, weights=None, weak=False,
139
+ geo_allowed=None, city_label="") -> tuple[str, bool]:
140
+ """Return (markdown, used_llm). Output is guaranteed grounded.
141
+
142
+ ``geo_allowed`` is the per-city geographic gazetteer (districts, river,
143
+ landmarks) the LLM may name; ``city_label`` localises the guide's voice
144
+ (e.g. "London" instead of the old hardcoded "Parisian").
145
+ """
146
  template = template_narration(
147
  plain, discovery, pois, vibe, mode, start_label, end_label, posture, weights, weak
148
  )
 
157
  t0 = time.time()
158
  try:
159
  text = _llm_narration(plain, discovery, pois, vibe, mode,
160
+ start_label, end_label, weights, geo_allowed, city_label)
161
+ ok, offenders = grounding.verify_grounded(
162
+ text, pois, start_label, end_label, extra_allowed=geo_allowed)
163
  latency = int((time.time() - t0) * 1000)
164
  if ok and text.strip():
165
  trace.log_trace("narration", meta, {"text": text}, latency,
 
186
 
187
 
188
  def _llm_narration(plain, discovery, pois, vibe, mode, start_label, end_label,
189
+ weights=None, geo_allowed=None, city_label="") -> str:
190
+ """Generate narration with MiniCPM5-1B, constrained to the allowed names.
191
+
192
+ The model may colour the route with the listed neighbourhoods / river /
193
+ landmarks (``geo_allowed``) so it reads like a real guide — but it still must
194
+ not invent a *venue* to visit. Anything it slips past that rule is caught by
195
+ the grounding gate and the template ships instead.
196
+ """
197
  from discoverroute.narrate.llm import run_inference
198
 
199
  names = [taxonomy.display_label(p) for p in pois]
 
203
  extra = round(discovery.time_min - plain.time_min)
204
  total_min = round(discovery.time_min + getattr(discovery, "dwell_s", 0.0) / 60.0)
205
  weights_line = _weights_summary(weights)
206
+ guide = f"{city_label} " if city_label else ""
207
+ context_terms = ", ".join(geo_allowed) if geo_allowed else ""
208
 
209
  system = (
210
+ f"You are a {guide}city guide who knows every street. Write a warm, "
211
+ "vivid, first-person itinerary for this route sensory and specific, "
212
+ "the kind of thing a local would actually say. Reference the user's "
213
+ "stated vibe directly, and in one sentence per stop say why it fits.\n"
214
+ "You MAY set the scene with the districts, river, and landmarks listed "
215
+ "under 'You may reference' name them freely to give the walk a sense "
216
+ "of place.\n"
217
+ "HARD RULE: every specific stop you tell the user to visit must be one of "
218
+ "the 'Ordered stops', spelled exactly. Never invent a café, shop, museum, "
219
+ "restaurant or any other named venue that isn't in that list. If unsure, "
220
+ "describe a place by its type, not a made-up name.\n"
221
+ "Format as markdown with one bold header per stop."
222
  )
223
  user = (
224
  f"Vibe: {vibe or 'open to anything'}\n"
225
  + (f"Weights extracted: {weights_line}\n" if weights_line else "")
226
  + f"Mode: {mode} from {start_label or 'the start'} to "
227
  f"{end_label or 'the destination'}\n"
228
+ + (f"You may reference (scene-setting only, do not list as stops): "
229
+ f"{context_terms}\n" if context_terms else "")
230
+ + f"Ordered stops (the ONLY venues you may name):\n{bullet}\n"
231
  f"Total time: {total_min} minutes (about {extra} minutes of discovery)\n\n"
232
  "Write the itinerary."
233
  )
234
  messages = [{"role": "system", "content": system},
235
  {"role": "user", "content": user}]
236
+ # ≤480 tokens comfortably covers ~6 stops (one short paragraph each) and keeps
237
+ # generation inside the 45s ZeroGPU slice (see llm.GPU_DURATION_S).
238
+ return run_inference(messages, max_new_tokens=480)
src/discoverroute/pipeline.py CHANGED
@@ -185,10 +185,13 @@ def _plan_route_impl(
185
  if discovery is None or not selected:
186
  break
187
  used_ids.update(p.osm_id for p in selected)
 
 
188
  itinerary_md, _ = narrate(
189
  plain, discovery, selected, vibe=vibe, mode=mode,
190
  start_label=start_query.strip(), end_label=dest_query.strip(),
191
  posture=posture, weights=weights, weak=weak_match,
 
192
  )
193
  alternatives.append(Alternative(
194
  discovery=discovery, pois=selected,
 
185
  if discovery is None or not selected:
186
  break
187
  used_ids.update(p.osm_id for p in selected)
188
+ from discoverroute.narrate import gazetteer
189
+ geo_allowed = gazetteer.geo_terms(area.key, area.label)
190
  itinerary_md, _ = narrate(
191
  plain, discovery, selected, vibe=vibe, mode=mode,
192
  start_label=start_query.strip(), end_label=dest_query.strip(),
193
  posture=posture, weights=weights, weak=weak_match,
194
+ geo_allowed=geo_allowed, city_label=area.label,
195
  )
196
  alternatives.append(Alternative(
197
  discovery=discovery, pois=selected,
tests/test_narration.py CHANGED
@@ -69,6 +69,40 @@ def test_gate_allows_unnamed_by_type():
69
  assert ok, offenders
70
 
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  class _Route:
73
  def __init__(self, time_min):
74
  self.time_min = time_min
 
69
  assert ok, offenders
70
 
71
 
72
+ def test_geo_gazetteer_allows_real_districts():
73
+ """A guide may name the city's real districts/river when given the gazetteer,
74
+ so vivid prose passes the gate instead of being thrown out for the template."""
75
+ from discoverroute.narrate import gazetteer
76
+ geo = gazetteer.geo_terms("paris", "Paris")
77
+ text = ("From the Marais, drift down toward the Seine and into the Latin "
78
+ "Quarter, passing Jardin des Plantes for some green and Fontaine "
79
+ "Médicis. A very Parisian wander with Roman echoes near the Panthéon.")
80
+ ok, offenders = grounding.verify_grounded(
81
+ text, POIS, start_label="Bastille", end_label="Panthéon",
82
+ extra_allowed=geo,
83
+ )
84
+ assert ok, offenders
85
+
86
+
87
+ def test_geo_gazetteer_still_blocks_invented_venue():
88
+ """Loosening for districts must NOT let an invented venue through."""
89
+ from discoverroute.narrate import gazetteer
90
+ geo = gazetteer.geo_terms("paris", "Paris")
91
+ text = ("Cross the Marais, then stop at Café des Mensonges, a charming "
92
+ "invented spot, before Jardin des Plantes.")
93
+ ok, offenders = grounding.verify_grounded(
94
+ text, POIS, start_label="Bastille", extra_allowed=geo,
95
+ )
96
+ assert not ok
97
+ assert any("Mensonges" in o for o in offenders)
98
+
99
+
100
+ def test_uncurated_city_has_no_extra_context():
101
+ """An on-demand area (bbox-hash key) gets no gazetteer => fails closed."""
102
+ from discoverroute.narrate import gazetteer
103
+ assert gazetteer.geo_terms("48.1,2.2,48.2,2.3", "this area") == ["this area"]
104
+
105
+
106
  class _Route:
107
  def __init__(self, time_min):
108
  self.time_min = time_min