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
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:
1 parent
ff0aafdb1e
commit
fb07f962eb
14 files changed
+1137
-238
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user