Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268) #116

Merged
bvandeusen merged 14 commits from dev into main 2026-08-03 08:38:25 -04:00
4 changed files with 263 additions and 25 deletions
Showing only changes of commit 14aa22198f - Show all commits
+52 -6
View File
@@ -1022,20 +1022,66 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
} }
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
WITH seeds AS ( -- Per-user artist suggestions ranked by taste signal x similarity, projected
-- through artist_similarity_unmatched (out-of-library candidates only).
--
-- Seeds are TIERED (rule #131) so the surface never empties:
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
-- SIGNED by internal/taste, so an artist the user has drifted away
-- from stops contributing instead of accumulating forever.
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
-- (new account, or before the first daily recompute).
--
-- The signal is log-damped: contribution is signal x similarity, and the old
-- undamped sum let one heavily-played artist's neighbours take every slot --
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
--
-- Candidates already in the library, or already requested and not terminal,
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event,
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
-- its neighbours at the user (issue #2367 mechanism 3).
SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
),
profile_seeds AS (
SELECT tpa.artist_id, tpa.weight AS raw_signal
FROM taste_profile_artists tpa
WHERE tpa.user_id = $1 AND tpa.weight > 0
),
fallback_seeds AS (
SELECT a.id AS artist_id, SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END) 5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0) + COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
AS signal, AS raw_signal
(gla.artist_id IS NOT NULL) AS is_liked,
COUNT(pe.id)::bigint AS play_count
FROM artists a FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1 LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
LEFT JOIN tracks t ON t.artist_id = a.id LEFT JOIN tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 LEFT JOIN play_events pe
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
GROUP BY a.id, gla.artist_id GROUP BY a.id, gla.artist_id
), ),
seeds AS (
SELECT s.artist_id,
ln(1.0 + s.raw_signal) AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COALESCE(ap.play_count, 0)::bigint AS play_count
FROM (
SELECT artist_id, raw_signal FROM profile_seeds
UNION ALL
SELECT artist_id, raw_signal FROM fallback_seeds
) s
LEFT JOIN general_likes_artists gla
ON gla.artist_id = s.artist_id AND gla.user_id = $1
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
WHERE s.raw_signal > 0
),
contributions AS ( contributions AS (
SELECT u.candidate_mbid, SELECT u.candidate_mbid,
u.candidate_name, u.candidate_name,
+52 -13
View File
@@ -258,27 +258,66 @@ ORDER BY started_at DESC
LIMIT 1; LIMIT 1;
-- name: SuggestArtistsForUser :many -- name: SuggestArtistsForUser :many
-- M5c: per-user artist suggestions ranked by signal x similarity. The -- Per-user artist suggestions ranked by taste signal x similarity, projected
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays -- through artist_similarity_unmatched (out-of-library candidates only).
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against --
-- artist_similarity_unmatched and filters out candidates already in -- Seeds are TIERED (rule #131) so the surface never empties:
-- library or already in a non-terminal lidarr_request. The outer SELECT -- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
-- aggregates per candidate, returning the top-3 contributing seeds for -- SIGNED by internal/taste, so an artist the user has drifted away
-- attribution. $1=user_id, $2=half_life_days, $3=limit. -- from stops contributing instead of accumulating forever.
WITH seeds AS ( -- tier 2 - likes + completed plays, used ONLY when the profile has no rows
-- (new account, or before the first daily recompute).
--
-- The signal is log-damped: contribution is signal x similarity, and the old
-- undamped sum let one heavily-played artist's neighbours take every slot --
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
--
-- Candidates already in the library, or already requested and not terminal,
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event,
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
-- its neighbours at the user (issue #2367 mechanism 3).
SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
),
profile_seeds AS (
SELECT tpa.artist_id, tpa.weight AS raw_signal
FROM taste_profile_artists tpa
WHERE tpa.user_id = $1 AND tpa.weight > 0
),
fallback_seeds AS (
SELECT a.id AS artist_id, SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END) 5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0) + COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
AS signal, AS raw_signal
(gla.artist_id IS NOT NULL) AS is_liked,
COUNT(pe.id)::bigint AS play_count
FROM artists a FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1 LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
LEFT JOIN tracks t ON t.artist_id = a.id LEFT JOIN tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 LEFT JOIN play_events pe
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
GROUP BY a.id, gla.artist_id GROUP BY a.id, gla.artist_id
), ),
seeds AS (
SELECT s.artist_id,
ln(1.0 + s.raw_signal) AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COALESCE(ap.play_count, 0)::bigint AS play_count
FROM (
SELECT artist_id, raw_signal FROM profile_seeds
UNION ALL
SELECT artist_id, raw_signal FROM fallback_seeds
) s
LEFT JOIN general_likes_artists gla
ON gla.artist_id = s.artist_id AND gla.user_id = $1
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
WHERE s.raw_signal > 0
),
contributions AS ( contributions AS (
SELECT u.candidate_mbid, SELECT u.candidate_mbid,
u.candidate_name, u.candidate_name,
+17 -6
View File
@@ -1,7 +1,14 @@
// suggestions.go is the M5c per-user artist-suggestion service. Reads // suggestions.go is the per-user artist-suggestion service behind the
// the user's likes + plays, projects them through artist_similarity_unmatched // Discover request surface. Seeds from the taste profile (falling back to
// via a single CTE, returns top-N candidates with top-3 attribution seeds // likes + completed plays for a user who has none yet), projects those seeds
// resolved to artist names. // through artist_similarity_unmatched via a single CTE, and returns top-N
// out-of-library candidates with top-3 attribution seeds resolved to names.
//
// Originally M5c, which seeded from raw likes + plays. That signal grew
// without bound and counted skips as engagement, so a few heavily-played
// artists monopolized every slot and the surface entrenched harder the more
// the user listened. Reworked to seed from the taste profile in issue #2367;
// see internal/db/queries/recommendation.sql for the tiering.
package recommendation package recommendation
import ( import (
@@ -32,8 +39,12 @@ type SeedContribution struct {
} }
// SuggestArtists returns top-N artist suggestions for the user. limit is // SuggestArtists returns top-N artist suggestions for the user. limit is
// capped at 50 (default 12 when out of range); halfLifeDays is the // capped at 50 (default 12 when out of range).
// recency-decay half-life for plays (default 30, operator-tunable). //
// halfLifeDays (default 30) is the recency-decay half-life for the TIER-2
// seed path only — the likes + completed-plays fallback used while the user
// has no taste-profile rows yet. Once the profile is populated it seeds
// instead, carrying its own decay, so this knob stops applying.
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) { func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
if limit <= 0 || limit > 50 { if limit <= 0 || limit > 50 {
limit = 12 limit = 12
@@ -330,3 +330,145 @@ func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
t.Errorf("len = %d, want 0 (new user has no signal)", len(out)) t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
} }
} }
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)
ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`,
userID, artistID, weight,
); err != nil {
t.Fatalf("set taste weight: %v", err)
}
}
func insertSkippedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
t.Helper()
ctx := context.Background()
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx,
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, 'skip-test') RETURNING id`,
userID, startedAt,
).Scan(&sessionID); err != nil {
t.Fatalf("insert play_session: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
VALUES ($1, $2, $3, $4, true)`,
userID, trackID, sessionID, startedAt,
); err != nil {
t.Fatalf("insert skipped play_event: %v", err)
}
}
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
// play on the seed artist. Before #2367 the profile was ignored entirely here.
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seed := seedArtist(t, pool, "Profile Seed", "")
setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 1 {
t.Fatalf("len = %d, want 1 (taste weight alone should seed)", len(out))
}
if out[0].MBID != "out-mbid" {
t.Errorf("mbid = %q, want out-mbid", out[0].MBID)
}
if out[0].Score <= 0 {
t.Errorf("score = %v, want > 0", out[0].Score)
}
}
// Once the profile has any positive row, tier 2 is not consulted — so an artist
// the user played but that the taste engine did not keep does NOT seed. That is
// the point of the rewrite: the taste engine decides what counts as affinity.
func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
kept := seedArtist(t, pool, "Kept By Taste", "")
dropped := seedArtist(t, pool, "Dropped By Taste", "")
setTasteWeight(t, pool, user.ID, kept.ID, 3.0)
// `dropped` has real completed plays but no profile row.
album := seedAlbumForArtist(t, pool, dropped.ID, "Album")
track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track")
insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour))
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 1 {
t.Fatalf("len = %d, want 1 (tier 2 must not run alongside tier 1)", len(out))
}
if out[0].MBID != "kept-cand" {
t.Errorf("mbid = %q, want kept-cand", out[0].MBID)
}
}
// A non-positive taste weight is not affinity. Guarded by a second, positive
// row so tier 1 stays active — otherwise an empty tier 1 would fall through to
// tier 2 and the assertion would pass for the wrong reason.
func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
positive := seedArtist(t, pool, "Still Liked", "")
abandoned := seedArtist(t, pool, "Abandoned", "")
setTasteWeight(t, pool, user.ID, positive.ID, 2.0)
setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5)
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
for _, s := range out {
if s.MBID == "neg-cand" {
t.Fatalf("negative-weight artist seeded a suggestion: %+v", s)
}
}
if len(out) != 1 || out[0].MBID != "pos-cand" {
t.Errorf("out = %+v, want only pos-cand", out)
}
}
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
// skipping an artist repeatedly increased its signal and pushed more of its
// neighbours at the user (#2367 mechanism 3).
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
skipped := seedArtist(t, pool, "Only Skipped", "")
album := seedAlbumForArtist(t, pool, skipped.ID, "Album")
track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track")
for i := 0; i < 5; i++ {
insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour))
}
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 0 {
t.Errorf("len = %d, want 0 (skips are not affinity): %+v", len(out), out)
}
}