feat(taste): mood taste facet — #1534
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m44s

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:
2026-07-14 10:32:26 -04:00
parent 199fec2058
commit f0c08e7326
22 changed files with 482 additions and 48 deletions
+8
View File
@@ -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 {
+12 -4
View File
@@ -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
}
+64
View File
@@ -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
+34
View File
@@ -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