// suggestions.go is the M5c per-user artist-suggestion service. Reads // the user's likes + plays, projects them through artist_similarity_unmatched // via a single CTE, returns top-N candidates with top-3 attribution seeds // resolved to artist names. 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 is the // recency-decay half-life for plays (default 30, operator-tunable). 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 }