343 lines
12 KiB
Python
343 lines
12 KiB
Python
"""Anthropic implementation of structured intent and streamed reranking."""
|
|
|
|
import json
|
|
import re
|
|
from collections.abc import AsyncGenerator
|
|
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=10, max_length=50)
|
|
|
|
|
|
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())
|
|
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,
|
|
) -> AsyncGenerator[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:
|
|
"""Incrementally extract complete recommendation objects from JSON text."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Create an empty parser for one rerank response."""
|
|
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]:
|
|
"""Consume a text delta and return newly completed selections."""
|
|
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:
|
|
if self._object_start is None:
|
|
raise RecommenderOutputError("Rerank parser lost the object start position")
|
|
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:
|
|
"""Map validated intent output to the application domain."""
|
|
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,
|
|
)
|