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
@@ -271,6 +271,14 @@ func TestUserJourney(t *testing.T) {
|
||||
c.ok("GET", "/api/v1/me/likes", nil, &likes)
|
||||
require.Equal(t, 1, likes.Total)
|
||||
|
||||
// Персональные подсказки: лайкнутый трек — первым среди треков альбома.
|
||||
var sug struct {
|
||||
Tracks []track `json:"tracks"`
|
||||
}
|
||||
c.ok("GET", "/api/v1/search/suggest?q="+url.QueryEscape("кино"), nil, &sug)
|
||||
require.NotEmpty(t, sug.Tracks)
|
||||
require.Equal(t, tr.ID, sug.Tracks[0].ID)
|
||||
|
||||
// 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается.
|
||||
var onb struct {
|
||||
Required bool `json:"required"`
|
||||
|
||||
@@ -170,7 +170,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
|
||||
AuthService: authSvc,
|
||||
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
|
||||
Search: search.NewHandler(meili, libStore, indexer, dto, log),
|
||||
Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log),
|
||||
Playback: pb,
|
||||
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
|
||||
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -28,12 +28,15 @@ type Handler struct {
|
||||
meili *Meili
|
||||
catalog *library.Store
|
||||
indexer *Indexer
|
||||
aff *Affinity
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик поиска.
|
||||
func NewHandler(meili *Meili, catalog *library.Store, indexer *Indexer, dto library.DTO, log *slog.Logger) *Handler {
|
||||
return &Handler{meili: meili, catalog: catalog, indexer: indexer, dto: dto, log: log}
|
||||
func NewHandler(
|
||||
meili *Meili, catalog *library.Store, indexer *Indexer, aff *Affinity, dto library.DTO, log *slog.Logger,
|
||||
) *Handler {
|
||||
return &Handler{meili: meili, catalog: catalog, indexer: indexer, aff: aff, dto: dto, log: log}
|
||||
}
|
||||
|
||||
type limits struct{ artists, albums, tracks, offset int }
|
||||
@@ -150,10 +153,16 @@ func (h *Handler) hydrate(ctx context.Context, f found, corrected string) (gen.S
|
||||
|
||||
// SearchSuggest реализует gen.StrictServerInterface.
|
||||
func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestRequestObject) (gen.SearchSuggestResponseObject, error) {
|
||||
f, corrected, err := h.run(ctx, req.Params.Q, limits{artists: 3, albums: 3, tracks: 5})
|
||||
want := limits{artists: 3, albums: 3, tracks: 5}
|
||||
l := want
|
||||
if h.aff != nil {
|
||||
l = limits{artists: want.artists * suggestPool, albums: want.albums * suggestPool, tracks: want.tracks * suggestPool}
|
||||
}
|
||||
f, corrected, err := h.run(ctx, req.Params.Q, l)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f = h.personal(ctx, f, want)
|
||||
out, err := h.hydrate(ctx, f, corrected)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -161,6 +170,23 @@ func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestReques
|
||||
return gen.SearchSuggest200JSONResponse(out), nil
|
||||
}
|
||||
|
||||
// personal оставляет в подсказках по want.* лучших с учётом вкуса пользователя.
|
||||
// Без пользователя или при сбое — просто первые по релевантности.
|
||||
func (h *Handler) personal(ctx context.Context, f found, want limits) found {
|
||||
var aff map[uuid.UUID]float64
|
||||
if p, ok := auth.PrincipalFrom(ctx); ok && h.aff != nil {
|
||||
all := ids(append(append(append([]string{}, f.artists...), f.albums...), f.tracks...))
|
||||
var err error
|
||||
if aff, err = h.aff.Of(ctx, p.UserID, all); err != nil {
|
||||
h.log.WarnContext(ctx, "suggest personalization failed", "err", err)
|
||||
}
|
||||
}
|
||||
f.artists = personalize(f.artists, aff, want.artists)
|
||||
f.albums = personalize(f.albums, aff, want.albums)
|
||||
f.tracks = personalize(f.tracks, aff, want.tracks)
|
||||
return f
|
||||
}
|
||||
|
||||
// Search реализует gen.StrictServerInterface.
|
||||
func (h *Handler) Search(ctx context.Context, req gen.SearchRequestObject) (gen.SearchResponseObject, error) {
|
||||
typ := gen.All
|
||||
|
||||
@@ -3,6 +3,8 @@ package search
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -20,3 +22,14 @@ func TestSwitchLayout(t *testing.T) {
|
||||
assert.Equal(t, "кино", SwitchLayout("rbyj"))
|
||||
assert.Equal(t, "", SwitchLayout("123"))
|
||||
}
|
||||
|
||||
func TestPersonalizeLiftsFamiliarAndSinksDisliked(t *testing.T) {
|
||||
liked, disliked := uuid.New(), uuid.New()
|
||||
other := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}
|
||||
found := []string{other[0], disliked.String(), other[1], other[2], liked.String()}
|
||||
aff := map[uuid.UUID]float64{liked: 30, disliked: -10}
|
||||
|
||||
got := personalize(found, aff, 3)
|
||||
assert.Equal(t, []string{liked.String(), other[0], other[1]}, got)
|
||||
assert.Equal(t, found[:2], personalize(found, nil, 2), "без вкуса — порядок релевантности")
|
||||
}
|
||||
Reference in new issue
Block a user