test: add scenario eval runner, recorded fixtures, and baseline comparison
This commit is contained in:
parent
3dc1af5f0c
commit
0664cc2d27
38 changed files with 6986 additions and 5 deletions
219
eval/recording/redaction.py
Normal file
219
eval/recording/redaction.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue