From fff91c446b1282d53efd6cdd32434acec03f0f82 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:19:52 +0300 Subject: [PATCH] feat(recommendations): collect demographics and advance age cohorts --- api/openapi.yaml | 20 +- apps/mobile/src/app/(tabs)/index.tsx | 5 + apps/mobile/src/app/(tabs)/profile.tsx | 3 + apps/mobile/src/ui/Onboarding.tsx | 85 +++ apps/web/e2e/scenarios.spec.ts | 9 + apps/web/src/App.test.tsx | 27 + apps/web/src/pages/HomePage.tsx | 3 +- apps/web/src/pages/Onboarding.tsx | 37 +- apps/web/src/pages/SettingsPage.tsx | 16 + apps/web/src/test/fakeApi.ts | 10 +- docs/recommendations.md | 25 +- packages/api-client/src/generated/schema.ts | 15 +- packages/core/src/index.ts | 2 + packages/core/src/onboarding.ts | 2 + packages/i18n/locales/en/common.json | 21 +- packages/i18n/locales/ru/common.json | 21 +- services/api/cmd/api/e2e_test.go | 29 +- services/api/internal/api/gen/api.gen.go | 606 ++++++++++-------- services/api/internal/wave/onboarding.go | 42 +- services/api/internal/wave/wave.go | 24 +- .../00015_onboarding_demographics.sql | 22 + services/recommender/pipu_rec/wave/store.py | 32 + .../recommender/tests/test_db_integration.py | 27 + services/recommender/tests/test_onboarding.py | 19 + 24 files changed, 778 insertions(+), 324 deletions(-) create mode 100644 apps/mobile/src/ui/Onboarding.tsx create mode 100644 packages/core/src/onboarding.ts create mode 100644 services/api/migrations/00015_onboarding_demographics.sql create mode 100644 services/recommender/tests/test_onboarding.py diff --git a/api/openapi.yaml b/api/openapi.yaml index b7883eb..4d9f392 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1427,9 +1427,10 @@ paths: get: operationId: getOnboarding tags: [wave] - summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора + summary: Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения description: | - `required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет. + `required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации. + `choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере. responses: "200": description: Варианты и состояние @@ -1819,6 +1820,8 @@ components: properties: required: type: boolean + choice: + $ref: "#/components/schemas/OnboardingChoice" artists: type: array items: @@ -1854,6 +1857,19 @@ components: type: object required: [artist_ids, genres] properties: + gender: + type: string + nullable: true + enum: [male, female, other] + age_group: + type: string + nullable: true + enum: [under_13, "13_19", "20_29", "30_39", "40_49", "50_59", 60_plus] + description: Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы. + age_group_year: + type: integer + readOnly: true + description: Год последнего выбора возрастной группы. artist_ids: type: array maxItems: 50 diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index d4cdaff..54d3d01 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -9,6 +9,7 @@ import { player } from '../../player/controller'; import { usePlayerState } from '../../player/hooks'; import { useColors } from '../../theme'; import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit'; +import { OnboardingGate } from '../../ui/Onboarding'; import { WaveOrb } from '../../ui/wave/WaveOrb'; /** Имена из /wave/options (Material Symbols) → MaterialIcons. */ @@ -22,6 +23,10 @@ function iconFor(name: string): IconName { * На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md). */ export default function HomeScreen() { + return ; +} + +function WaveHome() { const colors = useColors(); const { t } = useTranslation(); const tr = t as unknown as (k: string) => string; diff --git a/apps/mobile/src/app/(tabs)/profile.tsx b/apps/mobile/src/app/(tabs)/profile.tsx index 3cd3796..8463316 100644 --- a/apps/mobile/src/app/(tabs)/profile.tsx +++ b/apps/mobile/src/app/(tabs)/profile.tsx @@ -15,6 +15,7 @@ import { player } from '../../player/controller'; import { radius, useColors } from '../../theme'; import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit'; import { useDownloads } from '../../ui/downloads'; +import { RecommendationSettings } from '../../ui/Onboarding'; import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings'; import { useUpdateState } from '../../ui/UpdateDialog'; @@ -74,6 +75,8 @@ export default function ProfileScreen() { @{user.username} · {t(`roles.${user.role}`)} + +
diff --git a/apps/mobile/src/ui/Onboarding.tsx b/apps/mobile/src/ui/Onboarding.tsx new file mode 100644 index 0000000..c40200a --- /dev/null +++ b/apps/mobile/src/ui/Onboarding.tsx @@ -0,0 +1,85 @@ +import type { components } from '@pipu/api-client'; +import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'; + +import { useSession, useSessionCtx, useUser } from '../lib/session'; +import { useColors } from '../theme'; +import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from './kit'; + +type Options = components['schemas']['Onboarding']; +type Choice = components['schemas']['OnboardingChoice']; + +function useOnboarding() { + const session = useSession(); + const user = useUser(); + const { server } = useSessionCtx(); + return useQuery({ queryKey: ['onboarding', server, user.id], + queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), + staleTime: 0, refetchInterval: 30_000 }); +} + +export function OnboardingGate({ children }: { children: ReactNode }) { + const query = useOnboarding(); + const client = useQueryClient(); + if (query.isPending) return ; + if (!query.data?.required) return <>{children}; + return + void client.invalidateQueries({ queryKey: ['onboarding'] })} /> + ; +} + +export function RecommendationSettings() { + const { t } = useTranslation(); + const query = useOnboarding(); + const client = useQueryClient(); + const [editing, setEditing] = useState(false); + const errorText = useErrorText(); + return + {editing && query.data ? { + setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] }); + }} /> : + {GENDERS.map((g) => )} + +
+
+

{t('onboarding.age_group')}

+

{t('onboarding.age_hint')}

+
+ + {AGE_GROUPS.map((g) => )} +
+

{t('onboarding.artists')}

@@ -101,11 +118,11 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () = {save.error ? errorText(save.error) : null}
- -
diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 800cfe3..187cb4c 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -12,6 +12,7 @@ import { formatDateTime } from '../shared/lib/format'; import { Button, Field, FormError, Segmented } from '../shared/ui/ui'; import { LanguageSwitch } from '../widgets/LanguageSwitch'; import s from './page.module.css'; +import { Onboarding } from './Onboarding'; type Theme = NonNullable; type WaveVariant = NonNullable; @@ -24,6 +25,7 @@ export function SettingsPage() {

{t('settings.title')}

+ @@ -357,3 +359,17 @@ function SessionsSection() {
); } + +function RecommendationSettings() { + const { t } = useTranslation(); + const session = useSession(); + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + const errorText = useErrorText(); + const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 }); + return
+ {editing && options.data ? { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} /> + : } + {options.error ? errorText(options.error) : null} +
; +} diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index 3f12f94..6957de8 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -70,6 +70,7 @@ export function makeUser(username: string, role: Role, extra: Partial */ export function fakeApi(initial: FakeUser[]) { const users = new Map(initial.map((u) => [u.id, { ...u }])); + const onboarding = new Map(); const prefs = new Map(); let current: string | null = null; // id вошедшего (одна сессия на тест) let token = 0; @@ -206,7 +207,10 @@ export function fakeApi(initial: FakeUser[]) { case 'POST /api/v1/uploads/seen': return new Response(null, { status: 204 }); case 'GET /api/v1/onboarding': - return json({ required: false, artists: [], genres: [] }); + return json({ required: onboarding.get(user!.id) === null, artists: [], genres: [], choice: onboarding.get(user!.id) ?? undefined }); + case 'PUT /api/v1/me/onboarding': + onboarding.set(user!.id, body as components['schemas']['OnboardingChoice']); + return new Response(null, { status: 204 }); case 'GET /api/v1/me/likes': case 'GET /api/v1/me/dislikes': case 'GET /api/v1/me/history': @@ -285,8 +289,10 @@ export function fakeApi(initial: FakeUser[]) { case `GET /api/v1/artists/${kino.id}/albums`: return json({ items: [albumSummary] }); } - if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) + if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) { + onboarding.set(path.split('/')[5]!, null); return new Response(null, { status: 204 }); + } const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path); if (userPath && req.method === 'PATCH') { if (!user || user.role === 'user') return problem(403, 'auth.forbidden'); diff --git a/docs/recommendations.md b/docs/recommendations.md index 3670dc2..18aa951 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -219,10 +219,27 @@ MMR-разнообразие и квоты остаются. Пока модел - **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды. Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки (по одному треку из разных кластеров), exploration 40% на первые 30 треков. -- Реализация онбординга: `GET /onboarding` (40 исполнителей с наибольшим числом треков, 24 жанра; `required` — - не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор — - пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1, - жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны. +- Онбординг: `GET /onboarding` возвращает 40 исполнителей, 24 жанра, сохранённый `choice` и `required`. + Анкета показывается на главной в вебе/desktop и Android до первого заполнения или пропуска, независимо + от истории и наличия библиотеки. Её можно повторно открыть в настройках профиля. +- `PUT /me/onboarding` сохраняет исполнителей, жанры, необязательный пол (`male`, `female`, `other`) и + возрастную группу (`under_13`, `13_19`, `20_29`, `30_39`, `40_49`, `50_59`, `60_plus`) в + `user_profiles_rec.onboarding`. Пустые списки и `null` для пола/возраста означают пропуск. +- Вместе с исходной возрастной группой сервер сохраняет год выбора (`age_group_year`); клиент не может + подменить его. SQL-функция `rec_age_group` прибавляет прошедшие годы к середине выбранного диапазона + (для открытых диапазонов: 6 и 65 лет). Например, 13–19 лет в 2006 → примерно 30–39 лет в 2026. + API возвращает текущую приблизительную группу. При сохранении той же текущей группы исходная группа + и год остаются прежними; выбор другой группы задаёт новую точку отсчёта. Это оценка, не дата рождения. +- Пока собственных оценок меньше 30, рекомендатель добавляет псевдооценки: исполнители +1, жанры +0.5, + а треки, понравившиеся минимум двум другим незаблокированным слушателям с тем же полом и текущей + возрастной группой, — до +0.25. Если указано одно поле, сравнивается только оно; если ни одного — + демографического сигнала нет. Личные оценки не перезаписываются, дизлайки исключаются. + На каждый источник действует лимит 300 треков. Музыкальные предпочтения пола/возраста заранее не задаются. +- Запасная Go-волна учитывает выбранных исполнителей/жанры и те же демографические когорты после личных + лайков и любимых исполнителей. Случайные треки сохраняют разнообразие. +- Администраторский сброс удаляет профиль целиком, включая все ответы и год выбора. Клиенты обновляют + состояние анкеты каждые 30 секунд, а веб также при возвращении на главную/в окно. Пользователь проходит + настройку снова; текущее воспроизведение не прерывается. - **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания. ## 8. Обучение на всех пользователях diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index 0b95b28..b509136 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -1225,8 +1225,9 @@ export interface paths { cookie?: never; }; /** - * Онбординг вкуса — исполнители и жанры библиотеки для выбора - * @description `required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет. + * Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения + * @description `required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации. + * `choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере. */ get: operations["getOnboarding"]; put?: never; @@ -1472,6 +1473,7 @@ export interface components { }; Onboarding: { required: boolean; + choice?: components["schemas"]["OnboardingChoice"]; artists: { /** Format: uuid */ id: string; @@ -1487,6 +1489,15 @@ export interface components { }[]; }; OnboardingChoice: { + /** @enum {string|null} */ + gender?: "male" | "female" | "other" | null; + /** + * @description Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы. + * @enum {string|null} + */ + age_group?: "under_13" | "13_19" | "20_29" | "30_39" | "40_49" | "50_59" | "60_plus" | null; + /** @description Год последнего выбора возрастной группы. */ + readonly age_group_year?: number; artist_ids: string[]; genres: string[]; }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 32ec127..8a5d0fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,3 +27,5 @@ export { checkServer, normalizeServerUrl, type ServerCheck } from './server'; export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim'; export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave'; + +export { AGE_GROUPS, GENDERS } from './onboarding'; diff --git a/packages/core/src/onboarding.ts b/packages/core/src/onboarding.ts new file mode 100644 index 0000000..a45766d --- /dev/null +++ b/packages/core/src/onboarding.ts @@ -0,0 +1,2 @@ +export const AGE_GROUPS = ['under_13', '13_19', '20_29', '30_39', '40_49', '50_59', '60_plus'] as const; +export const GENDERS = ['male', 'female', 'other'] as const; diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index dc919ea..103c744 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -318,7 +318,26 @@ "pick_more_one": "Pick {{count}} more artist", "pick_more_few": "Pick {{count}} more artists", "pick_more_many": "Pick {{count}} more artists", - "pick_more_other": "Pick {{count}} more artists" + "pick_more_other": "Pick {{count}} more artists", + "gender": "Gender", + "age_group": "Age group", + "unspecified": "Prefer not to say", + "edit": "Recommendation preferences", + "age_hint": "Your group will advance approximately from the year selected. You can correct it in your profile.", + "genders": { + "male": "Male", + "female": "Female", + "other": "Other" + }, + "ages": { + "under_13": "До 13", + "13_19": "13–19", + "20_29": "20–29", + "30_39": "30–39", + "40_49": "40–49", + "50_59": "50–59", + "60_plus": "60+" + } }, "playlists": { "name": "Name", diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 9fe7e53..214a317 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -318,7 +318,26 @@ "pick_more_one": "Выберите ещё {{count}} исполнителя", "pick_more_few": "Выберите ещё {{count}} исполнителя", "pick_more_many": "Выберите ещё {{count}} исполнителей", - "pick_more_other": "Выберите ещё {{count}} исполнителя" + "pick_more_other": "Выберите ещё {{count}} исполнителя", + "gender": "Пол", + "age_group": "Возрастная группа", + "unspecified": "Не указывать", + "edit": "Настроить рекомендации", + "age_hint": "Группа будет обновляться приблизительно с учётом года выбора. Её можно исправить в профиле.", + "genders": { + "male": "Мужской", + "female": "Женский", + "other": "Другой" + }, + "ages": { + "under_13": "До 13", + "13_19": "13–19", + "20_29": "20–29", + "30_39": "30–39", + "40_49": "40–49", + "50_59": "50–59", + "60_plus": "60+" + } }, "playlists": { "name": "Название", diff --git a/services/api/cmd/api/e2e_test.go b/services/api/cmd/api/e2e_test.go index f57508a..11b2ed3 100644 --- a/services/api/cmd/api/e2e_test.go +++ b/services/api/cmd/api/e2e_test.go @@ -29,6 +29,7 @@ import ( "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" + "pipu/api/internal/api/gen" "pipu/api/internal/library" "pipu/api/internal/library/meta" "pipu/api/internal/platform/config" @@ -281,17 +282,41 @@ func TestUserJourney(t *testing.T) { // 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается. var onb struct { - Required bool `json:"required"` + Choice *gen.OnboardingChoice `json:"choice"` + Required bool `json:"required"` Artists []struct { ID string `json:"id"` } `json:"artists"` } c.ok("GET", "/api/v1/onboarding", nil, &onb) require.NotEmpty(t, onb.Artists) - c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{onb.Artists[0].ID}, "genres": []string{}}, nil) + c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{onb.Artists[0].ID}, "genres": []string{}, "gender": "male", "age_group": "13_19", "age_group_year": 1900}, nil) c.ok("GET", "/api/v1/onboarding", nil, &onb) require.False(t, onb.Required) + require.NotNil(t, onb.Choice) + require.Equal(t, gen.Male, *onb.Choice.Gender) + require.Equal(t, gen.N1319, *onb.Choice.AgeGroup) + require.Greater(t, *onb.Choice.AgeGroupYear, 1900, "client cannot forge the selection year") + _, err = pool.Exec(ctx, `UPDATE user_profiles_rec SET onboarding = jsonb_set(onboarding, + '{age_group_year}', to_jsonb(EXTRACT(YEAR FROM CURRENT_DATE)::int - 20)) WHERE user_id = $1`, tokens.User.ID) + require.NoError(t, err) + c.ok("GET", "/api/v1/onboarding", nil, &onb) + require.Equal(t, gen.N3039, *onb.Choice.AgeGroup, "13–19 twenty years ago is approximately 30–39 now") + originalYear := *onb.Choice.AgeGroupYear + c.ok("PUT", "/api/v1/me/onboarding", onb.Choice, nil) + c.ok("GET", "/api/v1/onboarding", nil, &onb) + require.Equal(t, originalYear, *onb.Choice.AgeGroupYear, "editing tastes preserves the age anchor") + require.Equal(t, gen.N3039, *onb.Choice.AgeGroup) + c.ok("POST", "/api/v1/admin/users/"+tokens.User.ID+"/reset-recommendations", nil, nil) + onb.Choice = nil + c.ok("GET", "/api/v1/onboarding", nil, &onb) + require.True(t, onb.Required) + require.Nil(t, onb.Choice, "all onboarding answers are cleared") + c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{}, "genres": []string{}}, nil) + c.ok("GET", "/api/v1/onboarding", nil, &onb) + require.False(t, onb.Required, "skip is remembered") + // 8. «Моя волна»: рекомендатель недоступен — запасная волна из библиотеки. var wave struct { SessionID string `json:"session_id"` diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index 4633e34..a0d5b1a 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -53,6 +53,24 @@ const ( HealthStatusStatusOk HealthStatusStatus = "ok" ) +// Defines values for OnboardingChoiceAgeGroup. +const ( + N1319 OnboardingChoiceAgeGroup = "13_19" + N2029 OnboardingChoiceAgeGroup = "20_29" + N3039 OnboardingChoiceAgeGroup = "30_39" + N4049 OnboardingChoiceAgeGroup = "40_49" + N5059 OnboardingChoiceAgeGroup = "50_59" + N60Plus OnboardingChoiceAgeGroup = "60_plus" + Under13 OnboardingChoiceAgeGroup = "under_13" +) + +// Defines values for OnboardingChoiceGender. +const ( + Female OnboardingChoiceGender = "female" + Male OnboardingChoiceGender = "male" + Other OnboardingChoiceGender = "other" +) + // Defines values for PlayEventContextType. const ( PlayEventContextTypeAlbum PlayEventContextType = "album" @@ -603,6 +621,7 @@ type Onboarding struct { Name string `json:"name"` TrackCount int `json:"track_count"` } `json:"artists"` + Choice *OnboardingChoice `json:"choice,omitempty"` Genres []struct { Name string `json:"name"` TrackCount int `json:"track_count"` @@ -612,10 +631,22 @@ type Onboarding struct { // OnboardingChoice defines model for OnboardingChoice. type OnboardingChoice struct { - ArtistIds []openapi_types.UUID `json:"artist_ids"` - Genres []string `json:"genres"` + // AgeGroup Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы. + AgeGroup *OnboardingChoiceAgeGroup `json:"age_group"` + + // AgeGroupYear Год последнего выбора возрастной группы. + AgeGroupYear *int `json:"age_group_year,omitempty"` + ArtistIds []openapi_types.UUID `json:"artist_ids"` + Gender *OnboardingChoiceGender `json:"gender"` + Genres []string `json:"genres"` } +// OnboardingChoiceAgeGroup Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы. +type OnboardingChoiceAgeGroup string + +// OnboardingChoiceGender defines model for OnboardingChoice.Gender. +type OnboardingChoiceGender string + // PathPattern defines model for PathPattern. type PathPattern struct { IsEnabled bool `json:"is_enabled"` @@ -1882,7 +1913,7 @@ type ServerInterface interface { // Завершить сессию на устройстве // (DELETE /api/v1/me/sessions/{id}) RevokeMySession(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) - // Онбординг вкуса — исполнители и жанры библиотеки для выбора + // Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения // (GET /api/v1/onboarding) GetOnboarding(w http.ResponseWriter, r *http.Request) // Страница поиска @@ -2355,7 +2386,7 @@ func (_ Unimplemented) RevokeMySession(w http.ResponseWriter, r *http.Request, i w.WriteHeader(http.StatusNotImplemented) } -// Онбординг вкуса — исполнители и жанры библиотеки для выбора +// Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения // (GET /api/v1/onboarding) func (_ Unimplemented) GetOnboarding(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) @@ -8304,7 +8335,7 @@ type StrictServerInterface interface { // Завершить сессию на устройстве // (DELETE /api/v1/me/sessions/{id}) RevokeMySession(ctx context.Context, request RevokeMySessionRequestObject) (RevokeMySessionResponseObject, error) - // Онбординг вкуса — исполнители и жанры библиотеки для выбора + // Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения // (GET /api/v1/onboarding) GetOnboarding(ctx context.Context, request GetOnboardingRequestObject) (GetOnboardingResponseObject, error) // Страница поиска @@ -10879,286 +10910,291 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "hIU8hV6vce/Wvf8bAAD//7F7c4hPbwEA", + "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", + "7iBqh3IBAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/wave/onboarding.go b/services/api/internal/wave/onboarding.go index a602358..4461ca2 100644 --- a/services/api/internal/wave/onboarding.go +++ b/services/api/internal/wave/onboarding.go @@ -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) } diff --git a/services/api/internal/wave/wave.go b/services/api/internal/wave/wave.go index 195a564..8834d5c 100644 --- a/services/api/internal/wave/wave.go +++ b/services/api/internal/wave/wave.go @@ -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] }) // Половина пачки — любимое, половина — случайное: запасная волна не должна быть одними лайками. diff --git a/services/api/migrations/00015_onboarding_demographics.sql b/services/api/migrations/00015_onboarding_demographics.sql new file mode 100644 index 0000000..0df88fc --- /dev/null +++ b/services/api/migrations/00015_onboarding_demographics.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- Keep the original group and year; approximate current age from its midpoint. +-- +goose StatementBegin +CREATE FUNCTION rec_age_group(choice jsonb) RETURNS text LANGUAGE sql STABLE AS $$ + WITH age AS ( + SELECT CASE choice->>'age_group' + WHEN 'under_13' THEN 6 WHEN '13_19' THEN 16 WHEN '20_29' THEN 24 + WHEN '30_39' THEN 34 WHEN '40_49' THEN 44 WHEN '50_59' THEN 54 + WHEN '60_plus' THEN 65 END + + GREATEST(0, EXTRACT(YEAR FROM CURRENT_DATE)::int + - COALESCE((choice->>'age_group_year')::int, EXTRACT(YEAR FROM CURRENT_DATE)::int)) AS n + ) + SELECT CASE WHEN n IS NULL THEN NULL WHEN n < 13 THEN 'under_13' WHEN n < 20 THEN '13_19' + WHEN n < 30 THEN '20_29' WHEN n < 40 THEN '30_39' WHEN n < 50 THEN '40_49' + WHEN n < 60 THEN '50_59' ELSE '60_plus' END FROM age +$$; +-- +goose StatementEnd +CREATE INDEX user_profiles_rec_gender_idx ON user_profiles_rec ((onboarding->>'gender')); + +-- +goose Down +DROP INDEX user_profiles_rec_gender_idx; +DROP FUNCTION rec_age_group(jsonb); diff --git a/services/recommender/pipu_rec/wave/store.py b/services/recommender/pipu_rec/wave/store.py index 142dc3c..7cdb1de 100644 --- a/services/recommender/pipu_rec/wave/store.py +++ b/services/recommender/pipu_rec/wave/store.py @@ -161,6 +161,9 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile: ).fetchone() if row and row[3] and len(scores) < ONBOARDING_UNTIL: apply_onboarding(conn, scores, row[3]) + apply_demographics(conn, scores, row[3], user_id) + for tid in disliked: + scores.pop(tid, None) exploration = float(row[0]) if row else 0.15 taste_cf = parse_vector(row[1]) if row else None bandit = {str(k): [float(x) for x in v] for k, v in (row[2] or {}).items()} if row else {} @@ -212,6 +215,35 @@ def apply_onboarding(conn: psycopg.Connection[Any], scores: dict[str, float], ch scores.setdefault(tid, v) +def apply_demographics( + conn: psycopg.Connection[Any], scores: dict[str, float], choice: dict[str, Any], user_id: str +) -> None: + """A weak cold-start prior from actual likes of peers, without assumed musical stereotypes.""" + gender, age = choice.get("gender"), choice.get("age_group") + if not gender and not age: + return + rows = conn.execute( + """ + WITH own AS ( + SELECT onboarding FROM user_profiles_rec WHERE user_id = %s + ), peers AS ( + SELECT p.user_id FROM user_profiles_rec p JOIN users u ON u.id = p.user_id, own + WHERE p.user_id <> %s AND NOT u.is_blocked + AND (%s::text IS NULL OR p.onboarding->>'gender' = %s) + AND (%s::text IS NULL OR rec_age_group(p.onboarding) = rec_age_group(own.onboarding)) + ) + SELECT r.track_id::text, count(DISTINCT r.user_id) AS fans + FROM track_reactions r JOIN peers p ON p.user_id = r.user_id + WHERE r.reaction = 'like' + GROUP BY r.track_id HAVING count(DISTINCT r.user_id) >= 2 + ORDER BY fans DESC, r.track_id LIMIT %s + """, + (user_id, user_id, gender, gender, age, ONBOARDING_LIMIT), + ) + for tid, fans in rows: + scores.setdefault(tid, min(0.25, 0.05 * fans)) + + def seed_tracks(conn: psycopg.Connection[Any], seed_type: str, seed_id: str) -> list[str]: """Треки, от которых запущена волна: трек, альбом или исполнитель.""" queries = { diff --git a/services/recommender/tests/test_db_integration.py b/services/recommender/tests/test_db_integration.py index ec26b8e..f72ad63 100644 --- a/services/recommender/tests/test_db_integration.py +++ b/services/recommender/tests/test_db_integration.py @@ -181,3 +181,30 @@ def test_impression_features_roundtrip(world: dict[str, Any]) -> None: assert any(r[0] == session and r[2] == "complete" for r in rows) result = train_and_save(conn, time.time()) assert "skipped" in result["ranker"], "too few impressions — v1 stays" + + +def test_demographic_prior_advances_age_and_preserves_own_feedback(world: dict[str, Any]) -> None: + user = uid() + with psycopg.connect(DSN, autocommit=True) as conn: + conn.execute("INSERT INTO users (id, username, password_hash) VALUES (%s, %s, 'x')", (user, f"cohort-{user[:6]}")) + # Target selected 13–19 twenty years ago; matching listeners are now 30–39. + conn.execute( + "INSERT INTO user_profiles_rec (user_id, onboarding) VALUES (%s, " + "jsonb_build_object('age_group', '13_19', 'gender', 'male', " + "'age_group_year', EXTRACT(YEAR FROM CURRENT_DATE)::int - 20))", + (user,), + ) + for peer in world["users"][:3]: + conn.execute( + "INSERT INTO user_profiles_rec (user_id, onboarding) VALUES (%s, %s) " + "ON CONFLICT (user_id) DO UPDATE SET onboarding = EXCLUDED.onboarding", + (peer, json.dumps({"gender": "male", "age_group": "30_39"})), + ) + profile = load_profile(conn, user) + track = world["tracks"][0] # two matching listeners liked it + assert 0 < profile.scores[track] <= 0.25 + assert not profile.played + conn.execute("INSERT INTO track_reactions (user_id, track_id, reaction) VALUES (%s, %s, 'dislike')", (user, track)) + assert track not in load_profile(conn, user).scores + conn.execute("UPDATE user_profiles_rec SET onboarding = %s WHERE user_id = %s", (json.dumps({"gender": "female"}), user)) + assert not load_profile(conn, user).scores, "no invented preferences when the cohort has no evidence" diff --git a/services/recommender/tests/test_onboarding.py b/services/recommender/tests/test_onboarding.py new file mode 100644 index 0000000..ab89c7d --- /dev/null +++ b/services/recommender/tests/test_onboarding.py @@ -0,0 +1,19 @@ +from unittest.mock import MagicMock + +from pipu_rec.wave.store import apply_demographics + + +def test_missing_demographics_do_not_create_a_prior() -> None: + conn = MagicMock() + scores = {"own": 3.0} + apply_demographics(conn, scores, {}, "user") + conn.execute.assert_not_called() + assert scores == {"own": 3.0} + + +def test_cohort_prior_is_weak_and_never_overwrites_personal_scores() -> None: + conn = MagicMock() + conn.execute.return_value = [("liked", 10), ("skipped", 10), ("peer", 100), ("small", 2)] + scores = {"liked": 3.0, "skipped": -1.0} + apply_demographics(conn, scores, {"gender": "male", "age_group": "13_19"}, "user") + assert scores == {"liked": 3.0, "skipped": -1.0, "peer": 0.25, "small": 0.1}