feat(recommendation): SuggestArtists service for M5c
Add per-user artist-suggestion service ranking out-of-library MBIDs by signal x similarity. Single-CTE SQL collects user likes (5x weight) and recency-decayed plays, joins against artist_similarity_unmatched, and filters in-library candidates plus non-terminal lidarr_requests. The service resolves top-3 attribution seeds to artist names in a batched GetArtistsByIDs call so the UI can render "because you liked X" reasons. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user