Tristan Leduc
Round-3 fixes: wire interp weights, ref-name guard, tighten attraction gloss
720767c
Raw
History Blame Contribute Delete
3.5 kB
"""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)