Spaces:
Running on Zero
Running on Zero
| """Persistent taste profile blended with per-trip mood (spec P1-1). | |
| A profile is standing free-text preferences + a bag of saved place categories | |
| (places the user tagged as loved). Effective routing affinity combines the | |
| profile with the current trip's mood: effective = f(taste, mood). | |
| No accounts: the profile is a single local object persisted per device in the | |
| browser (gr.BrowserState in the UI). This module is pure logic over a plain dict | |
| so it is fully testable without the UI. | |
| """ | |
| from __future__ import annotations | |
| from discoverroute import config | |
| from discoverroute.data import taxonomy | |
| from discoverroute.routing.scoring import Weights | |
| # How much a single saved place in a category lifts that category's affinity, | |
| # saturating so a handful of saves matters but a hundred doesn't dominate. | |
| _SAVED_STEP = 0.5 | |
| def empty_profile() -> dict: | |
| return {"standing_text": "", "saved_categories": []} | |
| def _saved_affinity(saved_categories: list[str]) -> dict[str, float]: | |
| """Affinity contribution from saved places (count -> saturating boost).""" | |
| counts: dict[str, int] = {} | |
| for c in saved_categories or []: | |
| if c in taxonomy.CATEGORIES: | |
| counts[c] = counts.get(c, 0) + 1 | |
| out = {c: 0.0 for c in taxonomy.CATEGORIES} | |
| for c, n in counts.items(): | |
| out[c] = 1.0 - (1.0 / (1.0 + _SAVED_STEP * n)) # 1 save→0.33, 2→0.5, ∞→1 | |
| return out | |
| def profile_affinity(profile: dict) -> dict[str, float] | None: | |
| """Affinity implied by the profile alone, or None if the profile is empty.""" | |
| profile = profile or {} | |
| text = (profile.get("standing_text") or "").strip() | |
| saved = profile.get("saved_categories") or [] | |
| if not text and not saved: | |
| return None | |
| from discoverroute.interpret import embed | |
| base = embed.vibe_to_affinity(text) if text else {c: 0.0 for c in taxonomy.CATEGORIES} | |
| saved_aff = _saved_affinity(saved) | |
| # take the stronger signal per category, then floor for a little exploration | |
| merged = {c: max(base.get(c, 0.0), saved_aff.get(c, 0.0)) for c in taxonomy.CATEGORIES} | |
| floor = config.AFFINITY_FLOOR | |
| return {c: floor + (1.0 - floor) * v for c, v in merged.items()} | |
| def effective_weights(profile: dict, trip_vibe: str = "", | |
| mood_blend: float = 0.6, trip_affinity=None) -> Weights: | |
| """Blend persistent taste with the current trip's mood into scoring weights. | |
| ``mood_blend`` is the weight on the per-trip vibe (0 = profile only, | |
| 1 = mood only). When only one signal exists, it is used directly; when | |
| neither does, every category is weighted equally (neutral). | |
| ``trip_affinity`` lets the caller pass the vibe affinity it already computed | |
| (e.g. the interpreter's, which carries discovery-cue adjustments) instead of | |
| re-deriving it here — so those adjustments aren't silently dropped. | |
| """ | |
| prof = profile_affinity(profile) | |
| trip = trip_affinity | |
| if trip is None and (trip_vibe or "").strip(): | |
| from discoverroute.interpret.affinity import affinity_only | |
| trip = affinity_only(trip_vibe) | |
| if prof is None and trip is None: | |
| affinity = {c: 1.0 for c in taxonomy.CATEGORIES} | |
| elif prof is None: | |
| affinity = trip | |
| elif trip is None: | |
| affinity = prof | |
| else: | |
| affinity = { | |
| c: (1 - mood_blend) * prof.get(c, 0.0) + mood_blend * trip.get(c, 0.0) | |
| for c in taxonomy.CATEGORIES | |
| } | |
| return Weights(category_affinity=affinity, w_category=1.0) | |