feat: add key-free demo mode replaying recorded fixtures
This commit is contained in:
parent
0664cc2d27
commit
401a0ddea7
21 changed files with 1040 additions and 31 deletions
128
backend/app/adapters/demo/recommender.py
Normal file
128
backend/app/adapters/demo/recommender.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Anthropic recommender adapter backed by recorded response chunks."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextvars import ContextVar
|
||||
|
||||
from app.adapters.anthropic.llm import (
|
||||
IntentOutput,
|
||||
RecommendationObjectParser,
|
||||
RerankOutput,
|
||||
to_intent,
|
||||
)
|
||||
from app.adapters.demo.cassette import DemoCassette, load_cassette
|
||||
from app.adapters.demo.scenario import select_replay_scenario
|
||||
from app.config import Settings
|
||||
from app.domain.models import (
|
||||
ConversationTurn,
|
||||
Intent,
|
||||
PreviousRecommendation,
|
||||
RerankSelection,
|
||||
Track,
|
||||
)
|
||||
from app.ports.protocols import RecommenderOutputError
|
||||
|
||||
|
||||
class DemoRecommender:
|
||||
"""Replay recorded intent and rerank output through live validation."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Bind replay pacing and task-local scenario state."""
|
||||
self.settings = settings
|
||||
self._cassette: ContextVar[DemoCassette | None] = ContextVar(
|
||||
"demo_recommender_cassette",
|
||||
default=None,
|
||||
)
|
||||
|
||||
async def create_intent(
|
||||
self,
|
||||
query: str,
|
||||
history: tuple[ConversationTurn, ...],
|
||||
previous_recommendations: tuple[PreviousRecommendation, ...],
|
||||
taste_summary: str,
|
||||
candidate_count: int,
|
||||
) -> Intent:
|
||||
"""Parse the selected scenario's recorded structured intent."""
|
||||
match = select_replay_scenario(query, bool(previous_recommendations))
|
||||
cassette = load_cassette(match.key)
|
||||
self._cassette.set(cassette)
|
||||
return parse_recorded_intent(cassette.intent_response_body)
|
||||
|
||||
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]:
|
||||
"""Replay recorded SSE chunks through the live incremental parser."""
|
||||
cassette = self._cassette.get()
|
||||
if cassette is None:
|
||||
raise RecommenderOutputError("Demo rerank has no selected scenario")
|
||||
parser = RecommendationObjectParser()
|
||||
async for text_delta in _text_deltas(
|
||||
cassette.rerank_response_chunks,
|
||||
self.settings.demo_chunk_delay_seconds,
|
||||
):
|
||||
for selection in parser.feed(text_delta):
|
||||
yield RerankSelection(selection.track_id, selection.justification)
|
||||
try:
|
||||
validated = RerankOutput.model_validate_json(parser.complete_text)
|
||||
except ValueError as error:
|
||||
raise RecommenderOutputError("Recorded rerank response is invalid") from error
|
||||
if len(validated.recommendations) > selection_count:
|
||||
raise RecommenderOutputError("Recorded rerank returned too many selections")
|
||||
|
||||
|
||||
def parse_recorded_intent(response_body: bytes) -> Intent:
|
||||
"""Parse an Anthropic message body through the live intent output model."""
|
||||
try:
|
||||
response = json.loads(response_body)
|
||||
content = response["content"]
|
||||
text = content[0]["text"]
|
||||
if not isinstance(text, str):
|
||||
raise TypeError
|
||||
return to_intent(IntentOutput.model_validate_json(text))
|
||||
except (IndexError, KeyError, TypeError, ValueError) as error:
|
||||
raise RecommenderOutputError("Recorded intent response is invalid") from error
|
||||
|
||||
|
||||
async def _text_deltas(
|
||||
chunks: tuple[bytes, ...],
|
||||
delay_seconds: float,
|
||||
) -> AsyncGenerator[str]:
|
||||
buffer = b""
|
||||
for chunk_index, chunk in enumerate(chunks):
|
||||
if chunk_index and delay_seconds > 0:
|
||||
await asyncio.sleep(delay_seconds)
|
||||
buffer += chunk
|
||||
buffer = buffer.replace(b"\r\n", b"\n")
|
||||
while b"\n\n" in buffer:
|
||||
event, buffer = buffer.split(b"\n\n", 1)
|
||||
text_delta = _event_text_delta(event)
|
||||
if text_delta is not None:
|
||||
yield text_delta
|
||||
if buffer:
|
||||
text_delta = _event_text_delta(buffer)
|
||||
if text_delta is not None:
|
||||
yield text_delta
|
||||
|
||||
|
||||
def _event_text_delta(event: bytes) -> str | None:
|
||||
data_lines = [line[5:].strip() for line in event.splitlines() if line.startswith(b"data:")]
|
||||
if not data_lines:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(b"\n".join(data_lines))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or payload.get("type") != "content_block_delta":
|
||||
return None
|
||||
delta = payload.get("delta")
|
||||
if not isinstance(delta, dict) or delta.get("type") != "text_delta":
|
||||
return None
|
||||
text = delta.get("text")
|
||||
return text if isinstance(text, str) else None
|
||||
Loading…
Add table
Add a link
Reference in a new issue