The Discover request surface was the one recommendation surface still on its M5c implementation from early May. #796's taste profile, #1488's taste_unheard bucket and #1490's folksonomy enrichment all modernized in-library surfaces; this one was never in scope for any of them, so it still projected raw likes + plays through artist_similarity_unmatched. Two defects fall out of that signal, `5*liked + Σexp(-age/halflife)` summed over every play of the artist. It is unbounded, and contribution is signal × similarity — so a handful of heavily-played artists monopolize all twelve slots, and their share GROWS the more the user listens. The surface entrenched harder the better it knew you, which is exactly backwards and matches the reported "goes stale once it has a strong signal of your taste". It also counted every play_event with no was_skipped filter, so skipping an artist repeatedly INCREASED its signal and pushed more of its neighbours at the user. ListMostPlayedTracksForUser and the taste engine both filter skips; this query was the odd one out. Seeds now come from taste_profile_artists.weight, which the taste engine has already engagement-graded, time-decayed and signed — an artist the user drifted away from stops contributing instead of accumulating forever, and can even contribute negatively. Tiered per rule #131 rather than hard-switched: tier 1 is the profile, tier 2 is likes + completed plays for a user who has no profile rows yet (new account, or before the first daily recompute), so the surface never empties. The old unfiltered-play signal is gone, not kept behind a toggle. The signal is also log-damped, so one artist cannot take every slot even when its weight dwarfs the rest. $2 stays wired to the tier-2 decay: it is genuinely still used there, and dropping the parameter would have changed the generated signature. sqlc's image is not on this workstation and the change preserves the query signature exactly — same three params, same seven columns — so only the embedded SQL const moves. Both copies are edited and verified byte-identical rather than pulling a container onto the operator's machine; a malformed query fails the integration lane loudly, which is the real check either way. Four integration tests cover what changed: a taste weight alone seeds with no like or play; tier 2 does not run alongside tier 1; a non-positive weight never seeds (guarded by a second positive row, so an empty tier 1 can't make it pass for the wrong reason); and skip-only history seeds nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
3.6 KiB
Go
114 lines
3.6 KiB
Go
// suggestions.go is the per-user artist-suggestion service behind the
|
|
// Discover request surface. Seeds from the taste profile (falling back to
|
|
// likes + completed plays for a user who has none yet), projects those seeds
|
|
// 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
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// ArtistSuggestion is one ranked candidate with its top-3 attribution seeds.
|
|
type ArtistSuggestion struct {
|
|
MBID string
|
|
Name string
|
|
Score float64
|
|
Attribution []SeedContribution
|
|
}
|
|
|
|
// SeedContribution is one of the top-3 contributing seeds for a candidate.
|
|
type SeedContribution struct {
|
|
ArtistID pgtype.UUID
|
|
Name string
|
|
Contribution float64
|
|
IsLiked bool
|
|
PlayCount int64
|
|
}
|
|
|
|
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
|
// capped at 50 (default 12 when out of range).
|
|
//
|
|
// 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) {
|
|
if limit <= 0 || limit > 50 {
|
|
limit = 12
|
|
}
|
|
if halfLifeDays <= 0 {
|
|
halfLifeDays = 30
|
|
}
|
|
q := dbq.New(pool)
|
|
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
|
|
UserID: userID,
|
|
Column2: halfLifeDays,
|
|
Limit: int32(limit),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("suggest: query: %w", err)
|
|
}
|
|
if len(rows) == 0 {
|
|
return []ArtistSuggestion{}, nil
|
|
}
|
|
|
|
// Collect the union of top-3 seed IDs across all rows for one batched
|
|
// name lookup. pgtype.UUID is a comparable struct so it works as a map
|
|
// key directly.
|
|
seedSet := make(map[pgtype.UUID]struct{}, len(rows)*3)
|
|
for _, r := range rows {
|
|
for _, sid := range r.TopSeedIds {
|
|
seedSet[sid] = struct{}{}
|
|
}
|
|
}
|
|
seedIDs := make([]pgtype.UUID, 0, len(seedSet))
|
|
for id := range seedSet {
|
|
seedIDs = append(seedIDs, id)
|
|
}
|
|
artists, err := q.GetArtistsByIDs(ctx, seedIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("suggest: resolve seeds: %w", err)
|
|
}
|
|
nameByID := make(map[pgtype.UUID]string, len(artists))
|
|
for _, a := range artists {
|
|
nameByID[a.ID] = a.Name
|
|
}
|
|
|
|
out := make([]ArtistSuggestion, 0, len(rows))
|
|
for _, r := range rows {
|
|
attribution := make([]SeedContribution, 0, len(r.TopSeedIds))
|
|
for i, sid := range r.TopSeedIds {
|
|
if i >= len(r.TopContributions) || i >= len(r.TopIsLiked) || i >= len(r.TopPlayCounts) {
|
|
break
|
|
}
|
|
attribution = append(attribution, SeedContribution{
|
|
ArtistID: sid,
|
|
Name: nameByID[sid],
|
|
Contribution: r.TopContributions[i],
|
|
IsLiked: r.TopIsLiked[i],
|
|
PlayCount: r.TopPlayCounts[i],
|
|
})
|
|
}
|
|
out = append(out, ArtistSuggestion{
|
|
MBID: r.CandidateMbid,
|
|
Name: r.CandidateName,
|
|
Score: r.TotalScore,
|
|
Attribution: attribution,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|