discovery-by-llm/backend/app/api/recommendations.py

176 lines
6 KiB
Python

"""Authenticated recommendation streaming and playlist creation routes."""
import asyncio
from collections.abc import AsyncIterator, Callable
from typing import cast
import structlog
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from app.adapters.spotify.errors import SpotifyAuthenticationError, SpotifyError
from app.adapters.spotify.session import SpotifySession
from app.api.routes import resolve_session
from app.api.schemas import (
NDJSON_CONTENT_TYPE,
DoneEvent,
ErrorEvent,
MetadataEvent,
PlaylistCreateRequest,
PlaylistCreateResponse,
RecommendationRequest,
StreamEvent,
TrackCard,
TrackEvent,
WarningEvent,
)
from app.config import Settings
from app.domain.models import ConversationTurn, PreviousRecommendation
from app.pipeline.event import (
PipelineDoneEvent,
PipelineErrorEvent,
PipelineEvent,
PipelineMetadataEvent,
PipelineTrackEvent,
PipelineWarningEvent,
)
from app.pipeline.orchestrator import RecommendationPipeline
from app.ports.protocols import MusicCatalog, PlaylistWriter
SpotifyClientFactory = Callable[[SpotifySession], MusicCatalog | PlaylistWriter]
router = APIRouter()
@router.post("/api/recommendations")
async def recommendations(
request: Request,
payload: RecommendationRequest,
) -> StreamingResponse:
"""Stream one authenticated discovery response as NDJSON."""
resolved = resolve_session(request)
if resolved is None:
raise HTTPException(status_code=401, detail="Not authenticated")
pipeline = cast(RecommendationPipeline, request.app.state.recommendation_pipeline)
factory = cast(SpotifyClientFactory, request.app.state.spotify_client_factory)
catalog = cast(MusicCatalog, factory(resolved.session))
request_id = cast(str, request.state.request_id)
history = tuple(ConversationTurn(turn.role, turn.content) for turn in payload.history)
previous = tuple(
PreviousRecommendation(
item.rank,
item.track_id,
item.title,
tuple(item.artists),
)
for item in payload.prior_recommendations
)
return StreamingResponse(
_stream_lines(
pipeline,
resolved.session_id,
request_id,
catalog,
payload.query,
history,
previous,
),
media_type=NDJSON_CONTENT_TYPE,
)
@router.post("/api/playlists", response_model=PlaylistCreateResponse)
async def create_playlist(
request: Request,
payload: PlaylistCreateRequest,
) -> PlaylistCreateResponse:
"""Create and fill one authenticated Spotify playlist."""
resolved = resolve_session(request)
if resolved is None:
raise HTTPException(status_code=401, detail="Not authenticated")
settings = cast(Settings, request.app.state.settings)
factory = cast(SpotifyClientFactory, request.app.state.spotify_client_factory)
writer = cast(PlaylistWriter, factory(resolved.session))
playlist_name = f"{settings.playlist_name_prefix} {payload.name}"
try:
playlist = await writer.create_playlist(
playlist_name,
"Music discovery selected by the listener.",
)
await writer.add_tracks_to_playlist(playlist.id, payload.track_uris)
except SpotifyAuthenticationError as error:
structlog.get_logger().warning("playlist_write_failed", error_type=type(error).__name__)
raise HTTPException(status_code=401, detail="Spotify authentication expired") from error
except (SpotifyError, ValueError) as error:
structlog.get_logger().warning("playlist_write_failed", error_type=type(error).__name__)
raise HTTPException(status_code=502, detail="Spotify playlist creation failed") from error
return PlaylistCreateResponse(url=playlist.url)
async def _stream_lines(
pipeline: RecommendationPipeline,
session_id: str,
request_id: str,
catalog: MusicCatalog,
query: str,
history: tuple[ConversationTurn, ...],
previous: tuple[PreviousRecommendation, ...],
) -> AsyncIterator[str]:
event_stream = pipeline.stream(
session_id,
request_id,
catalog,
query,
history,
previous,
)
try:
async for event in event_stream:
yield f"{_to_wire_event(event).model_dump_json()}\n"
except asyncio.CancelledError:
raise
except Exception as error:
structlog.get_logger().exception(
"recommendation_stream_failed",
error_type=type(error).__name__,
)
failure = ErrorEvent(
code="recommendation_failed",
message="Recommendation could not be completed.",
)
yield f"{failure.model_dump_json()}\n"
finally:
await event_stream.aclose()
def _to_wire_event(event: PipelineEvent) -> StreamEvent:
if isinstance(event, PipelineMetadataEvent):
return MetadataEvent(
request_id=event.request_id,
intent_summary=event.intent_summary,
candidate_count=event.candidate_count,
)
if isinstance(event, PipelineTrackEvent):
track = event.track
return TrackEvent(
rank=event.rank,
track=TrackCard(
id=track.id,
uri=track.uri,
title=track.title,
artists=list(track.artists),
album_name=track.album_name,
album_art_url=track.album_art_url,
external_url=track.external_url,
),
justification=event.justification,
)
if isinstance(event, PipelineWarningEvent):
return WarningEvent(code=event.code, message=event.message)
if isinstance(event, PipelineErrorEvent):
return ErrorEvent(code=event.code, message=event.message)
if isinstance(event, PipelineDoneEvent):
return DoneEvent(track_count=event.track_count, total_ms=event.total_ms)
raise AssertionError("Unhandled pipeline event")