"""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], }, )