diff --git a/docs/decisions.md b/docs/decisions.md index 50273b8..c829b2d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -170,3 +170,20 @@ CI на каждый PR собирает все образы без push, что **Последствия.** Серверу нужен доступ к репозиторию и интернет на время сборки; первая сборка — 5–15 минут, нужно ~5–10 ГБ под кэш сборки. Откат — `update.sh` на предыдущий тег (пересборка из кэша). Отпадают секреты `REGISTRY_USER` / `REGISTRY_TOKEN`. Заменяет часть ADR-001 / ci-cd о публикации образов. + +--- + +## ADR-014. Анализатор v1 — признаки на numpy, Essentia позже + +**Контекст.** Нейросетевые модели Essentia (Discogs-EffNet, жанры, настроения) требуют essentia-tensorflow: +тяжёлый образ, ограниченный набор платформ и версий Python. Волне v1 нужны звучание и базовые признаки уже сейчас. + +**Решение.** Анализатор `basic-1` (`services/recommender/pipu_rec/analysis/features.py`) считает признаки сам, +на numpy поверх декодирования ffmpeg: 3 фрагмента по 30 с; эмбеддинг 256 = форма спектра, разброс, динамика +и ритмичность по 64 mel-полосам (рекомендатель стандартизирует его по библиотеке); темп по автокорреляции онсетов; +тональность по хромаграмме; громкость, энергия, танцевальность, акустичность, длина вступления. +Valence — грубая оценка (лад + яркость); `mood_*`, `arousal`, жанры анализа не заполняются. + +**Последствия.** Анализ работает на любом CPU без моделей. Контексты используют энергию, темп, танцевальность +и valence; настроения «весёлое» / «грустное» пока приблизительны. Переход на Essentia — новая версия +`analyzer_ver`: воркер сам ставит пересчёт всех треков. diff --git a/services/api/migrations/00006_wave.sql b/services/api/migrations/00006_wave.sql new file mode 100644 index 0000000..d717f5a --- /dev/null +++ b/services/api/migrations/00006_wave.sql @@ -0,0 +1,106 @@ +-- +goose Up +CREATE TABLE track_features ( + track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + analyzer_ver text NOT NULL, + bpm real, + bpm_confidence real, + key text, + scale text, + loudness_lufs real, + energy real, + danceability real, + valence real, + arousal real, + acousticness real, + instrumentalness real, + mood_happy real, + mood_sad real, + mood_relaxed real, + mood_aggressive real, + mood_party real, + intro_ms int, + analyzed_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE track_embeddings ( + track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + audio vector(256), + cf vector(64), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX track_embeddings_audio_idx ON track_embeddings USING hnsw (audio vector_cosine_ops); + +CREATE TABLE analysis_jobs ( + track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + status text NOT NULL DEFAULT 'queued', + attempts int NOT NULL DEFAULT 0, + error text, + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX analysis_jobs_queued_idx ON analysis_jobs (updated_at) WHERE status = 'queued'; + +-- Каждый новый трек сразу встаёт в очередь анализа; существующие — тоже. +-- +goose StatementBegin +CREATE FUNCTION enqueue_analysis() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO analysis_jobs (track_id) VALUES (NEW.id) ON CONFLICT DO NOTHING; + RETURN NEW; +END $$; +-- +goose StatementEnd +CREATE TRIGGER tracks_enqueue_analysis AFTER INSERT ON tracks FOR EACH ROW EXECUTE FUNCTION enqueue_analysis(); +INSERT INTO analysis_jobs (track_id) SELECT id FROM tracks ON CONFLICT DO NOTHING; + +CREATE TABLE wave_sessions ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + activity text, + mood text, + seed_type text, + seed_id uuid, + started_at timestamptz NOT NULL DEFAULT now(), + ended_at timestamptz, + tracks_served int NOT NULL DEFAULT 0 +); +CREATE INDEX wave_sessions_user_idx ON wave_sessions (user_id, started_at DESC); + +CREATE TABLE wave_impressions ( + wave_session_id uuid REFERENCES wave_sessions(id) ON DELETE CASCADE, + position int NOT NULL, + track_id uuid NOT NULL, + candidate_source text NOT NULL, + score real, + model_ver text, + served_at timestamptz NOT NULL DEFAULT now(), + outcome text, + PRIMARY KEY (wave_session_id, position) +); +CREATE INDEX wave_impressions_track_idx ON wave_impressions (wave_session_id, track_id); + +CREATE TABLE user_profiles_rec ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + taste_audio vector(256), + taste_cf vector(64), + genre_prefs jsonb NOT NULL DEFAULT '{}', + bandit jsonb NOT NULL DEFAULT '{}', + exploration real NOT NULL DEFAULT 0.15, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE model_versions ( + id text PRIMARY KEY, + kind text NOT NULL, + metrics jsonb, + is_active boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE model_versions; +DROP TABLE user_profiles_rec; +DROP TABLE wave_impressions; +DROP TABLE wave_sessions; +DROP TRIGGER tracks_enqueue_analysis ON tracks; +DROP FUNCTION enqueue_analysis(); +DROP TABLE analysis_jobs; +DROP TABLE track_embeddings; +DROP TABLE track_features; diff --git a/services/recommender/Dockerfile b/services/recommender/Dockerfile index 3748d07..b57dbb7 100644 --- a/services/recommender/Dockerfile +++ b/services/recommender/Dockerfile @@ -15,7 +15,8 @@ COPY services/recommender/ ./ RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev FROM python:3.12-slim -RUN useradd --system --uid 10001 pipu +# ffmpeg декодирует аудио для анализатора. +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 pipu COPY --from=build /app /app ARG VERSION=0.0.0-dev ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1 diff --git a/services/recommender/pipu_rec/analysis/__init__.py b/services/recommender/pipu_rec/analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/recommender/pipu_rec/analysis/features.py b/services/recommender/pipu_rec/analysis/features.py new file mode 100644 index 0000000..c06d9f2 --- /dev/null +++ b/services/recommender/pipu_rec/analysis/features.py @@ -0,0 +1,206 @@ +"""Аудиопризнаки трека на numpy: тембр (эмбеддинг), темп, громкость, энергия, тональность. + +Версия `basic-1` — без нейросетевых моделей (ADR-014): работает на любом CPU без TensorFlow. +Нейросетевые признаки Essentia (жанры, настроения, valence/arousal) — следующая версия анализатора; +смена `ANALYZER_VER` ставит пересчёт. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +from numpy.typing import NDArray + +ANALYZER_VER = "basic-1" +SAMPLE_RATE = 22050 +N_FFT = 2048 +HOP = 512 +N_MELS = 64 +EMBEDDING_DIM = 256 +SEGMENT_S = 30.0 + +Float = NDArray[np.float32] + + +@dataclass(frozen=True) +class Features: + bpm: float | None + bpm_confidence: float + key: str | None + scale: str | None + loudness_lufs: float + energy: float + danceability: float + valence: float + acousticness: float + intro_ms: int + embedding: Float # (256,), L2-нормирован + + +def segments(samples: Float, sr: int = SAMPLE_RATE, seconds: float = SEGMENT_S) -> list[Float]: + """Три фрагмента по 30 с — начало (после первых 10%), середина, конец; короткий трек — целиком.""" + n = int(seconds * sr) + if len(samples) <= 3 * n: + return [samples] + total = len(samples) + starts = [int(total * 0.1), total // 2 - n // 2, total - n - int(total * 0.05)] + return [samples[s : s + n] for s in starts] + + +def _stft_mag(x: Float) -> Float: + if len(x) < N_FFT: + x = np.pad(x, (0, N_FFT - len(x))) + frames = 1 + (len(x) - N_FFT) // HOP + idx = np.arange(N_FFT)[None, :] + HOP * np.arange(frames)[:, None] + window = np.hanning(N_FFT).astype(np.float32) + return np.abs(np.fft.rfft(x[idx] * window, axis=1)).astype(np.float32) # (frames, bins) + + +def _mel_filterbank(sr: int = SAMPLE_RATE) -> Float: + def hz_to_mel(f: NDArray[np.float64]) -> NDArray[np.float64]: + return 2595.0 * np.log10(1.0 + f / 700.0) + + def mel_to_hz(m: NDArray[np.float64]) -> NDArray[np.float64]: + return 700.0 * (10 ** (m / 2595.0) - 1.0) + + bins = N_FFT // 2 + 1 + freqs = np.linspace(0, sr / 2, bins) + mel_points = mel_to_hz(np.linspace(hz_to_mel(np.array(30.0)), hz_to_mel(np.array(sr / 2)), N_MELS + 2)) + fb = np.zeros((N_MELS, bins), dtype=np.float32) + for m in range(N_MELS): + lo, mid, hi = mel_points[m], mel_points[m + 1], mel_points[m + 2] + up = (freqs - lo) / (mid - lo) + down = (hi - freqs) / (hi - mid) + fb[m] = np.maximum(0, np.minimum(up, down)) + return fb + + +_MEL = _mel_filterbank() + +# Профили тональностей Крумханзла. +_MAJOR = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]) +_MINOR = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]) +_NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + + +def _log_mel(mag: Float) -> Float: + """log-mel в дБ с полом на −60 дБ от максимума: шум в пустых полосах не превращается в «атаки».""" + mel = mag**2 @ _MEL.T + return (10 * np.log10(mel + 1e-6 * float(mel.max()) + 1e-12)).astype(np.float32) + + +def estimate_tempo(onset: Float, sr: int = SAMPLE_RATE) -> tuple[float | None, float]: + """Темп по автокорреляции огибающей онсетов, 60–200 BPM с мягким приором к 120. + Возвращает (bpm, уверенность 0..1); без выраженного ритма — (None, 0).""" + fps = sr / HOP + env = onset - onset.mean() + # Ровный звук без атак: огибающая — численный шум, «ритма» в нём нет. + if len(env) < int(fps * 4) or float(onset.std()) < 0.5: + return None, 0.0 + ac = np.correlate(env, env, mode="full")[len(env) - 1 :] + ac /= ac[0] + 1e-9 + lags = np.arange(len(ac)) + bpm_of = 60.0 * fps / np.maximum(lags, 1) + ok = (bpm_of >= 60) & (bpm_of <= 200) + if not np.any(ok): + return None, 0.0 + prior = np.exp(-0.5 * (np.log2(bpm_of / 120.0) / 0.9) ** 2) + weighted = np.where(ok, ac * prior, -np.inf) + lag = int(np.argmax(weighted)) + # Уточнение по параболе вокруг пика. + if 1 <= lag < len(ac) - 1: + a, b, c = ac[lag - 1], ac[lag], ac[lag + 1] + denom = a - 2 * b + c + shift = 0.5 * (a - c) / denom if denom != 0 else 0.0 + else: + shift = 0.0 + bpm = 60.0 * fps / (lag + shift) + # Ясность пульса: насколько пик выше типичного уровня автокорреляции в диапазоне темпов. + base = float(np.median(ac[ok])) + confidence = float(np.clip((ac[lag] - base) / (1 - base + 1e-9), 0, 1)) + if confidence < 0.1: + return None, confidence + return round(float(bpm), 1), confidence + + +def _key(mag: Float, sr: int = SAMPLE_RATE) -> tuple[str | None, str | None, float]: + """Тональность по хромаграмме; возвращает (нота, mode, уверенность).""" + freqs = np.linspace(0, sr / 2, mag.shape[1]) + valid = (freqs > 60) & (freqs < 5000) + pitch = np.round(12 * np.log2(freqs[valid] / 440.0) + 69).astype(int) % 12 + energy = mag[:, valid].sum(axis=0) + chroma = np.bincount(pitch, weights=energy, minlength=12) + if chroma.sum() <= 0: + return None, None, 0.0 + chroma = chroma / chroma.sum() + best = (-2.0, 0, "major") + for shift in range(12): + for name, prof in (("major", _MAJOR), ("minor", _MINOR)): + r = float(np.corrcoef(chroma, np.roll(prof, shift))[0, 1]) + if r > best[0]: + best = (r, shift, name) + r, shift, scale = best + return _NOTES[shift], scale, max(r, 0.0) + + +def analyze(samples: Float, sr: int = SAMPLE_RATE) -> Features: + """Признаки трека по моно-сигналу sr Гц (float32, −1..1).""" + if sr != SAMPLE_RATE: + raise ValueError(f"expected {SAMPLE_RATE} Hz") + parts = segments(samples, sr) + mags = [_stft_mag(p) for p in parts] + mag = np.concatenate(mags, axis=0) + logmel = _log_mel(mag) # (frames, mels) + + # Огибающая онсетов — положительный прирост log-mel; темп по каждому фрагменту, берём самый уверенный. + tempos = [] + for m in mags: + lm = _log_mel(m) + onset = np.maximum(np.diff(lm, axis=0), 0).sum(axis=1).astype(np.float32) + tempos.append(estimate_tempo(onset, sr)) + bpm, bpm_conf = max(tempos, key=lambda t: t[1]) + flux = np.maximum(np.diff(logmel, axis=0), 0).mean() if len(logmel) > 1 else 0.0 + + rms = np.sqrt(np.mean(samples.astype(np.float64) ** 2) + 1e-12) + loudness = float(-0.691 + 20 * np.log10(rms)) # без K-фильтра: близко к LUFS для музыки + + centroid = float((mag * np.arange(mag.shape[1])).sum() / (mag.sum() + 1e-9) / mag.shape[1]) + brightness = float(np.clip(centroid * 4, 0, 1)) + loud_part = np.clip((loudness + 30) / 22, 0, 1) + energy = float(np.clip(0.5 * loud_part + 0.3 * np.clip(flux / 3, 0, 1) + 0.2 * brightness, 0, 1)) + danceability = float(np.clip(bpm_conf * 1.6, 0, 1)) if bpm else 0.0 + key, scale, key_conf = _key(mag, sr) + mode = 1.0 if scale == "major" else -1.0 if scale == "minor" else 0.0 + valence = float(np.clip(0.45 + 0.2 * mode * key_conf + 0.35 * (brightness - 0.4), 0, 1)) + # Акустичность: мало высоких частот и плавная динамика. + acousticness = float(np.clip(1 - brightness * 1.4 - flux / 6, 0, 1)) + + # Вступление: время, пока громкость первого фрагмента не дойдёт до половины медианной. + first = np.sqrt((mags[0] ** 2).mean(axis=1)) + above = np.nonzero(first >= 0.5 * np.median(first))[0] + intro_ms = int(above[0] * HOP / sr * 1000) if len(above) else 0 + + # Эмбеддинг: форма спектра (среднее без общей громкости), разброс, динамика по полосам, ритмичность. + mean = logmel.mean(axis=0) + shape = mean - mean.mean() + std = logmel.std(axis=0) + diff = np.diff(logmel, axis=0) if len(logmel) > 1 else np.zeros((1, N_MELS), np.float32) + delta = np.abs(diff).mean(axis=0) + band_flux = np.maximum(diff, 0).std(axis=0) + emb = np.concatenate([shape / 10, std / 10, delta / 3, band_flux / 3]).astype(np.float32) + emb = emb / (np.linalg.norm(emb) + 1e-9) + + return Features( + bpm=bpm, + bpm_confidence=round(bpm_conf, 3), + key=key, + scale=scale, + loudness_lufs=round(loudness, 2), + energy=round(energy, 3), + danceability=round(danceability, 3), + valence=round(valence, 3), + acousticness=round(acousticness, 3), + intro_ms=intro_ms, + embedding=emb, + ) diff --git a/services/recommender/pipu_rec/analysis/worker.py b/services/recommender/pipu_rec/analysis/worker.py new file mode 100644 index 0000000..1f41a56 --- /dev/null +++ b/services/recommender/pipu_rec/analysis/worker.py @@ -0,0 +1,204 @@ +"""Воркер аудиоанализа: берёт задачи из analysis_jobs, считает признаки и пишет их в БД.""" + +from __future__ import annotations + +import logging +import subprocess +import time +from concurrent.futures import Future, ProcessPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import psycopg + +from pipu_rec.analysis.features import ANALYZER_VER, SAMPLE_RATE, Features, analyze + +log = logging.getLogger(__name__) + +MAX_ATTEMPTS = 3 +MAX_DURATION_S = 15 * 60 +IDLE_SLEEP_S = 10.0 + + +@dataclass(frozen=True) +class Job: + track_id: str + path: str + + +def decode(path: str, ffmpeg: str = "ffmpeg") -> np.ndarray: + """Моно float32 22 050 Гц; путь — отдельный аргумент, без shell.""" + out = subprocess.run( + [ + ffmpeg, + "-v", + "error", + "-t", + str(MAX_DURATION_S), + "-i", + path, + "-ac", + "1", + "-ar", + str(SAMPLE_RATE), + "-f", + "f32le", + "-", + ], + capture_output=True, + check=False, + timeout=300, + ) + if out.returncode != 0: + raise RuntimeError(out.stderr.decode(errors="replace").strip()[:500] or "ffmpeg failed") + samples = np.frombuffer(out.stdout, dtype=np.float32) + if samples.size < SAMPLE_RATE: + raise RuntimeError("audio is shorter than 1 s") + return samples + + +def analyze_file(path: str, ffmpeg: str) -> Features: + """Выполняется в процессе пула.""" + return analyze(decode(path, ffmpeg)) + + +def vector_literal(v: np.ndarray) -> str: + return "[" + ",".join(f"{x:.6f}" for x in v.tolist()) + "]" + + +class Worker: + def __init__( + self, dsn: str, library_root: str, host_root: str, workers: int, ffmpeg: str = "ffmpeg" + ) -> None: + self.dsn = dsn + self.library_root = Path(library_root) + self.host_root = Path(host_root) + self.workers = max(1, workers) + self.ffmpeg = ffmpeg + + def resolve(self, source_id: str, source_type: str, local_path: str | None, rel_path: str) -> str: + """Путь к файлу внутри контейнера анализатора — те же точки монтирования, что у api.""" + if source_type == "local": + return str(self.host_root.joinpath(local_path or "", rel_path)) + return str(self.library_root.joinpath(source_id, rel_path)) + + def claim(self, conn: psycopg.Connection[Any], limit: int) -> list[Job]: + """Забирает задачи; SKIP LOCKED — несколько анализаторов не возьмут одну и ту же.""" + rows = conn.execute( + """ + UPDATE analysis_jobs j SET status = 'running', attempts = attempts + 1, updated_at = now() + WHERE j.track_id IN ( + SELECT a.track_id FROM analysis_jobs a + JOIN tracks t ON t.id = a.track_id + JOIN media_files f ON f.id = t.primary_file_id + JOIN library_sources s ON s.id = f.source_id AND s.status = 'mounted' + WHERE a.status = 'queued' + ORDER BY a.updated_at + LIMIT %s + FOR UPDATE OF a SKIP LOCKED) + RETURNING j.track_id::text + """, + (limit,), + ).fetchall() + conn.commit() + if not rows: + return [] + files = conn.execute( + """ + SELECT t.id::text, s.id::text, s.type::text, s.local_path, f.rel_path + FROM tracks t + JOIN media_files f ON f.id = t.primary_file_id + JOIN library_sources s ON s.id = f.source_id + WHERE t.id = ANY(%s::uuid[]) + """, + ([r[0] for r in rows],), + ).fetchall() + return [Job(track_id=r[0], path=self.resolve(r[1], r[2], r[3], r[4])) for r in files] + + def save(self, conn: psycopg.Connection[Any], track_id: str, f: Features) -> None: + conn.execute( + """ + INSERT INTO track_features (track_id, analyzer_ver, bpm, bpm_confidence, key, scale, + loudness_lufs, energy, danceability, valence, acousticness, intro_ms, analyzed_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now()) + ON CONFLICT (track_id) DO UPDATE SET analyzer_ver = EXCLUDED.analyzer_ver, bpm = EXCLUDED.bpm, + bpm_confidence = EXCLUDED.bpm_confidence, key = EXCLUDED.key, scale = EXCLUDED.scale, + loudness_lufs = EXCLUDED.loudness_lufs, energy = EXCLUDED.energy, + danceability = EXCLUDED.danceability, valence = EXCLUDED.valence, + acousticness = EXCLUDED.acousticness, intro_ms = EXCLUDED.intro_ms, analyzed_at = now() + """, + ( + track_id, + ANALYZER_VER, + f.bpm, + f.bpm_confidence, + f.key, + f.scale, + f.loudness_lufs, + f.energy, + f.danceability, + f.valence, + f.acousticness, + f.intro_ms, + ), + ) + conn.execute( + """ + INSERT INTO track_embeddings (track_id, audio, updated_at) VALUES (%s, %s::vector, now()) + ON CONFLICT (track_id) DO UPDATE SET audio = EXCLUDED.audio, updated_at = now() + """, + (track_id, vector_literal(f.embedding)), + ) + conn.execute( + "UPDATE analysis_jobs SET status = 'done', error = NULL, updated_at = now() WHERE track_id = %s", + (track_id,), + ) + conn.commit() + + def fail(self, conn: psycopg.Connection[Any], track_id: str, err: str) -> None: + conn.execute( + """ + UPDATE analysis_jobs SET status = CASE WHEN attempts >= %s THEN 'failed' ELSE 'queued' END, + error = %s, updated_at = now() + WHERE track_id = %s + """, + (MAX_ATTEMPTS, err[:500], track_id), + ) + conn.commit() + + def requeue_stale(self, conn: psycopg.Connection[Any]) -> None: + """Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь.""" + conn.execute( + "UPDATE analysis_jobs SET status = 'queued' " + "WHERE status = 'running' AND updated_at < now() - interval '30 minutes'" + ) + conn.execute( + """ + UPDATE analysis_jobs a SET status = 'queued', attempts = 0, updated_at = now() + FROM track_features f WHERE f.track_id = a.track_id AND f.analyzer_ver <> %s AND a.status = 'done' + """, + (ANALYZER_VER,), + ) + conn.commit() + + def run_forever(self) -> None: + log.info("analyzer started: workers=%d version=%s", self.workers, ANALYZER_VER) + with psycopg.connect(self.dsn) as conn, ProcessPoolExecutor(self.workers) as pool: + self.requeue_stale(conn) + while True: + jobs = self.claim(conn, self.workers * 2) + if not jobs: + time.sleep(IDLE_SLEEP_S) + continue + futures: list[tuple[Job, Future[Features]]] = [ + (j, pool.submit(analyze_file, j.path, self.ffmpeg)) for j in jobs + ] + for job, fut in futures: + try: + self.save(conn, job.track_id, fut.result(timeout=600)) + except Exception as exc: + log.warning("analysis failed: track=%s err=%s", job.track_id, exc) + conn.rollback() + self.fail(conn, job.track_id, str(exc)) diff --git a/services/recommender/pipu_rec/api/app.py b/services/recommender/pipu_rec/api/app.py index a6eb689..5d89bab 100644 --- a/services/recommender/pipu_rec/api/app.py +++ b/services/recommender/pipu_rec/api/app.py @@ -10,6 +10,8 @@ from fastapi import Depends, FastAPI, Header, HTTPException, status from fastapi.responses import JSONResponse from pipu_rec.settings import Settings +from pipu_rec.wave.engine import MODEL_VER +from pipu_rec.wave.service import BatchRequest, BatchResponse, WaveService Check = Callable[[], Awaitable[None]] @@ -18,7 +20,9 @@ log = logging.getLogger(__name__) CHECK_TIMEOUT_S = 2.0 -def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> FastAPI: +def create_app( + settings: Settings, checks: dict[str, Check] | None = None, wave: WaveService | None = None +) -> FastAPI: app = FastAPI(title="pipu-recommender", version=settings.version, docs_url=None, redoc_url=None) checks = checks or {} @@ -54,6 +58,13 @@ def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> Fa @app.get("/internal/status", dependencies=[Depends(require_token)]) async def internal_status() -> dict[str, object]: - return {"version": settings.version, "models": [], "ranker": "heuristic-v1"} + return {"version": settings.version, "models": [], "ranker": MODEL_VER} + + @app.post("/internal/wave/batch", dependencies=[Depends(require_token)]) + async def wave_batch(req: BatchRequest) -> BatchResponse: + if wave is None: + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="wave.unavailable") + # Сборка — numpy и синхронные запросы к БД: в пуле потоков, чтобы не блокировать цикл событий. + return await asyncio.to_thread(wave.batch, req) return app diff --git a/services/recommender/pipu_rec/cli.py b/services/recommender/pipu_rec/cli.py index 9e78dc8..9e5a2b6 100644 --- a/services/recommender/pipu_rec/cli.py +++ b/services/recommender/pipu_rec/cli.py @@ -1,15 +1,17 @@ """Точка входа: `pipu-rec serve` (онлайн-API) или `pipu-rec analyze` (воркер аудиоанализа).""" import argparse -import asyncio import logging import psycopg import redis.asyncio as aioredis import uvicorn +from pipu_rec.analysis.worker import Worker from pipu_rec.api.app import create_app from pipu_rec.settings import Settings +from pipu_rec.wave.contexts import load_contexts +from pipu_rec.wave.service import WaveService log = logging.getLogger("pipu_rec") @@ -24,19 +26,19 @@ def _serve(settings: Settings) -> None: async def check_redis() -> None: await rdb.ping() - app = create_app(settings, {"postgres": check_postgres, "redis": check_redis}) + wave = WaveService(settings.database_url, settings.redis_url, load_contexts(settings.contexts_path)) + app = create_app(settings, {"postgres": check_postgres, "redis": check_redis}, wave) uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level) def _analyze(settings: Settings, workers: int) -> None: - # Аудиоанализ (Essentia) появится в M4; сейчас воркер только держит процесс живым. - log.info("analyzer started: workers=%d version=%s (no-op until M4)", workers, settings.version) - - async def idle() -> None: - while True: - await asyncio.sleep(3600) - - asyncio.run(idle()) + Worker( + settings.database_url, + settings.library_root, + settings.library_host_root, + workers, + settings.ffmpeg_path, + ).run_forever() def main() -> None: diff --git a/services/recommender/pipu_rec/settings.py b/services/recommender/pipu_rec/settings.py index 7514d7b..3139ffe 100644 --- a/services/recommender/pipu_rec/settings.py +++ b/services/recommender/pipu_rec/settings.py @@ -13,4 +13,8 @@ class Settings(BaseSettings): redis_url: str = Field(default="redis://redis:6379/0") internal_token: str = Field(default="", description="Общий секрет для вызовов из api") log_level: str = "info" + library_root: str = "/library" + library_host_root: str = "/library-host" + ffmpeg_path: str = "ffmpeg" + contexts_path: str = Field(default="", description="contexts.yaml; пусто — встроенный") version: str = Field(default="0.0.0-dev", alias="PIPU_VERSION") diff --git a/services/recommender/pipu_rec/wave/__init__.py b/services/recommender/pipu_rec/wave/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/recommender/pipu_rec/wave/contexts.py b/services/recommender/pipu_rec/wave/contexts.py new file mode 100644 index 0000000..04b0c71 --- /dev/null +++ b/services/recommender/pipu_rec/wave/contexts.py @@ -0,0 +1,73 @@ +"""Профили занятий и настроений: соответствие трека контексту (context_fit).""" + +from __future__ import annotations + +from importlib import resources +from pathlib import Path + +import numpy as np +import yaml +from numpy.typing import NDArray +from pydantic import BaseModel, model_validator + +FEATURES = ("bpm", "energy", "danceability", "valence", "acousticness") + + +class Rule(BaseModel): + feature: str + min: float | None = None + max: float | None = None + sigma: float + half_time: bool = False + + @model_validator(mode="after") + def check(self) -> Rule: + if self.feature not in FEATURES: + raise ValueError(f"unknown feature {self.feature!r}; known: {', '.join(FEATURES)}") + if self.min is None and self.max is None: + raise ValueError("rule needs min or max") + if self.sigma <= 0: + raise ValueError("sigma must be positive") + return self + + +class Context(BaseModel): + icon: str + rules: list[Rule] + + +class Contexts(BaseModel): + activities: dict[str, Context] + moods: dict[str, Context] + + +def load_contexts(path: str = "") -> Contexts: + """contexts.yaml из пути или встроенный; ошибка в файле — ошибка запуска (Pydantic).""" + raw = ( + Path(path).read_text("utf-8") + if path + else resources.files(__package__).joinpath("contexts.yaml").read_text("utf-8") + ) + return Contexts.model_validate(yaml.safe_load(raw)) + + +def _bell(x: NDArray[np.float64], rule: Rule) -> NDArray[np.float64]: + lo = -np.inf if rule.min is None else rule.min + hi = np.inf if rule.max is None else rule.max + dist = np.where(x < lo, lo - x, np.where(x > hi, x - hi, 0.0)) + return np.asarray(np.exp(-0.5 * (dist / rule.sigma) ** 2)) + + +def fit(features: dict[str, NDArray[np.float64]], rules: list[Rule]) -> NDArray[np.float64]: + """Произведение «колоколов» по правилам; NaN (признака нет) — нейтрально (1). + half_time: для темпа засчитывается и половинный (бег под 85 BPM — тот же шаг, что 170).""" + n = len(next(iter(features.values()))) + out = np.ones(n) + for rule in rules: + x = features[rule.feature] + known = ~np.isnan(x) + b = _bell(np.nan_to_num(x), rule) + if rule.half_time: + b = np.maximum(b, _bell(np.nan_to_num(x) * 2, rule)) + out *= np.where(known, b, 1.0) + return out diff --git a/services/recommender/pipu_rec/wave/contexts.yaml b/services/recommender/pipu_rec/wave/contexts.yaml new file mode 100644 index 0000000..f25f2db --- /dev/null +++ b/services/recommender/pipu_rec/wave/contexts.yaml @@ -0,0 +1,56 @@ +# Занятия и настроения «Моей волны» (docs/recommendations.md#64-занятия-и-настроения). +# Каждое правило — «колокол» по признаку трека: внутри [min, max] — 1, снаружи спадает по Гауссу с sigma. +# Признак, которого у трека нет (ещё не проанализирован), не штрафует. +# Изменения применяются после перезапуска recommender, пересборка образа не нужна (CONTEXTS_PATH). +activities: + running: + icon: directions_run + rules: + - {feature: bpm, min: 150, max: 185, sigma: 12, half_time: true} + - {feature: energy, min: 0.7, sigma: 0.12} + - {feature: danceability, min: 0.5, sigma: 0.2} + workout: + icon: fitness_center + rules: + - {feature: energy, min: 0.75, sigma: 0.1} + - {feature: bpm, min: 110, max: 180, sigma: 20, half_time: true} + waking_up: + icon: wb_sunny + rules: + - {feature: energy, min: 0.3, max: 0.7, sigma: 0.12} + - {feature: valence, min: 0.5, sigma: 0.15} + focus: + icon: headphones + rules: + - {feature: energy, min: 0.3, max: 0.6, sigma: 0.12} + - {feature: acousticness, min: 0.3, sigma: 0.25} + commute: + icon: directions_car + rules: + - {feature: energy, min: 0.4, max: 0.8, sigma: 0.15} + chill: + icon: weekend + rules: + - {feature: energy, max: 0.5, sigma: 0.12} + sleep: + icon: bedtime + rules: + - {feature: energy, max: 0.35, sigma: 0.1} + - {feature: bpm, max: 95, sigma: 12} +moods: + energetic: + icon: bolt + rules: + - {feature: energy, min: 0.7, sigma: 0.12} + happy: + icon: sentiment_satisfied + rules: + - {feature: valence, min: 0.6, sigma: 0.12} + calm: + icon: spa + rules: + - {feature: energy, max: 0.45, sigma: 0.12} + sad: + icon: rainy + rules: + - {feature: valence, max: 0.4, sigma: 0.12} diff --git a/services/recommender/pipu_rec/wave/engine.py b/services/recommender/pipu_rec/wave/engine.py new file mode 100644 index 0000000..56c325b --- /dev/null +++ b/services/recommender/pipu_rec/wave/engine.py @@ -0,0 +1,306 @@ +"""Сборка пачки «Моей волны» (v1: эвристика). Чистые функции над массивами — без БД и сети.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +from numpy.typing import NDArray + +from pipu_rec.wave.contexts import FEATURES, Rule, fit + +MODEL_VER = "heuristic-v1" + +Vec = NDArray[np.float32] + + +@dataclass +class CatalogIndex: + """Каталог в памяти: строка i — трек ids[i].""" + + ids: list[str] + artist: list[str] # основной исполнитель + emb: Vec # (n, d), стандартизирован и нормирован; нулевой — нет анализа + has_emb: NDArray[np.bool_] + features: dict[str, NDArray[np.float64]] # NaN — нет признака + popularity: NDArray[np.float64] # 0..1 + pos: dict[str, int] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.pos = {t: i for i, t in enumerate(self.ids)} + + def __len__(self) -> int: + return len(self.ids) + + @staticmethod + def build( + ids: list[str], + artist: list[str], + raw_emb: list[Vec | None], + features: dict[str, list[float | None]], + plays: list[int], + dim: int = 256, + ) -> CatalogIndex: + n = len(ids) + emb = np.zeros((n, dim), dtype=np.float32) + has = np.zeros(n, dtype=bool) + for i, e in enumerate(raw_emb): + if e is not None: + emb[i], has[i] = e, True + if has.sum() >= 2: + # Стандартизация по библиотеке: косинус сравнивает отличия треков, а не общий «звук библиотеки». + mu = emb[has].mean(axis=0) + sd = emb[has].std(axis=0) + 1e-6 + emb[has] = (emb[has] - mu) / sd + emb[has] /= np.linalg.norm(emb[has], axis=1, keepdims=True) + 1e-9 + feats = { + k: np.array([np.nan if v is None else v for v in features.get(k, [None] * n)], dtype=np.float64) + for k in FEATURES + } + p = np.log1p(np.array(plays, dtype=np.float64)) + pop = p / p.max() if n and p.max() > 0 else np.zeros(n) + return CatalogIndex(ids=ids, artist=artist, emb=emb, has_emb=has, features=feats, popularity=pop) + + +@dataclass +class Profile: + """Вкус пользователя: оценки треков и производные.""" + + scores: dict[str, float] # implicit_score > 0 — нравится + disliked: set[str] + disliked_artists: set[str] + recent: set[str] # сыграны за последние 3 часа + played: set[str] # слышал когда-либо + liked_recent: set[str] # лайкнутые, игравшие за 24 ч — штраф + rediscover: set[str] # любимые, не игравшие > 60 дней + exploration: float = 0.15 + + +@dataclass +class Session: + """Состояние сессии волны (Redis).""" + + vec: list[float] | None = None + served: list[str] = field(default_factory=list) + recent_artists: list[str] = field(default_factory=list) + skips_in_row: int = 0 + + +@dataclass(frozen=True) +class Pick: + id: str + source: str + score: float + + +# Вклад реакции в сессионный вектор: s ← normalize(s + η·r·e). +REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5} +ETA = 0.3 + + +def taste_centroids(index: CatalogIndex, profile: Profile, max_k: int = 6, seed: int = 0) -> Vec: + """Центроиды вкуса: k-means по эмбеддингам треков с положительной оценкой (веса = оценка). + k растёт с историей: 1 центроид на ~8 любимых треков, не больше max_k.""" + rows = [(index.pos[t], s) for t, s in profile.scores.items() if s > 0 and t in index.pos] + rows = [(i, s) for i, s in rows if index.has_emb[i]] + if not rows: + return np.zeros((0, index.emb.shape[1]), dtype=np.float32) + x = index.emb[[i for i, _ in rows]] + w = np.array([s for _, s in rows]) + k = int(min(max_k, max(1, len(rows) // 8))) + rng = np.random.default_rng(seed) + centers = x[rng.choice(len(x), size=k, replace=False)] + for _ in range(10): + assign = np.argmax(x @ centers.T, axis=1) + for c in range(k): + m = assign == c + if m.any(): + v = (x[m] * w[m, None]).sum(axis=0) + centers[c] = v / (np.linalg.norm(v) + 1e-9) + return centers.astype(np.float32) + + +def update_session( + index: CatalogIndex, session: Session, feedback: list[tuple[str, str]], centroids: Vec +) -> None: + """Обновляет сессионный вектор и счётчик скипов по реакциям на выданные треки. + Три скипа подряд — «смена курса»: вектор переводится к наименее похожему центроиду вкуса.""" + d = index.emb.shape[1] + vec = np.array(session.vec, dtype=np.float32) if session.vec else np.zeros(d, dtype=np.float32) + for track_id, outcome in feedback: + i = index.pos.get(track_id) + r = REACTION_WEIGHT.get(outcome) + if r is None: + continue + session.skips_in_row = session.skips_in_row + 1 if outcome in ("skip", "skip_early", "dislike") else 0 + if i is None or not index.has_emb[i]: + continue + vec = vec + ETA * r * index.emb[i] + norm = float(np.linalg.norm(vec)) + vec = vec / norm if norm > 0 else vec + if session.skips_in_row >= 3 and len(centroids): + sims = centroids @ vec if np.any(vec) else np.zeros(len(centroids)) + vec = centroids[int(np.argmin(sims))].copy() + session.skips_in_row = 0 + session.vec = vec.tolist() if np.any(vec) else None + + +def _top(scores: NDArray[np.float64], mask: NDArray[np.bool_], k: int) -> NDArray[np.intp]: + idx = np.nonzero(mask)[0] + if len(idx) <= k: + return idx + part = idx[np.argpartition(-scores[idx], k)[:k]] + return part + + +def build_batch( + index: CatalogIndex, + profile: Profile, + session: Session, + rules: list[Rule], + n: int = 5, + exclude: set[str] | None = None, + seed_ids: list[str] | None = None, + rng: np.random.Generator | None = None, +) -> list[Pick]: + """Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия.""" + rng = rng or np.random.default_rng() + size = len(index) + if size == 0: + return [] + centroids = taste_centroids(index, profile) + svec = np.array(session.vec, dtype=np.float32) if session.vec else None + + # 2. Фильтры. + excluded = profile.disliked | profile.recent | set(session.served) | (exclude or set()) + allowed = np.array( + [ + t not in excluded and a not in profile.disliked_artists + for t, a in zip(index.ids, index.artist, strict=True) + ] + ) + if not allowed.any(): + allowed = np.array([t not in profile.disliked for t in index.ids]) + if not allowed.any(): + return [] + + # Сходства со вкусом и сессией. + sim_taste = (index.emb @ centroids.T).max(axis=1) if len(centroids) else np.zeros(size) + sim_session = index.emb @ svec if svec is not None else np.zeros(size) + sim_taste = np.where(index.has_emb, sim_taste, 0.0) + sim_session = np.where(index.has_emb, sim_session, 0.0) + + # Привязанность к исполнителю — сглаженное среднее оценок его треков. + totals: dict[str, list[float]] = {} + for t, s in profile.scores.items(): + i = index.pos.get(t) + if i is not None: + totals.setdefault(index.artist[i], []).append(s) + affinity = {a: sum(v) / (len(v) + 2) for a, v in totals.items()} + artist_aff = np.array([np.tanh(affinity.get(a, 0.0) / 3) for a in index.artist]) + + # 1. Источники кандидатов. + sources: dict[int, str] = {} + + def add(idxs: NDArray[np.intp] | list[int], source: str) -> None: + for i in idxs: + sources.setdefault(int(i), source) + + if seed_ids: + seeds = [index.pos[s] for s in seed_ids if s in index.pos and index.has_emb[index.pos[s]]] + if seeds: + q = index.emb[seeds].mean(axis=0) + add(_top((index.emb @ q).astype(np.float64), allowed & index.has_emb, 100), "seed") + query = np.maximum(sim_session, sim_taste) if svec is not None or len(centroids) else None + if query is not None: + # «Новое» помечаем раньше audio_sim: неслышанный трек рядом со вкусом считается исследованием. + never = np.array([t not in profile.played for t in index.ids]) + add(_top(sim_taste + 0.3 * sim_session, allowed & index.has_emb & never, 50), "fresh") + add(_top(query, allowed & index.has_emb, 150), "audio_sim") + liked_artists = {a for a, v in affinity.items() if v > 0.3} + if liked_artists: + by_artist = [int(i) for i in np.nonzero(allowed)[0] if index.artist[i] in liked_artists] + add(rng.permutation(by_artist)[:60].tolist() if by_artist else [], "artist") + redis_ = [index.pos[t] for t in profile.rediscover if t in index.pos and allowed[index.pos[t]]] + add(rng.permutation(redis_)[:30].tolist() if redis_ else [], "rediscover") + add(_top(index.popularity + rng.random(size) * 0.3, allowed, 30), "popular") + # Холодный старт и исследование: случайные треки из разных частей библиотеки. + add(rng.permutation(np.nonzero(allowed)[0])[:40].tolist(), "explore") + + cand = np.array(sorted(sources)) + # 3. Оценка v1. + score = ( + 0.35 * sim_session[cand] + + 0.25 * sim_taste[cand] + + 0.15 * artist_aff[cand] + + 0.05 * index.popularity[cand] + + 0.02 * rng.random(len(cand)) # разбивает ничьи, особенно при холодном старте + ) + liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand]) + score = score - 0.2 * liked_recent + + # 4. Соответствие контексту; если отсеялось слишком много — порог ослабляется. + if rules: + f = fit({k: v[cand] for k, v in index.features.items()}, rules) + # Нужен запас для разнообразия, но не больше 50 и не больше четверти пула. + enough = min(50, max(3 * n, len(cand) // 4)) + for threshold in (0.35, 0.15, 0.0): + keep = f >= threshold + if keep.sum() >= enough or threshold == 0.0: + break + cand, score, f = cand[keep], score[keep], f[keep] + score = score + 0.5 * f # контекст важнее вкуса, когда выбран явно + if len(cand) == 0: + return [] + + # 5. MMR + правила: 1 трек исполнителя на пачку, не больше 2 на последние 10; доля «нового». + picks: list[Pick] = [] + chosen: list[int] = [] + recent_artists = session.recent_artists[-10:] + want_fresh = max(0, round(n * profile.exploration)) + order = list(range(len(cand))) + lam = 0.75 + while len(picks) < n and order: + best_j, best_v = -1, -np.inf + for j in order: + i = int(cand[j]) + a = index.artist[i] + if any(index.artist[c] == a for c in chosen) or recent_artists.count(a) >= 2: + continue + redundancy = max((float(index.emb[i] @ index.emb[c]) for c in chosen), default=0.0) + v = lam * float(score[j]) - (1 - lam) * redundancy + if v > best_v: + best_j, best_v = j, v + if best_j < 0: + # Правила разнообразия не дают набрать пачку — ослабляем их, но не повторяем треки. + if recent_artists: + recent_artists = [] + continue + if chosen: + chosen_artists = {index.artist[c] for c in chosen} + rest = [j for j in order if index.artist[int(cand[j])] in chosen_artists] + if not rest: + break + best_j = max(rest, key=lambda j: score[j]) + else: + break + i = int(cand[best_j]) + chosen.append(i) + order.remove(best_j) + picks.append(Pick(id=index.ids[i], source=sources[i], score=round(float(score[best_j]), 4))) + + # Исследование: «новых» (неслышанных) меньше доли exploration — заменяем ими слабейшие выборы. + fresh_left = sorted( + (j for j in order if sources[int(cand[j])] == "fresh"), key=lambda j: score[j], reverse=True + ) + while sum(p.source == "fresh" for p in picks) < want_fresh and fresh_left: + j = fresh_left.pop(0) + i = int(cand[j]) + others = [k for k, p in enumerate(picks) if p.source != "fresh"] + if not others: + break + weakest = min(others, key=lambda k: picks[k].score) + if any(index.artist[index.pos[p.id]] == index.artist[i] for k, p in enumerate(picks) if k != weakest): + continue + picks[weakest] = Pick(id=index.ids[i], source="fresh", score=round(float(score[j]), 4)) + return picks diff --git a/services/recommender/pipu_rec/wave/service.py b/services/recommender/pipu_rec/wave/service.py new file mode 100644 index 0000000..5e353c7 --- /dev/null +++ b/services/recommender/pipu_rec/wave/service.py @@ -0,0 +1,93 @@ +"""Сервис «Моей волны»: собирает пачку из каталога, профиля, сессии и контекста.""" + +from __future__ import annotations + +from typing import Literal + +import numpy as np +import psycopg +from pydantic import BaseModel, Field + +from pipu_rec.wave.contexts import Contexts, Rule +from pipu_rec.wave.engine import MODEL_VER, Pick, build_batch, taste_centroids, update_session +from pipu_rec.wave.store import IndexHolder, SessionStore, load_profile, seed_tracks + +MAX_SERVED_MEMORY = 300 + +Outcome = Literal["complete", "skip", "skip_early", "like", "dislike"] + + +class Feedback(BaseModel): + track_id: str + outcome: Outcome + + +class Seed(BaseModel): + type: Literal["track", "album", "artist"] + id: str + + +class BatchRequest(BaseModel): + user_id: str + session_id: str + activity: str | None = None + mood: str | None = None + seed: Seed | None = None + exclude_ids: list[str] = Field(default_factory=list, max_length=1000) + feedback: list[Feedback] = Field(default_factory=list, max_length=200) + n: int = Field(default=5, ge=1, le=20) + + +class BatchTrack(BaseModel): + id: str + source: str + score: float + + +class BatchResponse(BaseModel): + tracks: list[BatchTrack] + model_ver: str + + +class WaveService: + def __init__(self, dsn: str, redis_url: str, contexts: Contexts) -> None: + self.dsn = dsn + self.index = IndexHolder(dsn) + self.sessions = SessionStore(redis_url) + self.contexts = contexts + + def rules(self, activity: str | None, mood: str | None) -> list[Rule]: + """Занятие и настроение комбинируются — пересечение профилей; неизвестный ключ игнорируется.""" + rules: list[Rule] = [] + if activity and activity in self.contexts.activities: + rules += self.contexts.activities[activity].rules + if mood and mood in self.contexts.moods: + rules += self.contexts.moods[mood].rules + return rules + + def batch(self, req: BatchRequest) -> BatchResponse: + index = self.index.get() + with psycopg.connect(self.dsn) as conn: + profile = load_profile(conn, req.user_id) + seeds = seed_tracks(conn, req.seed.type, req.seed.id) if req.seed else None + session = self.sessions.get(req.session_id) + update_session( + index, session, [(f.track_id, f.outcome) for f in req.feedback], taste_centroids(index, profile) + ) + picks: list[Pick] = build_batch( + index, + profile, + session, + self.rules(req.activity, req.mood), + n=req.n, + exclude=set(req.exclude_ids), + seed_ids=seeds, + rng=np.random.default_rng(), + ) + session.served = (session.served + [p.id for p in picks])[-MAX_SERVED_MEMORY:] + artists = [index.artist[index.pos[p.id]] for p in picks] + session.recent_artists = (session.recent_artists + artists)[-10:] + self.sessions.put(req.session_id, session) + return BatchResponse( + tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks], model_ver=MODEL_VER + ) diff --git a/services/recommender/pipu_rec/wave/store.py b/services/recommender/pipu_rec/wave/store.py new file mode 100644 index 0000000..b180c5d --- /dev/null +++ b/services/recommender/pipu_rec/wave/store.py @@ -0,0 +1,194 @@ +"""Загрузка каталога, профиля и сессии волны из PostgreSQL / Redis.""" + +from __future__ import annotations + +import json +import logging +import math +import threading +import time +from dataclasses import asdict +from typing import Any, cast + +import numpy as np +import psycopg +import redis + +from pipu_rec.wave.engine import CatalogIndex, Profile, Session + +log = logging.getLogger(__name__) + +INDEX_TTL_S = 300 +SESSION_TTL_S = 12 * 3600 +HALF_LIFE_DAYS = 90.0 + + +def parse_vector(text: str | None) -> np.ndarray | None: + """pgvector в текстовом виде «[0.1,0.2,...]».""" + if not text: + return None + return np.array(text.strip("[]").split(","), dtype=np.float32) + + +def load_index(conn: psycopg.Connection[Any]) -> CatalogIndex: + """Доступные треки: основной файл в подключённом хранилище и на месте.""" + rows = conn.execute( + """ + SELECT t.id::text, + COALESCE((SELECT ta.artist_id::text FROM track_artists ta + WHERE ta.track_id = t.id AND ta.role = 'main' ORDER BY ta.position LIMIT 1), + al.album_artist_id::text), + e.audio::text, f.bpm, f.energy, f.danceability, f.valence, f.acousticness, + COALESCE((SELECT sum(play_count) FROM user_track_stats s WHERE s.track_id = t.id), 0) + FROM tracks t + JOIN albums al ON al.id = t.album_id + JOIN media_files mf ON mf.id = t.primary_file_id AND mf.missing_since IS NULL + JOIN library_sources src ON src.id = mf.source_id AND src.status = 'mounted' + LEFT JOIN track_embeddings e ON e.track_id = t.id + LEFT JOIN track_features f ON f.track_id = t.id + """ + ).fetchall() + feats: dict[str, list[float | None]] = { + k: [] for k in ("bpm", "energy", "danceability", "valence", "acousticness") + } + for r in rows: + for k, v in zip(feats, r[3:8], strict=True): + feats[k].append(None if v is None else float(v)) + return CatalogIndex.build( + ids=[r[0] for r in rows], + artist=[r[1] for r in rows], + raw_emb=[parse_vector(r[2]) for r in rows], + features=feats, + plays=[int(r[8]) for r in rows], + ) + + +class IndexHolder: + """Каталог в памяти; перечитывается раз в INDEX_TTL_S в фоне, не блокируя чтение.""" + + def __init__(self, dsn: str) -> None: + self.dsn = dsn + self._index: CatalogIndex | None = None + self._loaded = 0.0 + self._lock = threading.Lock() + + def get(self) -> CatalogIndex: + if self._index is None: + self.refresh() + elif time.monotonic() - self._loaded > INDEX_TTL_S and self._lock.acquire(blocking=False): + threading.Thread(target=self._refresh_locked, daemon=True).start() + assert self._index is not None + return self._index + + def refresh(self) -> None: + with self._lock: + self._load() + + def _refresh_locked(self) -> None: + try: + self._load() + finally: + self._lock.release() + + def _load(self) -> None: + started = time.monotonic() + with psycopg.connect(self.dsn) as conn: + self._index = load_index(conn) + self._loaded = time.monotonic() + log.info( + "catalog index loaded: tracks=%d analysed=%d in %.0f ms", + len(self._index), + int(self._index.has_emb.sum()), + (self._loaded - started) * 1000, + ) + + +def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile: + """implicit_score из агрегатов с затуханием по давности; лайк +3, дизлайк — исключение.""" + now = time.time() + scores: dict[str, float] = {} + played: set[str] = set() + last: dict[str, float] = {} + for tid, plays, complete, skips, early, last_at in conn.execute( + """ + SELECT track_id::text, play_count, complete_count, skip_count, early_skip_count, last_played_at + FROM user_track_stats WHERE user_id = %s + """, + (user_id,), + ): + played.add(tid) + raw = complete * 1.0 + max(plays - complete, 0) * 0.7 - early * 1.0 - max(skips - early, 0) * 0.2 + ts = last_at.timestamp() if last_at else now + last[tid] = ts + scores[tid] = raw * math.exp(-math.log(2) * (now - ts) / 86400 / HALF_LIFE_DAYS) + disliked: set[str] = set() + liked: set[str] = set() + for tid, reaction in conn.execute( + "SELECT track_id::text, reaction::text FROM track_reactions WHERE user_id = %s", (user_id,) + ): + if reaction == "like": + liked.add(tid) + scores[tid] = scores.get(tid, 0.0) + 3.0 + else: + disliked.add(tid) + scores.pop(tid, None) + disliked_artists = { + r[0] + for r in conn.execute( + "SELECT artist_id::text FROM artist_reactions WHERE user_id = %s AND reaction = 'dislike'", + (user_id,), + ) + } + recent = { + r[0] + for r in conn.execute( + """ + SELECT DISTINCT track_id::text FROM play_events + WHERE user_id = %s AND type = 'play_start' AND occurred_at > now() - interval '3 hours' + """, + (user_id,), + ) + } + row = conn.execute("SELECT exploration FROM user_profiles_rec WHERE user_id = %s", (user_id,)).fetchone() + exploration = float(row[0]) if row else 0.15 + if len(played) < 30: + exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках + day = 86400 + return Profile( + scores=scores, + disliked=disliked, + disliked_artists=disliked_artists, + recent=recent, + played=played, + liked_recent={t for t in liked if now - last.get(t, 0) < day}, + rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day}, + exploration=exploration, + ) + + +def seed_tracks(conn: psycopg.Connection[Any], seed_type: str, seed_id: str) -> list[str]: + """Треки, от которых запущена волна: трек, альбом или исполнитель.""" + queries = { + "track": "SELECT %s::uuid::text", + "album": "SELECT id::text FROM tracks WHERE album_id = %s", + "artist": "SELECT track_id::text FROM track_artists WHERE artist_id = %s", + } + q = queries.get(seed_type) + if not q: + return [] + return [r[0] for r in conn.execute(q, (seed_id,))] + + +class SessionStore: + def __init__(self, url: str) -> None: + self.rdb = redis.Redis.from_url(url, socket_connect_timeout=2, socket_timeout=2) + + def get(self, session_id: str) -> Session: + raw = cast(bytes | None, self.rdb.get(f"wave:session:{session_id}")) + if not raw: + return Session() + data = json.loads(raw) + return Session(**data) + + def put(self, session_id: str, s: Session) -> None: + self.rdb.set(f"wave:session:{session_id}", json.dumps(asdict(s)), ex=SESSION_TTL_S) diff --git a/services/recommender/pyproject.toml b/services/recommender/pyproject.toml index db28d6c..7ce2bea 100644 --- a/services/recommender/pyproject.toml +++ b/services/recommender/pyproject.toml @@ -9,6 +9,8 @@ dependencies = [ "pydantic-settings>=2.9,<3", "psycopg[binary]>=3.2,<4", "redis>=6.2,<7", + "numpy>=2.2,<3", + "pyyaml>=6.0,<7", ] [project.scripts] @@ -20,6 +22,7 @@ dev = [ "httpx>=0.28,<1", "ruff>=0.12,<1", "mypy>=1.16,<2", + "types-pyyaml>=6.0", ] [build-system] @@ -35,6 +38,8 @@ target-version = "py312" [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"] +# Комментарии и докстринги на русском: «с», «у», «−» — не опечатки в латинице. +ignore = ["RUF001", "RUF002", "RUF003"] [tool.mypy] strict = true diff --git a/services/recommender/tests/test_app.py b/services/recommender/tests/test_app.py index 67be128..1a5dd86 100644 --- a/services/recommender/tests/test_app.py +++ b/services/recommender/tests/test_app.py @@ -57,3 +57,10 @@ def test_internal_status_requires_token() -> None: r = c.get("/internal/status", headers={"Authorization": "Bearer secret"}) assert r.status_code == 200 assert r.json()["version"] == "1.2.3" + + +def test_wave_batch_requires_token_and_service() -> None: + body = {"user_id": "u", "session_id": "s"} + assert client().post("/internal/wave/batch", json=body).status_code == 401 + r = client().post("/internal/wave/batch", json=body, headers={"Authorization": "Bearer secret"}) + assert r.status_code == 503 diff --git a/services/recommender/tests/test_engine.py b/services/recommender/tests/test_engine.py new file mode 100644 index 0000000..79d0603 --- /dev/null +++ b/services/recommender/tests/test_engine.py @@ -0,0 +1,142 @@ +import numpy as np +import pytest + +from pipu_rec.wave.contexts import load_contexts +from pipu_rec.wave.engine import CatalogIndex, Profile, Session, build_batch, taste_centroids, update_session + + +def library(n_per_cluster: int = 20, seed: int = 0) -> CatalogIndex: + """Три «жанра» — кластера эмбеддингов; в кластере 5 исполнителей по 4 трека.""" + rng = np.random.default_rng(seed) + centers = rng.normal(size=(3, 32)) + ids, artists, embs = [], [], [] + feats: dict[str, list[float | None]] = { + "bpm": [], + "energy": [], + "danceability": [], + "valence": [], + "acousticness": [], + } + for c in range(3): + for k in range(n_per_cluster): + ids.append(f"t{c}-{k}") + artists.append(f"a{c}-{k // 4}") + embs.append((centers[c] + 0.3 * rng.normal(size=32)).astype(np.float32)) + feats["bpm"].append([90.0, 128.0, 172.0][c]) + feats["energy"].append([0.2, 0.5, 0.85][c]) + feats["danceability"].append(0.7) + feats["valence"].append(0.5) + feats["acousticness"].append(None) + return CatalogIndex.build(ids, artists, embs, feats, plays=[1] * len(ids), dim=32) + + +def profile(**kw: object) -> Profile: + base = dict( + scores={}, + disliked=set(), + disliked_artists=set(), + recent=set(), + played=set(), + liked_recent=set(), + rediscover=set(), + exploration=0.0, + ) + base.update(kw) + return Profile(**base) # type: ignore[arg-type] + + +def cluster(track_id: str) -> int: + return int(track_id[1]) + + +def test_cold_start_returns_a_diverse_batch() -> None: + idx = library() + picks = build_batch(idx, profile(), Session(), [], n=5, rng=np.random.default_rng(1)) + assert len(picks) == 5 + assert len({p.id for p in picks}) == 5 + assert len({idx.artist[idx.pos[p.id]] for p in picks}) == 5, "one track per artist in a batch" + + +def test_taste_pulls_towards_liked_cluster() -> None: + idx = library() + liked = {f"t2-{k}": 3.0 for k in range(16)} + picks = build_batch( + idx, profile(scores=liked, played=set(liked)), Session(), [], n=5, rng=np.random.default_rng(2) + ) + assert sum(cluster(p.id) == 2 for p in picks) >= 4 + + +def test_dislikes_recent_and_served_are_excluded() -> None: + idx = library(5) + p = profile(disliked={"t0-0"}, disliked_artists={"a1-0"}, recent={"t2-0"}) + s = Session(served=["t2-1"]) + for seed in range(5): + ids = {x.id for x in build_batch(idx, p, s, [], n=5, rng=np.random.default_rng(seed))} + assert not ids & {"t0-0", "t2-0", "t2-1"} + assert not any(idx.artist[idx.pos[i]] == "a1-0" for i in ids), "disliked artist" + + +def test_running_context_prefers_fast_energetic_tracks() -> None: + idx = library() + running = load_contexts().activities["running"].rules + picks = build_batch(idx, profile(), Session(), running, n=5, rng=np.random.default_rng(3)) + assert all(cluster(p.id) == 2 for p in picks), [p.id for p in picks] + + +def test_half_time_counts_for_running() -> None: + from pipu_rec.wave.contexts import fit + + running = load_contexts().activities["running"].rules + f = fit( + { + "bpm": np.array([85.0, 120.0]), + "energy": np.array([0.8, 0.8]), + "danceability": np.array([0.7, 0.7]), + "valence": np.array([np.nan, np.nan]), + "acousticness": np.array([np.nan, np.nan]), + }, + running, + ) + assert f[0] > 0.9 > f[1] + + +def test_session_vector_follows_reactions_and_changes_course() -> None: + idx = library() + liked = {f"t0-{k}": 3.0 for k in range(10)} | {f"t2-{k}": 3.0 for k in range(10)} + cents = taste_centroids(idx, profile(scores=liked)) + s = Session() + update_session(idx, s, [("t1-0", "like"), ("t1-1", "complete")], cents) + assert s.vec is not None + without = build_batch(idx, profile(scores=liked), Session(), [], n=5, rng=np.random.default_rng(4)) + picks = build_batch(idx, profile(scores=liked), s, [], n=5, rng=np.random.default_rng(4)) + assert sum(cluster(p.id) == 1 for p in picks) > sum(cluster(p.id) == 1 for p in without) + assert sum(cluster(p.id) == 1 for p in picks) >= 2, "the session pulls the wave towards its reactions" + + update_session(idx, s, [("t1-2", "skip_early"), ("t1-3", "skip_early"), ("t1-4", "dislike")], cents) + assert s.skips_in_row == 0, "course changed after three skips" + vec = np.array(s.vec) + assert float(vec @ idx.emb[idx.pos["t1-0"]]) < 0.5 + + +def test_exploration_brings_unheard_tracks() -> None: + idx = library() + heard = {f"t2-{k}" for k in range(20)} + liked = {t: 3.0 for t in heard} + picks = build_batch( + idx, + profile(scores=liked, played=heard, exploration=0.4), + Session(), + [], + n=5, + rng=np.random.default_rng(5), + ) + assert sum(p.source == "fresh" for p in picks) >= 2 + + +def test_bad_context_file_is_rejected(tmp_path) -> None: # type: ignore[no-untyped-def] + bad = tmp_path / "c.yaml" + bad.write_text( + "activities: {x: {icon: a, rules: [{feature: tempo, min: 1, sigma: 1}]}}\nmoods: {}\n", "utf-8" + ) + with pytest.raises(ValueError): + load_contexts(str(bad)) diff --git a/services/recommender/tests/test_features.py b/services/recommender/tests/test_features.py new file mode 100644 index 0000000..acbb160 --- /dev/null +++ b/services/recommender/tests/test_features.py @@ -0,0 +1,56 @@ +import numpy as np + +from pipu_rec.analysis.features import EMBEDDING_DIM, SAMPLE_RATE, analyze, estimate_tempo, segments + + +def metronome(bpm: float, seconds: float, freq: float = 1000.0) -> np.ndarray: + t = np.arange(int(seconds * SAMPLE_RATE)) / SAMPLE_RATE + period = 60.0 / bpm + phase = t % period + click = np.sin(2 * np.pi * freq * t) * np.exp(-phase * 60) + return (0.5 * click).astype(np.float32) + + +def test_tempo_of_a_metronome() -> None: + for bpm in (90, 128, 170): + f = analyze(metronome(bpm, 40)) + assert f.bpm is not None + # двойной / половинный темп допустим для щелчков, но для этих значений приор держит исходный + assert abs(f.bpm - bpm) < 2.5, (bpm, f.bpm) + + +def test_no_rhythm_in_a_steady_tone() -> None: + t = np.arange(SAMPLE_RATE * 20) / SAMPLE_RATE + tone = (0.3 * np.sin(2 * np.pi * 440 * t)).astype(np.float32) + f = analyze(tone) + assert f.bpm is None or f.bpm_confidence < 0.3 + assert f.key == "A" + assert f.embedding.shape == (EMBEDDING_DIM,) + assert abs(float(np.linalg.norm(f.embedding)) - 1) < 1e-4 + + +def test_louder_and_busier_means_more_energy() -> None: + quiet = (metronome(80, 30) * 0.05).astype(np.float32) + loud = metronome(160, 30) + noise = np.random.default_rng(1).normal(0, 0.2, len(loud)).astype(np.float32) + assert analyze(loud + noise).energy > analyze(quiet).energy + + +def test_similar_sounds_have_closer_embeddings() -> None: + a = analyze(metronome(120, 30, 1000)) + b = analyze(metronome(124, 30, 1100)) + rng = np.random.default_rng(2) + c = analyze(rng.normal(0, 0.3, SAMPLE_RATE * 30).astype(np.float32)) + assert float(a.embedding @ b.embedding) > float(a.embedding @ c.embedding) + + +def test_segments_cover_long_tracks() -> None: + long = np.zeros(SAMPLE_RATE * 300, dtype=np.float32) + parts = segments(long) + assert len(parts) == 3 + assert all(len(p) == SAMPLE_RATE * 30 for p in parts) + assert len(segments(np.zeros(SAMPLE_RATE * 60, dtype=np.float32))) == 1 + + +def test_estimate_tempo_needs_signal() -> None: + assert estimate_tempo(np.zeros(100, dtype=np.float32)) == (None, 0.0) diff --git a/services/recommender/uv.lock b/services/recommender/uv.lock index 0abf9be..a250848 100644 --- a/services/recommender/uv.lock +++ b/services/recommender/uv.lock @@ -207,6 +207,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "numpy" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" }, + { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.45.1" @@ -243,8 +262,10 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, + { name = "numpy" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, + { name = "pyyaml" }, { name = "redis" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -255,13 +276,16 @@ dev = [ { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115,<1" }, + { name = "numpy", specifier = ">=2.2,<3" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, { name = "pydantic-settings", specifier = ">=2.9,<3" }, + { name = "pyyaml", specifier = ">=6.0,<7" }, { name = "redis", specifier = ">=6.2,<7" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" }, ] @@ -272,6 +296,7 @@ dev = [ { name = "mypy", specifier = ">=1.16,<2" }, { name = "pytest", specifier = ">=8.4,<9" }, { name = "ruff", specifier = ">=0.12,<1" }, + { name = "types-pyyaml", specifier = ">=6.0" }, ] [[package]] @@ -478,6 +503,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"