"""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"