feat(taste): mood taste facet — #1534
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste facet alongside artists + genre tags + eras: signed weights over canonical mood buckets (melancholic / energetic / chill / …) derived from a track's enriched folksonomy tags (#1490). - internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to canonical mood buckets (synonyms collapse). Imported by both the taste builder and the scorer so a track's mood is derived identically. - Migration 0047: taste_profile_moods table + taste_tuning.mood_scale (DEFAULT 0.5). - Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors EraScale); accumulate folds each play/like's mood buckets at base*MoodScale; persist atomic-replaces the mood rows. - Scorer (internal/recommendation): TasteProfile gains a mood term (own tanh scale + additive 0.12 share, so it never weakens the existing signal when a track has no mood tags). Match now takes the candidate's mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in the primary similarity loader only — the near-whole-library fallback pool passes nil (mood → 0) to avoid a full-library tag scan. - Tuning lab: mood_scale threaded through recsettings + admin API + web card ("Mood weight" row) + Go/web tests. Coverage is partial (grows with tag enrichment; richer once Last.fm is keyed), so mood is a supplement — neutral for tracks with no mood tags. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||
)
|
||||
|
||||
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
||||
@@ -62,7 +63,10 @@ func LoadCandidates(
|
||||
PlayCount: int(r.PlayCount),
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
|
||||
// Fallback path: mood is scored only in the primary
|
||||
// (similarity) loader — loading per-candidate tags over this
|
||||
// near-whole-library pool isn't worth it (nil moods → 0).
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, nil),
|
||||
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||
},
|
||||
})
|
||||
@@ -151,6 +155,15 @@ func LoadCandidatesFromSimilarity(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trackIDs := make([]pgtype.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
trackIDs = append(trackIDs, r.Track.ID)
|
||||
}
|
||||
moods, err := loadCandidateMoods(ctx, q, trackIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
@@ -175,7 +188,8 @@ func LoadCandidatesFromSimilarity(
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
SimilarityScore: simScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
|
||||
TasteMatchScore: profile.Match(
|
||||
r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, moods[r.Track.ID]),
|
||||
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||
},
|
||||
})
|
||||
@@ -183,6 +197,33 @@ func LoadCandidatesFromSimilarity(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadCandidateMoods fetches the enriched tags for the given candidate tracks
|
||||
// and reduces each to its canonical mood buckets (internal/mood, #1534), so the
|
||||
// scorer can apply the mood facet per candidate. Tracks with no mood-word tags
|
||||
// are absent from the map (→ no mood signal). Empty input short-circuits.
|
||||
func loadCandidateMoods(
|
||||
ctx context.Context, q *dbq.Queries, trackIDs []pgtype.UUID,
|
||||
) (map[pgtype.UUID][]string, error) {
|
||||
if len(trackIDs) == 0 {
|
||||
return map[pgtype.UUID][]string{}, nil
|
||||
}
|
||||
rows, err := q.ListTrackTagsForTracks(ctx, trackIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagsByTrack := make(map[pgtype.UUID][]string)
|
||||
for _, r := range rows {
|
||||
tagsByTrack[r.TrackID] = append(tagsByTrack[r.TrackID], r.Tag)
|
||||
}
|
||||
out := make(map[pgtype.UUID][]string, len(tagsByTrack))
|
||||
for id, tags := range tagsByTrack {
|
||||
if m := mood.Of(tags); len(m) > 0 {
|
||||
out[id] = m
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
||||
// one query and groups them by track_id. Rows whose session_vector fails
|
||||
// to unmarshal are skipped with no error (don't poison scoring over one
|
||||
|
||||
Reference in New Issue
Block a user