128 lines
4.3 KiB
Python
128 lines
4.3 KiB
Python
"""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"
|