File size: 3,501 Bytes
f00d669
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
720767c
f00d669
 
 
 
 
720767c
 
 
 
f00d669
 
720767c
 
10cf611
 
f00d669
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
"""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)