feat(reco): radio + similar with metadata fallback (§6.5)
POST /radio + /radio/next (stateless infinite feed: seed track / from-likes,
exploration mix, client-passed exclude_ids) and GET /tracks|artists/{id}/similar,
replacing the stubs. Recommender port abstracts the (future) ML service —
NullRecommender is wired now so RecommendationService always uses its metadata
heuristics (genre/artist similarity, random exploration filler), never a hard ML
dependency. Adds TrackRepository.list_similar/sample_playable + Artist.list_similar,
reason codes for the client, RemoteRecommender skeleton (TODO: ML contract).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""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:
|
||||
found = [a for i in ids if (a := await self._artists.get_by_id(i))]
|
||||
return REASON_ML, found
|
||||
|
||||
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."""
|
||||
return [t for i in ids if (t := await self._tracks.get_by_id(i))]
|
||||
Reference in New Issue
Block a user