test: add scenario eval runner, recorded fixtures, and baseline comparison

This commit is contained in:
Justin Visser 2026-08-10 22:21:04 +02:00
parent 3dc1af5f0c
commit 0664cc2d27
38 changed files with 6986 additions and 5 deletions

View file

@ -0,0 +1,14 @@
"""Record and redact external HTTP interactions for fixture replay."""
from recording.model import RecordedInteraction
from recording.redaction import redact_cassette
from recording.storage import write_cassettes
from recording.transport import Httpx2RecordingTransport, HttpxRecordingTransport
__all__ = [
"Httpx2RecordingTransport",
"HttpxRecordingTransport",
"RecordedInteraction",
"redact_cassette",
"write_cassettes",
]

30
eval/recording/model.py Normal file
View file

@ -0,0 +1,30 @@
"""Transport-neutral cassette interaction model."""
import base64
from dataclasses import dataclass
@dataclass(frozen=True)
class RecordedInteraction:
"""One completed HTTP exchange with exact response chunks."""
method: str
url: str
status: int
request_body: bytes
response_chunks: tuple[bytes, ...]
def as_dict(self) -> dict[str, object]:
"""Encode byte fields losslessly for JSON storage."""
return {
"method": self.method,
"url": self.url,
"status": self.status,
"request_body_base64": _encode(self.request_body),
"response_body_base64": _encode(b"".join(self.response_chunks)),
"response_chunks_base64": [_encode(chunk) for chunk in self.response_chunks],
}
def _encode(value: bytes) -> str:
return base64.b64encode(value).decode("ascii")

219
eval/recording/redaction.py Normal file
View file

@ -0,0 +1,219 @@
"""Remove credentials and listener identity from persisted cassettes."""
import base64
import copy
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import cast
from urllib.parse import urlparse
SYNTHETIC_ACCOUNT_ID = "synthetic-account"
SYNTHETIC_DISPLAY_NAME = "Synthetic Listener"
SYNTHETIC_TASTE_PATH = Path(__file__).with_name("synthetic_taste.json")
BEARER_PATTERN = re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)
SENSITIVE_PATH_PARTS = {"authorize", "oauth", "token"}
@dataclass(frozen=True)
class _ListenerIdentities:
account_ids: frozenset[str]
display_names: frozenset[str]
def is_sensitive_url(url: str) -> bool:
"""Return whether an authentication exchange must never be recorded."""
parsed = urlparse(url)
path_parts = {part.casefold() for part in parsed.path.split("/") if part}
return parsed.hostname == "accounts.spotify.com" or bool(path_parts & SENSITIVE_PATH_PARTS)
def redact_cassette(interactions: list[dict[str, object]]) -> list[dict[str, object]]:
"""Return safe interactions without mutating or persisting the source."""
safe = [
_drop_headers(copy.deepcopy(interaction))
for interaction in interactions
if not is_sensitive_url(str(interaction.get("url", "")))
]
identities = _collect_identities(safe)
synthetic_taste = _load_synthetic_taste()
return [_redact_interaction(interaction, identities, synthetic_taste) for interaction in safe]
def _drop_headers(value: object) -> object:
if isinstance(value, dict):
return {
key: _drop_headers(item)
for key, item in value.items()
if not str(key).casefold().endswith("headers")
}
if isinstance(value, list):
return [_drop_headers(item) for item in value]
return value
def _collect_identities(interactions: list[object]) -> _ListenerIdentities:
account_ids: set[str] = set()
display_names: set[str] = set()
for interaction in interactions:
if not isinstance(interaction, dict) or not _is_current_user_url(
str(interaction.get("url", ""))
):
continue
payload = _decode_json(str(interaction.get("response_body_base64", "")))
if not isinstance(payload, dict):
continue
for key in ("id", "account_id"):
value = payload.get(key)
if isinstance(value, str) and value:
account_ids.add(value)
display_name = payload.get("display_name")
if isinstance(display_name, str) and display_name:
display_names.add(display_name)
return _ListenerIdentities(frozenset(account_ids), frozenset(display_names))
def _redact_interaction(
interaction: object,
identities: _ListenerIdentities,
synthetic_taste: dict[str, object],
) -> dict[str, object]:
if not isinstance(interaction, dict):
raise ValueError("Cassette interaction must be a mapping")
redacted = {str(key): value for key, value in interaction.items()}
url = _replace_text(str(redacted.get("url", "")), identities)
redacted["url"] = url
redacted["request_body_base64"] = _redact_body(
str(redacted.get("request_body_base64", "")), identities, None, synthetic_taste
)
replacement = _taste_replacement(url, synthetic_taste)
original_body = str(redacted.get("response_body_base64", ""))
response_body = _redact_body(original_body, identities, replacement, synthetic_taste)
redacted["response_body_base64"] = response_body
chunks = redacted.get("response_chunks_base64", [])
if response_body != original_body:
redacted["response_chunks_base64"] = [response_body] if response_body else []
elif isinstance(chunks, list):
redacted["response_chunks_base64"] = [
_redact_body(str(chunk), identities, None, synthetic_taste) for chunk in chunks
]
return cast(dict[str, object], _redact_value(redacted, identities, synthetic_taste))
def _redact_body(
encoded: str,
identities: _ListenerIdentities,
replacement: object | None,
synthetic_taste: dict[str, object],
) -> str:
try:
raw = base64.b64decode(encoded, validate=True)
except ValueError:
raw = b""
if replacement is not None:
value = replacement
else:
try:
value = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError):
text = raw.decode("utf-8", errors="replace")
return _encode(_replace_text(text, identities).encode())
redacted = _redact_value(value, identities, synthetic_taste)
return _encode(json.dumps(redacted, ensure_ascii=True, separators=(",", ":")).encode("ascii"))
def _redact_value(
value: object,
identities: _ListenerIdentities,
synthetic_taste: dict[str, object],
) -> object:
if isinstance(value, dict):
return {
str(key): _taste_summary(synthetic_taste)
if str(key) == "taste_profile"
else _synthetic_identity(item, identities, synthetic_taste)
for key, item in value.items()
if not str(key).casefold().endswith("headers")
}
if isinstance(value, list):
return [_redact_value(item, identities, synthetic_taste) for item in value]
if isinstance(value, str):
text = _replace_text(value, identities)
try:
nested = json.loads(text)
except json.JSONDecodeError:
return text
if isinstance(nested, (dict, list)):
return json.dumps(
_redact_value(nested, identities, synthetic_taste),
ensure_ascii=True,
separators=(",", ":"),
)
return text
return value
def _synthetic_identity(
value: object,
identities: _ListenerIdentities,
synthetic_taste: dict[str, object],
) -> object:
if isinstance(value, str) and value in identities.display_names:
return SYNTHETIC_DISPLAY_NAME
if isinstance(value, str) and value in identities.account_ids:
return SYNTHETIC_ACCOUNT_ID
return _redact_value(value, identities, synthetic_taste)
def _replace_text(value: str, identities: _ListenerIdentities) -> str:
redacted = BEARER_PATTERN.sub("[REDACTED]", value)
replacements = {
**{identity: SYNTHETIC_ACCOUNT_ID for identity in identities.account_ids},
**{identity: SYNTHETIC_DISPLAY_NAME for identity in identities.display_names},
}
for identity, replacement in sorted(
replacements.items(), key=lambda item: len(item[0]), reverse=True
):
redacted = redacted.replace(identity, replacement)
return redacted
def _taste_replacement(url: str, synthetic_taste: dict[str, object]) -> object | None:
path = urlparse(url).path.rstrip("/")
if path.endswith("/me/top/artists"):
return synthetic_taste.get("top_artists")
if path.endswith("/me/top/tracks"):
return synthetic_taste.get("top_tracks")
if path.endswith("/me/tracks"):
return synthetic_taste.get("saved_tracks")
return None
def _is_current_user_url(url: str) -> bool:
return urlparse(url).path.rstrip("/").endswith("/v1/me")
def _taste_summary(synthetic_taste: dict[str, object]) -> str:
summary = synthetic_taste.get("summary")
if not isinstance(summary, str):
raise ValueError("Synthetic taste fixture must contain a summary")
return summary
def _load_synthetic_taste() -> dict[str, object]:
payload: object = json.loads(SYNTHETIC_TASTE_PATH.read_text(encoding="ascii"))
if not isinstance(payload, dict):
raise ValueError("Synthetic taste fixture must be a mapping")
return cast(dict[str, object], payload)
def _decode_json(encoded: str) -> object:
try:
return json.loads(base64.b64decode(encoded, validate=True))
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
return None
def _encode(value: bytes) -> str:
return base64.b64encode(value).decode("ascii")

32
eval/recording/storage.py Normal file
View file

@ -0,0 +1,32 @@
"""Persist raw and redacted cassette stores."""
import json
from pathlib import Path
from recording.model import RecordedInteraction
from recording.redaction import redact_cassette
def write_cassettes(
fixture_root: Path,
scenario_key: str,
recordings: dict[str, list[RecordedInteraction]],
) -> tuple[Path, Path]:
"""Write ignored raw data first, then separately mapped safe cassettes."""
raw_root = fixture_root / "raw" / scenario_key
redacted_root = fixture_root / scenario_key
raw_root.mkdir(parents=True, exist_ok=True)
redacted_root.mkdir(parents=True, exist_ok=True)
for service, interactions in recordings.items():
raw_payload = [interaction.as_dict() for interaction in interactions]
_write_json(raw_root / f"{service}.json", raw_payload)
redacted_payload = redact_cassette(raw_payload)
_write_json(redacted_root / f"{service}.json", redacted_payload)
return raw_root, redacted_root
def _write_json(path: Path, payload: object) -> None:
path.write_text(
json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + "\n",
encoding="ascii",
)

View file

@ -0,0 +1,95 @@
{
"saved_tracks": {
"href": "https://api.spotify.com/v1/me/tracks",
"items": [
{
"added_at": "2026-01-01T00:00:00Z",
"track": {
"album": {
"images": [],
"name": "Synthetic Saved Album"
},
"artists": [
{
"name": "Synthetic Saved Artist"
}
],
"external_urls": {
"spotify": "https://open.spotify.com/track/synthetic-saved-track"
},
"id": "synthetic-saved-track",
"name": "Synthetic Saved Track",
"uri": "spotify:track:synthetic-saved-track"
}
}
],
"limit": 50,
"next": null,
"offset": 0,
"previous": null,
"total": 1
},
"summary": "Short-term top artists: Synthetic Focus Artist; Long-term top artists: Synthetic Jazz Artist; Short-term top tracks: Synthetic Focus Track by Synthetic Focus Artist; Long-term top tracks: Synthetic Jazz Track by Synthetic Jazz Artist; Saved-track sample: Synthetic Saved Track by Synthetic Saved Artist",
"top_artists": {
"href": "https://api.spotify.com/v1/me/top/artists",
"items": [
{
"id": "synthetic-focus-artist",
"name": "Synthetic Focus Artist"
},
{
"id": "synthetic-jazz-artist",
"name": "Synthetic Jazz Artist"
}
],
"limit": 50,
"next": null,
"offset": 0,
"previous": null,
"total": 2
},
"top_tracks": {
"href": "https://api.spotify.com/v1/me/top/tracks",
"items": [
{
"album": {
"images": [],
"name": "Synthetic Focus Album"
},
"artists": [
{
"name": "Synthetic Focus Artist"
}
],
"external_urls": {
"spotify": "https://open.spotify.com/track/synthetic-focus-track"
},
"id": "synthetic-focus-track",
"name": "Synthetic Focus Track",
"uri": "spotify:track:synthetic-focus-track"
},
{
"album": {
"images": [],
"name": "Synthetic Jazz Album"
},
"artists": [
{
"name": "Synthetic Jazz Artist"
}
],
"external_urls": {
"spotify": "https://open.spotify.com/track/synthetic-jazz-track"
},
"id": "synthetic-jazz-track",
"name": "Synthetic Jazz Track",
"uri": "spotify:track:synthetic-jazz-track"
}
],
"limit": 50,
"next": null,
"offset": 0,
"previous": null,
"total": 2
}
}

154
eval/recording/transport.py Normal file
View file

@ -0,0 +1,154 @@
"""Recording transports for Spotify's httpx2 and Anthropic's httpx."""
from collections.abc import AsyncIterator, Callable
from typing import cast
import httpx
import httpx2
from recording.model import RecordedInteraction
from recording.redaction import is_sensitive_url
class HttpxRecordingTransport(httpx.AsyncBaseTransport):
"""Wrap an httpx transport and retain completed response chunk sequences."""
def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
"""Bind the real transport and start an empty in-memory cassette."""
self.transport = transport
self.interactions: list[RecordedInteraction] = []
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
"""Forward one request and wrap its response stream for capture."""
request.headers["Accept-Encoding"] = "identity"
request_body = await request.aread()
response = await self.transport.handle_async_request(request)
if is_sensitive_url(str(request.url)):
return response
stream = _HttpxRecordingStream(
cast(httpx.AsyncByteStream, response.stream),
lambda chunks: self._finish(request, request_body, response.status_code, chunks),
)
return httpx.Response(
response.status_code,
headers=response.headers,
stream=stream,
extensions=response.extensions,
request=request,
)
async def aclose(self) -> None:
"""Close the wrapped transport."""
await self.transport.aclose()
def _finish(
self,
request: httpx.Request,
request_body: bytes,
status: int,
chunks: tuple[bytes, ...],
) -> None:
self.interactions.append(
RecordedInteraction(request.method, str(request.url), status, request_body, chunks)
)
class Httpx2RecordingTransport(httpx2.AsyncBaseTransport):
"""Wrap an httpx2 transport and retain completed response chunk sequences."""
def __init__(self, transport: httpx2.AsyncBaseTransport) -> None:
"""Bind the real transport and start an empty in-memory cassette."""
self.transport = transport
self.interactions: list[RecordedInteraction] = []
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
"""Forward one request and wrap its response stream for capture."""
request.headers["Accept-Encoding"] = "identity"
request_body = await request.aread()
response = await self.transport.handle_async_request(request)
if is_sensitive_url(str(request.url)):
return response
stream = _Httpx2RecordingStream(
cast(httpx2.AsyncByteStream, response.stream),
lambda chunks: self._finish(request, request_body, response.status_code, chunks),
)
return httpx2.Response(
response.status_code,
headers=response.headers,
stream=stream,
extensions=response.extensions,
request=request,
)
async def aclose(self) -> None:
"""Close the wrapped transport."""
await self.transport.aclose()
def _finish(
self,
request: httpx2.Request,
request_body: bytes,
status: int,
chunks: tuple[bytes, ...],
) -> None:
self.interactions.append(
RecordedInteraction(request.method, str(request.url), status, request_body, chunks)
)
class _HttpxRecordingStream(httpx.AsyncByteStream):
def __init__(
self,
stream: httpx.AsyncByteStream,
finish: Callable[[tuple[bytes, ...]], None],
) -> None:
self.stream = stream
self.finish = finish
self.chunks: list[bytes] = []
self.is_finished = False
async def __aiter__(self) -> AsyncIterator[bytes]:
try:
async for chunk in self.stream:
self.chunks.append(chunk)
yield chunk
finally:
self._finish()
async def aclose(self) -> None:
await self.stream.aclose()
self._finish()
def _finish(self) -> None:
if not self.is_finished:
self.is_finished = True
self.finish(tuple(self.chunks))
class _Httpx2RecordingStream(httpx2.AsyncByteStream):
def __init__(
self,
stream: httpx2.AsyncByteStream,
finish: Callable[[tuple[bytes, ...]], None],
) -> None:
self.stream = stream
self.finish = finish
self.chunks: list[bytes] = []
self.is_finished = False
async def __aiter__(self) -> AsyncIterator[bytes]:
try:
async for chunk in self.stream:
self.chunks.append(chunk)
yield chunk
finally:
self._finish()
async def aclose(self) -> None:
await self.stream.aclose()
self._finish()
def _finish(self) -> None:
if not self.is_finished:
self.is_finished = True
self.finish(tuple(self.chunks))