diff --git a/api/openapi.yaml b/api/openapi.yaml index 6feb699..c3df041 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1256,6 +1256,38 @@ paths: description: Снято default: $ref: "#/components/responses/Problem" + /api/v1/onboarding: + get: + operationId: getOnboarding + tags: [wave] + summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора + description: | + `required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет. + responses: + "200": + description: Варианты и состояние + content: + application/json: + schema: + $ref: "#/components/schemas/Onboarding" + default: + $ref: "#/components/responses/Problem" + /api/v1/me/onboarding: + put: + operationId: saveOnboarding + tags: [wave] + summary: Сохранить выбор онбординга (или пропуск — пустые списки) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingChoice" + responses: + "204": + description: Сохранено + default: + $ref: "#/components/responses/Problem" /api/v1/wave/options: get: operationId: getWaveOptions @@ -1442,6 +1474,59 @@ components: schema: $ref: "#/components/schemas/Problem" schemas: + Onboarding: + type: object + required: [required, artists, genres] + properties: + required: + type: boolean + artists: + type: array + items: + type: object + required: [id, name, track_count, cover_url, cover_colors] + properties: + id: + type: string + format: uuid + name: + type: string + track_count: + type: integer + cover_url: + type: string + nullable: true + description: Обложка самого большого альбома исполнителя + cover_colors: + type: array + items: + type: string + genres: + type: array + items: + type: object + required: [name, track_count] + properties: + name: + type: string + track_count: + type: integer + OnboardingChoice: + type: object + required: [artist_ids, genres] + properties: + artist_ids: + type: array + maxItems: 50 + items: + type: string + format: uuid + genres: + type: array + maxItems: 30 + items: + type: string + maxLength: 100 UserRef: type: object required: [id, username] diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index 2a3cf0c..8f1f3c4 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -1,5 +1,5 @@ import { unwrap } from '@pipu/core'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -24,6 +24,7 @@ import { import { cx, FormError, Icon } from '../shared/ui/ui'; import { WaveButton } from '../widgets/wave/WaveButton'; import s from './home.module.css'; +import { Onboarding } from './Onboarding'; /** Иконки Material Symbols по именам из /wave/options. */ const ICONS: Record = { @@ -44,8 +45,28 @@ const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF']; type Tr = (key: string) => string; -/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */ +/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */ export function HomePage() { + const session = useSession(); + const queryClient = useQueryClient(); + const onboarding = useQuery({ + queryKey: ['onboarding'], + queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), + staleTime: Infinity, + }); + if (onboarding.data?.required) { + return ( + queryClient.setQueryData(['onboarding'], { ...onboarding.data, required: false })} + /> + ); + } + return ; +} + +/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */ +function WaveHome() { const { t } = useTranslation(); const tr = t as unknown as Tr; const session = useSession(); diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx new file mode 100644 index 0000000..6bc6365 --- /dev/null +++ b/apps/web/src/pages/Onboarding.tsx @@ -0,0 +1,113 @@ +import type { components } from '@pipu/api-client'; +import { unwrap } from '@pipu/core'; +import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useSession } from '../app/session'; +import { Cover } from '../entities/catalog'; +import { useErrorText } from '../shared/lib/errors'; +import { checkIcon } from '../shared/ui/icons'; +import { Button, cx, FormError, Icon } from '../shared/ui/ui'; +import s from './onboarding.module.css'; + +type Options = components['schemas']['Onboarding']; + +/** Минимум исполнителей, чтобы волне было от чего оттолкнуться. */ +const MIN_ARTISTS = 3; + +/** + * Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны», + * пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим. + */ +export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) { + const { t } = useTranslation(); + const session = useSession(); + const errorText = useErrorText(); + const [artists, setArtists] = useState>(new Set()); + const [genres, setGenres] = useState>(new Set()); + const save = useMutation({ + mutationFn: async (skip: boolean) => + unwrap( + await session.client.PUT('/api/v1/me/onboarding', { + body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] }, + }), + ), + onSuccess: onDone, + }); + // Функциональное обновление: быстрые нажатия подряд не теряют выбор. + const toggle = (update: (fn: (prev: Set) => Set) => void, key: string) => + update((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0); + + return ( +
+
+

{t('onboarding.title')}

+

{t('onboarding.subtitle')}

+
+ +
+

{t('onboarding.artists')}

+
+ {options.artists.map((a) => { + const on = artists.has(a.id); + return ( + + ); + })} +
+
+ + {options.genres.length > 0 && ( +
+

{t('onboarding.genres')}

+
+ {options.genres.map((g) => ( + + ))} +
+
+ )} + + {save.error ? errorText(save.error) : null} +
+ + +
+
+ ); +} diff --git a/apps/web/src/pages/onboarding.module.css b/apps/web/src/pages/onboarding.module.css new file mode 100644 index 0000000..2ebcb50 --- /dev/null +++ b/apps/web/src/pages/onboarding.module.css @@ -0,0 +1,124 @@ +.page { + display: flex; + flex-direction: column; + gap: 28px; + max-width: 1100px; + margin: 0 auto; + animation: pipuIn 0.2s ease-out; +} + +.head h1 { + margin: 0 0 8px; + font-family: var(--font-display); + font-size: 32px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.head p { + margin: 0; + color: var(--text2); +} + +.label { + margin: 0 0 14px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text3); +} + +.artists { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 18px 12px; +} + +.artist { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 10px 6px; + border-radius: var(--radius-lg); + text-align: center; +} + +.artist:hover { + background: var(--hover); +} + +.avatar { + position: relative; + border-radius: 50%; + transition: + box-shadow 0.15s, + transform 0.15s; +} + +.on .avatar { + box-shadow: + 0 0 0 3px var(--bg), + 0 0 0 5px var(--accent); + transform: scale(0.96); +} + +.check { + position: absolute; + right: -2px; + bottom: -2px; + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent); + color: #fff; + box-shadow: var(--glow); +} + +.name { + font-size: 14px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.genres { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.genre { + height: 38px; + padding: 0 16px; + border-radius: var(--radius-pill); + border: 1px solid var(--line); + background: var(--surface2); + font-weight: 600; + font-size: 14px; +} + +.genre[aria-pressed='true'] { + background: var(--accent); + border-color: var(--accent); + color: #fff; + box-shadow: var(--glow); +} + +.actions { + position: sticky; + bottom: 16px; + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 12px; + border-radius: var(--radius-lg); + border: 1px solid var(--line2); + background: var(--glass); + backdrop-filter: blur(28px) saturate(1.3); +} diff --git a/docs/recommendations.md b/docs/recommendations.md index 534fc99..f27de36 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -209,6 +209,10 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis - **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды. Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки (по одному треку из разных кластеров), exploration 40% на первые 30 треков. +- Реализация онбординга: `GET /onboarding` (40 исполнителей с наибольшим числом треков, 24 жанра; `required` — + не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор — + пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1, + жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны. - **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания. ## 8. Обучение на всех пользователях diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index 2967aa1..6c3f3c2 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -1066,6 +1066,43 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/onboarding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Онбординг вкуса — исполнители и жанры библиотеки для выбора + * @description `required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет. + */ + get: operations["getOnboarding"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/me/onboarding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Сохранить выбор онбординга (или пропуск — пустые списки) */ + put: operations["saveOnboarding"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/wave/options": { parameters: { query?: never; @@ -1181,6 +1218,26 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + Onboarding: { + required: boolean; + artists: { + /** Format: uuid */ + id: string; + name: string; + track_count: number; + /** @description Обложка самого большого альбома исполнителя */ + cover_url: string | null; + cover_colors: string[]; + }[]; + genres: { + name: string; + track_count: number; + }[]; + }; + OnboardingChoice: { + artist_ids: string[]; + genres: string[]; + }; UserRef: { /** Format: uuid */ id: string; @@ -3599,6 +3656,50 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + getOnboarding: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Варианты и состояние */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Onboarding"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + saveOnboarding: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OnboardingChoice"]; + }; + }; + responses: { + /** @description Сохранено */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; getWaveOptions: { parameters: { query?: never; diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 7db970c..2f2b9a6 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -278,5 +278,17 @@ "cache": "Recent tracks cache", "cache_clear": "Clear ({{size}} MB)", "cache_pending": "Will be cleared on next launch" + }, + "onboarding": { + "title": "What do you like to listen to?", + "subtitle": "Pick a few artists and genres to start My Wave. After that it learns from what you play.", + "artists": "Artists", + "genres": "Genres", + "skip": "Skip", + "done": "Done — to the wave", + "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" } } diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 5957dc2..427a587 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -278,5 +278,17 @@ "cache": "Кэш недавних треков", "cache_clear": "Очистить ({{size}} МБ)", "cache_pending": "Очистится при следующем запуске" + }, + "onboarding": { + "title": "Что вы любите слушать?", + "subtitle": "Отметьте несколько исполнителей и жанров — с них начнётся «Моя волна». Дальше она будет учиться на ваших прослушиваниях.", + "artists": "Исполнители", + "genres": "Жанры", + "skip": "Пропустить", + "done": "Готово — к волне", + "pick_more_one": "Выберите ещё {{count}} исполнителя", + "pick_more_few": "Выберите ещё {{count}} исполнителя", + "pick_more_many": "Выберите ещё {{count}} исполнителей", + "pick_more_other": "Выберите ещё {{count}} исполнителя" } } diff --git a/services/api/cmd/api/e2e_test.go b/services/api/cmd/api/e2e_test.go index d6494b2..c1f2d21 100644 --- a/services/api/cmd/api/e2e_test.go +++ b/services/api/cmd/api/e2e_test.go @@ -271,6 +271,19 @@ func TestUserJourney(t *testing.T) { c.ok("GET", "/api/v1/me/likes", nil, &likes) require.Equal(t, 1, likes.Total) + // 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается. + var onb struct { + 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("GET", "/api/v1/onboarding", nil, &onb) + require.False(t, onb.Required) + // 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 4731a02..10503b4 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -488,6 +488,30 @@ type Me struct { Username string `json:"username"` } +// Onboarding defines model for Onboarding. +type Onboarding struct { + Artists []struct { + CoverColors []string `json:"cover_colors"` + + // CoverUrl Обложка самого большого альбома исполнителя + CoverUrl *string `json:"cover_url"` + Id openapi_types.UUID `json:"id"` + Name string `json:"name"` + TrackCount int `json:"track_count"` + } `json:"artists"` + Genres []struct { + Name string `json:"name"` + TrackCount int `json:"track_count"` + } `json:"genres"` + Required bool `json:"required"` +} + +// OnboardingChoice defines model for OnboardingChoice. +type OnboardingChoice struct { + ArtistIds []openapi_types.UUID `json:"artist_ids"` + Genres []string `json:"genres"` +} + // PathPattern defines model for PathPattern. type PathPattern struct { IsEnabled bool `json:"is_enabled"` @@ -1394,6 +1418,9 @@ type UpdateMeJSONRequestBody = UpdateMeRequest // SetBootstrapCredentialsJSONRequestBody defines body for SetBootstrapCredentials for application/json ContentType. type SetBootstrapCredentialsJSONRequestBody = BootstrapCredentialsRequest +// SaveOnboardingJSONRequestBody defines body for SaveOnboarding for application/json ContentType. +type SaveOnboardingJSONRequestBody = OnboardingChoice + // ChangePasswordJSONRequestBody defines body for ChangePassword for application/json ContentType. type ChangePasswordJSONRequestBody = ChangePasswordRequest @@ -1564,6 +1591,9 @@ type ServerInterface interface { // Лайки, новые сверху // (GET /api/v1/me/likes) GetLikes(w http.ResponseWriter, r *http.Request, params GetLikesParams) + // Сохранить выбор онбординга (или пропуск — пустые списки) + // (PUT /api/v1/me/onboarding) + SaveOnboarding(w http.ResponseWriter, r *http.Request) // Сменить пароль // (POST /api/v1/me/password) ChangePassword(w http.ResponseWriter, r *http.Request) @@ -1585,6 +1615,9 @@ 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) // Страница поиска // (GET /api/v1/search) Search(w http.ResponseWriter, r *http.Request, params SearchParams) @@ -1927,6 +1960,12 @@ func (_ Unimplemented) GetLikes(w http.ResponseWriter, r *http.Request, params G w.WriteHeader(http.StatusNotImplemented) } +// Сохранить выбор онбординга (или пропуск — пустые списки) +// (PUT /api/v1/me/onboarding) +func (_ Unimplemented) SaveOnboarding(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Сменить пароль // (POST /api/v1/me/password) func (_ Unimplemented) ChangePassword(w http.ResponseWriter, r *http.Request) { @@ -1969,6 +2008,12 @@ 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) +} + // Страница поиска // (GET /api/v1/search) func (_ Unimplemented) Search(w http.ResponseWriter, r *http.Request, params SearchParams) { @@ -3519,6 +3564,26 @@ func (siw *ServerInterfaceWrapper) GetLikes(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } +// SaveOnboarding operation middleware +func (siw *ServerInterfaceWrapper) SaveOnboarding(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SaveOnboarding(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ChangePassword operation middleware func (siw *ServerInterfaceWrapper) ChangePassword(w http.ResponseWriter, r *http.Request) { @@ -3692,6 +3757,26 @@ func (siw *ServerInterfaceWrapper) RevokeMySession(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } +// GetOnboarding operation middleware +func (siw *ServerInterfaceWrapper) GetOnboarding(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetOnboarding(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // Search operation middleware func (siw *ServerInterfaceWrapper) Search(w http.ResponseWriter, r *http.Request) { @@ -4497,6 +4582,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/me/likes", wrapper.GetLikes) }) + r.Group(func(r chi.Router) { + r.Put(options.BaseURL+"/api/v1/me/onboarding", wrapper.SaveOnboarding) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/me/password", wrapper.ChangePassword) }) @@ -4518,6 +4606,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Delete(options.BaseURL+"/api/v1/me/sessions/{id}", wrapper.RevokeMySession) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/onboarding", wrapper.GetOnboarding) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/search", wrapper.Search) }) @@ -5956,6 +6047,34 @@ func (response GetLikesdefaultApplicationProblemPlusJSONResponse) VisitGetLikesR return json.NewEncoder(w).Encode(response.Body) } +type SaveOnboardingRequestObject struct { + Body *SaveOnboardingJSONRequestBody +} + +type SaveOnboardingResponseObject interface { + VisitSaveOnboardingResponse(w http.ResponseWriter) error +} + +type SaveOnboarding204Response struct { +} + +func (response SaveOnboarding204Response) VisitSaveOnboardingResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type SaveOnboardingdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response SaveOnboardingdefaultApplicationProblemPlusJSONResponse) VisitSaveOnboardingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type ChangePasswordRequestObject struct { Body *ChangePasswordJSONRequestBody } @@ -6155,6 +6274,34 @@ func (response RevokeMySessiondefaultApplicationProblemPlusJSONResponse) VisitRe return json.NewEncoder(w).Encode(response.Body) } +type GetOnboardingRequestObject struct { +} + +type GetOnboardingResponseObject interface { + VisitGetOnboardingResponse(w http.ResponseWriter) error +} + +type GetOnboarding200JSONResponse Onboarding + +func (response GetOnboarding200JSONResponse) VisitGetOnboardingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetOnboardingdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response GetOnboardingdefaultApplicationProblemPlusJSONResponse) VisitGetOnboardingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type SearchRequestObject struct { Params SearchParams } @@ -6812,6 +6959,9 @@ type StrictServerInterface interface { // Лайки, новые сверху // (GET /api/v1/me/likes) GetLikes(ctx context.Context, request GetLikesRequestObject) (GetLikesResponseObject, error) + // Сохранить выбор онбординга (или пропуск — пустые списки) + // (PUT /api/v1/me/onboarding) + SaveOnboarding(ctx context.Context, request SaveOnboardingRequestObject) (SaveOnboardingResponseObject, error) // Сменить пароль // (POST /api/v1/me/password) ChangePassword(ctx context.Context, request ChangePasswordRequestObject) (ChangePasswordResponseObject, error) @@ -6833,6 +6983,9 @@ 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) // Страница поиска // (GET /api/v1/search) Search(ctx context.Context, request SearchRequestObject) (SearchResponseObject, error) @@ -8220,6 +8373,37 @@ func (sh *strictHandler) GetLikes(w http.ResponseWriter, r *http.Request, params } } +// SaveOnboarding operation middleware +func (sh *strictHandler) SaveOnboarding(w http.ResponseWriter, r *http.Request) { + var request SaveOnboardingRequestObject + + var body SaveOnboardingJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SaveOnboarding(ctx, request.(SaveOnboardingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SaveOnboarding") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SaveOnboardingResponseObject); ok { + if err := validResponse.VisitSaveOnboardingResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ChangePassword operation middleware func (sh *strictHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { var request ChangePasswordRequestObject @@ -8415,6 +8599,30 @@ func (sh *strictHandler) RevokeMySession(w http.ResponseWriter, r *http.Request, } } +// GetOnboarding operation middleware +func (sh *strictHandler) GetOnboarding(w http.ResponseWriter, r *http.Request) { + var request GetOnboardingRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetOnboarding(ctx, request.(GetOnboardingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetOnboarding") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetOnboardingResponseObject); ok { + if err := validResponse.VisitGetOnboardingResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // Search operation middleware func (sh *strictHandler) Search(w http.ResponseWriter, r *http.Request, params SearchParams) { var request SearchRequestObject @@ -8911,241 +9119,246 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "YSFPodutPb77+P8FAAD//9wKjL6lLQEA", + "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", } // 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 new file mode 100644 index 0000000..e826cac --- /dev/null +++ b/services/api/internal/wave/onboarding.go @@ -0,0 +1,141 @@ +package wave + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + + "pipu/api/internal/api/gen" +) + +// Онбординг вкуса (docs/recommendations.md#7): новый пользователь отмечает исполнителей и жанры — рекомендатель +// берёт их как начальный вкус, пока своей истории мало. Пропуск сохраняется пустым выбором. + +const ( + onboardingArtists = 40 + onboardingGenres = 24 + // История, после которой онбординг уже не предлагается: вкус и так виден по прослушиваниям. + onboardingMinHistory = 10 +) + +// GetOnboarding реализует gen.StrictServerInterface. +func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestObject) (gen.GetOnboardingResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + out := gen.GetOnboarding200JSONResponse{} + var done bool + var plays int + 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 { + return nil, fmt.Errorf("wave: onboarding state: %w", err) + } + + rows, err := h.pool.Query(ctx, ` + SELECT a.id, a.name, count(*) AS n, + (SELECT al.id FROM albums al WHERE al.album_artist_id = a.id + ORDER BY (SELECT count(*) FROM tracks t WHERE t.album_id = al.id) DESC, al.id LIMIT 1) + FROM artists a JOIN track_artists ta ON ta.artist_id = a.id AND ta.role = 'main' + GROUP BY a.id ORDER BY n DESC, a.name LIMIT $1`, onboardingArtists) + if err != nil { + return nil, fmt.Errorf("wave: onboarding artists: %w", err) + } + type artist struct { + id, album *uuid.UUID + name string + n int + } + var artists []artist + for rows.Next() { + var a artist + var id uuid.UUID + if err := rows.Scan(&id, &a.name, &a.n, &a.album); err != nil { + rows.Close() + return nil, fmt.Errorf("wave: onboarding artists: %w", err) + } + a.id = &id + artists = append(artists, a) + } + rows.Close() + dto := h.dto() + for _, a := range artists { + item := struct { + CoverColors []string `json:"cover_colors"` + CoverUrl *string `json:"cover_url"` + Id uuid.UUID `json:"id"` + Name string `json:"name"` + TrackCount int `json:"track_count"` + }{Id: *a.id, Name: a.name, TrackCount: a.n, CoverColors: []string{}} + if a.album != nil { + if al, err := h.catalog.Album(ctx, *a.album); err == nil { + sum := dto.AlbumSummary(al) + item.CoverUrl, item.CoverColors = sum.CoverUrl, sum.CoverColors + } + } + out.Artists = append(out.Artists, item) + } + + grows, err := h.pool.Query(ctx, ` + SELECT g.name, count(DISTINCT tg.track_id) AS n FROM track_genres tg JOIN genres g ON g.id = tg.genre_id + GROUP BY g.name ORDER BY n DESC, g.name LIMIT $1`, onboardingGenres) + if err != nil { + return nil, fmt.Errorf("wave: onboarding genres: %w", err) + } + for grows.Next() { + var g struct { + Name string `json:"name"` + TrackCount int `json:"track_count"` + } + if err := grows.Scan(&g.Name, &g.TrackCount); err != nil { + grows.Close() + return nil, fmt.Errorf("wave: onboarding genres: %w", err) + } + out.Genres = append(out.Genres, g) + } + grows.Close() + if out.Artists == nil { + out.Artists = []struct { + CoverColors []string `json:"cover_colors"` + CoverUrl *string `json:"cover_url"` + Id uuid.UUID `json:"id"` + Name string `json:"name"` + TrackCount int `json:"track_count"` + }{} + } + if out.Genres == nil { + out.Genres = []struct { + Name string `json:"name"` + TrackCount int `json:"track_count"` + }{} + } + out.Required = !done && plays < onboardingMinHistory && len(out.Artists) > 0 + return out, nil +} + +// SaveOnboarding реализует gen.StrictServerInterface. +func (h *Handler) SaveOnboarding(ctx context.Context, req gen.SaveOnboardingRequestObject) (gen.SaveOnboardingResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + ids := req.Body.ArtistIds + if ids == nil { + ids = []uuid.UUID{} + } + genres := req.Body.Genres + if genres == nil { + genres = []string{} + } + raw, _ := json.Marshal(map[string]any{"artist_ids": ids, "genres": genres}) + 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()`, + p.UserID, raw); err != nil { + return nil, fmt.Errorf("wave: save onboarding: %w", err) + } + return gen.SaveOnboarding204Response{}, nil +} diff --git a/services/api/migrations/00009_onboarding.sql b/services/api/migrations/00009_onboarding.sql new file mode 100644 index 0000000..65752c2 --- /dev/null +++ b/services/api/migrations/00009_onboarding.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Онбординг «Моей волны» (docs/recommendations.md#7): выбранные исполнители и жанры — начальный вкус, +-- пока своей истории мало. NULL — онбординг ещё не пройден и не пропущен. +ALTER TABLE user_profiles_rec ADD COLUMN onboarding jsonb; + +-- +goose Down +ALTER TABLE user_profiles_rec DROP COLUMN onboarding; diff --git a/services/recommender/pipu_rec/wave/store.py b/services/recommender/pipu_rec/wave/store.py index 4030171..142dc3c 100644 --- a/services/recommender/pipu_rec/wave/store.py +++ b/services/recommender/pipu_rec/wave/store.py @@ -156,8 +156,11 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile: ) } row = conn.execute( - "SELECT exploration, taste_cf::text, bandit FROM user_profiles_rec WHERE user_id = %s", (user_id,) + "SELECT exploration, taste_cf::text, bandit, onboarding FROM user_profiles_rec WHERE user_id = %s", + (user_id,), ).fetchone() + if row and row[3] and len(scores) < ONBOARDING_UNTIL: + apply_onboarding(conn, scores, row[3]) 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 {} @@ -179,6 +182,36 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile: ) +# Пока своих оценок меньше, выбор онбординга задаёт начальный вкус (docs/recommendations.md#7). +ONBOARDING_UNTIL = 30 +ONBOARDING_LIMIT = 300 + + +def apply_onboarding(conn: psycopg.Connection[Any], scores: dict[str, float], choice: dict[str, Any]) -> None: + """Псевдооценки: треки выбранных исполнителей +1, выбранных жанров +0.5 (до ONBOARDING_LIMIT треков). + Собственные оценки не перезаписываются; в «слышанное» эти треки не попадают.""" + artists = [str(a) for a in choice.get("artist_ids") or []] + genres = [str(g).lower() for g in choice.get("genres") or []] + pseudo: dict[str, float] = {} + if artists: + for (tid,) in conn.execute( + "SELECT DISTINCT track_id::text FROM track_artists WHERE artist_id::text = ANY(%s) LIMIT %s", + (artists, ONBOARDING_LIMIT), + ): + pseudo[tid] = pseudo.get(tid, 0.0) + 1.0 + if genres: + for (tid,) in conn.execute( + """ + SELECT DISTINCT tg.track_id::text FROM track_genres tg JOIN genres g ON g.id = tg.genre_id + WHERE lower(g.name) = ANY(%s) LIMIT %s + """, + (genres, ONBOARDING_LIMIT), + ): + pseudo[tid] = pseudo.get(tid, 0.0) + 0.5 + for tid, v in pseudo.items(): + scores.setdefault(tid, v) + + 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 6d7e2b9..fcb074b 100644 --- a/services/recommender/tests/test_db_integration.py +++ b/services/recommender/tests/test_db_integration.py @@ -3,6 +3,7 @@ Локально: TEST_DATABASE_URL=postgresql://… uv run pytest tests/test_db_integration.py """ +import json import os import time import uuid @@ -139,3 +140,22 @@ def test_training_and_wave_queries(world: dict[str, Any]) -> None: assert active is not None and active[0] == 1 picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0)) assert len(picks) == 5 + + +def test_onboarding_seeds_taste(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"new-{user[:6]}") + ) + artist = conn.execute( + "SELECT artist_id::text FROM track_artists WHERE track_id = %s", (world["tracks"][0],) + ).fetchone() + assert artist is not None + conn.execute( + "INSERT INTO user_profiles_rec (user_id, onboarding) VALUES (%s, %s)", + (user, json.dumps({"artist_ids": [artist[0]], "genres": []})), + ) + profile = load_profile(conn, user) + assert len(profile.scores) >= 10, "chosen artist's tracks become the initial taste" + assert not profile.played