Files
mcma-backend/app/application/recommendation_service.py
Цвылев Александр Вадимович d16c6085c9 perf(reco): batch id->entity hydration
radio/similar resolved ids one get_by_id at a time (N round-trips). Add
TrackRepository.get_many and use it (plus ArtistRepository.get_many) to
hydrate in a single query, preserving requested order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 10:53:35 +03:00

190 lines
6.2 KiB
Python

"""Recommendation / radio service (plan §6.5).
Tries the external ML recommender first; when it's unavailable or declines
(returns ``None``), falls back to metadata heuristics over the catalogue — so
similar/radio always work, worse, without ML (graceful-degradation invariant).
``reason`` values are short codes (``ml`` / ``similar`` / ``from_likes`` /
``discover``) the client localizes for the "why is this playing?" affordance.
"""
import random
import uuid
from dataclasses import dataclass
from app.domain.entities.track import Artist, Track
from app.domain.errors import NotFoundError
from app.domain.ports import (
ArtistRepository,
LikeRepository,
Recommender,
TrackRepository,
)
REASON_ML = "ml"
REASON_SIMILAR = "similar"
REASON_FROM_LIKES = "from_likes"
REASON_DISCOVER = "discover"
_LIKED_SEED_POOL = 50
@dataclass(frozen=True, slots=True)
class RadioPick:
track: Track
reason: str
class RecommendationService:
def __init__(
self,
*,
recommender: Recommender,
tracks: TrackRepository,
artists: ArtistRepository,
likes: LikeRepository,
) -> None:
self._recommender = recommender
self._tracks = tracks
self._artists = artists
self._likes = likes
# -- similar ---------------------------------------------------------------
async def similar_tracks(
self, track_id: uuid.UUID, *, limit: int
) -> tuple[str, list[Track]]:
seed = await self._tracks.get_by_id(track_id)
if seed is None:
raise NotFoundError(f"Track {track_id} not found.")
if self._recommender.is_available():
ids = await self._recommender.similar_track_ids(
track_id, limit=limit, exclude_ids=[track_id]
)
if ids is not None:
return REASON_ML, await self._hydrate_tracks(ids)
found = await self._tracks.list_similar(
genre=seed.genre,
artist_id=seed.artist_id,
exclude_ids=[track_id],
limit=limit,
)
return REASON_SIMILAR, found
async def similar_artists(
self, artist_id: uuid.UUID, *, limit: int
) -> tuple[str, list[Artist]]:
if await self._artists.get_by_id(artist_id) is None:
raise NotFoundError(f"Artist {artist_id} not found.")
if self._recommender.is_available():
ids = await self._recommender.similar_artist_ids(artist_id, limit=limit)
if ids is not None:
by_id = {a.id: a for a in await self._artists.get_many(ids)}
return REASON_ML, [by_id[i] for i in ids if i in by_id]
found = await self._artists.list_similar(artist_id=artist_id, limit=limit)
return REASON_SIMILAR, found
# -- radio -----------------------------------------------------------------
async def radio(
self,
*,
user_id: uuid.UUID,
seed_track_id: uuid.UUID | None,
from_likes: bool,
exploration: float,
limit: int,
exclude_ids: list[uuid.UUID],
) -> tuple[str, list[RadioPick]]:
exploration = min(1.0, max(0.0, exploration))
if self._recommender.is_available():
ids = await self._recommender.radio_track_ids(
seed_track_id=seed_track_id,
exploration=exploration,
limit=limit,
exclude_ids=exclude_ids,
)
if ids is not None:
picks = [
RadioPick(track=t, reason=REASON_ML)
for t in await self._hydrate_tracks(ids)
]
return REASON_ML, picks
return "metadata", await self._radio_fallback(
user_id=user_id,
seed_track_id=seed_track_id,
from_likes=from_likes,
exploration=exploration,
limit=limit,
exclude_ids=exclude_ids,
)
async def _radio_fallback(
self,
*,
user_id: uuid.UUID,
seed_track_id: uuid.UUID | None,
from_likes: bool,
exploration: float,
limit: int,
exclude_ids: list[uuid.UUID],
) -> list[RadioPick]:
exclude = list(dict.fromkeys(exclude_ids)) # de-dupe, keep order
explore_n = round(limit * exploration)
similar_n = limit - explore_n
picks: list[RadioPick] = []
seed, seed_reason = await self._resolve_seed(
user_id, seed_track_id, from_likes
)
if seed is not None and similar_n > 0:
for track in await self._tracks.list_similar(
genre=seed.genre,
artist_id=seed.artist_id,
exclude_ids=exclude,
limit=similar_n,
):
picks.append(RadioPick(track=track, reason=seed_reason))
exclude.append(track.id)
# Fill the remainder (exploration + any similarity shortfall) with random
# playable tracks — this is also the total fallback when there's no seed.
remaining = limit - len(picks)
if remaining > 0:
for track in await self._tracks.sample_playable(
exclude_ids=exclude, limit=remaining
):
picks.append(RadioPick(track=track, reason=REASON_DISCOVER))
exclude.append(track.id)
random.shuffle(picks)
return picks
async def _resolve_seed(
self,
user_id: uuid.UUID,
seed_track_id: uuid.UUID | None,
from_likes: bool,
) -> tuple[Track | None, str]:
if seed_track_id is not None:
return await self._tracks.get_by_id(seed_track_id), REASON_SIMILAR
if from_likes:
liked = await self._likes.list_liked_tracks(
user_id=user_id, limit=_LIKED_SEED_POOL, offset=0
)
if liked:
return random.choice(liked), REASON_FROM_LIKES
return None, REASON_DISCOVER
async def _hydrate_tracks(self, ids: list[uuid.UUID]) -> list[Track]:
"""Resolve ids → tracks preserving order, skipping any that vanished.
One batched query rather than N per-id round-trips."""
by_id = {t.id: t for t in await self._tracks.get_many(ids)}
return [by_id[i] for i in ids if i in by_id]