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/cassette.py
Normal file
128
backend/app/adapters/demo/cassette.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Decode recorded Spotify and Anthropic responses for demo replay."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
from app.adapters.demo.scenario import FIXTURE_ROOT
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecordedResponse:
|
||||
"""One decoded response retained from a recorded HTTP interaction."""
|
||||
|
||||
method: str
|
||||
url: str
|
||||
status: int
|
||||
response_body: bytes
|
||||
response_chunks: tuple[bytes, ...]
|
||||
|
||||
def json_body(self) -> object:
|
||||
"""Parse the decoded response body as JSON."""
|
||||
return json.loads(self.response_body)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemoCassette:
|
||||
"""The recorded service responses needed by one demo scenario."""
|
||||
|
||||
key: str
|
||||
spotify_responses: tuple[RecordedResponse, ...]
|
||||
intent_response_bodies: tuple[bytes, ...]
|
||||
rerank_response_chunks: tuple[bytes, ...]
|
||||
|
||||
@property
|
||||
def intent_response_body(self) -> bytes:
|
||||
"""Return the final intent response recorded for this scenario."""
|
||||
return self.intent_response_bodies[-1]
|
||||
|
||||
@property
|
||||
def spotify_search_responses(self) -> tuple[RecordedResponse, ...]:
|
||||
"""Return recorded Spotify search responses in capture order."""
|
||||
return tuple(response for response in self.spotify_responses if "/search?" in response.url)
|
||||
|
||||
@property
|
||||
def spotify_taste_responses(self) -> tuple[RecordedResponse, ...]:
|
||||
"""Return recorded Spotify taste responses in capture order."""
|
||||
return tuple(
|
||||
response
|
||||
for response in self.spotify_responses
|
||||
if any(
|
||||
endpoint in response.url
|
||||
for endpoint in ("/me/top/artists", "/me/top/tracks", "/me/tracks?")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def load_cassette(scenario_key: str) -> DemoCassette:
|
||||
"""Load and decode both service cassettes for one scenario."""
|
||||
scenario_root = FIXTURE_ROOT / scenario_key
|
||||
spotify = _load_responses(scenario_root / "spotify.json")
|
||||
anthropic = _load_responses(scenario_root / "anthropic.json")
|
||||
intent_responses = tuple(
|
||||
response for response in anthropic if _is_anthropic_message(response.response_body)
|
||||
)
|
||||
rerank_responses = tuple(
|
||||
response for response in anthropic if not _is_anthropic_message(response.response_body)
|
||||
)
|
||||
if not spotify or not intent_responses or not rerank_responses:
|
||||
raise ValueError(f"Scenario cassette is incomplete: {scenario_key}")
|
||||
return DemoCassette(
|
||||
key=scenario_key,
|
||||
spotify_responses=spotify,
|
||||
intent_response_bodies=tuple(response.response_body for response in intent_responses),
|
||||
rerank_response_chunks=rerank_responses[-1].response_chunks,
|
||||
)
|
||||
|
||||
|
||||
def _load_responses(path: Path) -> tuple[RecordedResponse, ...]:
|
||||
payload = json.loads(path.read_text(encoding="ascii"))
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError(f"Cassette must contain a response list: {path}")
|
||||
return tuple(_decode_response(entry) for entry in payload)
|
||||
|
||||
|
||||
def _decode_response(payload: object) -> RecordedResponse:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Cassette response must be a mapping")
|
||||
try:
|
||||
chunks_value = payload["response_chunks_base64"]
|
||||
if not isinstance(chunks_value, list):
|
||||
raise TypeError
|
||||
chunks = tuple(_decode_base64(value) for value in chunks_value)
|
||||
return RecordedResponse(
|
||||
method=_string(payload["method"]),
|
||||
url=_string(payload["url"]),
|
||||
status=_integer(payload["status"]),
|
||||
response_body=_decode_base64(payload["response_body_base64"]),
|
||||
response_chunks=chunks,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ValueError("Cassette response is invalid") from error
|
||||
|
||||
|
||||
def _decode_base64(value: object) -> bytes:
|
||||
return base64.b64decode(_string(value), validate=True)
|
||||
|
||||
|
||||
def _string(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object) -> int:
|
||||
if not isinstance(value, int):
|
||||
raise TypeError
|
||||
return value
|
||||
|
||||
|
||||
def _is_anthropic_message(body: bytes) -> bool:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return False
|
||||
return isinstance(payload, dict) and payload.get("type") == "message"
|
||||
Loading…
Add table
Add a link
Reference in a new issue