Files
pipu-music/services/recommender/tests/test_db_integration.py
T
mrleo1nidandClaude Opus 5.5 58bd14ef38
ci / db / db (push) Failing after 1m15s
ci / api / api (push) Successful in 17s
ci / recommender / recommender (push) Successful in 32s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m41s
feat(recommender): LightGBM lambdarank ranker with impression features (M6)
The wave logs each served candidate's feature vector to rec_impression_features.
Nightly training joins them with impression outcomes, validates on the last week
and activates the model only when it beats the v1 order and the active model.
The service loads the active ranker and uses its order in place of the v1 score.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:41:26 +03:00

184 lines
7.5 KiB
Python

"""SQL рекомендателя на настоящей схеме (CI-job `ci / db`; схему заранее накатывают миграции api).
Локально: TEST_DATABASE_URL=postgresql://… uv run pytest tests/test_db_integration.py
"""
import json
import os
import time
import uuid
from typing import Any
import numpy as np
import psycopg
import pytest
from pipu_rec.analysis.features import Features
from pipu_rec.analysis.worker import Worker
from pipu_rec.train.pipeline import load_interactions, train_all
from pipu_rec.wave.engine import Profile, Session, build_batch
from pipu_rec.wave.store import load_index, load_profile
DSN = os.environ.get("TEST_DATABASE_URL", "")
pytestmark = pytest.mark.skipif(not DSN, reason="TEST_DATABASE_URL is not set")
def uid() -> str:
return str(uuid.uuid4())
@pytest.fixture()
def world() -> dict[str, Any]:
"""Хранилище, 2 исполнителя × 10 треков, 6 слушателей с событиями и реакциями."""
with psycopg.connect(DSN, autocommit=True) as conn:
src = uid()
conn.execute(
"INSERT INTO library_sources (id, name, type, local_path, status) VALUES (%s, 'it', 'local', '', 'mounted')",
(src,),
)
tracks: list[str] = []
for a in range(2):
artist = uid()
conn.execute(
"INSERT INTO artists (id, name, sort_name, name_norm) VALUES (%s, %s, %s, %s)",
(artist, f"A{a}", f"A{a}", f"a{a}-{artist[:6]}"),
)
album = uid()
conn.execute(
"INSERT INTO albums (id, title, title_norm, album_artist_id) VALUES (%s, %s, %s, %s)",
(album, f"Al{a}", f"al{a}", artist),
)
for i in range(10):
t, f = uid(), uid()
conn.execute(
"INSERT INTO tracks (id, title, title_norm, album_id, duration_ms) VALUES (%s, %s, %s, %s, 200000)",
(t, f"T{a}{i}", f"t{a}{i}", album),
)
conn.execute(
"INSERT INTO media_files (id, source_id, rel_path, size_bytes, mtime, format, duration_ms, track_id) "
"VALUES (%s, %s, %s, 1, now(), 'flac', 200000, %s)",
(f, src, f"{a}/{i}.flac", t),
)
conn.execute("UPDATE tracks SET primary_file_id = %s WHERE id = %s", (f, t))
conn.execute(
"INSERT INTO track_artists (track_id, artist_id, role, position) VALUES (%s, %s, 'main', 0)",
(t, artist),
)
tracks.append(t)
users = []
for u in range(6):
user = uid()
conn.execute(
"INSERT INTO users (id, username, password_hash) VALUES (%s, %s, 'x')",
(user, f"u{u}-{user[:6]}"),
)
users.append(user)
group = tracks[:10] if u % 2 == 0 else tracks[10:]
for k, t in enumerate(group):
kind = "play_end" if k < 8 else "skip"
conn.execute(
"INSERT INTO play_events (id, user_id, track_id, type, occurred_at, played_ms, client_type) "
"VALUES (%s, %s, %s, %s, now() - interval '20 days', %s, 'web')",
(uid(), user, t, kind, 200000 if kind == "play_end" else 3000),
)
conn.execute(
"INSERT INTO user_track_stats (user_id, track_id, play_count, complete_count, last_played_at) "
"VALUES (%s, %s, 1, %s, now() - interval '20 days')",
(user, t, 1 if kind == "play_end" else 0),
)
conn.execute(
"INSERT INTO track_reactions (user_id, track_id, reaction) VALUES (%s, %s, 'like')",
(user, group[0]),
)
return {"tracks": tracks, "users": users}
def test_analysis_worker_saves_features(world: dict[str, Any]) -> None:
w = Worker(DSN, "/library", "/library-host", 1)
emb = np.zeros(256, dtype=np.float32)
emb[0] = 1.0
feats = Features(
bpm=120.0,
bpm_confidence=0.9,
key="C",
scale="major",
loudness_lufs=-12.0,
energy=0.6,
danceability=0.5,
valence=0.5,
acousticness=0.2,
intro_ms=1000,
embedding=emb,
)
with psycopg.connect(DSN, autocommit=True) as conn:
jobs = w.claim(conn, 5)
assert jobs, "every new track is queued for analysis by the trigger"
w.save(conn, jobs[0].track_id, feats)
w.fail(conn, jobs[1].track_id, "boom")
w.requeue_stale(conn)
row = conn.execute(
"SELECT bpm FROM track_features WHERE track_id = %s", (jobs[0].track_id,)
).fetchone()
assert row is not None and row[0] == pytest.approx(120.0)
def test_training_and_wave_queries(world: dict[str, Any]) -> None:
with psycopg.connect(DSN) as conn:
assert len(load_interactions(conn)) >= 60
result = train_all(DSN, now=time.time())
assert result.get("activated") is True, result
with psycopg.connect(DSN) as conn:
index = load_index(conn)
assert set(world["tracks"]) <= set(index.ids)
assert index.has_cf.sum() >= 20
profile: Profile = load_profile(conn, world["users"][0])
assert profile.taste_cf is not None
assert profile.recent_likes
active = conn.execute(
"SELECT count(*) FROM model_versions WHERE kind = 'als' AND is_active"
).fetchone()
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
def test_impression_features_roundtrip(world: dict[str, Any]) -> None:
from pipu_rec.train.ranker import load_rows, train_and_save
from pipu_rec.wave.engine import FEATURE_NAMES
session, user, track = uid(), world["users"][0], world["tracks"][0]
with psycopg.connect(DSN, autocommit=True) as conn:
conn.execute("INSERT INTO wave_sessions (id, user_id) VALUES (%s, %s)", (session, user))
conn.execute(
"INSERT INTO wave_impressions (wave_session_id, position, track_id, candidate_source, outcome) "
"VALUES (%s, 0, %s, 'audio_sim', 'complete')",
(session, track),
)
conn.execute(
"INSERT INTO rec_impression_features (wave_session_id, track_id, features) VALUES (%s, %s, %s)",
(session, track, [0.5] * len(FEATURE_NAMES)),
)
rows = load_rows(conn)
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"