591a938e71
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>
111 lines
3.6 KiB
Python
111 lines
3.6 KiB
Python
"""Recommender adapters (plan §6.5).
|
|
|
|
``NullRecommender`` is the default: no ML service, so every method reports
|
|
unavailable and the ``RecommendationService`` uses its metadata fallback. When
|
|
an embedding service exists, wire ``RemoteRecommender`` (skeleton below) to
|
|
``ML_SERVICE_URL`` — its exact request/response contract is TODO pending that
|
|
service. Both keep the invariant: ML is optional, never a hard dependency.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
import httpx
|
|
|
|
from app.core.logging import get_logger
|
|
|
|
log = get_logger(__name__)
|
|
|
|
|
|
class NullRecommender:
|
|
"""No ML configured — always unavailable, always ``None`` (→ fallback)."""
|
|
|
|
def is_available(self) -> bool:
|
|
return False
|
|
|
|
async def similar_track_ids(
|
|
self, track_id: uuid.UUID, *, limit: int, exclude_ids: list[uuid.UUID]
|
|
) -> list[uuid.UUID] | None:
|
|
return None
|
|
|
|
async def similar_artist_ids(
|
|
self, artist_id: uuid.UUID, *, limit: int
|
|
) -> list[uuid.UUID] | None:
|
|
return None
|
|
|
|
async def radio_track_ids(
|
|
self,
|
|
*,
|
|
seed_track_id: uuid.UUID | None,
|
|
exploration: float,
|
|
limit: int,
|
|
exclude_ids: list[uuid.UUID],
|
|
) -> list[uuid.UUID] | None:
|
|
return None
|
|
|
|
|
|
_TIMEOUT_SECONDS = 5.0
|
|
|
|
|
|
class RemoteRecommender:
|
|
"""HTTP client for an external embedding/recommender service.
|
|
|
|
TODO: the request/response schema below is a placeholder — align it with the
|
|
real ML service once its contract is known. Until then this stays unused
|
|
(``deps`` wires ``NullRecommender``). Every call is defensive: any error or a
|
|
malformed body returns ``None`` so the service degrades to metadata, matching
|
|
the graceful-degradation invariant.
|
|
"""
|
|
|
|
def __init__(self, base_url: str) -> None:
|
|
self._base_url = base_url.rstrip("/")
|
|
|
|
def is_available(self) -> bool:
|
|
return True
|
|
|
|
async def _post_ids(self, path: str, payload: dict[str, object]) -> list[uuid.UUID] | None:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
|
|
resp = await client.post(f"{self._base_url}{path}", json=payload)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
ids = data.get("track_ids") if isinstance(data, dict) else None
|
|
if not isinstance(ids, list):
|
|
return None
|
|
return [uuid.UUID(str(i)) for i in ids]
|
|
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
|
log.warning("recommender.remote_failed", path=path, error=str(exc))
|
|
return None
|
|
|
|
async def similar_track_ids(
|
|
self, track_id: uuid.UUID, *, limit: int, exclude_ids: list[uuid.UUID]
|
|
) -> list[uuid.UUID] | None:
|
|
return await self._post_ids(
|
|
"/similar/tracks",
|
|
{"track_id": str(track_id), "limit": limit,
|
|
"exclude": [str(i) for i in exclude_ids]},
|
|
)
|
|
|
|
async def similar_artist_ids(
|
|
self, artist_id: uuid.UUID, *, limit: int
|
|
) -> list[uuid.UUID] | None:
|
|
# Artist recommendations aren't part of the placeholder track contract.
|
|
return None
|
|
|
|
async def radio_track_ids(
|
|
self,
|
|
*,
|
|
seed_track_id: uuid.UUID | None,
|
|
exploration: float,
|
|
limit: int,
|
|
exclude_ids: list[uuid.UUID],
|
|
) -> list[uuid.UUID] | None:
|
|
return await self._post_ids(
|
|
"/radio",
|
|
{
|
|
"seed_track_id": str(seed_track_id) if seed_track_id else None,
|
|
"exploration": exploration,
|
|
"limit": limit,
|
|
"exclude": [str(i) for i in exclude_ids],
|
|
},
|
|
)
|