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
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -15,9 +15,9 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: astral-sh/setup-uv@v5
|
- uses: astral-sh/setup-uv@v5
|
||||||
- run: uv sync --frozen
|
- run: uv sync --frozen
|
||||||
- run: uv run ruff check .
|
- run: uv run ruff check . ../eval
|
||||||
- run: uv run ruff format --check .
|
- run: uv run ruff format --check . ../eval
|
||||||
- run: uv run mypy app
|
- run: uv run mypy app tests ../eval
|
||||||
- run: uv run pytest
|
- run: uv run pytest
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -18,3 +18,8 @@ frontend/dist/
|
||||||
|
|
||||||
# eval: raw (unredacted) recordings never enter the repo
|
# eval: raw (unredacted) recordings never enter the repo
|
||||||
eval/fixtures/raw/
|
eval/fixtures/raw/
|
||||||
|
eval/reports/
|
||||||
|
|
||||||
|
# eval outputs (reports, baseline snapshots) are generated, never shipped
|
||||||
|
eval/reports/
|
||||||
|
eval/snapshots/
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
import httpx2
|
import httpx2
|
||||||
import structlog
|
import structlog
|
||||||
from anthropic import AsyncAnthropic
|
from anthropic import AsyncAnthropic
|
||||||
|
|
@ -28,6 +29,7 @@ FRONTEND_DIST = Path(__file__).parent / "static"
|
||||||
def create_app(
|
def create_app(
|
||||||
application_settings: Settings | None = None,
|
application_settings: Settings | None = None,
|
||||||
http_transport: httpx2.AsyncBaseTransport | None = None,
|
http_transport: httpx2.AsyncBaseTransport | None = None,
|
||||||
|
anthropic_http_client: httpx.AsyncClient | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
"""Build the FastAPI app: API routes plus the built SPA on one port."""
|
"""Build the FastAPI app: API routes plus the built SPA on one port."""
|
||||||
active_settings = application_settings or settings
|
active_settings = application_settings or settings
|
||||||
|
|
@ -48,6 +50,7 @@ def create_app(
|
||||||
anthropic_client = AsyncAnthropic(
|
anthropic_client = AsyncAnthropic(
|
||||||
api_key=active_settings.anthropic_api_key or "unused-demo-key",
|
api_key=active_settings.anthropic_api_key or "unused-demo-key",
|
||||||
timeout=active_settings.llm_timeout_seconds,
|
timeout=active_settings.llm_timeout_seconds,
|
||||||
|
http_client=anthropic_http_client,
|
||||||
)
|
)
|
||||||
application.state.anthropic = anthropic_client
|
application.state.anthropic = anthropic_client
|
||||||
application.state.recommendation_pipeline = RecommendationPipeline(
|
application.state.recommendation_pipeline = RecommendationPipeline(
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ requires-python = "==3.13.*"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anthropic==0.121.0",
|
"anthropic==0.121.0",
|
||||||
"fastapi>=0.116",
|
"fastapi>=0.116",
|
||||||
|
"httpx>=0.28",
|
||||||
"httpx2>=2.10",
|
"httpx2>=2.10",
|
||||||
"pydantic-settings>=2.10",
|
"pydantic-settings>=2.10",
|
||||||
|
"pyyaml>=6.0",
|
||||||
"structlog>=25.4",
|
"structlog>=25.4",
|
||||||
"uvicorn[standard]>=0.35",
|
"uvicorn[standard]>=0.35",
|
||||||
]
|
]
|
||||||
|
|
@ -17,6 +19,7 @@ dev = [
|
||||||
"mypy>=1.17",
|
"mypy>=1.17",
|
||||||
"pytest>=8.4",
|
"pytest>=8.4",
|
||||||
"ruff>=0.12",
|
"ruff>=0.12",
|
||||||
|
"types-pyyaml>=6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|
@ -36,4 +39,5 @@ module = ["app.domain.*", "app.ports.*", "app.pipeline.*"]
|
||||||
strict = true
|
strict = true
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
pythonpath = [".", "../eval"]
|
||||||
|
testpaths = ["tests", "../eval/tests"]
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,4 @@ def test_anthropic_client_uses_configured_timeout(monkeypatch: pytest.MonkeyPatc
|
||||||
response = client.get("/api/health")
|
response = client.get("/api/health")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
constructor.assert_called_once_with(api_key="unused-demo-key", timeout=42.0)
|
constructor.assert_called_once_with(api_key="unused-demo-key", timeout=42.0, http_client=None)
|
||||||
|
|
|
||||||
15
backend/uv.lock
generated
15
backend/uv.lock
generated
|
|
@ -132,8 +132,10 @@ source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "httpx2" },
|
{ name = "httpx2" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
{ name = "structlog" },
|
{ name = "structlog" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
@ -143,14 +145,17 @@ dev = [
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "types-pyyaml" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "anthropic", specifier = "==0.121.0" },
|
{ name = "anthropic", specifier = "==0.121.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.116" },
|
{ name = "fastapi", specifier = ">=0.116" },
|
||||||
|
{ name = "httpx", specifier = ">=0.28" },
|
||||||
{ name = "httpx2", specifier = ">=2.10" },
|
{ name = "httpx2", specifier = ">=2.10" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.10" },
|
{ name = "pydantic-settings", specifier = ">=2.10" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
{ name = "structlog", specifier = ">=25.4" },
|
{ name = "structlog", specifier = ">=25.4" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35" },
|
||||||
]
|
]
|
||||||
|
|
@ -160,6 +165,7 @@ dev = [
|
||||||
{ name = "mypy", specifier = ">=1.17" },
|
{ name = "mypy", specifier = ">=1.17" },
|
||||||
{ name = "pytest", specifier = ">=8.4" },
|
{ name = "pytest", specifier = ">=8.4" },
|
||||||
{ name = "ruff", specifier = ">=0.12" },
|
{ name = "ruff", specifier = ">=0.12" },
|
||||||
|
{ name = "types-pyyaml", specifier = ">=6.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -580,6 +586,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-pyyaml"
|
||||||
|
version = "6.0.12.20260724"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.16.0"
|
version = "4.16.0"
|
||||||
|
|
|
||||||
|
|
@ -370,3 +370,29 @@ Wat ik heb laten vallen of uitgesteld:
|
||||||
|
|
||||||
- Een repair loop die gefaalde kandidaten opnieuw aan het model
|
- Een repair loop die gefaalde kandidaten opnieuw aan het model
|
||||||
voorlegt; blijft staan als potentiele vervolgstap.
|
voorlegt; blijft staan als potentiele vervolgstap.
|
||||||
|
|
||||||
|
### Evaluatie en fixtures
|
||||||
|
|
||||||
|
Wat ik deed:
|
||||||
|
|
||||||
|
- Een eval-script dat de app de 8 standaardvragen stelt en de antwoorden
|
||||||
|
controleert: komen de events in de juiste volgorde, zijn alle tracks
|
||||||
|
uniek, heeft elke track een reden, kwam de eerste kaart binnen het
|
||||||
|
tijdsbudget, en gaat het antwoord echt over wat er gevraagd werd.
|
||||||
|
- Dezelfde 8 vragen ook aan de kale Spotify search gesteld; de
|
||||||
|
vergelijking tussen die twee wordt de tabel in de README.
|
||||||
|
- Elk scenario 1 keer opgenomen tegen de echte API's en opgeslagen als
|
||||||
|
cassettes; demo mode speelt die af, dus de app werkt straks ook zonder
|
||||||
|
keys. Persoonlijke data gaat er bij het opnemen uit (nepprofiel, geen
|
||||||
|
tokens); de ruwe opnames blijven buiten git.
|
||||||
|
- Dit draait ook in CI, zonder keys.
|
||||||
|
|
||||||
|
Waarom:
|
||||||
|
|
||||||
|
- De claim dat de LLM-laag iets toevoegt moet meetbaar zijn, niet
|
||||||
|
beweerd. En de reviewer moet de app kunnen starten zonder eigen keys.
|
||||||
|
|
||||||
|
Wat ik heb laten vallen of uitgesteld:
|
||||||
|
|
||||||
|
- Byte-snapshots per pipeline-stap; de checks hierboven en de cassettes
|
||||||
|
zijn nu het bewijs.
|
||||||
|
|
|
||||||
114
eval/baseline.py
Normal file
114
eval/baseline.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Record bare Spotify search results and render side-by-side comparisons."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from scenario import Scenario
|
||||||
|
|
||||||
|
TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||||
|
SEARCH_URL = "https://api.spotify.com/v1/search"
|
||||||
|
|
||||||
|
|
||||||
|
async def record_baselines(
|
||||||
|
scenarios: tuple[Scenario, ...],
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
snapshot_root: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Snapshot the top ten results from bare searches for every query."""
|
||||||
|
snapshot_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
token_response = await client.post(
|
||||||
|
TOKEN_URL,
|
||||||
|
data={"grant_type": "client_credentials"},
|
||||||
|
auth=(client_id, client_secret),
|
||||||
|
)
|
||||||
|
token_response.raise_for_status()
|
||||||
|
access_token = token_response.json()["access_token"]
|
||||||
|
for scenario in scenarios:
|
||||||
|
response = await client.get(
|
||||||
|
SEARCH_URL,
|
||||||
|
params={"q": scenario.query, "type": "track", "limit": 10},
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
tracks = response.json().get("tracks", {}).get("items", [])[:10]
|
||||||
|
_write_json(
|
||||||
|
snapshot_root / f"{scenario.key}.json",
|
||||||
|
{"key": scenario.key, "query": scenario.query, "tracks": tracks},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_comparison(
|
||||||
|
scenarios: tuple[Scenario, ...],
|
||||||
|
snapshot_root: Path,
|
||||||
|
report_root: Path,
|
||||||
|
top_n: int,
|
||||||
|
) -> Path:
|
||||||
|
"""Write a markdown comparison for scenarios with both result arms."""
|
||||||
|
report_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
lines = ["# Pipeline and bare search comparison", ""]
|
||||||
|
compared = 0
|
||||||
|
for scenario in scenarios:
|
||||||
|
baseline_path = snapshot_root / f"{scenario.key}.json"
|
||||||
|
pipeline_path = report_root / f"{scenario.key}.json"
|
||||||
|
if not baseline_path.exists() or not pipeline_path.exists():
|
||||||
|
continue
|
||||||
|
baseline = json.loads(baseline_path.read_text(encoding="ascii"))
|
||||||
|
pipeline = json.loads(pipeline_path.read_text(encoding="ascii"))
|
||||||
|
lines.extend(_comparison_section(scenario, pipeline, baseline, top_n))
|
||||||
|
compared += 1
|
||||||
|
if compared == 0:
|
||||||
|
lines.extend(["No scenario has results from both arms yet.", ""])
|
||||||
|
output_path = report_root / "baseline-comparison.md"
|
||||||
|
output_path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def _comparison_section(
|
||||||
|
scenario: Scenario,
|
||||||
|
pipeline: object,
|
||||||
|
baseline: object,
|
||||||
|
top_n: int,
|
||||||
|
) -> list[str]:
|
||||||
|
pipeline_tracks = pipeline.get("tracks", []) if isinstance(pipeline, dict) else []
|
||||||
|
baseline_tracks = baseline.get("tracks", []) if isinstance(baseline, dict) else []
|
||||||
|
lines = [f"## {scenario.chip}", "", "| Rank | Pipeline | Bare search |", "| ---: | --- | --- |"]
|
||||||
|
for index in range(top_n):
|
||||||
|
pipeline_label = _pipeline_label(pipeline_tracks, index)
|
||||||
|
baseline_label = _spotify_label(baseline_tracks, index)
|
||||||
|
lines.append(f"| {index + 1} | {pipeline_label} | {baseline_label} |")
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _pipeline_label(tracks: object, index: int) -> str:
|
||||||
|
if not isinstance(tracks, list) or index >= len(tracks):
|
||||||
|
return ""
|
||||||
|
event = tracks[index]
|
||||||
|
track = event.get("track") if isinstance(event, dict) else None
|
||||||
|
return _track_label(track, "title")
|
||||||
|
|
||||||
|
|
||||||
|
def _spotify_label(tracks: object, index: int) -> str:
|
||||||
|
if not isinstance(tracks, list) or index >= len(tracks):
|
||||||
|
return ""
|
||||||
|
return _track_label(tracks[index], "name")
|
||||||
|
|
||||||
|
|
||||||
|
def _track_label(track: object, title_key: str) -> str:
|
||||||
|
if not isinstance(track, dict):
|
||||||
|
return ""
|
||||||
|
artists = track.get("artists", [])
|
||||||
|
names = [
|
||||||
|
artist.get("name", "") if isinstance(artist, dict) else str(artist) for artist in artists
|
||||||
|
]
|
||||||
|
return f"{track.get(title_key, '')} by {', '.join(names)}"
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
65
eval/fixtures/dinner-background/anthropic.json
Normal file
65
eval/fixtures/dinner-background/anthropic.json
Normal file
File diff suppressed because one or more lines are too long
442
eval/fixtures/dinner-background/spotify.json
Normal file
442
eval/fixtures/dinner-background/spotify.json
Normal file
File diff suppressed because one or more lines are too long
72
eval/fixtures/discover-new/anthropic.json
Normal file
72
eval/fixtures/discover-new/anthropic.json
Normal file
File diff suppressed because one or more lines are too long
616
eval/fixtures/discover-new/spotify.json
Normal file
616
eval/fixtures/discover-new/spotify.json
Normal file
File diff suppressed because one or more lines are too long
55
eval/fixtures/dutch-chill/anthropic.json
Normal file
55
eval/fixtures/dutch-chill/anthropic.json
Normal file
File diff suppressed because one or more lines are too long
894
eval/fixtures/dutch-chill/spotify.json
Normal file
894
eval/fixtures/dutch-chill/spotify.json
Normal file
File diff suppressed because one or more lines are too long
127
eval/fixtures/focus-coding-refine/anthropic.json
Normal file
127
eval/fixtures/focus-coding-refine/anthropic.json
Normal file
File diff suppressed because one or more lines are too long
413
eval/fixtures/focus-coding-refine/spotify.json
Normal file
413
eval/fixtures/focus-coding-refine/spotify.json
Normal file
File diff suppressed because one or more lines are too long
65
eval/fixtures/focus-coding/anthropic.json
Normal file
65
eval/fixtures/focus-coding/anthropic.json
Normal file
File diff suppressed because one or more lines are too long
795
eval/fixtures/focus-coding/spotify.json
Normal file
795
eval/fixtures/focus-coding/spotify.json
Normal file
File diff suppressed because one or more lines are too long
66
eval/fixtures/nineties-nostalgia/anthropic.json
Normal file
66
eval/fixtures/nineties-nostalgia/anthropic.json
Normal file
File diff suppressed because one or more lines are too long
439
eval/fixtures/nineties-nostalgia/spotify.json
Normal file
439
eval/fixtures/nineties-nostalgia/spotify.json
Normal file
File diff suppressed because one or more lines are too long
66
eval/fixtures/rainy-sunday/anthropic.json
Normal file
66
eval/fixtures/rainy-sunday/anthropic.json
Normal file
File diff suppressed because one or more lines are too long
408
eval/fixtures/rainy-sunday/spotify.json
Normal file
408
eval/fixtures/rainy-sunday/spotify.json
Normal file
File diff suppressed because one or more lines are too long
64
eval/fixtures/workout-energy/anthropic.json
Normal file
64
eval/fixtures/workout-energy/anthropic.json
Normal file
File diff suppressed because one or more lines are too long
773
eval/fixtures/workout-energy/spotify.json
Normal file
773
eval/fixtures/workout-energy/spotify.json
Normal file
File diff suppressed because one or more lines are too long
284
eval/live.py
Normal file
284
eval/live.py
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
"""Exercise the live recommendation stream and assert behavior properties."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from scenario import Scenario
|
||||||
|
from wire import validate_event
|
||||||
|
|
||||||
|
FACET_TERMS: dict[tuple[str, str], tuple[str, ...]] = {
|
||||||
|
("activity", "coding"): ("coding", "programming"),
|
||||||
|
("activity", "workout"): ("workout", "gym"),
|
||||||
|
("familiarity", "mix"): ("mix", "familiar", "new", "discover"),
|
||||||
|
("familiarity", "new"): ("new", "unfamiliar", "discover", "surprise"),
|
||||||
|
("language", "nl"): ("dutch", "nederlandse"),
|
||||||
|
("era", "1990s"): ("1990", "nineties", "90s"),
|
||||||
|
("mood", "energetic"): ("energetic", "high-energy", "high energy"),
|
||||||
|
("mood", "relaxed"): ("relaxed", "laid-back", "laid back", "couch-friendly"),
|
||||||
|
("mood", "subdued"): ("subdued", "background", "unobtrusive", "not too present"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LiveLimits:
|
||||||
|
"""Quality and latency thresholds for one live request."""
|
||||||
|
|
||||||
|
minimum_tracks: int
|
||||||
|
maximum_tracks: int
|
||||||
|
minimum_artists: int
|
||||||
|
first_track_budget_ms: int
|
||||||
|
total_budget_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScenarioResult:
|
||||||
|
"""Serializable observations and property failures for one scenario."""
|
||||||
|
|
||||||
|
scenario: Scenario
|
||||||
|
events: list[dict[str, object]] = field(default_factory=list)
|
||||||
|
failures: list[str] = field(default_factory=list)
|
||||||
|
checks: dict[str, bool] = field(default_factory=dict)
|
||||||
|
first_track_ms: int | None = None
|
||||||
|
total_ms: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tracks(self) -> list[dict[str, object]]:
|
||||||
|
"""Return track event payloads in streamed order."""
|
||||||
|
return [event for event in self.events if event.get("type") == "track"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def passed(self) -> bool:
|
||||||
|
"""Return whether all required properties passed."""
|
||||||
|
return not self.failures
|
||||||
|
|
||||||
|
def as_report(self) -> dict[str, object]:
|
||||||
|
"""Render the stable JSON report shape."""
|
||||||
|
return {
|
||||||
|
"key": self.scenario.key,
|
||||||
|
"query": self.scenario.query,
|
||||||
|
"after": self.scenario.after,
|
||||||
|
"status": "passed" if self.passed else "failed",
|
||||||
|
"first_track_ms": self.first_track_ms,
|
||||||
|
"total_ms": self.total_ms,
|
||||||
|
"checks": self.checks,
|
||||||
|
"failures": self.failures,
|
||||||
|
"events": self.events,
|
||||||
|
"tracks": self.tracks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_live_scenarios(
|
||||||
|
scenarios: tuple[Scenario, ...],
|
||||||
|
base_url: str,
|
||||||
|
report_root: Path,
|
||||||
|
limits: LiveLimits,
|
||||||
|
) -> tuple[ScenarioResult, ...]:
|
||||||
|
"""Run all scenarios sequentially and write one report for each."""
|
||||||
|
report_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
results_by_key: dict[str, ScenarioResult] = {}
|
||||||
|
async with httpx.AsyncClient(base_url=base_url, timeout=None) as client:
|
||||||
|
for scenario in scenarios:
|
||||||
|
parent = results_by_key.get(scenario.after) if scenario.after is not None else None
|
||||||
|
result = await run_live_scenario(client, scenario, limits, parent)
|
||||||
|
_write_report(report_root / f"{scenario.key}.json", result.as_report())
|
||||||
|
results_by_key[scenario.key] = result
|
||||||
|
return tuple(results_by_key[scenario.key] for scenario in scenarios)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_live_scenario(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
scenario: Scenario,
|
||||||
|
limits: LiveLimits,
|
||||||
|
parent: ScenarioResult | None = None,
|
||||||
|
) -> ScenarioResult:
|
||||||
|
"""Run one strict NDJSON request and evaluate its properties."""
|
||||||
|
result = ScenarioResult(scenario)
|
||||||
|
if scenario.after is not None and (parent is None or not parent.tracks):
|
||||||
|
result.failures.append("parent scenario did not produce usable recommendations")
|
||||||
|
return result
|
||||||
|
payload = build_request(scenario, parent)
|
||||||
|
started_at = time.monotonic()
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(limits.total_budget_ms / 1000):
|
||||||
|
async with client.stream("POST", "/api/recommendations", json=payload) as response:
|
||||||
|
if response.status_code != 200:
|
||||||
|
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||||
|
result.failures.append(f"HTTP {response.status_code}: {body}")
|
||||||
|
else:
|
||||||
|
await _consume_response(response, result, started_at)
|
||||||
|
except TimeoutError:
|
||||||
|
result.failures.append("stream exceeded the configured total latency budget")
|
||||||
|
except (httpx.HTTPError, ValueError) as error:
|
||||||
|
result.failures.append(f"stream failed: {error}")
|
||||||
|
result.total_ms = _elapsed_ms(started_at)
|
||||||
|
_evaluate_result(result, limits)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_request(scenario: Scenario, parent: ScenarioResult | None) -> dict[str, object]:
|
||||||
|
"""Build the frozen request shape, including client-owned turn context."""
|
||||||
|
payload: dict[str, object] = {"schema_version": 1, "query": scenario.query}
|
||||||
|
if parent is None:
|
||||||
|
return payload
|
||||||
|
prior = []
|
||||||
|
labels = []
|
||||||
|
for event in parent.tracks:
|
||||||
|
track = event["track"]
|
||||||
|
if not isinstance(track, dict):
|
||||||
|
continue
|
||||||
|
rank = event["rank"]
|
||||||
|
artists = track["artists"]
|
||||||
|
prior_artists = list(artists)[:10] if isinstance(artists, list) else []
|
||||||
|
prior.append(
|
||||||
|
{
|
||||||
|
"rank": rank,
|
||||||
|
"track_id": track["id"],
|
||||||
|
"title": track["title"],
|
||||||
|
"artists": prior_artists,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
labels.append(
|
||||||
|
f"{rank}. {track['title']} by {', '.join(str(name) for name in prior_artists)}"
|
||||||
|
)
|
||||||
|
payload["history"] = [
|
||||||
|
{"role": "user", "content": parent.scenario.query},
|
||||||
|
{"role": "assistant", "content": "\n".join(labels)[:2000]},
|
||||||
|
]
|
||||||
|
payload["prior_recommendations"] = prior
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_result(result: ScenarioResult, limits: LiveLimits) -> None:
|
||||||
|
event_types = [str(event.get("type")) for event in result.events]
|
||||||
|
terminal_type = event_types[-1] if event_types else None
|
||||||
|
order_is_valid = (
|
||||||
|
bool(event_types)
|
||||||
|
and event_types[0] == "metadata"
|
||||||
|
and terminal_type in {"done", "error"}
|
||||||
|
and all(event_type in {"track", "warning"} for event_type in event_types[1:-1])
|
||||||
|
and event_types.count("metadata") == 1
|
||||||
|
)
|
||||||
|
_record_check(result, "event_order", order_is_valid, "event order is invalid")
|
||||||
|
if terminal_type == "error":
|
||||||
|
terminal = result.events[-1]
|
||||||
|
result.failures.append(f"terminal error: {terminal.get('code', 'unknown')}")
|
||||||
|
|
||||||
|
tracks = result.tracks
|
||||||
|
if terminal_type == "done":
|
||||||
|
done_count = result.events[-1].get("track_count")
|
||||||
|
_record_check(
|
||||||
|
result,
|
||||||
|
"done_track_count",
|
||||||
|
done_count == len(tracks),
|
||||||
|
"done event track count did not match streamed tracks",
|
||||||
|
)
|
||||||
|
track_ids = [_track_field(event, "id") for event in tracks]
|
||||||
|
_record_check(
|
||||||
|
result,
|
||||||
|
"unique_track_ids",
|
||||||
|
len(track_ids) == len(set(track_ids)),
|
||||||
|
"duplicate track ids were returned",
|
||||||
|
)
|
||||||
|
justifications = [event.get("justification") for event in tracks]
|
||||||
|
_record_check(
|
||||||
|
result,
|
||||||
|
"non_empty_justifications",
|
||||||
|
all(isinstance(value, str) and bool(value.strip()) for value in justifications),
|
||||||
|
"a track justification was empty",
|
||||||
|
)
|
||||||
|
_record_check(
|
||||||
|
result,
|
||||||
|
"track_count",
|
||||||
|
limits.minimum_tracks <= len(tracks) <= limits.maximum_tracks,
|
||||||
|
f"track count {len(tracks)} is outside configured bounds",
|
||||||
|
)
|
||||||
|
artists = {str(artist).casefold() for event in tracks for artist in _track_artists(event)}
|
||||||
|
_record_check(
|
||||||
|
result,
|
||||||
|
"distinct_artists",
|
||||||
|
len(artists) >= limits.minimum_artists,
|
||||||
|
f"distinct artist count {len(artists)} is below configured minimum",
|
||||||
|
)
|
||||||
|
_record_check(
|
||||||
|
result,
|
||||||
|
"first_track_latency",
|
||||||
|
result.first_track_ms is not None and result.first_track_ms <= limits.first_track_budget_ms,
|
||||||
|
"time to first track exceeded the configured budget",
|
||||||
|
)
|
||||||
|
_record_check(
|
||||||
|
result,
|
||||||
|
"total_latency",
|
||||||
|
result.total_ms is not None and result.total_ms <= limits.total_budget_ms,
|
||||||
|
"total latency exceeded the configured budget",
|
||||||
|
)
|
||||||
|
_evaluate_facets(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_facets(result: ScenarioResult) -> None:
|
||||||
|
metadata = next(
|
||||||
|
(event for event in result.events if event.get("type") == "metadata"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
summary = str(metadata.get("intent_summary", "")).casefold() if metadata else ""
|
||||||
|
for name, value in result.scenario.facets.items():
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
terms = FACET_TERMS.get((name, value), (value.casefold(),))
|
||||||
|
_record_check(
|
||||||
|
result,
|
||||||
|
f"facet_{name}",
|
||||||
|
any(term in summary for term in terms),
|
||||||
|
f"intent summary did not express {name}={value}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _consume_response(
|
||||||
|
response: httpx.Response,
|
||||||
|
result: ScenarioResult,
|
||||||
|
started_at: float,
|
||||||
|
) -> None:
|
||||||
|
content_type = response.headers.get("content-type", "").split(";", 1)[0]
|
||||||
|
if content_type != "application/x-ndjson":
|
||||||
|
result.failures.append(f"unexpected content type: {content_type or 'missing'}")
|
||||||
|
return
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
result.failures.append("NDJSON stream contained an empty line")
|
||||||
|
continue
|
||||||
|
event = validate_event(line)
|
||||||
|
event_payload = event.model_dump(mode="json")
|
||||||
|
result.events.append(event_payload)
|
||||||
|
if event_payload["type"] == "track" and result.first_track_ms is None:
|
||||||
|
result.first_track_ms = _elapsed_ms(started_at)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_check(result: ScenarioResult, name: str, passed: bool, failure: str) -> None:
|
||||||
|
result.checks[name] = passed
|
||||||
|
if not passed:
|
||||||
|
result.failures.append(failure)
|
||||||
|
|
||||||
|
|
||||||
|
def _track_field(event: dict[str, object], key: str) -> str:
|
||||||
|
track = event.get("track")
|
||||||
|
return str(track.get(key, "")) if isinstance(track, dict) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _track_artists(event: dict[str, object]) -> list[object]:
|
||||||
|
track = event.get("track")
|
||||||
|
artists = track.get("artists") if isinstance(track, dict) else None
|
||||||
|
return artists if isinstance(artists, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _elapsed_ms(started_at: float) -> int:
|
||||||
|
return round((time.monotonic() - started_at) * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_report(path: Path, report: dict[str, object]) -> None:
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(report, ensure_ascii=True, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="ascii",
|
||||||
|
)
|
||||||
142
eval/record_fixtures.py
Normal file
142
eval/record_fixtures.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""Record one scenario through a locally constructed application."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import httpx2
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from live import ScenarioResult, build_request
|
||||||
|
from recording import Httpx2RecordingTransport, HttpxRecordingTransport, write_cassettes
|
||||||
|
from scenario import SCENARIO_PATH, Scenario, load_scenarios
|
||||||
|
from wire import BACKEND_ROOT, validate_event
|
||||||
|
|
||||||
|
EVAL_ROOT = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Record the selected scenario and persist raw plus safe cassettes."""
|
||||||
|
arguments = _parse_arguments()
|
||||||
|
scenarios = load_scenarios(arguments.scenarios)
|
||||||
|
selected = next((item for item in scenarios if item.key == arguments.scenario), None)
|
||||||
|
if selected is None:
|
||||||
|
raise SystemExit(f"Unknown scenario: {arguments.scenario}")
|
||||||
|
spotify_client_id = _required_environment("SPOTIFY_CLIENT_ID")
|
||||||
|
spotify_refresh_token = _required_environment("SPOTIFY_SEED_REFRESH_TOKEN")
|
||||||
|
anthropic_api_key = _required_environment("ANTHROPIC_API_KEY")
|
||||||
|
|
||||||
|
spotify_transport = Httpx2RecordingTransport(httpx2.AsyncHTTPTransport())
|
||||||
|
anthropic_transport = HttpxRecordingTransport(httpx.AsyncHTTPTransport())
|
||||||
|
anthropic_http_client = httpx.AsyncClient(transport=anthropic_transport)
|
||||||
|
app = _create_recording_app(
|
||||||
|
spotify_client_id,
|
||||||
|
spotify_refresh_token,
|
||||||
|
anthropic_api_key,
|
||||||
|
spotify_transport,
|
||||||
|
anthropic_http_client,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with TestClient(app, base_url=arguments.base_url) as client:
|
||||||
|
_record_selected(client, scenarios, selected, spotify_transport)
|
||||||
|
finally:
|
||||||
|
raw_root, redacted_root = write_cassettes(
|
||||||
|
arguments.fixture_dir,
|
||||||
|
selected.key,
|
||||||
|
{
|
||||||
|
"spotify": spotify_transport.interactions,
|
||||||
|
"anthropic": anthropic_transport.interactions,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(f"Raw cassettes: {raw_root}")
|
||||||
|
print(f"Redacted cassettes: {redacted_root}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _record_selected(
|
||||||
|
client: TestClient,
|
||||||
|
scenarios: tuple[Scenario, ...],
|
||||||
|
selected: Scenario,
|
||||||
|
spotify_transport: Httpx2RecordingTransport,
|
||||||
|
) -> None:
|
||||||
|
if selected.after is not None:
|
||||||
|
parent_scenario = next(item for item in scenarios if item.key == selected.after)
|
||||||
|
parent = _send_turn(client, parent_scenario, None)
|
||||||
|
search_count = _search_count(spotify_transport)
|
||||||
|
_send_turn(client, selected, parent)
|
||||||
|
if _search_count(spotify_transport) != search_count:
|
||||||
|
raise RuntimeError("Refinement issued new Spotify searches")
|
||||||
|
else:
|
||||||
|
_send_turn(client, selected, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_turn(
|
||||||
|
client: TestClient,
|
||||||
|
scenario: Scenario,
|
||||||
|
parent: ScenarioResult | None,
|
||||||
|
) -> ScenarioResult:
|
||||||
|
response = client.post("/api/recommendations", json=build_request(scenario, parent))
|
||||||
|
response.raise_for_status()
|
||||||
|
result = ScenarioResult(scenario)
|
||||||
|
for line in response.text.splitlines():
|
||||||
|
event = validate_event(line)
|
||||||
|
result.events.append(cast(dict[str, object], event.model_dump(mode="json")))
|
||||||
|
if not result.events or result.events[-1].get("type") != "done":
|
||||||
|
raise RuntimeError(f"Scenario {scenario.key} did not complete")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _create_recording_app(
|
||||||
|
spotify_client_id: str,
|
||||||
|
spotify_refresh_token: str,
|
||||||
|
anthropic_api_key: str,
|
||||||
|
spotify_transport: Httpx2RecordingTransport,
|
||||||
|
anthropic_http_client: httpx.AsyncClient,
|
||||||
|
) -> FastAPI:
|
||||||
|
backend_path = str(BACKEND_ROOT)
|
||||||
|
if backend_path not in sys.path:
|
||||||
|
sys.path.insert(0, backend_path)
|
||||||
|
config_module = import_module("app.config")
|
||||||
|
main_module = import_module("app.main")
|
||||||
|
application_settings = config_module.Settings(
|
||||||
|
app_mode=config_module.AppMode.LIVE,
|
||||||
|
spotify_client_id=spotify_client_id,
|
||||||
|
spotify_seed_refresh_token=spotify_refresh_token,
|
||||||
|
anthropic_api_key=anthropic_api_key,
|
||||||
|
)
|
||||||
|
return cast(
|
||||||
|
FastAPI,
|
||||||
|
main_module.create_app(
|
||||||
|
application_settings=application_settings,
|
||||||
|
http_transport=spotify_transport,
|
||||||
|
anthropic_http_client=anthropic_http_client,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_count(transport: Httpx2RecordingTransport) -> int:
|
||||||
|
return sum("/search" in interaction.url for interaction in transport.interactions)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_environment(name: str) -> str:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if not value:
|
||||||
|
raise SystemExit(f"{name} is required for recording")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--scenario", required=True)
|
||||||
|
parser.add_argument("--base-url", required=True)
|
||||||
|
parser.add_argument("--scenarios", type=Path, default=SCENARIO_PATH)
|
||||||
|
parser.add_argument("--fixture-dir", type=Path, default=EVAL_ROOT / "fixtures")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
14
eval/recording/__init__.py
Normal file
14
eval/recording/__init__.py
Normal 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
30
eval/recording/model.py
Normal 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
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")
|
||||||
32
eval/recording/storage.py
Normal file
32
eval/recording/storage.py
Normal 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",
|
||||||
|
)
|
||||||
95
eval/recording/synthetic_taste.json
Normal file
95
eval/recording/synthetic_taste.json
Normal 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
154
eval/recording/transport.py
Normal 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))
|
||||||
98
eval/run_eval.py
Normal file
98
eval/run_eval.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Command-line entry point for live and baseline evaluation."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from baseline import record_baselines, write_comparison
|
||||||
|
from live import LiveLimits, ScenarioResult, run_live_scenarios
|
||||||
|
from scenario import SCENARIO_PATH, load_scenarios
|
||||||
|
|
||||||
|
EVAL_ROOT = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run the selected evaluation arms and return a process status."""
|
||||||
|
arguments = _parse_arguments()
|
||||||
|
scenarios = load_scenarios(arguments.scenarios)
|
||||||
|
exit_code = 0
|
||||||
|
if arguments.base_url is None:
|
||||||
|
raise SystemExit("--base-url is required")
|
||||||
|
limits = LiveLimits(
|
||||||
|
minimum_tracks=arguments.min_tracks,
|
||||||
|
maximum_tracks=arguments.max_tracks,
|
||||||
|
minimum_artists=arguments.min_artists,
|
||||||
|
first_track_budget_ms=arguments.first_track_budget_ms,
|
||||||
|
total_budget_ms=arguments.total_budget_ms,
|
||||||
|
)
|
||||||
|
results = asyncio.run(
|
||||||
|
run_live_scenarios(scenarios, arguments.base_url, arguments.report_dir, limits)
|
||||||
|
)
|
||||||
|
_print_summary(results)
|
||||||
|
if not all(result.passed for result in results):
|
||||||
|
exit_code = 1
|
||||||
|
|
||||||
|
if arguments.baseline:
|
||||||
|
client_id = os.environ.get("SPOTIFY_CLIENT_ID")
|
||||||
|
client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET")
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
print("Spotify client credentials are absent; skipping baseline.")
|
||||||
|
else:
|
||||||
|
baseline_root = arguments.snapshot_dir / "baseline"
|
||||||
|
asyncio.run(record_baselines(scenarios, client_id, client_secret, baseline_root))
|
||||||
|
comparison_path = write_comparison(
|
||||||
|
scenarios,
|
||||||
|
arguments.snapshot_dir / "baseline",
|
||||||
|
arguments.report_dir,
|
||||||
|
arguments.comparison_top_n,
|
||||||
|
)
|
||||||
|
print(f"Comparison: {comparison_path}")
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--base-url")
|
||||||
|
parser.add_argument("--baseline", action="store_true")
|
||||||
|
parser.add_argument("--scenarios", type=Path, default=SCENARIO_PATH)
|
||||||
|
parser.add_argument("--snapshot-dir", type=Path, default=EVAL_ROOT / "snapshots")
|
||||||
|
parser.add_argument("--report-dir", type=Path, default=EVAL_ROOT / "reports")
|
||||||
|
parser.add_argument("--min-tracks", type=int, default=8)
|
||||||
|
parser.add_argument("--max-tracks", type=int, default=15)
|
||||||
|
parser.add_argument("--min-artists", type=int, default=5)
|
||||||
|
parser.add_argument("--first-track-budget-ms", type=int, default=20_000)
|
||||||
|
parser.add_argument("--total-budget-ms", type=int, default=30_000)
|
||||||
|
parser.add_argument("--comparison-top-n", type=int, default=10)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
if arguments.min_tracks < 0 or arguments.max_tracks < arguments.min_tracks:
|
||||||
|
parser.error("track bounds are invalid")
|
||||||
|
if min(arguments.min_artists, arguments.first_track_budget_ms, arguments.total_budget_ms) < 0:
|
||||||
|
parser.error("artist and latency limits must be non-negative")
|
||||||
|
if arguments.comparison_top_n < 1:
|
||||||
|
parser.error("comparison top N must be positive")
|
||||||
|
return arguments
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(results: tuple[ScenarioResult, ...]) -> None:
|
||||||
|
print("| Scenario | Status | Tracks | Artists | First track | Total |")
|
||||||
|
print("| --- | --- | ---: | ---: | ---: | ---: |")
|
||||||
|
for result in results:
|
||||||
|
artists = {str(artist).casefold() for event in result.tracks for artist in _artists(event)}
|
||||||
|
first = f"{result.first_track_ms} ms" if result.first_track_ms is not None else "n/a"
|
||||||
|
total = f"{result.total_ms} ms" if result.total_ms is not None else "n/a"
|
||||||
|
status = "PASS" if result.passed else "FAIL"
|
||||||
|
print(
|
||||||
|
f"| {result.scenario.key} | {status} | {len(result.tracks)} | "
|
||||||
|
f"{len(artists)} | {first} | {total} |"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _artists(event: dict[str, object]) -> list[object]:
|
||||||
|
track = event.get("track")
|
||||||
|
artists = track.get("artists") if isinstance(track, dict) else None
|
||||||
|
return artists if isinstance(artists, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
79
eval/scenario.py
Normal file
79
eval/scenario.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""Load the shared evaluation scenario catalog."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
SCENARIO_PATH = Path(__file__).with_name("scenarios.yaml")
|
||||||
|
FacetValue = str | bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Scenario:
|
||||||
|
"""One golden query and its expected intent facets."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
chip: str
|
||||||
|
query: str
|
||||||
|
facets: dict[str, FacetValue]
|
||||||
|
after: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_refinement(self) -> bool:
|
||||||
|
"""Return whether this query follows another scenario."""
|
||||||
|
return self.after is not None
|
||||||
|
|
||||||
|
|
||||||
|
def load_scenarios(path: Path = SCENARIO_PATH) -> tuple[Scenario, ...]:
|
||||||
|
"""Read and validate the shared YAML scenario catalog."""
|
||||||
|
payload = yaml.safe_load(path.read_text(encoding="ascii"))
|
||||||
|
if not isinstance(payload, dict) or payload.get("version") != 1:
|
||||||
|
raise ValueError("Scenario catalog must have version 1")
|
||||||
|
entries = payload.get("scenarios")
|
||||||
|
if not isinstance(entries, list) or not entries:
|
||||||
|
raise ValueError("Scenario catalog must contain scenarios")
|
||||||
|
|
||||||
|
scenarios = tuple(_parse_scenario(entry) for entry in entries)
|
||||||
|
keys = [scenario.key for scenario in scenarios]
|
||||||
|
if len(keys) != len(set(keys)):
|
||||||
|
raise ValueError("Scenario keys must be unique")
|
||||||
|
known_keys = set(keys)
|
||||||
|
for scenario in scenarios:
|
||||||
|
if scenario.after is not None and scenario.after not in known_keys:
|
||||||
|
raise ValueError(f"Unknown parent scenario: {scenario.after}")
|
||||||
|
return scenarios
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_scenario(payload: object) -> Scenario:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("Each scenario must be a mapping")
|
||||||
|
required = {"key", "chip", "query", "facets"}
|
||||||
|
allowed = required | {"after"}
|
||||||
|
if set(payload) - allowed or not required <= set(payload):
|
||||||
|
raise ValueError("Scenario fields must match the catalog schema")
|
||||||
|
facets = payload["facets"]
|
||||||
|
if not isinstance(facets, dict) or not facets:
|
||||||
|
raise ValueError("Scenario facets must be a non-empty mapping")
|
||||||
|
if not all(
|
||||||
|
isinstance(key, str) and isinstance(value, (str, bool)) for key, value in facets.items()
|
||||||
|
):
|
||||||
|
raise ValueError("Scenario facets must contain string or boolean values")
|
||||||
|
after = payload.get("after")
|
||||||
|
if after is not None and not isinstance(after, str):
|
||||||
|
raise ValueError("Scenario after must be a key")
|
||||||
|
return Scenario(
|
||||||
|
key=_required_string(payload, "key"),
|
||||||
|
chip=_required_string(payload, "chip"),
|
||||||
|
query=_required_string(payload, "query"),
|
||||||
|
facets=cast(dict[str, FacetValue], facets),
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_string(payload: dict[object, object], key: str) -> str:
|
||||||
|
value = payload.get(key)
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ValueError(f"Scenario {key} must be a non-empty string")
|
||||||
|
return value
|
||||||
49
eval/scenarios.yaml
Normal file
49
eval/scenarios.yaml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
version: 1
|
||||||
|
scenarios:
|
||||||
|
- key: focus-coding
|
||||||
|
chip: Focus while coding
|
||||||
|
query: something calm for while I am programming, but not boring
|
||||||
|
facets:
|
||||||
|
activity: coding
|
||||||
|
familiarity: mix
|
||||||
|
- key: workout-energy
|
||||||
|
chip: Energy for the gym
|
||||||
|
query: high-energy music for a heavy workout
|
||||||
|
facets:
|
||||||
|
activity: workout
|
||||||
|
mood: energetic
|
||||||
|
- key: dutch-chill
|
||||||
|
chip: Dutch and relaxed
|
||||||
|
query: relaxed Dutch-language music for the couch
|
||||||
|
facets:
|
||||||
|
language: nl
|
||||||
|
mood: relaxed
|
||||||
|
- key: nineties-nostalgia
|
||||||
|
chip: 90s nostalgia
|
||||||
|
query: take me back to the nineties
|
||||||
|
facets:
|
||||||
|
era: 1990s
|
||||||
|
- key: discover-new
|
||||||
|
chip: Surprise me with something new
|
||||||
|
query: something I do not know yet but will probably like
|
||||||
|
facets:
|
||||||
|
familiarity: new
|
||||||
|
- key: rainy-sunday
|
||||||
|
chip: Rainy Sunday
|
||||||
|
query: melancholic but warm, for a rainy Sunday morning
|
||||||
|
facets:
|
||||||
|
mood: melancholic
|
||||||
|
- key: dinner-background
|
||||||
|
chip: Background for dinner
|
||||||
|
query: something jazzy for during dinner, not too present
|
||||||
|
facets:
|
||||||
|
genre: jazz
|
||||||
|
mood: subdued
|
||||||
|
- key: focus-coding-refine
|
||||||
|
chip: More electronic
|
||||||
|
query: a bit more electronic, and drop number 3
|
||||||
|
after: focus-coding
|
||||||
|
facets:
|
||||||
|
refinement: true
|
||||||
|
pool_reuse: true
|
||||||
|
re_grounding: false
|
||||||
78
eval/tests/test_live.py
Normal file
78
eval/tests/test_live.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""Tests for strict live stream property evaluation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from live import LiveLimits, run_live_scenario
|
||||||
|
from scenario import Scenario
|
||||||
|
from wire import validate_event
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_scenario_accepts_a_valid_property_stream() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.post("/api/recommendations")
|
||||||
|
async def recommendations() -> StreamingResponse:
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
"type": "metadata",
|
||||||
|
"request_id": "request",
|
||||||
|
"intent_summary": "Calm music for coding with a familiar discovery mix.",
|
||||||
|
"candidate_count": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "track",
|
||||||
|
"rank": 1,
|
||||||
|
"track": {
|
||||||
|
"id": "track",
|
||||||
|
"uri": "spotify:track:track",
|
||||||
|
"title": "Track",
|
||||||
|
"artists": ["Artist"],
|
||||||
|
"album_name": "Album",
|
||||||
|
"album_art_url": None,
|
||||||
|
"external_url": None,
|
||||||
|
},
|
||||||
|
"justification": "A focused fit.",
|
||||||
|
},
|
||||||
|
{"type": "done", "track_count": 1, "total_ms": 1},
|
||||||
|
]
|
||||||
|
body = "".join(json.dumps(event) + "\n" for event in events)
|
||||||
|
return StreamingResponse(iter((body,)), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
result = await run_live_scenario(
|
||||||
|
client,
|
||||||
|
Scenario(
|
||||||
|
"focus-coding",
|
||||||
|
"Focus while coding",
|
||||||
|
"calm coding music",
|
||||||
|
{"activity": "coding", "familiarity": "mix"},
|
||||||
|
),
|
||||||
|
LiveLimits(1, 1, 1, 1_000, 1_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.passed
|
||||||
|
assert result.checks["event_order"]
|
||||||
|
assert result.checks["done_track_count"]
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_wire_validation_rejects_extra_fields() -> None:
|
||||||
|
line = json.dumps(
|
||||||
|
{
|
||||||
|
"type": "done",
|
||||||
|
"track_count": 1,
|
||||||
|
"total_ms": 1,
|
||||||
|
"unexpected": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="fields do not match"):
|
||||||
|
validate_event(line)
|
||||||
134
eval/tests/test_recording.py
Normal file
134
eval/tests/test_recording.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""Tests for cassette transport fidelity and redaction."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import httpx2
|
||||||
|
from recording import Httpx2RecordingTransport, HttpxRecordingTransport, redact_cassette
|
||||||
|
|
||||||
|
|
||||||
|
class HttpxChunks(httpx.AsyncByteStream):
|
||||||
|
"""Yield fixed httpx byte chunks."""
|
||||||
|
|
||||||
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||||
|
yield b"first-"
|
||||||
|
yield b"second"
|
||||||
|
|
||||||
|
|
||||||
|
class Httpx2Chunks(httpx2.AsyncByteStream):
|
||||||
|
"""Yield fixed httpx2 byte chunks."""
|
||||||
|
|
||||||
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||||
|
yield b"first-"
|
||||||
|
yield b"second"
|
||||||
|
|
||||||
|
|
||||||
|
def test_httpx_recording_transport_preserves_chunk_boundaries() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, stream=HttpxChunks())
|
||||||
|
|
||||||
|
transport = HttpxRecordingTransport(httpx.MockTransport(handler))
|
||||||
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
|
response = await client.get("https://api.anthropic.com/v1/messages")
|
||||||
|
|
||||||
|
assert response.content == b"first-second"
|
||||||
|
assert transport.interactions[0].response_chunks == (b"first-", b"second")
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_httpx2_recording_transport_preserves_chunk_boundaries() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
async def handler(request: httpx2.Request) -> httpx2.Response:
|
||||||
|
return httpx2.Response(200, stream=Httpx2Chunks())
|
||||||
|
|
||||||
|
transport = Httpx2RecordingTransport(httpx2.MockTransport(handler))
|
||||||
|
async with httpx2.AsyncClient(transport=transport) as client:
|
||||||
|
response = await client.get("https://api.spotify.com/v1/search?q=test")
|
||||||
|
|
||||||
|
assert response.content == b"first-second"
|
||||||
|
assert transport.interactions[0].response_chunks == (b"first-", b"second")
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_redaction_removes_headers_tokens_identity_and_taste() -> None:
|
||||||
|
interactions = [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://accounts.spotify.com/api/token",
|
||||||
|
"request_headers": {"Authorization": "Bearer raw-token"},
|
||||||
|
"request_body_base64": _encode(b"client_secret=secret"),
|
||||||
|
"response_body_base64": _encode(b'{"access_token":"raw-token"}'),
|
||||||
|
"response_chunks_base64": [],
|
||||||
|
"status": 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://api.spotify.com/v1/me",
|
||||||
|
"response_headers": {"Set-Cookie": "secret"},
|
||||||
|
"request_body_base64": "",
|
||||||
|
"response_body_base64": _encode(
|
||||||
|
b'{"id":"real-account-id","display_name":"Real Listener"}'
|
||||||
|
),
|
||||||
|
"response_chunks_base64": [],
|
||||||
|
"status": 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://api.spotify.com/v1/me/top/artists?limit=50",
|
||||||
|
"headers": {"Authorization": "Bearer raw-token"},
|
||||||
|
"request_body_base64": "",
|
||||||
|
"response_body_base64": _encode(b'{"items":[{"name":"Private Artist"}]}'),
|
||||||
|
"response_chunks_base64": [],
|
||||||
|
"status": 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.anthropic.com/v1/messages",
|
||||||
|
"request_body_base64": _encode(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"content": json.dumps(
|
||||||
|
{
|
||||||
|
"taste_profile": "Private Artist",
|
||||||
|
"account": "real-account-id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
),
|
||||||
|
"response_body_base64": _encode(b"Bearer raw-token"),
|
||||||
|
"response_chunks_base64": [_encode(b"Bearer raw-token")],
|
||||||
|
"status": 200,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
redacted = redact_cassette(interactions)
|
||||||
|
rendered = json.dumps(redacted)
|
||||||
|
decoded_bodies = " ".join(
|
||||||
|
base64.b64decode(str(item.get(field, ""))).decode(errors="replace")
|
||||||
|
for item in redacted
|
||||||
|
for field in ("request_body_base64", "response_body_base64")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(redacted) == 3
|
||||||
|
assert "headers" not in rendered.casefold()
|
||||||
|
assert "raw-token" not in rendered + decoded_bodies
|
||||||
|
assert "real-account-id" not in rendered + decoded_bodies
|
||||||
|
assert "Real Listener" not in rendered + decoded_bodies
|
||||||
|
assert "Private Artist" not in rendered + decoded_bodies
|
||||||
|
assert "Synthetic Focus Artist" in decoded_bodies
|
||||||
|
assert "accounts.spotify.com" not in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(value: bytes) -> str:
|
||||||
|
return base64.b64encode(value).decode("ascii")
|
||||||
46
eval/wire.py
Normal file
46
eval/wire.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Validate NDJSON events against the application-owned wire schemas."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
from pydantic import BaseModel, TypeAdapter
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).parents[1] / "backend"
|
||||||
|
EVENT_CLASS_NAMES = {
|
||||||
|
"metadata": "MetadataEvent",
|
||||||
|
"track": "TrackEvent",
|
||||||
|
"warning": "WarningEvent",
|
||||||
|
"error": "ErrorEvent",
|
||||||
|
"done": "DoneEvent",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_event(line: str) -> BaseModel:
|
||||||
|
"""Parse one complete line with exact fields and strict value types."""
|
||||||
|
schemas = _load_schemas()
|
||||||
|
payload = json.loads(line)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("NDJSON event must be an object")
|
||||||
|
event_type = payload.get("type")
|
||||||
|
if not isinstance(event_type, str) or event_type not in EVENT_CLASS_NAMES:
|
||||||
|
raise ValueError("NDJSON event has an unknown type")
|
||||||
|
event_class = getattr(schemas, EVENT_CLASS_NAMES[event_type])
|
||||||
|
if set(payload) != set(event_class.model_fields):
|
||||||
|
raise ValueError(f"{event_type} event fields do not match the wire schema")
|
||||||
|
if event_type == "track":
|
||||||
|
track = payload.get("track")
|
||||||
|
track_class = schemas.TrackCard
|
||||||
|
if not isinstance(track, dict) or set(track) != set(track_class.model_fields):
|
||||||
|
raise ValueError("track card fields do not match the wire schema")
|
||||||
|
adapter: TypeAdapter[BaseModel] = TypeAdapter(schemas.StreamEvent)
|
||||||
|
return adapter.validate_json(line, strict=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_schemas() -> ModuleType:
|
||||||
|
backend_path = str(BACKEND_ROOT)
|
||||||
|
if backend_path not in sys.path:
|
||||||
|
sys.path.insert(0, backend_path)
|
||||||
|
return import_module("app.api.schemas")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue