feat(rec): audio analyzer and My Wave v1 engine (M4)
ci / api / api (push) Successful in 27s
ci / recommender / recommender (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m26s

- migration 00006: track features and embeddings (pgvector, HNSW),
  analysis queue filled by a trigger on new tracks, wave sessions and
  impressions, recommender profiles, model versions
- analyzer basic-1 (ADR-014): ffmpeg decode, three 30 s excerpts,
  256-d timbre/rhythm embedding, tempo by onset autocorrelation, key,
  loudness, energy, danceability, acousticness, intro length; worker
  with SKIP LOCKED claims, retries and re-analysis on version change
- wave engine v1: candidates (session/taste similarity, fresh,
  liked artists, rediscover, popular, exploration, seed), filters
  (dislikes, played in 3 h, already served), heuristic score, activity
  and mood fit from contexts.yaml with relaxing thresholds, MMR with
  one track per artist, exploration share, session vector updated by
  reactions and a change of course after three skips
- POST /internal/wave/batch with the catalog index cached in memory
  and the session kept in Redis

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 12:20:55 +03:00
1 parent 976ffb574b
commit ef29b85320
20 files changed
+1530 -13

No files matched your search

+17
View File
@@ -170,3 +170,20 @@ CI на каждый PR собирает все образы без push, что
**Последствия.** Серверу нужен доступ к репозиторию и интернет на время сборки; первая сборка — 5–15 минут, **Последствия.** Серверу нужен доступ к репозиторию и интернет на время сборки; первая сборка — 5–15 минут,
нужно ~5–10 ГБ под кэш сборки. Откат — `update.sh` на предыдущий тег (пересборка из кэша). Отпадают секреты нужно ~5–10 ГБ под кэш сборки. Откат — `update.sh` на предыдущий тег (пересборка из кэша). Отпадают секреты
`REGISTRY_USER` / `REGISTRY_TOKEN`. Заменяет часть ADR-001 / ci-cd о публикации образов. `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`: воркер сам ставит пересчёт всех треков.
+106
View File
@@ -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;
+2 -1
View File
@@ -15,7 +15,8 @@ COPY services/recommender/ ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
FROM python:3.12-slim 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 COPY --from=build /app /app
ARG VERSION=0.0.0-dev ARG VERSION=0.0.0-dev
ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1 ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1
Whitespace-only changes.
@@ -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,
)
@@ -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))
+13 -2
View File
@@ -10,6 +10,8 @@ from fastapi import Depends, FastAPI, Header, HTTPException, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pipu_rec.settings import Settings 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]] Check = Callable[[], Awaitable[None]]
@@ -18,7 +20,9 @@ log = logging.getLogger(__name__)
CHECK_TIMEOUT_S = 2.0 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) app = FastAPI(title="pipu-recommender", version=settings.version, docs_url=None, redoc_url=None)
checks = checks or {} 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)]) @app.get("/internal/status", dependencies=[Depends(require_token)])
async def internal_status() -> dict[str, object]: 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 return app
+12 -10
View File
@@ -1,15 +1,17 @@
"""Точка входа: `pipu-rec serve` (онлайн-API) или `pipu-rec analyze` (воркер аудиоанализа).""" """Точка входа: `pipu-rec serve` (онлайн-API) или `pipu-rec analyze` (воркер аудиоанализа)."""
import argparse import argparse
import asyncio
import logging import logging
import psycopg import psycopg
import redis.asyncio as aioredis import redis.asyncio as aioredis
import uvicorn import uvicorn
from pipu_rec.analysis.worker import Worker
from pipu_rec.api.app import create_app from pipu_rec.api.app import create_app
from pipu_rec.settings import Settings 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") log = logging.getLogger("pipu_rec")
@@ -24,19 +26,19 @@ def _serve(settings: Settings) -> None:
async def check_redis() -> None: async def check_redis() -> None:
await rdb.ping() 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) uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level)
def _analyze(settings: Settings, workers: int) -> None: def _analyze(settings: Settings, workers: int) -> None:
# Аудиоанализ (Essentia) появится в M4; сейчас воркер только держит процесс живым. Worker(
log.info("analyzer started: workers=%d version=%s (no-op until M4)", workers, settings.version) settings.database_url,
settings.library_root,
async def idle() -> None: settings.library_host_root,
while True: workers,
await asyncio.sleep(3600) settings.ffmpeg_path,
).run_forever()
asyncio.run(idle())
def main() -> None: def main() -> None:
@@ -13,4 +13,8 @@ class Settings(BaseSettings):
redis_url: str = Field(default="redis://redis:6379/0") redis_url: str = Field(default="redis://redis:6379/0")
internal_token: str = Field(default="", description="Общий секрет для вызовов из api") internal_token: str = Field(default="", description="Общий секрет для вызовов из api")
log_level: str = "info" 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") version: str = Field(default="0.0.0-dev", alias="PIPU_VERSION")
Whitespace-only changes.
@@ -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
@@ -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}
@@ -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
@@ -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
)
+194
View File
@@ -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)
+5
View File
@@ -9,6 +9,8 @@ dependencies = [
"pydantic-settings>=2.9,<3", "pydantic-settings>=2.9,<3",
"psycopg[binary]>=3.2,<4", "psycopg[binary]>=3.2,<4",
"redis>=6.2,<7", "redis>=6.2,<7",
"numpy>=2.2,<3",
"pyyaml>=6.0,<7",
] ]
[project.scripts] [project.scripts]
@@ -20,6 +22,7 @@ dev = [
"httpx>=0.28,<1", "httpx>=0.28,<1",
"ruff>=0.12,<1", "ruff>=0.12,<1",
"mypy>=1.16,<2", "mypy>=1.16,<2",
"types-pyyaml>=6.0",
] ]
[build-system] [build-system]
@@ -35,6 +38,8 @@ target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"] select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
# Комментарии и докстринги на русском: «с», «у», «−» — не опечатки в латинице.
ignore = ["RUF001", "RUF002", "RUF003"]
[tool.mypy] [tool.mypy]
strict = true strict = true
+7
View File
@@ -57,3 +57,10 @@ def test_internal_status_requires_token() -> None:
r = c.get("/internal/status", headers={"Authorization": "Bearer secret"}) r = c.get("/internal/status", headers={"Authorization": "Bearer secret"})
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["version"] == "1.2.3" 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
+142
View File
@@ -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))
@@ -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)
+34
View File
@@ -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" }, { 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]] [[package]]
name = "opentelemetry-api" name = "opentelemetry-api"
version = "1.45.1" version = "1.45.1"
@@ -243,8 +262,10 @@ version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "numpy" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyyaml" },
{ name = "redis" }, { name = "redis" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@@ -255,13 +276,16 @@ dev = [
{ name = "mypy" }, { name = "mypy" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-pyyaml" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.115,<1" }, { name = "fastapi", specifier = ">=0.115,<1" },
{ name = "numpy", specifier = ">=2.2,<3" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" },
{ name = "pydantic-settings", specifier = ">=2.9,<3" }, { name = "pydantic-settings", specifier = ">=2.9,<3" },
{ name = "pyyaml", specifier = ">=6.0,<7" },
{ name = "redis", specifier = ">=6.2,<7" }, { name = "redis", specifier = ">=6.2,<7" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" },
] ]
@@ -272,6 +296,7 @@ dev = [
{ name = "mypy", specifier = ">=1.16,<2" }, { name = "mypy", specifier = ">=1.16,<2" },
{ name = "pytest", specifier = ">=8.4,<9" }, { name = "pytest", specifier = ">=8.4,<9" },
{ name = "ruff", specifier = ">=0.12,<1" }, { name = "ruff", specifier = ">=0.12,<1" },
{ name = "types-pyyaml", specifier = ">=6.0" },
] ]
[[package]] [[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" }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.16.0" version = "4.16.0"