feat(search): personalize suggestions by listening history (M6)
Suggest fetches three times as many candidates and reranks them in the API: what the user plays, finishes and likes moves up a few positions and what they dislike moves down, with Meilisearch relevance still the base order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e250837de9
commit
fbbe5d7528
6 files changed
+138
-6
No files matched your search
@@ -0,0 +1,83 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// suggestPool — во сколько раз больше кандидатов берётся из Meilisearch для переранжирования подсказок.
|
||||
const suggestPool = 3
|
||||
|
||||
// Affinity — насколько пользователь «знает» исполнителей, альбомы и треки: прослушивания, доигрывания,
|
||||
// лайки и дизлайки. Отрицательное — не любит. Нужен для персональных подсказок поиска.
|
||||
type Affinity struct{ pool *pgxpool.Pool }
|
||||
|
||||
// NewAffinity создаёт источник предпочтений.
|
||||
func NewAffinity(pool *pgxpool.Pool) *Affinity { return &Affinity{pool: pool} }
|
||||
|
||||
// Of возвращает вес для каждого из ids, о котором что-то известно.
|
||||
func (a *Affinity) Of(ctx context.Context, user uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]float64, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
WITH s AS (
|
||||
SELECT track_id, sum(w) AS w FROM (
|
||||
SELECT track_id, (play_count + 2 * complete_count - early_skip_count)::float8 AS w
|
||||
FROM user_track_stats WHERE user_id = $1
|
||||
UNION ALL
|
||||
SELECT track_id, CASE reaction WHEN 'like' THEN 10 ELSE -10 END FROM track_reactions WHERE user_id = $1
|
||||
) x GROUP BY track_id
|
||||
)
|
||||
SELECT track_id, w FROM s WHERE track_id = ANY($2)
|
||||
UNION ALL
|
||||
SELECT t.album_id, sum(s.w) FROM s JOIN tracks t ON t.id = s.track_id WHERE t.album_id = ANY($2) GROUP BY t.album_id
|
||||
UNION ALL
|
||||
SELECT ta.artist_id, sum(s.w) FROM s JOIN track_artists ta ON ta.track_id = s.track_id
|
||||
WHERE ta.artist_id = ANY($2) GROUP BY ta.artist_id
|
||||
UNION ALL
|
||||
SELECT artist_id, CASE reaction WHEN 'like' THEN 20 ELSE -20 END FROM artist_reactions
|
||||
WHERE user_id = $1 AND artist_id = ANY($2)`, user, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[uuid.UUID]float64{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var w float64
|
||||
if err := rows.Scan(&id, &w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] += w
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// personalize переставляет найденное: знакомое и любимое поднимается на несколько позиций, нелюбимое
|
||||
// опускается; релевантность Meilisearch остаётся основой порядка. Возвращает не больше n.
|
||||
func personalize(found []string, aff map[uuid.UUID]float64, n int) []string {
|
||||
type item struct {
|
||||
id string
|
||||
key float64
|
||||
}
|
||||
items := make([]item, len(found))
|
||||
for i, s := range found {
|
||||
key := float64(i)
|
||||
if id, err := uuid.Parse(s); err == nil {
|
||||
if w := aff[id]; w > 0 {
|
||||
key -= 2 * math.Log1p(w)
|
||||
} else if w < 0 {
|
||||
key += 3
|
||||
}
|
||||
}
|
||||
items[i] = item{s, key}
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool { return items[i].key < items[j].key })
|
||||
out := make([]string, 0, min(n, len(items)))
|
||||
for _, it := range items[:min(n, len(items))] {
|
||||
out = append(out, it.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user