From fbbe5d7528e3c2e3b8d999edd815885d6e2bee91 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:55:07 +0300 Subject: [PATCH] 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 --- docs/library-and-storage.md | 6 +- services/api/cmd/api/e2e_test.go | 8 ++ services/api/cmd/api/main.go | 2 +- services/api/internal/search/personal.go | 83 +++++++++++++++++++ services/api/internal/search/service.go | 32 ++++++- services/api/internal/search/translit_test.go | 13 +++ 6 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 services/api/internal/search/personal.go diff --git a/docs/library-and-storage.md b/docs/library-and-storage.md index 9377a0b..0221004 100644 --- a/docs/library-and-storage.md +++ b/docs/library-and-storage.md @@ -161,7 +161,9 @@ Meilisearch, индексы: - Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней). - `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс, отменяет предыдущий запрос (`AbortController`). -- Персонализация (опционально, этап 2): треки, которые пользователь лайкал / часто слушал, поднимаются выше - в выдаче `suggest` (re-rank первых 20 на стороне `api`). +- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api` + (`internal/search/personal.go`). Вес объекта — прослушивания + 2 × доигрывания − ранние скипы, лайк трека ±10, + реакция на исполнителя ±20; альбом и исполнитель суммируют вес своих треков. Знакомое поднимается на + 2·ln(1+вес) позиций, нелюбимое опускается на 3 — релевантность остаётся основой порядка. - Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача `reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма). diff --git a/services/api/cmd/api/e2e_test.go b/services/api/cmd/api/e2e_test.go index c1f2d21..f57508a 100644 --- a/services/api/cmd/api/e2e_test.go +++ b/services/api/cmd/api/e2e_test.go @@ -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"` diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index 8b532e9..9f8cb38 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -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), diff --git a/services/api/internal/search/personal.go b/services/api/internal/search/personal.go new file mode 100644 index 0000000..5f888c9 --- /dev/null +++ b/services/api/internal/search/personal.go @@ -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 +} diff --git a/services/api/internal/search/service.go b/services/api/internal/search/service.go index d0460f3..886017b 100644 --- a/services/api/internal/search/service.go +++ b/services/api/internal/search/service.go @@ -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 diff --git a/services/api/internal/search/translit_test.go b/services/api/internal/search/translit_test.go index fa2c7b2..75d85b3 100644 --- a/services/api/internal/search/translit_test.go +++ b/services/api/internal/search/translit_test.go @@ -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), "без вкуса — порядок релевантности") +}