277898a49a
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>
103 lines
2.9 KiB
Go
103 lines
2.9 KiB
Go
// 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
|
|
}
|