feat: taste onboarding for new listeners (M6)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Failing after 1m29s
ci / ts / ts (packages + web) (push) Successful in 49s
ci / api / api (push) Successful in 1m25s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 48s
ci / recommender / recommender (push) Successful in 21s

- GET /onboarding offers the library's top artists (with album covers)
  and genres and says whether to show it (not done and < 10 plays);
  PUT /me/onboarding stores the choice or a skip (migration 00009).
- The recommender seeds the taste from it while the user has fewer than
  30 scored tracks: chosen artists' tracks +1, chosen genres +0.5.
- Web: onboarding replaces the wave on the home page until done or
  skipped; artist grid with covers, genre chips, at least 3 artists.
- e2e journey and the recommender DB test cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 22:35:43 +03:00
1 parent ff0aafdb1e
commit fb07f962eb
14 files changed
+1137 -238

No files matched your search

+34 -1
View File
@@ -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 = {
@@ -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