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>
184 lines
7.5 KiB
Python
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"
|