Spaces:
Running on Zero
Running on Zero
Tristan Leduc
Round-4 fixes: drop overclaiming match-tag, dedup stops, hidden excludes famous
c16ccf9 | """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") | |
| 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) | |