// 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 }