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

142
eval/record_fixtures.py Normal file
View 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())