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:
@@ -52,6 +52,7 @@ type tasteTuningResp struct {
|
||||
EngagementFull float64 `json:"engagement_full"`
|
||||
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
||||
EraScale float64 `json:"era_scale"`
|
||||
MoodScale float64 `json:"mood_scale"`
|
||||
}
|
||||
|
||||
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||
@@ -62,6 +63,7 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||
EngagementFull: t.EngagementFull,
|
||||
EnrichedTagScale: t.EnrichedTagScale,
|
||||
EraScale: t.EraScale,
|
||||
MoodScale: t.MoodScale,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -562,6 +562,13 @@ type TasteProfileEra struct {
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileMood struct {
|
||||
UserID pgtype.UUID
|
||||
Mood string
|
||||
Weight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileTag struct {
|
||||
UserID pgtype.UUID
|
||||
Tag string
|
||||
@@ -578,6 +585,7 @@ type TasteTuning struct {
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
const getTasteTuning = `-- name: GetTasteTuning :one
|
||||
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale FROM taste_tuning WHERE singleton = true
|
||||
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
|
||||
`
|
||||
|
||||
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||
@@ -25,6 +25,7 @@ func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||
&i.UpdatedAt,
|
||||
&i.EnrichedTagScale,
|
||||
&i.EraScale,
|
||||
&i.MoodScale,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -125,9 +126,10 @@ UPDATE taste_tuning
|
||||
engagement_full = $4,
|
||||
enriched_tag_scale = $5,
|
||||
era_scale = $6,
|
||||
mood_scale = $7,
|
||||
updated_at = now()
|
||||
WHERE singleton = true
|
||||
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale
|
||||
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale
|
||||
`
|
||||
|
||||
type UpdateTasteTuningParams struct {
|
||||
@@ -137,6 +139,7 @@ type UpdateTasteTuningParams struct {
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
||||
@@ -147,6 +150,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
||||
arg.EngagementFull,
|
||||
arg.EnrichedTagScale,
|
||||
arg.EraScale,
|
||||
arg.MoodScale,
|
||||
)
|
||||
var i TasteTuning
|
||||
err := row.Scan(
|
||||
@@ -158,6 +162,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
||||
&i.UpdatedAt,
|
||||
&i.EnrichedTagScale,
|
||||
&i.EraScale,
|
||||
&i.MoodScale,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -224,8 +229,9 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
||||
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||
INSERT INTO taste_tuning (
|
||||
singleton, half_life_days, engagement_hard_skip,
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6)
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||
mood_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (singleton) DO NOTHING
|
||||
`
|
||||
|
||||
@@ -236,6 +242,7 @@ type UpsertTasteTuningDefaultsParams struct {
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
||||
@@ -246,6 +253,7 @@ func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTaste
|
||||
arg.EngagementFull,
|
||||
arg.EnrichedTagScale,
|
||||
arg.EraScale,
|
||||
arg.MoodScale,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@ func (q *Queries) DeleteTasteProfileErasForUser(ctx context.Context, userID pgty
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileMoodsForUser = `-- name: DeleteTasteProfileMoodsForUser :exec
|
||||
DELETE FROM taste_profile_moods WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteTasteProfileMoodsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTasteProfileMoodsForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
|
||||
DELETE FROM taste_profile_tags WHERE user_id = $1
|
||||
`
|
||||
@@ -70,6 +79,22 @@ func (q *Queries) InsertTasteProfileEra(ctx context.Context, arg InsertTasteProf
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileMood = `-- name: InsertTasteProfileMood :exec
|
||||
INSERT INTO taste_profile_moods (user_id, mood, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertTasteProfileMoodParams struct {
|
||||
UserID pgtype.UUID
|
||||
Mood string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertTasteProfileMood(ctx context.Context, arg InsertTasteProfileMoodParams) error {
|
||||
_, err := q.db.Exec(ctx, insertTasteProfileMood, arg.UserID, arg.Mood, arg.Weight)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
|
||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -304,6 +329,45 @@ func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTaste
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileMoodsForUser = `-- name: ListTasteProfileMoodsForUser :many
|
||||
SELECT mood, weight
|
||||
FROM taste_profile_moods
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListTasteProfileMoodsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListTasteProfileMoodsForUserRow struct {
|
||||
Mood string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// Top-weighted taste moods (#1534); consumed by the scorer's mood term.
|
||||
func (q *Queries) ListTasteProfileMoodsForUser(ctx context.Context, arg ListTasteProfileMoodsForUserParams) ([]ListTasteProfileMoodsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTasteProfileMoodsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTasteProfileMoodsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListTasteProfileMoodsForUserRow
|
||||
if err := rows.Scan(&i.Mood, &i.Weight); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
|
||||
SELECT tag, weight
|
||||
FROM taste_profile_tags
|
||||
|
||||
@@ -110,6 +110,40 @@ func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayed
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTrackTagsForTracks = `-- name: ListTrackTagsForTracks :many
|
||||
SELECT tt.track_id, tt.tag
|
||||
FROM track_tags tt
|
||||
WHERE tt.track_id = ANY($1::uuid[])
|
||||
`
|
||||
|
||||
type ListTrackTagsForTracksRow struct {
|
||||
TrackID pgtype.UUID
|
||||
Tag string
|
||||
}
|
||||
|
||||
// Enriched tags for a set of candidate tracks (#1534), so the scorer can
|
||||
// derive each candidate's mood buckets at scoring time. One row per (track,
|
||||
// tag); the caller filters to mood words via the internal/mood vocabulary.
|
||||
func (q *Queries) ListTrackTagsForTracks(ctx context.Context, dollar_1 []pgtype.UUID) ([]ListTrackTagsForTracksRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTrackTagsForTracks, dollar_1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTrackTagsForTracksRow
|
||||
for rows.Next() {
|
||||
var i ListTrackTagsForTracksRow
|
||||
if err := rows.Scan(&i.TrackID, &i.Tag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTracksMissingTags = `-- name: ListTracksMissingTags :many
|
||||
|
||||
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS mood_scale;
|
||||
DROP TABLE IF EXISTS taste_profile_moods;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 0047_taste_profile_moods.up.sql — mood taste facet (#1534, milestone #160
|
||||
-- Opt 2b). A fourth taste facet alongside artists + tags + eras: signed weights
|
||||
-- over canonical mood buckets (melancholic / energetic / chill / …) derived
|
||||
-- from a track's enriched folksonomy tags (#1490) via the internal/mood
|
||||
-- vocabulary, rebuilt daily. Weight is a signed float — positive = drawn to
|
||||
-- that mood, negative = passively avoided. Coverage is partial (grows with tag
|
||||
-- enrichment), so it's a supplement, not a foundation. Mirrors
|
||||
-- taste_profile_tags: CASCADE on user delete, indexed by (user_id, weight DESC).
|
||||
CREATE TABLE taste_profile_moods (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mood text NOT NULL,
|
||||
weight double precision NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, mood)
|
||||
);
|
||||
|
||||
CREATE INDEX taste_profile_moods_user_weight_idx
|
||||
ON taste_profile_moods (user_id, weight DESC);
|
||||
|
||||
-- Mood-facet build knob, tunable in the lab (mirrors era_scale / enriched_tag_
|
||||
-- scale): scales how strongly a mood-tagged play imprints on the profile.
|
||||
-- DEFAULT 0.5 backfills the existing singleton and matches
|
||||
-- taste.DefaultConfig().MoodScale; 0 disables the mood facet entirely.
|
||||
ALTER TABLE taste_tuning
|
||||
ADD COLUMN mood_scale double precision NOT NULL DEFAULT 0.5;
|
||||
@@ -32,8 +32,9 @@ RETURNING *;
|
||||
-- name: UpsertTasteTuningDefaults :exec
|
||||
INSERT INTO taste_tuning (
|
||||
singleton, half_life_days, engagement_hard_skip,
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6)
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||
mood_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (singleton) DO NOTHING;
|
||||
|
||||
-- name: GetTasteTuning :one
|
||||
@@ -47,6 +48,7 @@ UPDATE taste_tuning
|
||||
engagement_full = $4,
|
||||
enriched_tag_scale = $5,
|
||||
era_scale = $6,
|
||||
mood_scale = $7,
|
||||
updated_at = now()
|
||||
WHERE singleton = true
|
||||
RETURNING *;
|
||||
|
||||
@@ -85,3 +85,18 @@ FROM taste_profile_eras
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: DeleteTasteProfileMoodsForUser :exec
|
||||
DELETE FROM taste_profile_moods WHERE user_id = $1;
|
||||
|
||||
-- name: InsertTasteProfileMood :exec
|
||||
INSERT INTO taste_profile_moods (user_id, mood, weight)
|
||||
VALUES ($1, $2, $3);
|
||||
|
||||
-- name: ListTasteProfileMoodsForUser :many
|
||||
-- Top-weighted taste moods (#1534); consumed by the scorer's mood term.
|
||||
SELECT mood, weight
|
||||
FROM taste_profile_moods
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2;
|
||||
|
||||
@@ -56,3 +56,11 @@ SELECT tt.track_id, tt.tag, tt.weight
|
||||
FROM track_tags tt
|
||||
JOIN general_likes gl ON gl.track_id = tt.track_id
|
||||
WHERE gl.user_id = $1;
|
||||
|
||||
-- name: ListTrackTagsForTracks :many
|
||||
-- Enriched tags for a set of candidate tracks (#1534), so the scorer can
|
||||
-- derive each candidate's mood buckets at scoring time. One row per (track,
|
||||
-- tag); the caller filters to mood words via the internal/mood vocabulary.
|
||||
SELECT tt.track_id, tt.tag
|
||||
FROM track_tags tt
|
||||
WHERE tt.track_id = ANY($1::uuid[]);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package mood maps folksonomy tags (from the track_tags cache, #1490) to a
|
||||
// small set of canonical mood buckets (#1534, milestone #160 Opt 2b). Both the
|
||||
// taste-profile builder and the recommendation scorer classify a track's tags
|
||||
// through Of() so a track's mood is derived the same way on both sides.
|
||||
//
|
||||
// The vocabulary is intentionally narrow: only words that clearly denote mood
|
||||
// (not genre). A track with no mood-word tags contributes nothing — the mood
|
||||
// facet is a supplement whose coverage grows with folksonomy enrichment
|
||||
// (thin on MusicBrainz-only, richer once Last.fm is keyed).
|
||||
package mood
|
||||
|
||||
import "strings"
|
||||
|
||||
// vocab maps a lowercased folksonomy tag to its canonical mood bucket. Several
|
||||
// synonyms collapse to one bucket so "sad" / "melancholy" / "melancholic" all
|
||||
// score the same dimension.
|
||||
var vocab = map[string]string{
|
||||
// melancholic
|
||||
"melancholic": "melancholic", "melancholy": "melancholic", "sad": "melancholic",
|
||||
"sombre": "melancholic", "somber": "melancholic", "wistful": "melancholic",
|
||||
"bittersweet": "melancholic", "mournful": "melancholic",
|
||||
// energetic
|
||||
"energetic": "energetic", "energy": "energetic", "upbeat": "energetic",
|
||||
"uplifting": "energetic", "party": "energetic", "anthemic": "energetic",
|
||||
// chill
|
||||
"chill": "chill", "chillout": "chill", "chilled": "chill", "mellow": "chill",
|
||||
"relaxing": "chill", "relaxed": "chill", "calm": "chill", "laid-back": "chill",
|
||||
"laidback": "chill", "soothing": "chill",
|
||||
// aggressive
|
||||
"aggressive": "aggressive", "angry": "aggressive", "intense": "aggressive",
|
||||
"heavy": "aggressive", "brutal": "aggressive",
|
||||
// dark
|
||||
"dark": "dark", "moody": "dark", "brooding": "dark", "haunting": "dark", "eerie": "dark",
|
||||
// dreamy
|
||||
"dreamy": "dreamy", "ethereal": "dreamy", "atmospheric": "dreamy",
|
||||
"ambient": "dreamy", "hypnotic": "dreamy",
|
||||
// happy
|
||||
"happy": "happy", "cheerful": "happy", "feel good": "happy", "feel-good": "happy",
|
||||
"feelgood": "happy", "joyful": "happy", "fun": "happy",
|
||||
// romantic
|
||||
"romantic": "romantic", "sensual": "romantic", "sexy": "romantic", "sentimental": "romantic",
|
||||
}
|
||||
|
||||
// Of returns the distinct canonical mood buckets present in tags, lowercasing +
|
||||
// trimming each tag before lookup. Returns nil when no tag maps to a mood, so
|
||||
// callers get no mood signal for a track with only genre/descriptive tags.
|
||||
func Of(tags []string) []string {
|
||||
var out []string
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range tags {
|
||||
key := strings.ToLower(strings.TrimSpace(t))
|
||||
if m, ok := vocab[key]; ok && !seen[m] {
|
||||
seen[m] = true
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package mood
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOf(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{"synonyms collapse", []string{"sad", "melancholy", "melancholic"}, []string{"melancholic"}},
|
||||
{"case + whitespace", []string{" CHILL ", "Mellow"}, []string{"chill"}},
|
||||
{"multiple buckets", []string{"energetic", "dark"}, []string{"dark", "energetic"}},
|
||||
{"genre-only → none", []string{"rock", "shoegaze", "post-punk"}, nil},
|
||||
{"empty", nil, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Of(c.in)
|
||||
sort.Strings(got)
|
||||
want := append([]string(nil), c.want...)
|
||||
sort.Strings(want)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("%s: Of(%v) = %v, want %v", c.name, c.in, got, want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("%s: Of(%v) = %v, want %v", c.name, c.in, got, want)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOf_Dedupes(t *testing.T) {
|
||||
// Three tags mapping to the same bucket yield one entry.
|
||||
if got := Of([]string{"chill", "mellow", "relaxed"}); len(got) != 1 || got[0] != "chill" {
|
||||
t.Errorf("Of dedupe = %v, want [chill]", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -16,29 +16,32 @@ import (
|
||||
// rest toward zero (as a per-user max-normalisation would). A weight at the
|
||||
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
|
||||
//
|
||||
// The era term (#1530) is added on TOP of artist+tag (shares don't sum to 1)
|
||||
// so it's a pure decade nudge that never weakens the existing artist/tag
|
||||
// signal when a track is undated; clampUnit bounds the combined result. Its
|
||||
// share is small — a decade is a coarse signal — and re-bakeable if the lab
|
||||
// shows it should carry more.
|
||||
// The era (#1530) and mood (#1534) terms are added on TOP of artist+tag
|
||||
// (shares don't sum to 1) so they're pure nudges that never weaken the existing
|
||||
// artist/tag signal when a track lacks a date or mood tags; clampUnit bounds
|
||||
// the combined result. Their shares are small — both are coarse, partial-
|
||||
// coverage signals — and re-bakeable if the lab shows they should carry more.
|
||||
const (
|
||||
tasteArtistScale = 4.0
|
||||
tasteTagScale = 3.0
|
||||
tasteEraScale = 4.0
|
||||
tasteMoodScale = 3.0
|
||||
tasteArtistShare = 0.7
|
||||
tasteTagShare = 0.3
|
||||
tasteEraShare = 0.15
|
||||
tasteMoodShare = 0.12
|
||||
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
||||
)
|
||||
|
||||
// TasteProfile is the read-side view of a user's learned taste: signed
|
||||
// weights over artists, genre tags, and decade/era buckets. The zero value
|
||||
// (and any unknown artist/tag/era) contributes 0, so cold-start users get no
|
||||
// taste effect.
|
||||
// TasteProfile is the read-side view of a user's learned taste: signed weights
|
||||
// over artists, genre tags, decade/era buckets, and mood buckets. The zero
|
||||
// value (and any unknown key) contributes 0, so cold-start users get no taste
|
||||
// effect.
|
||||
type TasteProfile struct {
|
||||
artists map[pgtype.UUID]float64
|
||||
tags map[string]float64
|
||||
eras map[string]float64
|
||||
moods map[string]float64
|
||||
}
|
||||
|
||||
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
|
||||
@@ -63,10 +66,17 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
moods, err := q.ListTasteProfileMoodsForUser(ctx, dbq.ListTasteProfileMoodsForUserParams{
|
||||
UserID: userID, Limit: tasteProfileLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
p := TasteProfile{
|
||||
artists: make(map[pgtype.UUID]float64, len(arts)),
|
||||
tags: make(map[string]float64, len(tags)),
|
||||
eras: make(map[string]float64, len(eras)),
|
||||
moods: make(map[string]float64, len(moods)),
|
||||
}
|
||||
for _, a := range arts {
|
||||
p.artists[a.ArtistID] = a.Weight
|
||||
@@ -77,13 +87,20 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
|
||||
for _, e := range eras {
|
||||
p.eras[e.Era] = e.Weight
|
||||
}
|
||||
for _, m := range moods {
|
||||
p.moods[m.Mood] = m.Weight
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
|
||||
// the artist's taste weight, the average of its genre tags' weights, and its
|
||||
// decade/era weight, each tanh-squashed. Absent artist/tags/era contribute 0.
|
||||
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string, releaseDate pgtype.Date) float64 {
|
||||
// the artist's taste weight, the average of its genre tags' weights, its
|
||||
// decade/era weight, and the average of its mood buckets' weights, each
|
||||
// tanh-squashed. moods are the candidate's canonical mood buckets (from
|
||||
// internal/mood; nil when unknown). Absent artist/tags/era/moods contribute 0.
|
||||
func (p TasteProfile) Match(
|
||||
artistID pgtype.UUID, genre *string, releaseDate pgtype.Date, moods []string,
|
||||
) float64 {
|
||||
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
|
||||
|
||||
var tg float64
|
||||
@@ -102,7 +119,16 @@ func (p TasteProfile) Match(artistID pgtype.UUID, genre *string, releaseDate pgt
|
||||
if decade := decadeOf(releaseDate); decade != "" {
|
||||
er = math.Tanh(p.eras[decade] / tasteEraScale)
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg + tasteEraShare*er)
|
||||
|
||||
var mo float64
|
||||
if len(moods) > 0 {
|
||||
var sum float64
|
||||
for _, m := range moods {
|
||||
sum += p.moods[m]
|
||||
}
|
||||
mo = math.Tanh((sum / float64(len(moods))) / tasteMoodScale)
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg + tasteEraShare*er + tasteMoodShare*mo)
|
||||
}
|
||||
|
||||
// decadeOf maps an album release date to a decade bucket ("1990s"), or "" for
|
||||
|
||||
@@ -34,23 +34,23 @@ func TestTasteProfile_Match(t *testing.T) {
|
||||
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0},
|
||||
}
|
||||
|
||||
if m := p.Match(loved, strPtr("Jazz"), noDate()); m <= 0.5 {
|
||||
if m := p.Match(loved, strPtr("Jazz"), noDate(), nil); m <= 0.5 {
|
||||
t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m)
|
||||
}
|
||||
if m := p.Match(disliked, strPtr("Noise"), noDate()); m >= -0.5 {
|
||||
if m := p.Match(disliked, strPtr("Noise"), noDate(), nil); m >= -0.5 {
|
||||
t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m)
|
||||
}
|
||||
if m := p.Match(unknown, nil, noDate()); m != 0 {
|
||||
if m := p.Match(unknown, nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
|
||||
}
|
||||
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
||||
// clearly positive.
|
||||
if m := p.Match(loved, strPtr("Unheard"), noDate()); m <= 0 {
|
||||
if m := p.Match(loved, strPtr("Unheard"), noDate(), nil); m <= 0 {
|
||||
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
|
||||
}
|
||||
// Output stays within [-1, 1] even with saturated inputs.
|
||||
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
|
||||
m := p.Match(a, strPtr("Jazz"), dateInYear(1994))
|
||||
m := p.Match(a, strPtr("Jazz"), dateInYear(1994), nil)
|
||||
if m < -1 || m > 1 {
|
||||
t.Errorf("Match out of [-1,1]: %.3f", m)
|
||||
}
|
||||
@@ -67,22 +67,43 @@ func TestTasteProfile_EraTerm(t *testing.T) {
|
||||
tags: map[string]float64{},
|
||||
eras: map[string]float64{"1990s": 8.0, "1980s": -8.0},
|
||||
}
|
||||
loved := p.Match(art, nil, dateInYear(1994))
|
||||
loved := p.Match(art, nil, dateInYear(1994), nil)
|
||||
if loved <= 0 {
|
||||
t.Errorf("loved era (1990s) = %.3f, want positive", loved)
|
||||
}
|
||||
disliked := p.Match(art, nil, dateInYear(1987))
|
||||
disliked := p.Match(art, nil, dateInYear(1987), nil)
|
||||
if disliked >= 0 {
|
||||
t.Errorf("disliked era (1980s) = %.3f, want negative", disliked)
|
||||
}
|
||||
if m := p.Match(art, nil, noDate()); m != 0 {
|
||||
if m := p.Match(art, nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("undated track = %.3f, want 0 (no era contribution)", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTasteProfile_MoodTerm verifies the mood facet nudges the match: with
|
||||
// artist/genre/era neutral, a loved mood lifts the score and a disliked mood
|
||||
// lowers it, while a track with no mood buckets is unaffected.
|
||||
func TestTasteProfile_MoodTerm(t *testing.T) {
|
||||
art := uuidN(1)
|
||||
p := TasteProfile{
|
||||
artists: map[pgtype.UUID]float64{},
|
||||
tags: map[string]float64{},
|
||||
moods: map[string]float64{"chill": 8.0, "aggressive": -8.0},
|
||||
}
|
||||
if m := p.Match(art, nil, noDate(), []string{"chill"}); m <= 0 {
|
||||
t.Errorf("loved mood (chill) = %.3f, want positive", m)
|
||||
}
|
||||
if m := p.Match(art, nil, noDate(), []string{"aggressive"}); m >= 0 {
|
||||
t.Errorf("disliked mood (aggressive) = %.3f, want negative", m)
|
||||
}
|
||||
if m := p.Match(art, nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("no moods = %.3f, want 0 (no mood contribution)", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
|
||||
var p TasteProfile // zero value: nil maps
|
||||
if m := p.Match(uuidN(1), strPtr("Jazz"), dateInYear(1994)); m != 0 {
|
||||
if m := p.Match(uuidN(1), strPtr("Jazz"), dateInYear(1994), nil); m != 0 {
|
||||
t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m)
|
||||
}
|
||||
}
|
||||
@@ -131,10 +152,10 @@ func TestLoadTasteProfile_RoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if m := p.Match(art.ID, strPtr("Jazz"), noDate()); m <= 0.5 {
|
||||
if m := p.Match(art.ID, strPtr("Jazz"), noDate(), nil); m <= 0.5 {
|
||||
t.Errorf("round-trip Match = %.3f, want strongly positive", m)
|
||||
}
|
||||
if m := p.Match(uuidN(9), nil, noDate()); m != 0 {
|
||||
if m := p.Match(uuidN(9), nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("absent artist Match = %.3f, want 0", m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
|
||||
target = &next.EnrichedTagScale
|
||||
case "era_scale":
|
||||
target = &next.EraScale
|
||||
case "mood_scale":
|
||||
target = &next.MoodScale
|
||||
default:
|
||||
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||
}
|
||||
@@ -184,5 +186,6 @@ func diffTaste(a, b TasteTuning) []fieldChange {
|
||||
add("engagement_full", a.EngagementFull, b.EngagementFull)
|
||||
add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
|
||||
add("era_scale", a.EraScale, b.EraScale)
|
||||
add("mood_scale", a.MoodScale, b.MoodScale)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ type TasteTuning struct {
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
||||
@@ -96,6 +97,7 @@ func ShippedTasteTuning() TasteTuning {
|
||||
EngagementFull: d.Engagement.FullCompletion,
|
||||
EnrichedTagScale: d.EnrichedTagScale,
|
||||
EraScale: d.EraScale,
|
||||
MoodScale: d.MoodScale,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +147,7 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
EngagementFull: st.EngagementFull,
|
||||
EnrichedTagScale: st.EnrichedTagScale,
|
||||
EraScale: st.EraScale,
|
||||
MoodScale: st.MoodScale,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("seed taste tuning: %w", err)
|
||||
}
|
||||
@@ -170,6 +173,7 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
EngagementFull: tt.EngagementFull,
|
||||
EnrichedTagScale: tt.EnrichedTagScale,
|
||||
EraScale: tt.EraScale,
|
||||
MoodScale: tt.MoodScale,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -221,6 +225,7 @@ func (s *Service) TasteConfig() taste.Config {
|
||||
}
|
||||
cfg.EnrichedTagScale = t.EnrichedTagScale
|
||||
cfg.EraScale = t.EraScale
|
||||
cfg.MoodScale = t.MoodScale
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -319,6 +324,7 @@ func (s *Service) persistTaste(
|
||||
EngagementFull: t.EngagementFull,
|
||||
EnrichedTagScale: t.EnrichedTagScale,
|
||||
EraScale: t.EraScale,
|
||||
MoodScale: t.MoodScale,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("update taste tuning: %w", err)
|
||||
}
|
||||
|
||||
@@ -224,6 +224,25 @@ func TestUpdateTaste_EraScale(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTaste_MoodScale(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
if err := s.UpdateTaste(context.Background(),
|
||||
map[string]float64{"mood_scale": 0.8}); err != nil {
|
||||
t.Fatalf("UpdateTaste: %v", err)
|
||||
}
|
||||
if got := s.Taste().MoodScale; got != 0.8 {
|
||||
t.Errorf("Taste().MoodScale = %v, want 0.8", got)
|
||||
}
|
||||
if got := s.TasteConfig().MoodScale; got != 0.8 {
|
||||
t.Errorf("TasteConfig().MoodScale = %v, want 0.8", got)
|
||||
}
|
||||
if err := s.UpdateTaste(context.Background(),
|
||||
map[string]float64{"mood_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
|
||||
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset_RestoresShippedAndAudits(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
|
||||
+61
-15
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||
)
|
||||
|
||||
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
|
||||
@@ -54,6 +55,13 @@ type Config struct {
|
||||
// lab (mirrors EnrichedTagScale, #1520).
|
||||
EraScale float64
|
||||
|
||||
// MoodScale weights the mood facet (#1534): each play adds base × this
|
||||
// scale to the canonical mood buckets (melancholic / chill / …) derived
|
||||
// from its enriched folksonomy tags via internal/mood. Coverage is partial
|
||||
// (depends on tag enrichment), so it's a supplement kept ≤ 1; 0 disables
|
||||
// the mood facet entirely. Tunable in the lab (mirrors EraScale).
|
||||
MoodScale float64
|
||||
|
||||
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
|
||||
// go. Aggregation already protects an artist the user likes (one skip
|
||||
// nets out against many good plays); the floor additionally bounds the
|
||||
@@ -75,6 +83,8 @@ type Config struct {
|
||||
MaxNegTags int
|
||||
MaxEras int
|
||||
MaxNegEras int
|
||||
MaxMoods int
|
||||
MaxNegMoods int
|
||||
}
|
||||
|
||||
// DefaultConfig returns the tuned starting configuration.
|
||||
@@ -88,6 +98,7 @@ func DefaultConfig() Config {
|
||||
TagLikeBonus: 0.5,
|
||||
EnrichedTagScale: 0.5,
|
||||
EraScale: 0.5,
|
||||
MoodScale: 0.5,
|
||||
ArtistFloor: -3.0,
|
||||
TagFloor: -3.0,
|
||||
WeightEpsilon: 0.05,
|
||||
@@ -97,6 +108,8 @@ func DefaultConfig() Config {
|
||||
MaxNegTags: 60,
|
||||
MaxEras: 30,
|
||||
MaxNegEras: 15,
|
||||
MaxMoods: 20,
|
||||
MaxNegMoods: 10,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,40 +129,42 @@ func BuildTasteProfile(
|
||||
) error {
|
||||
q := dbq.New(pool)
|
||||
|
||||
artistW, tagW, eraW, err := accumulate(ctx, q, userID, cfg)
|
||||
artistW, tagW, eraW, moodW, err := accumulate(ctx, q, userID, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyFloor(artistW, cfg.ArtistFloor)
|
||||
applyFloor(tagW, cfg.TagFloor)
|
||||
// Era shares the tag floor (both are text-keyed facets; a decade that's
|
||||
// been heavily skipped shouldn't dominate as an extreme negative either).
|
||||
// Era + mood share the tag floor (all text-keyed facets; a decade or mood
|
||||
// that's been heavily skipped shouldn't dominate as an extreme negative).
|
||||
applyFloor(eraW, cfg.TagFloor)
|
||||
applyFloor(moodW, cfg.TagFloor)
|
||||
|
||||
strLess := func(a, b string) bool { return a < b }
|
||||
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
|
||||
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
|
||||
eras := rankWeighted(eraW, cfg.WeightEpsilon, cfg.MaxEras, cfg.MaxNegEras, strLess)
|
||||
moods := rankWeighted(moodW, cfg.WeightEpsilon, cfg.MaxMoods, cfg.MaxNegMoods, strLess)
|
||||
|
||||
if err := persist(ctx, pool, userID, artists, tags, eras); err != nil {
|
||||
if err := persist(ctx, pool, userID, artists, tags, eras, moods); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID),
|
||||
"artists", len(artists), "tags", len(tags), "eras", len(eras))
|
||||
"artists", len(artists), "tags", len(tags), "eras", len(eras), "moods", len(moods))
|
||||
return nil
|
||||
}
|
||||
|
||||
// accumulate sums decayed play engagement and like bonuses into artist/tag/era
|
||||
// weight maps.
|
||||
// accumulate sums decayed play engagement and like bonuses into
|
||||
// artist/tag/era/mood weight maps.
|
||||
func accumulate(
|
||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
|
||||
) (map[pgtype.UUID]float64, map[string]float64, map[string]float64, error) {
|
||||
) (map[pgtype.UUID]float64, map[string]float64, map[string]float64, map[string]float64, error) {
|
||||
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
|
||||
UserID: userID,
|
||||
Column2: cfg.WindowDays,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
|
||||
}
|
||||
// Enriched folksonomy tags keyed by track (#1490) — folded into the tag
|
||||
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
|
||||
@@ -160,13 +175,14 @@ func accumulate(
|
||||
Column2: int32(cfg.WindowDays),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
|
||||
}
|
||||
playedTags := groupTagsByTrack(playedTagRows)
|
||||
|
||||
artistW := make(map[pgtype.UUID]float64)
|
||||
tagW := make(map[string]float64)
|
||||
eraW := make(map[string]float64)
|
||||
moodW := make(map[string]float64)
|
||||
for _, p := range plays {
|
||||
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
|
||||
artistW[p.ArtistID] += e
|
||||
@@ -175,15 +191,16 @@ func accumulate(
|
||||
}
|
||||
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
|
||||
foldEra(eraW, p.ReleaseDate, e, cfg.EraScale)
|
||||
foldMoods(moodW, playedTags[p.TrackID], e, cfg.MoodScale)
|
||||
}
|
||||
|
||||
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||
}
|
||||
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
|
||||
}
|
||||
likedTags := groupTagsByTrack(likedTagRows)
|
||||
for _, lt := range likedTracks {
|
||||
@@ -193,16 +210,17 @@ func accumulate(
|
||||
}
|
||||
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
|
||||
foldEra(eraW, lt.ReleaseDate, cfg.TagLikeBonus, cfg.EraScale)
|
||||
foldMoods(moodW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.MoodScale)
|
||||
}
|
||||
|
||||
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
|
||||
}
|
||||
for _, aid := range likedArtists {
|
||||
artistW[aid] += cfg.ArtistLikeBonus
|
||||
}
|
||||
return artistW, tagW, eraW, nil
|
||||
return artistW, tagW, eraW, moodW, nil
|
||||
}
|
||||
|
||||
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
|
||||
@@ -241,6 +259,24 @@ func foldEra(eraW map[string]float64, d pgtype.Date, base, scale float64) {
|
||||
}
|
||||
}
|
||||
|
||||
// foldMoods adds a track's canonical mood buckets (derived from its enriched
|
||||
// folksonomy tags via internal/mood) to moodW weighted by base × scale. base is
|
||||
// the play's decayed engagement or the tag-like bonus; scale (MoodScale) dials
|
||||
// the facet's strength. scale=0 disables the mood facet; tracks with no
|
||||
// mood-word tags contribute nothing.
|
||||
func foldMoods(moodW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
|
||||
if scale == 0 {
|
||||
return
|
||||
}
|
||||
names := make([]string, len(tags))
|
||||
for i, t := range tags {
|
||||
names[i] = t.Tag
|
||||
}
|
||||
for _, m := range mood.Of(names) {
|
||||
moodW[m] += base * scale
|
||||
}
|
||||
}
|
||||
|
||||
// decadeOf maps an album release date to a decade bucket like "1990s", or ""
|
||||
// for an absent/invalid date. Mirrored by the recommendation scorer so a
|
||||
// candidate's era is derived the same way it was learned.
|
||||
@@ -254,7 +290,7 @@ func decadeOf(d pgtype.Date) string {
|
||||
// persist atomic-replaces the user's profile rows inside one transaction.
|
||||
func persist(
|
||||
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||
artists []weighted[pgtype.UUID], tags, eras []weighted[string],
|
||||
artists []weighted[pgtype.UUID], tags, eras, moods []weighted[string],
|
||||
) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -293,6 +329,16 @@ func persist(
|
||||
return fmt.Errorf("taste: insert era: %w", err)
|
||||
}
|
||||
}
|
||||
if err := qtx.DeleteTasteProfileMoodsForUser(ctx, userID); err != nil {
|
||||
return fmt.Errorf("taste: delete moods: %w", err)
|
||||
}
|
||||
for _, m := range moods {
|
||||
if err := qtx.InsertTasteProfileMood(ctx, dbq.InsertTasteProfileMoodParams{
|
||||
UserID: userID, Mood: m.Key, Weight: m.Weight,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("taste: insert mood: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("taste: commit: %w", err)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type TasteTuning = {
|
||||
engagement_full: number;
|
||||
enriched_tag_scale: number;
|
||||
era_scale: number;
|
||||
mood_scale: number;
|
||||
};
|
||||
|
||||
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' },
|
||||
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' },
|
||||
{ key: 'enriched_tag_scale', label: 'Enriched tag weight', hint: 'How much folksonomy tags (MusicBrainz/Last.fm) count vs raw file genre, in [0, 1]. 0 = genre only.' },
|
||||
{ key: 'era_scale', label: 'Era weight', hint: 'How strongly a decade-play imprints on the era facet, in [0, 1]. 0 = era ignored.' }
|
||||
{ key: 'era_scale', label: 'Era weight', hint: 'How strongly a decade-play imprints on the era facet, in [0, 1]. 0 = era ignored.' },
|
||||
{ key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' }
|
||||
];
|
||||
|
||||
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
|
||||
|
||||
@@ -39,6 +39,7 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
|
||||
engagement_full: 0.9,
|
||||
enriched_tag_scale: 0.5,
|
||||
era_scale: 0.5,
|
||||
mood_scale: 0.5,
|
||||
...over
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user