Files
pipu-music/services/recommender/pipu_rec/api/app.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

71 lines
2.9 KiB
Python

"""FastAPI-приложение внутреннего API рекомендаций."""
import asyncio
import hmac
import logging
from collections.abc import Awaitable, Callable
from typing import Annotated
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]]
log = logging.getLogger(__name__)
CHECK_TIMEOUT_S = 2.0
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 {}
def require_token(authorization: Annotated[str, Header()] = "") -> None:
scheme, _, token = authorization.partition(" ")
expected = settings.internal_token
if scheme != "Bearer" or not expected or not hmac.compare_digest(token, expected):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="auth.invalid_internal_token")
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
async def run_check(name: str, check: Check) -> str:
try:
await asyncio.wait_for(check(), CHECK_TIMEOUT_S)
except Exception as exc:
log.warning("readiness check %s failed: %r", name, exc)
return "fail"
return "ok"
@app.get("/readyz")
async def readyz() -> JSONResponse:
# Проверки параллельные, каждая ограничена таймаутом; причину ошибки пишем только в лог.
names = list(checks)
outcomes = await asyncio.gather(*(run_check(n, checks[n]) for n in names))
results = dict(zip(names, outcomes, strict=True))
healthy = all(v == "ok" for v in results.values())
return JSONResponse(
{"status": "ok" if healthy else "degraded", "checks": results},
status_code=status.HTTP_200_OK if healthy else status.HTTP_503_SERVICE_UNAVAILABLE,
)
@app.get("/internal/status", dependencies=[Depends(require_token)])
async def internal_status() -> dict[str, object]:
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