Files
pipu-music/services/recommender/pipu_rec/wave/contexts.py
T
mrleo1nidandClaude Opus 5.5 ef29b85320
ci / api / api (push) Successful in 27s
ci / recommender / recommender (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m26s
feat(rec): audio analyzer and My Wave v1 engine (M4)
- 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>
2026-10-20 12:20:55 +03:00

74 lines
2.5 KiB
Python

"""Профили занятий и настроений: соответствие трека контексту (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