feat(recommendations): collect demographics and advance age cohorts
ci / ts / ts (packages + web) (push) Successful in 3m19s
ci / db / db (push) Successful in 53s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 51s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / recommender / recommender (push) Failing after 16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s

This commit is contained in:
mrleo1nid committed 2026-10-22 14:19:52 +03:00
1 parent e158558da3
commit fff91c446b
24 files changed
+778 -324

No files matched your search

+32 -10
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
// Онбординг вкуса (docs/recommendations.md#7): новый пользователь отмечает исполнителей и жанры — рекомендатель
@@ -16,8 +17,6 @@ import (
const (
onboardingArtists = 40
onboardingGenres = 24
// История, после которой онбординг уже не предлагается: вкус и так виден по прослушиваниям.
onboardingMinHistory = 10
)
// GetOnboarding реализует gen.StrictServerInterface.
@@ -27,13 +26,18 @@ func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestO
return nil, err
}
out := gen.GetOnboarding200JSONResponse{}
var done bool
var plays int
var raw []byte
if err := h.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM user_profiles_rec WHERE user_id = $1 AND onboarding IS NOT NULL),
(SELECT count(*) FROM user_track_stats WHERE user_id = $1)`, p.UserID).Scan(&done, &plays); err != nil {
SELECT (SELECT onboarding || jsonb_build_object('age_group', rec_age_group(onboarding))
FROM user_profiles_rec WHERE user_id = $1)`, p.UserID).Scan(&raw); err != nil {
return nil, fmt.Errorf("wave: onboarding state: %w", err)
}
if len(raw) > 0 {
out.Choice = &gen.OnboardingChoice{}
if err := json.Unmarshal(raw, out.Choice); err != nil {
return nil, err
}
}
rows, err := h.pool.Query(ctx, `
SELECT a.id, a.name, count(*) AS n,
@@ -112,7 +116,7 @@ func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestO
TrackCount int `json:"track_count"`
}{}
}
out.Required = !done && plays < onboardingMinHistory && len(out.Artists) > 0
out.Required = out.Choice == nil
return out, nil
}
@@ -122,6 +126,19 @@ func (h *Handler) SaveOnboarding(ctx context.Context, req gen.SaveOnboardingRequ
if err != nil {
return nil, err
}
if req.Body == nil {
return nil, apperr.Validation("body", "common.bad_request", "body required", nil)
}
if req.Body.Gender != nil && *req.Body.Gender != "male" && *req.Body.Gender != "female" && *req.Body.Gender != "other" {
return nil, apperr.Validation("gender", "common.bad_request", "invalid gender", nil)
}
if req.Body.AgeGroup != nil {
switch *req.Body.AgeGroup {
case "under_13", "13_19", "20_29", "30_39", "40_49", "50_59", "60_plus":
default:
return nil, apperr.Validation("age_group", "common.bad_request", "invalid age group", nil)
}
}
ids := req.Body.ArtistIds
if ids == nil {
ids = []uuid.UUID{}
@@ -130,10 +147,15 @@ func (h *Handler) SaveOnboarding(ctx context.Context, req gen.SaveOnboardingRequ
if genres == nil {
genres = []string{}
}
raw, _ := json.Marshal(map[string]any{"artist_ids": ids, "genres": genres})
raw, _ := json.Marshal(map[string]any{"artist_ids": ids, "genres": genres, "gender": req.Body.Gender, "age_group": req.Body.AgeGroup})
if _, err := h.pool.Exec(ctx, `
INSERT INTO user_profiles_rec (user_id, onboarding, updated_at) VALUES ($1, $2, now())
ON CONFLICT (user_id) DO UPDATE SET onboarding = EXCLUDED.onboarding, updated_at = now()`,
INSERT INTO user_profiles_rec (user_id, onboarding, updated_at) VALUES ($1, $2::jsonb || jsonb_build_object('age_group_year', EXTRACT(YEAR FROM CURRENT_DATE)::int), now())
ON CONFLICT (user_id) DO UPDATE SET onboarding = CASE
WHEN rec_age_group(user_profiles_rec.onboarding) = EXCLUDED.onboarding->>'age_group'
THEN EXCLUDED.onboarding || jsonb_build_object(
'age_group', user_profiles_rec.onboarding->'age_group',
'age_group_year', user_profiles_rec.onboarding->'age_group_year')
ELSE EXCLUDED.onboarding END, updated_at = now()`,
p.UserID, raw); err != nil {
return nil, fmt.Errorf("wave: save onboarding: %w", err)
}
+20 -4
View File
@@ -373,7 +373,18 @@ func (h *Handler) record(ctx context.Context, s session, picks []Pick, modelVer
// без дизлайков, недавно игравших и уже выданных; один трек исполнителя на пачку.
func (h *Handler) fallback(ctx context.Context, s session, served []string) ([]Pick, error) {
rows, err := h.pool.Query(ctx, `
WITH liked AS (
WITH own AS (
SELECT onboarding FROM user_profiles_rec WHERE user_id = $1
), cohort AS (
SELECT r.track_id FROM track_reactions r
JOIN user_profiles_rec p ON p.user_id = r.user_id
JOIN users u ON u.id = p.user_id, own
WHERE r.reaction = 'like' AND p.user_id <> $1 AND NOT u.is_blocked
AND (own.onboarding->>'gender' IS NOT NULL OR rec_age_group(own.onboarding) IS NOT NULL)
AND (own.onboarding->>'gender' IS NULL OR p.onboarding->>'gender' = own.onboarding->>'gender')
AND (rec_age_group(own.onboarding) IS NULL OR rec_age_group(p.onboarding) = rec_age_group(own.onboarding))
GROUP BY r.track_id HAVING count(DISTINCT r.user_id) >= 2
), liked AS (
SELECT track_id FROM track_reactions WHERE user_id = $1 AND reaction = 'like'
), fav_artists AS (
SELECT DISTINCT ta.artist_id FROM track_artists ta JOIN liked l ON l.track_id = ta.track_id
@@ -384,7 +395,12 @@ func (h *Handler) fallback(ctx context.Context, s session, served []string) ([]P
CASE WHEN t.id IN (SELECT track_id FROM liked) THEN 'liked'
WHEN EXISTS (SELECT 1 FROM track_artists ta WHERE ta.track_id = t.id
AND ta.artist_id IN (SELECT artist_id FROM fav_artists)) THEN 'artist'
ELSE 'random' END AS source
WHEN EXISTS (SELECT 1 FROM own, track_artists ta
WHERE ta.track_id = t.id AND own.onboarding->'artist_ids' ? ta.artist_id::text) THEN 'onboarding'
WHEN EXISTS (SELECT 1 FROM own, track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE tg.track_id = t.id AND own.onboarding->'genres' ? g.name) THEN 'onboarding'
WHEN t.id IN (SELECT track_id FROM cohort) THEN 'cohort'
ELSE 'random' END AS source
FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN media_files f ON f.id = t.primary_file_id AND f.missing_since IS NULL
@@ -395,7 +411,7 @@ func (h *Handler) fallback(ctx context.Context, s session, served []string) ([]P
AND e.type = 'play_start' AND e.occurred_at > now() - interval '3 hours')
)
SELECT DISTINCT ON (artist_id) id::text, source FROM (
SELECT * FROM pool ORDER BY CASE source WHEN 'liked' THEN 0 WHEN 'artist' THEN 1 ELSE 2 END, random() LIMIT 200
SELECT * FROM pool ORDER BY CASE source WHEN 'liked' THEN 0 WHEN 'artist' THEN 1 WHEN 'onboarding' THEN 2 WHEN 'cohort' THEN 3 ELSE 4 END, random() LIMIT 200
) c
ORDER BY artist_id, random()
LIMIT 50`, s.UserID, served)
@@ -410,7 +426,7 @@ func (h *Handler) fallback(ctx context.Context, s session, served []string) ([]P
return nil, fmt.Errorf("wave: fallback: %w", err)
}
// DISTINCT ON упорядочил по исполнителю — перемешиваем, предпочитая лайки и любимых исполнителей.
rank := map[string]int{"liked": 0, "artist": 1, "random": 2}
rank := map[string]int{"liked": 0, "artist": 1, "onboarding": 2, "cohort": 3, "random": 4}
shuffled := shuffle(picks)
stableSort(shuffled, func(a, b Pick) bool { return rank[a.Source] < rank[b.Source] })
// Половина пачки — любимое, половина — случайное: запасная волна не должна быть одними лайками.