feat: add recommendation service adapters

This commit is contained in:
Justin Visser 2026-08-10 12:05:59 +02:00
parent e970bdf542
commit 751391e6a2
9 changed files with 676 additions and 26 deletions

View file

@ -0,0 +1,339 @@
"""Anthropic implementation of structured intent and streamed reranking."""
import json
import re
from collections.abc import AsyncIterator
from typing import Literal, cast
from anthropic import AsyncAnthropic
from anthropic.lib._parse._transform import transform_schema
from anthropic.types import Message, TextBlockParam, Usage
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from app.config import Settings
from app.domain.models import (
ConversationTurn,
Familiarity,
Intent,
PreviousRecommendation,
RerankSelection,
Track,
TrackCandidate,
)
from app.observability.timing import record_llm_tokens
from app.ports.protocols import RecommenderOutputError
from app.prompts import INTENT_SYSTEM_PROMPT, RERANK_SYSTEM_PROMPT
Effort = Literal["low", "medium", "high", "xhigh", "max"]
_RECOMMENDATION_ARRAY = re.compile(r'"recommendations"\s*:\s*\[')
class CandidateOutput(BaseModel):
"""One bounded candidate in the intent response."""
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=200)
artist: str = Field(min_length=1, max_length=200)
class IntentOutput(BaseModel):
"""Bounded structured output for the intent call."""
model_config = ConfigDict(extra="forbid")
mood: list[str] = Field(default_factory=list, max_length=6)
activity: str | None = Field(default=None, max_length=100)
era: list[str] = Field(default_factory=list, max_length=5)
languages: list[str] = Field(default_factory=list, max_length=8)
genres: list[str] = Field(default_factory=list, max_length=8)
familiarity: Familiarity
is_refinement: bool
intent_summary: str = Field(min_length=1, max_length=300, pattern=r"^[^\r\n]+$")
candidates: list[CandidateOutput] = Field(min_length=30, max_length=40)
class RerankSelectionOutput(BaseModel):
"""One validated object extracted from the rerank stream."""
model_config = ConfigDict(extra="forbid")
track_id: str = Field(min_length=1, max_length=200)
justification: str = Field(min_length=1, max_length=300, pattern=r"^[^\r\n]+$")
class RerankOutput(BaseModel):
"""Complete bounded rerank output used for final validation."""
model_config = ConfigDict(extra="forbid")
recommendations: list[RerankSelectionOutput] = Field(min_length=1, max_length=50)
class AnthropicRecommender:
"""Use two Anthropic calls for intent generation and grounded ranking."""
def __init__(self, client: AsyncAnthropic, settings: Settings) -> None:
"""Bind the shared asynchronous client and immutable settings."""
self.client = client
self.settings = settings
async def create_intent(
self,
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
taste_summary: str,
candidate_count: int,
) -> Intent:
"""Interpret a request through Anthropic structured output."""
message = await self.client.messages.parse(
model=self.settings.llm_model,
max_tokens=self.settings.intent_max_tokens,
output_config={"effort": _parse_effort(self.settings.intent_effort)},
output_format=IntentOutput,
system=[_system_block(INTENT_SYSTEM_PROMPT)],
messages=[
{
"role": "user",
"content": _render_intent_input(
query,
history,
previous_recommendations,
taste_summary,
candidate_count,
),
}
],
)
_validate_stop_reason(message)
_record_usage(message.usage)
parsed = message.parsed_output
if parsed is None:
raise RecommenderOutputError("Intent response contained no structured output")
validated = IntentOutput.model_validate(parsed.model_dump())
if len(validated.candidates) != candidate_count:
raise RecommenderOutputError("Intent response returned the wrong candidate count")
return _to_intent(validated)
async def stream_rerank(
self,
intent: Intent,
grounded_tracks: tuple[Track, ...],
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection_count: int,
correction: str | None = None,
) -> AsyncIterator[RerankSelection]:
"""Yield each complete valid selection while the JSON is streaming."""
schema = transform_schema(RerankOutput.model_json_schema())
parser = _RecommendationObjectParser()
async with self.client.messages.stream(
model=self.settings.llm_model,
max_tokens=self.settings.rerank_max_tokens,
output_config={
"effort": _parse_effort(self.settings.rerank_effort),
"format": {"type": "json_schema", "schema": schema},
},
system=[_system_block(RERANK_SYSTEM_PROMPT)],
messages=[
{
"role": "user",
"content": _render_rerank_input(
intent,
grounded_tracks,
taste_summary,
history,
selection_count,
correction,
),
}
],
) as stream:
async for text_delta in stream.text_stream:
for selection in parser.feed(text_delta):
yield RerankSelection(
track_id=selection.track_id,
justification=selection.justification,
)
final_message = await stream.get_final_message()
_validate_stop_reason(final_message)
_record_usage(final_message.usage)
try:
validated = RerankOutput.model_validate_json(parser.complete_text)
except ValidationError as error:
raise RecommenderOutputError("Rerank response failed final validation") from error
if len(validated.recommendations) > selection_count:
raise RecommenderOutputError("Rerank response returned too many selections")
class _RecommendationObjectParser:
def __init__(self) -> None:
self.complete_text = ""
self._scan_index = 0
self._object_start: int | None = None
self._object_depth = 0
self._is_in_string = False
self._is_escaped = False
self._has_found_array = False
def feed(self, text_delta: str) -> list[RerankSelectionOutput]:
self.complete_text += text_delta
if not self._has_found_array:
match = _RECOMMENDATION_ARRAY.search(self.complete_text)
if match is None:
return []
self._has_found_array = True
self._scan_index = match.end()
selections: list[RerankSelectionOutput] = []
while self._scan_index < len(self.complete_text):
character = self.complete_text[self._scan_index]
completed = self._scan_character(character)
self._scan_index += 1
if completed is not None:
selections.append(completed)
return selections
def _scan_character(self, character: str) -> RerankSelectionOutput | None:
if self._object_start is None:
if character == "{":
self._object_start = self._scan_index
self._object_depth = 1
return None
if self._is_in_string:
if self._is_escaped:
self._is_escaped = False
elif character == "\\":
self._is_escaped = True
elif character == '"':
self._is_in_string = False
return None
if character == '"':
self._is_in_string = True
elif character == "{":
self._object_depth += 1
elif character == "}":
self._object_depth -= 1
if self._object_depth == 0:
return self._finish_object()
return None
def _finish_object(self) -> RerankSelectionOutput:
assert self._object_start is not None
object_text = self.complete_text[self._object_start : self._scan_index + 1]
self._object_start = None
try:
return RerankSelectionOutput.model_validate_json(object_text)
except ValidationError as error:
raise RecommenderOutputError("Rerank item failed validation") from error
def _to_intent(output: IntentOutput) -> Intent:
return Intent(
mood=tuple(output.mood),
activity=output.activity,
era=tuple(output.era),
languages=tuple(output.languages),
genres=tuple(output.genres),
familiarity=output.familiarity,
is_refinement=output.is_refinement,
intent_summary=output.intent_summary,
candidates=tuple(
TrackCandidate(title=candidate.title, artist=candidate.artist)
for candidate in output.candidates
),
)
def _render_intent_input(
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
taste_summary: str,
candidate_count: int,
) -> str:
payload = {
"query": query,
"history": [turn.__dict__ for turn in history],
"prior_recommendations": [
recommendation.__dict__ for recommendation in previous_recommendations
],
"taste_profile": taste_summary,
"required_candidate_count": candidate_count,
}
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
def _render_rerank_input(
intent: Intent,
grounded_tracks: tuple[Track, ...],
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection_count: int,
correction: str | None,
) -> str:
payload = {
"intent": {
"mood": intent.mood,
"activity": intent.activity,
"era": intent.era,
"languages": intent.languages,
"genres": intent.genres,
"familiarity": intent.familiarity,
"intent_summary": intent.intent_summary,
},
"grounded_pool": [
{"track_id": track.id, "title": track.title, "artists": track.artists}
for track in grounded_tracks
],
"taste_profile": taste_summary,
"history": [turn.__dict__ for turn in history],
"requested_selection_count": selection_count,
"correction": correction,
}
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
def _system_block(prompt: str) -> TextBlockParam:
return {
"type": "text",
"text": prompt,
"cache_control": {"type": "ephemeral"},
}
def _parse_effort(value: str) -> Effort:
allowed = {"low", "medium", "high", "xhigh", "max"}
if value not in allowed:
raise ValueError(f"Unsupported Anthropic effort: {value}")
return cast(Effort, value)
def _validate_stop_reason(message: Message) -> None:
stop_reason_messages = {
None: "Anthropic response had no stop reason",
"max_tokens": "Anthropic response reached its token limit",
"stop_sequence": "Anthropic response hit an unexpected stop sequence",
"tool_use": "Anthropic response attempted tool use",
"pause_turn": "Anthropic response paused before completion",
"refusal": "Anthropic response was refused",
"model_context_window_exceeded": "Anthropic context window was exceeded",
}
if message.stop_reason == "end_turn":
return
raise RecommenderOutputError(
stop_reason_messages.get(message.stop_reason, "Anthropic response stopped unexpectedly")
)
def _record_usage(usage: Usage) -> None:
cache_creation_tokens = usage.cache_creation_input_tokens or 0
cache_read_tokens = usage.cache_read_input_tokens or 0
record_llm_tokens(
usage.input_tokens + cache_creation_tokens + cache_read_tokens,
usage.output_tokens,
)

View file

@ -12,15 +12,22 @@ from app.adapters.spotify.errors import (
SpotifyUnavailableError,
)
from app.adapters.spotify.mapping import (
CreatedPlaylist,
CurrentUser,
parse_created_playlist,
parse_current_user,
parse_saved_track_page,
parse_search_tracks,
parse_top_artists,
parse_track_page,
)
from app.adapters.spotify.session import SpotifySession
from app.config import Settings
from app.domain.models import Track
from app.domain.models import CreatedPlaylist, Track
from app.observability.timing import increment_spotify_calls
from app.ports.protocols import TimeRange
SPOTIFY_SEARCH_LIMIT = 10
SPOTIFY_PAGE_LIMIT = 50
class SpotifyClient:
@ -39,6 +46,8 @@ class SpotifyClient:
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Search Spotify tracks and return only valid mapped results."""
if not 1 <= limit <= SPOTIFY_SEARCH_LIMIT:
raise ValueError("Spotify search limit must be between 1 and 10")
response = await self._request(
"GET",
"/search",
@ -46,6 +55,40 @@ class SpotifyClient:
)
return parse_search_tracks(response.json())
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
"""Fetch the user's top artists for a supported time range."""
response = await self._request(
"GET",
"/me/top/artists",
params={"time_range": time_range, "limit": limit},
)
return parse_top_artists(response.json())
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
"""Fetch the user's top tracks for a supported time range."""
response = await self._request(
"GET",
"/me/top/tracks",
params={"time_range": time_range, "limit": limit},
)
return parse_track_page(response.json())
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
"""Fetch a bounded saved-track sample across Spotify pages."""
tracks: list[Track] = []
while len(tracks) < limit:
page_limit = min(SPOTIFY_PAGE_LIMIT, limit - len(tracks))
response = await self._request(
"GET",
"/me/tracks",
params={"limit": page_limit, "offset": len(tracks)},
)
page = parse_saved_track_page(response.json())
tracks.extend(page)
if len(page) < page_limit:
break
return tracks
async def fetch_current_user(self) -> CurrentUser:
"""Fetch the authenticated Spotify user's stable identity."""
response = await self._request("GET", "/me")
@ -110,6 +153,7 @@ class SpotifyClient:
params: dict[str, str | int] | None,
json: dict[str, object] | None,
) -> httpx2.Response:
increment_spotify_calls()
return await self.http.request(
method,
f"{self.settings.spotify_api_base_url.rstrip('/')}{path}",

View file

@ -1,5 +1,7 @@
"""Typed failures raised by the Spotify adapter."""
from app.ports.protocols import CatalogQuotaExhaustedError
class SpotifyError(Exception):
"""Base class for Spotify adapter failures."""
@ -9,7 +11,7 @@ class SpotifyAuthenticationError(SpotifyError):
"""Spotify rejected authentication or token refresh."""
class SpotifyRateLimitedError(SpotifyError):
class SpotifyRateLimitedError(SpotifyError, CatalogQuotaExhaustedError):
"""Spotify rate limited a request that could not be retried."""
def __init__(self, retry_after_seconds: float | None, reason: str | None = None) -> None:

View file

@ -4,7 +4,8 @@ from collections.abc import Mapping
from dataclasses import dataclass
from typing import cast
from app.domain.models import Track
from app.domain.matching import track_key
from app.domain.models import CreatedPlaylist, Track
@dataclass(frozen=True)
@ -15,14 +16,6 @@ class CurrentUser:
display_name: str
@dataclass(frozen=True)
class CreatedPlaylist:
"""The application-owned result of creating a Spotify playlist."""
id: str
url: str
def parse_search_tracks(payload: object) -> list[Track]:
"""Map valid Spotify search items and discard malformed entries."""
root = _as_mapping(payload)
@ -32,17 +25,54 @@ def parse_search_tracks(payload: object) -> list[Track]:
return []
parsed_tracks: list[Track] = []
seen_ids: set[str] = set()
seen_keys: set[str] = set()
for item in items:
parsed_track = _parse_track(item)
if parsed_track is not None:
parsed_tracks.append(parsed_track)
if parsed_track is None:
continue
normalized_key = track_key(parsed_track)
if parsed_track.id in seen_ids or normalized_key in seen_keys:
continue
seen_ids.add(parsed_track.id)
seen_keys.add(normalized_key)
parsed_tracks.append(parsed_track)
return parsed_tracks
def parse_top_artists(payload: object) -> list[str]:
"""Map a top-artists page to valid artist names."""
root = _as_mapping(payload)
items = root.get("items") if root is not None else None
if not isinstance(items, list):
return []
return [
name for item in items if (name := _required_string(_as_mapping(item), "name")) is not None
]
def parse_track_page(payload: object) -> list[Track]:
"""Map a direct Spotify track page to domain tracks."""
root = _as_mapping(payload)
items = root.get("items") if root is not None else None
return _parse_track_items(items)
def parse_saved_track_page(payload: object) -> list[Track]:
"""Map a saved-track wrapper page to domain tracks."""
root = _as_mapping(payload)
items = root.get("items") if root is not None else None
if not isinstance(items, list):
return []
return _parse_track_items(
[wrapper.get("track") for item in items if (wrapper := _as_mapping(item)) is not None]
)
def parse_current_user(payload: object) -> CurrentUser:
"""Map a Spotify current-user response into stable identity fields."""
root = _as_mapping(payload)
account_id = _required_string(root, "account_id")
account_id = _required_string(root, "id") or _required_string(root, "account_id")
display_name = _required_string(root, "display_name")
if account_id is None or display_name is None:
raise ValueError("Spotify returned an invalid current-user response")
@ -90,6 +120,12 @@ def _parse_track(payload: object) -> Track | None:
)
def _parse_track_items(payload: object) -> list[Track]:
if not isinstance(payload, list):
return []
return [track for item in payload if (track := _parse_track(item)) is not None]
def _parse_artists(payload: object) -> tuple[str, ...] | None:
if not isinstance(payload, list) or not payload:
return None