Tristan Leduc Claude Fable 5 commited on
Commit
c16ccf9
Β·
1 Parent(s): 720767c

Round-4 fixes: drop overclaiming match-tag, dedup stops, hidden excludes famous

Browse files

Adversarial round 4 (NOT-CONVERGED) β€” root-caused to 3 issues:
1. The per-stop "a match for your vibe" tag overclaimed: any route that
backfills a lower-ranked category (bakery on wine, church on jazz, statue
on street-food/photography) falsely labelled those stops a match. Removed
the tag entirely β€” each stop's reason text + the interpretation panel carry
the honest framing; the model can't guarantee per-stop relevance.
2. No within-route dedup β†’ the same place ("UP /SIDE / DOWN / TOWN") appeared
twice in one route. Dedup the shortlist by osm_id AND name.
3. "hidden gems" still surfaced Notre Dame β€” the attraction-zeroing missed it
(it's viewpoint). Discovery cues now drop famous, well-documented POIs by
tag-richness (confidence >= FAMOUS_CONFIDENCE 0.85), category-independent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/discoverroute/config.py CHANGED
@@ -111,6 +111,10 @@ TOP_AFFINITY_CATEGORIES = 6
111
  # architecture" 0.51, nonsense ~0.49). We still route, but the narration says so
112
  # honestly instead of claiming "a match for your vibe".
113
  WEAK_MATCH_SIMILARITY = 0.55
 
 
 
 
114
  # Below this cosine-similarity span across categories, a vibe is treated as
115
  # off-domain/neutral rather than amplified into false preferences. Measured
116
  # (bge-small, 16-vibe battery): gibberish "asdfqwer" spans 0.081; the LOWEST
 
111
  # architecture" 0.51, nonsense ~0.49). We still route, but the narration says so
112
  # honestly instead of claiming "a match for your vibe".
113
  WEAK_MATCH_SIMILARITY = 0.55
114
+ # For "hidden gems"-style vibes, exclude well-documented (famous) POIs: a place
115
+ # this richly tagged isn't off the beaten path. Confidence is the tag-richness
116
+ # proxy; Notre Dame etc. sit at ~1.0. (Only applied when a discovery cue fires.)
117
+ FAMOUS_CONFIDENCE = 0.85
118
  # Below this cosine-similarity span across categories, a vibe is treated as
119
  # off-domain/neutral rather than amplified into false preferences. Measured
120
  # (bge-small, 16-vibe battery): gibberish "asdfqwer" spans 0.081; the LOWEST
src/discoverroute/interpret/vibe.py CHANGED
@@ -48,6 +48,7 @@ class Interpretation:
48
  confidence: float = 1.0 # best raw cosine to a category gloss
49
  weak: bool = False # True => out-of-vocabulary / weak match
50
  adventurousness: float = config.DEFAULT_ADVENTUROUSNESS # possibly cue-boosted
 
51
 
52
 
53
  def _contains(text: str, cues) -> bool:
@@ -63,7 +64,8 @@ def interpret(vibe: str, adventurousness: float = config.DEFAULT_ADVENTUROUSNESS
63
  # Discovery intent: drop "famous attraction" (opposite of "hidden") and lift
64
  # adventurousness so the route favours under-documented places. Copy first β€”
65
  # resolve_affinity is cached and returns a shared dict.
66
- if _contains(text, _HIDDEN_CUES):
 
67
  affinity = {**affinity, "attraction": 0.0}
68
  adventurousness = max(adventurousness, 0.8)
69
  weights = Weights(category_affinity=affinity, w_category=1.0)
@@ -95,7 +97,7 @@ def interpret(vibe: str, adventurousness: float = config.DEFAULT_ADVENTUROUSNESS
95
  explanation = _explain(vibe, top, affinity, posture, budget_hint, weak)
96
  return Interpretation(affinity, weights, posture, budget_hint, explanation, top,
97
  confidence=confidence, weak=weak,
98
- adventurousness=adventurousness)
99
 
100
 
101
  def _explain(vibe, top, affinity, posture, budget_hint, weak=False) -> str:
 
48
  confidence: float = 1.0 # best raw cosine to a category gloss
49
  weak: bool = False # True => out-of-vocabulary / weak match
50
  adventurousness: float = config.DEFAULT_ADVENTUROUSNESS # possibly cue-boosted
51
+ exclude_famous: bool = False # discovery cue => drop well-documented sights
52
 
53
 
54
  def _contains(text: str, cues) -> bool:
 
64
  # Discovery intent: drop "famous attraction" (opposite of "hidden") and lift
65
  # adventurousness so the route favours under-documented places. Copy first β€”
66
  # resolve_affinity is cached and returns a shared dict.
67
+ hidden = _contains(text, _HIDDEN_CUES)
68
+ if hidden:
69
  affinity = {**affinity, "attraction": 0.0}
70
  adventurousness = max(adventurousness, 0.8)
71
  weights = Weights(category_affinity=affinity, w_category=1.0)
 
97
  explanation = _explain(vibe, top, affinity, posture, budget_hint, weak)
98
  return Interpretation(affinity, weights, posture, budget_hint, explanation, top,
99
  confidence=confidence, weak=weak,
100
+ adventurousness=adventurousness, exclude_famous=hidden)
101
 
102
 
103
  def _explain(vibe, top, affinity, posture, budget_hint, weak=False) -> str:
src/discoverroute/narrate/narrate.py CHANGED
@@ -79,13 +79,12 @@ def template_narration(plain, discovery, pois, vibe, mode, start_label="",
79
  f"**{n} {place_word}** between {start_label or 'the start'} and "
80
  f"{end_label or 'the destination'}:\n"
81
  )
82
- # The categories the vibe leans on most β€” used to tie a stop back to the vibe.
83
- # A weak vibe has no real matches, so don't tag stops "a match for your vibe".
84
- top_cats: set[str] = set()
85
- aff = getattr(weights, "category_affinity", None)
86
- if v and aff and not weak:
87
- top_cats = set(sorted(aff, key=aff.get, reverse=True)[:3])
88
-
89
  lines = [lead]
90
  prev_cat = None
91
  for i, p in enumerate(pois, 1):
@@ -96,7 +95,7 @@ def template_narration(plain, discovery, pois, vibe, mode, start_label="",
96
  reason = _REASON.get(p.category, "a spot worth a look")
97
  prev_cat = p.category
98
  verb = _verb(posture.get(p.category, "pass"))
99
- tie = " β€” a match for your vibe" if (v and p.category in top_cats) else ""
100
  badge = _hours_badge(p, posture.get(p.category, "pass"))
101
  lines.append(f"{i}. **{label}** β€” {verb.lower()} for {reason}{tie}.{badge}")
102
  lines.append(
 
79
  f"**{n} {place_word}** between {start_label or 'the start'} and "
80
  f"{end_label or 'the destination'}:\n"
81
  )
82
+ # NOTE: we deliberately do NOT tag stops "a match for your vibe". Adversarial
83
+ # review showed that claim overreaches whenever a route backfills with a
84
+ # lower-ranked category (a bakery on a wine vibe, a church on a jazz vibe) β€”
85
+ # the affinity model can't guarantee per-stop relevance, so the blanket claim
86
+ # reads as a lie. Each stop's own reason text conveys its appeal honestly; the
87
+ # interpretation panel already shows how the vibe was read.
 
88
  lines = [lead]
89
  prev_cat = None
90
  for i, p in enumerate(pois, 1):
 
95
  reason = _REASON.get(p.category, "a spot worth a look")
96
  prev_cat = p.category
97
  verb = _verb(posture.get(p.category, "pass"))
98
+ tie = ""
99
  badge = _hours_badge(p, posture.get(p.category, "pass"))
100
  lines.append(f"{i}. **{label}** β€” {verb.lower()} for {reason}{tie}.{badge}")
101
  lines.append(
src/discoverroute/pipeline.py CHANGED
@@ -115,6 +115,7 @@ def _plan_route_impl(
115
  from discoverroute.data import taxonomy
116
  interp_md = ""
117
  weak_match = False
 
118
  top_requested = None # the #1 category the vibe asked for (for sparse feedback)
119
  posture = {c: taxonomy.posture(c) for c in taxonomy.CATEGORIES}
120
  has_vibe = bool((vibe or "").strip())
@@ -131,6 +132,7 @@ def _plan_route_impl(
131
  interp_md = interp.explanation
132
  weak_match = interp.weak
133
  adventurousness = interp.adventurousness # may be cue-boosted (e.g. "hidden gems")
 
134
  if not weak_match and interp.top_categories:
135
  top_requested = interp.top_categories[0]
136
  # Use the interpreter's OWN affinity (it carries discovery-cue
@@ -168,7 +170,7 @@ def _plan_route_impl(
168
  try:
169
  shortlist, matrix, time_fn = _prepare_discovery(
170
  graph, start, end, plain, mode, budget, weights, adventurousness,
171
- posture=posture)
172
  for _ in range(max(1, n_alternatives)):
173
  if shortlist is None:
174
  break
@@ -227,7 +229,7 @@ def _plan_route_impl(
227
 
228
 
229
  def _prepare_discovery(graph, start, end, plain, mode, budget, weights, adventurousness,
230
- posture=None):
231
  """Corridor β†’ score β†’ shortlist β†’ real travel matrix. Done ONCE per request.
232
 
233
  The expensive step is the matrix (cutoff-bounded multi-source Dijkstra), so we
@@ -238,13 +240,34 @@ def _prepare_discovery(graph, start, end, plain, mode, budget, weights, adventur
238
  candidates = poimod.corridor_pois(plain.coords, budget)
239
  if not candidates:
240
  return None, None, None
 
 
 
 
 
 
 
241
  scoring.score_pois(candidates, weights, adventurousness)
242
  # Open-now awareness: demote places that are closed right now (heavily for
243
  # stop-at categories, mildly for pass-by; unknown hours left untouched).
244
  from discoverroute.routing import hours
245
  hours.apply_open_now(candidates, posture)
246
- shortlist = sorted((p for p in candidates if p.score > 0),
247
- key=lambda p: p.score, reverse=True)[: config.SOLVER_CANDIDATES]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  if not shortlist:
249
  return None, None, None
250
 
 
115
  from discoverroute.data import taxonomy
116
  interp_md = ""
117
  weak_match = False
118
+ exclude_famous = False # discovery-cue vibes drop well-documented famous sights
119
  top_requested = None # the #1 category the vibe asked for (for sparse feedback)
120
  posture = {c: taxonomy.posture(c) for c in taxonomy.CATEGORIES}
121
  has_vibe = bool((vibe or "").strip())
 
132
  interp_md = interp.explanation
133
  weak_match = interp.weak
134
  adventurousness = interp.adventurousness # may be cue-boosted (e.g. "hidden gems")
135
+ exclude_famous = interp.exclude_famous
136
  if not weak_match and interp.top_categories:
137
  top_requested = interp.top_categories[0]
138
  # Use the interpreter's OWN affinity (it carries discovery-cue
 
170
  try:
171
  shortlist, matrix, time_fn = _prepare_discovery(
172
  graph, start, end, plain, mode, budget, weights, adventurousness,
173
+ posture=posture, exclude_famous=exclude_famous)
174
  for _ in range(max(1, n_alternatives)):
175
  if shortlist is None:
176
  break
 
229
 
230
 
231
  def _prepare_discovery(graph, start, end, plain, mode, budget, weights, adventurousness,
232
+ posture=None, exclude_famous=False):
233
  """Corridor β†’ score β†’ shortlist β†’ real travel matrix. Done ONCE per request.
234
 
235
  The expensive step is the matrix (cutoff-bounded multi-source Dijkstra), so we
 
240
  candidates = poimod.corridor_pois(plain.coords, budget)
241
  if not candidates:
242
  return None, None, None
243
+ # Discovery vibes ("hidden gems"): drop famous, well-documented sights so the
244
+ # route stays genuinely off the beaten path (Notre Dame etc. enter via several
245
+ # categories, so filter by tag-richness, not category).
246
+ if exclude_famous:
247
+ kept = [p for p in candidates if p.confidence < config.FAMOUS_CONFIDENCE]
248
+ if kept:
249
+ candidates = kept
250
  scoring.score_pois(candidates, weights, adventurousness)
251
  # Open-now awareness: demote places that are closed right now (heavily for
252
  # stop-at categories, mildly for pass-by; unknown hours left untouched).
253
  from discoverroute.routing import hours
254
  hours.apply_open_now(candidates, posture)
255
+ ranked = sorted((p for p in candidates if p.score > 0),
256
+ key=lambda p: p.score, reverse=True)
257
+ # Dedup within a route: the same OSM place can appear as multiple rows
258
+ # (multipolygon centroids) or two distinct ids can share a name β€” either way
259
+ # a route must never tell you to visit the same spot twice. Keep first (best).
260
+ seen_id, seen_name, shortlist = set(), set(), []
261
+ for p in ranked:
262
+ nkey = (p.name or "").strip().lower()
263
+ if p.osm_id in seen_id or (nkey and nkey in seen_name):
264
+ continue
265
+ seen_id.add(p.osm_id)
266
+ if nkey:
267
+ seen_name.add(nkey)
268
+ shortlist.append(p)
269
+ if len(shortlist) >= config.SOLVER_CANDIDATES:
270
+ break
271
  if not shortlist:
272
  return None, None, None
273