Tristan Leduc
Round-4 fixes: drop overclaiming match-tag, dedup stops, hidden excludes famous
c16ccf9
Raw
History Blame Contribute Delete
5.95 kB
"""Interpret a free-text vibe + adventurousness into inspectable preferences.
Produces the three things spec P0-5 requires:
(a) category affinity weights β€” from sentence embeddings (embed.py)
(b) a per-category stop/pass posture β€” category defaults, shifted by mood cues
(c) a budget interpretation β€” a hint read from explicit pace words in the vibe
Affinity is the load-bearing signal for routing; posture and the budget hint are
surfaced for the user and consumed by later bricks (dual-budget solving is P1-2).
Everything here is deterministic and debuggable β€” no hidden state.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from discoverroute import config
from discoverroute.data import taxonomy
from discoverroute.routing.scoring import Weights
# Mood cues that shift posture globally.
_STOP_CUES = ("crawl", "stop", "sit", "linger", "browse", "taste", "coffee",
"lunch", "dinner", "drink", "relax", "people-watch", "people watch")
_PASS_CUES = ("ride", "cycle", "bike", "wander", "stroll", "roll", "cruise",
"pass", "walk through", "loop", "scenic route")
# Pace cues that hint a budget (fraction of direct time to spend on discovery).
_LOW_BUDGET_CUES = ("quick", "short", "direct", "fast", "hurry", "straight")
_HIGH_BUDGET_CUES = ("long", "scenic", "leisurely", "all day", "all-day",
"explore", "meander", "take your time", "no rush", "epic")
# Discovery cues: the user wants the obscure, NOT famous sights. The "attraction"
# category ("a famous landmark or major sight") is the semantic opposite, yet it
# embeds close to "hidden gems" β€” so suppress it and bump adventurousness
# (which surfaces under-documented POIs) when these appear.
_HIDDEN_CUES = ("hidden", "off the beaten", "off-the-beaten", "secret", "local gem",
"hidden gem", "lesser known", "lesser-known", "undiscovered",
"underrated", "non-touristy", "non touristy", "like a local")
@dataclass
class Interpretation:
affinity: dict[str, float]
weights: Weights
posture: dict[str, str]
budget_hint: float | None # suggested budget, or None if not implied
explanation: str # human-readable, inspectable
top_categories: list[str] = field(default_factory=list)
confidence: float = 1.0 # best raw cosine to a category gloss
weak: bool = False # True => out-of-vocabulary / weak match
adventurousness: float = config.DEFAULT_ADVENTUROUSNESS # possibly cue-boosted
exclude_famous: bool = False # discovery cue => drop well-documented sights
def _contains(text: str, cues) -> bool:
return any(cue in text for cue in cues)
def interpret(vibe: str, adventurousness: float = config.DEFAULT_ADVENTUROUSNESS,
budget: float | None = None) -> Interpretation:
from discoverroute.interpret.affinity import resolve_affinity # lazy: model only if used
text = (vibe or "").strip().lower()
affinity, _source = resolve_affinity(vibe)
# Discovery intent: drop "famous attraction" (opposite of "hidden") and lift
# adventurousness so the route favours under-documented places. Copy first β€”
# resolve_affinity is cached and returns a shared dict.
hidden = _contains(text, _HIDDEN_CUES)
if hidden:
affinity = {**affinity, "attraction": 0.0}
adventurousness = max(adventurousness, 0.8)
weights = Weights(category_affinity=affinity, w_category=1.0)
# (b) posture: start from category defaults, then let the mood tilt it.
base_posture = {c: taxonomy.posture(c) for c in affinity}
if _contains(text, _STOP_CUES) and not _contains(text, _PASS_CUES):
posture = {c: "stop" for c in base_posture}
elif _contains(text, _PASS_CUES) and not _contains(text, _STOP_CUES):
posture = {c: "pass" for c in base_posture}
else:
posture = base_posture
# (c) budget hint from explicit pace words (slider stays authoritative).
budget_hint = None
if _contains(text, _HIGH_BUDGET_CUES):
budget_hint = 1.0
elif _contains(text, _LOW_BUDGET_CUES):
budget_hint = 0.2
top = sorted(affinity, key=affinity.get, reverse=True)[:4]
# Match confidence from the raw embedding cosine (independent of rescaling).
try:
from discoverroute.interpret import embed
confidence = embed.raw_top_similarity(vibe)
except Exception: # noqa: BLE001 - encoder unavailable
confidence = 1.0
weak = bool(text) and confidence < config.WEAK_MATCH_SIMILARITY
explanation = _explain(vibe, top, affinity, posture, budget_hint, weak)
return Interpretation(affinity, weights, posture, budget_hint, explanation, top,
confidence=confidence, weak=weak,
adventurousness=adventurousness, exclude_famous=hidden)
def _explain(vibe, top, affinity, posture, budget_hint, weak=False) -> str:
if not (vibe or "").strip():
return "_No vibe given β€” every kind of place is weighted equally._"
# Off-domain / unreadable vibe: the interpreter degraded to neutral (all
# categories equal). Say so honestly instead of inventing a confident top-4.
vals = list(affinity.values())
if vals and (max(vals) - min(vals)) < 1e-6:
return (f"_I couldn't read a clear taste from β€œ{vibe.strip()}” β€” "
f"weighting every kind of place equally._")
header = (f"**No strong match for β€œ{vibe.strip()}” β€” here's my closest guess:**"
if weak else f"**Reading β€œ{vibe.strip()}” as:**")
lines = [header]
for c in top:
nice = c.replace("_", " ")
lines.append(f"- {nice} (affinity {affinity[c]:.2f}, "
f"{'stop at' if posture[c] == 'stop' else 'pass by'})")
if budget_hint is not None:
lines.append(f"- pace hint β†’ budget β‰ˆ {budget_hint:.1f}")
return "\n".join(lines)