Compare commits
No commits in common. "fa356ba5c5aa7119bd1cf4fd350a4436d4fb979e" and "a3af8f45cc860b11f17e0d9f98f5f8692e315b95" have entirely different histories.
fa356ba5c5
...
a3af8f45cc
89 changed files with 210 additions and 9433 deletions
|
|
@ -1,10 +0,0 @@
|
|||
.git
|
||||
**/.venv
|
||||
**/__pycache__
|
||||
**/.mypy_cache
|
||||
**/.pytest_cache
|
||||
**/.ruff_cache
|
||||
frontend/node_modules
|
||||
eval/fixtures/raw
|
||||
dist
|
||||
**/dist
|
||||
18
.env.example
18
.env.example
|
|
@ -1,17 +1,5 @@
|
|||
# Runtime mode; set to demo for no-key fixtures or live for Spotify and Anthropic.
|
||||
APP_MODE=
|
||||
# Copy to .env and adjust. Without a .env the app starts in demo mode.
|
||||
APP_MODE=demo
|
||||
|
||||
# Spotify app client ID; required in live mode and unused in demo mode.
|
||||
# The redirect URI must exactly match the URI registered in the Spotify dashboard.
|
||||
SPOTIFY_CLIENT_ID=
|
||||
|
||||
# Spotify OAuth callback URI; set in live mode if the local default is not registered.
|
||||
SPOTIFY_REDIRECT_URI=
|
||||
|
||||
# Anthropic API key; required in live mode and unused in demo mode.
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Pre-authorized Spotify token; only for hosted instances without interactive login.
|
||||
SPOTIFY_SEED_REFRESH_TOKEN=
|
||||
|
||||
# Secure session cookie flag; set to true on every HTTPS deployment.
|
||||
SESSION_COOKIE_SECURE=
|
||||
|
|
|
|||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -15,9 +15,9 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- run: uv sync --frozen
|
||||
- run: uv run ruff check . ../eval
|
||||
- run: uv run ruff format --check . ../eval
|
||||
- run: uv run mypy app tests ../eval
|
||||
- run: uv run ruff check .
|
||||
- run: uv run ruff format --check .
|
||||
- run: uv run mypy app
|
||||
- run: uv run pytest
|
||||
|
||||
frontend:
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -18,8 +18,3 @@ frontend/dist/
|
|||
|
||||
# eval: raw (unredacted) recordings never enter the repo
|
||||
eval/fixtures/raw/
|
||||
eval/reports/
|
||||
|
||||
# eval outputs (reports, baseline snapshots) are generated, never shipped
|
||||
eval/reports/
|
||||
eval/snapshots/
|
||||
|
|
|
|||
|
|
@ -26,9 +26,10 @@ api -> pipeline -> ports <- adapters domain imports nothing app-level
|
|||
- Spotify JSON never escapes `adapters/spotify/mapping.py`.
|
||||
- Pipeline tunables (counts, thresholds, budgets, model id, effort, TTLs) live
|
||||
in `app/config.py` (pydantic-settings). No magic numbers in code.
|
||||
- LLM prompts live as constants in `app/prompts.py`, not inline in adapters.
|
||||
- `eval/scenarios.yaml` defines golden eval queries and fixture-recording keys.
|
||||
UI suggestion chips live in `frontend/src/lib/suggestions.ts`.
|
||||
- LLM prompts are versioned template files under `app/prompts/`, not inline
|
||||
strings.
|
||||
- `eval/scenarios.yaml` is one source of truth for three consumers: golden
|
||||
eval queries, demo fixture keys, and UI suggestion chips.
|
||||
|
||||
## Naming
|
||||
|
||||
|
|
|
|||
161
README.md
161
README.md
|
|
@ -1,168 +1,35 @@
|
|||
# discovery-by-llm
|
||||
|
||||
Music discovery in a chat form: describe the moment, get verified Spotify
|
||||
tracks with a reason per track, save the result as a playlist.
|
||||
This is a demo that serves as a proof of concept for LLM utilization for music discovery, specifically using the Spotify API. The form-factor is an LLM-chat like experience, with direct Spotify integration.
|
||||
|
||||
Spotify removed its recommendation and audio-intelligence endpoints for new
|
||||
apps, so in this PoC the LLM fills that role. It interprets the request and
|
||||
proposes candidates; Spotify verifies them and supplies the taste profile,
|
||||
album art and playback. The eval suite compares the output against bare
|
||||
Spotify search on the same questions.
|
||||
> Work in progress. This file is filled in during the build.
|
||||
> Chronological build log (Dutch): [docs/logboek.md](docs/logboek.md).
|
||||
|
||||
> Chronological build log, including what was dropped and why (Dutch):
|
||||
> [docs/logboek.md](docs/logboek.md).
|
||||
## Demo
|
||||
|
||||
## Running it
|
||||
|
||||
**Hosted instance** (credentials in the submission mail): a live deployment
|
||||
bound to my own Spotify account, with real personalisation and playlist
|
||||
writes. Spotify dev mode caps an app at 5 allowlisted users, so a reviewer
|
||||
cannot log in with their own account; I can allowlist a reviewer account on
|
||||
request.
|
||||
|
||||
**Without any keys:**
|
||||
|
||||
```
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Demo mode replays recorded API cassettes through the same pipeline and
|
||||
streaming path as live mode. The suggestion chips map to the recorded
|
||||
scenarios; any other input replays the nearest scenario, with a banner
|
||||
naming it. Playlist writes are simulated and labeled.
|
||||
|
||||
**Live with your own keys:** copy `.env.example` to `.env`, set
|
||||
`APP_MODE=live`, a Spotify client id (redirect URI
|
||||
`http://127.0.0.1:8888/callback`) and an Anthropic API key, then
|
||||
`docker compose up --build` and log in via Spotify.
|
||||
*(to follow: hosted instance + local `docker compose up --build`, with and
|
||||
without API keys)*
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
query ──► call 1: intent + candidates ──► grounding ──► call 2: rerank ──► cards
|
||||
(LLM, structured output) (Spotify (LLM, streamed
|
||||
▲ search per track)
|
||||
taste profile fan-out) │
|
||||
(cached, /me/top + saved) │ playlist write
|
||||
verified pool
|
||||
```
|
||||
|
||||
- **Call 1** interprets the request (mood, activity, era, language,
|
||||
familiarity) and proposes 35 candidate tracks, informed by a compressed
|
||||
taste profile fetched once per session.
|
||||
- **Grounding** resolves every candidate against Spotify search in a
|
||||
bounded concurrent fan-out with early stop, a request deadline and a
|
||||
name-to-id cache. Unverified candidates are dropped. Misses and
|
||||
mismatches are logged as separate rates.
|
||||
- **Call 2** reranks the verified pool and streams one justification per
|
||||
track as NDJSON events; cards render as they arrive.
|
||||
- Only track ids from the verified pool can reach the user. In live
|
||||
testing at familiarity `new`, the model fabricated 12 of 32 candidates;
|
||||
the matcher dropped all 12 and the user still received 15 verified
|
||||
tracks.
|
||||
- An honest limit on discovery depth: "new to you" means "not in your top
|
||||
or saved tracks", and the intent prompt favors well-known tracks from
|
||||
adjacent scenes because those verify reliably (chasing obscure work is
|
||||
where the model starts inventing titles). A listener deep in those
|
||||
scenes will recognize a fair share of the results. Going deeper needs a
|
||||
repair loop and a better novelty signal; see the last section.
|
||||
- Refinement turns rerank the existing pool; turn 2 makes zero Spotify
|
||||
calls.
|
||||
*(to follow: pipeline diagram and module map)*
|
||||
|
||||
## Choices
|
||||
|
||||
- **Two LLM calls around a deterministic grounding step**, no agent loop:
|
||||
predictable latency and every stage testable on its own.
|
||||
- **Hand-rolled Spotify client** (~200 lines over httpx). The endpoint
|
||||
surface is small and the retry, rate-limit and caching behavior is where
|
||||
the engineering lives. Retry policy is per endpoint: bounded Retry-After
|
||||
honor on reads, one token refresh, and no retries on playlist writes
|
||||
(side effects would be ambiguous).
|
||||
- **Ports and adapters.** Demo mode is a second implementation of the same
|
||||
ports. The domain model never sees Spotify JSON.
|
||||
- **NDJSON over a streamed POST.** The request has a body and EventSource
|
||||
cannot POST. Typed events (metadata / track / warning / error / done)
|
||||
with a strict client-side phase machine.
|
||||
- **Degradation is explicit.** Below the grounding floor the app returns
|
||||
what resolved plus a warning; quota exhaustion (QUOTA_EXCEEDED) is
|
||||
recognized and never retried; live mode never falls back to fixtures.
|
||||
- **Hosted seed session.** The public instance installs a session from an
|
||||
escrowed refresh token at startup, so every visitor shares my account,
|
||||
including playlist writes. HTTP basic auth at the edge is what makes
|
||||
that acceptable. Playlists carry a fixed name prefix for bulk cleanup.
|
||||
- **No embeddings.** Search is the only entry point into the catalog; an
|
||||
index does not pay for itself inside a PoC.
|
||||
*(to follow)*
|
||||
|
||||
## Performance
|
||||
## Performance and optimisations
|
||||
|
||||
Time to first card is 20 to 30 seconds, measured through the UI and in the
|
||||
eval suite; the spread is LLM latency. Streaming keeps the wait honest:
|
||||
metadata arrives early, cards render one by one, progress states name what
|
||||
is happening.
|
||||
|
||||
Under the hood:
|
||||
|
||||
- Search `limit` is capped at 10 since Feb 2026, so resolving 35 candidates
|
||||
requires a fan-out: bounded concurrency, early stop once the rerank pool
|
||||
is full, request deadline, partial results on quota exhaustion.
|
||||
- Name-to-id resolution cache and a session-scoped taste-profile cache.
|
||||
- Anthropic prompt caching between calls.
|
||||
- Per-request counters (Spotify calls, cache hits, LLM tokens) in
|
||||
structured logs, surfaced in the dev panel.
|
||||
|
||||
Bare search matches words; it has no notion of mood, era, energy or who is
|
||||
asking. The pipeline answers from the listener's actual scenes. Across all
|
||||
8 eval scenarios the pipeline delivered 11 to 15 verified tracks from 8 to
|
||||
20 distinct artists; on the discovery scenario, all 15 were outside the
|
||||
listener's top and saved tracks on Spotify. A sample (full table via
|
||||
`eval/run_eval.py --baseline`; the eval checks are plain code assertions,
|
||||
no LLM judging):
|
||||
|
||||
| "Energy for the gym" | Pipeline | Bare search |
|
||||
| -------------------- | ---------------------------------------- | -------------------------------------------------------------- |
|
||||
| 1 | Baddadan by Chase & Status, Bou, Flowdan | Redbone (with GloRilla) by Lil Baby |
|
||||
| 2 | Tough Talk by Chase & Status, Kwengface | Rock That Body by Black Eyed Peas |
|
||||
| 3 | Solar System by Sub Focus | Instigator by Inpatient, Ren, Chris Webby |
|
||||
| 8 | DJ Turn It Up by Dimension | Gym Power Beat by Ultimate Fitness Playlist Power Workout Trax |
|
||||
*(to follow)*
|
||||
|
||||
## Where to look
|
||||
|
||||
| Criterion | Where |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| Code quality and structure | `backend/app/` (domain / ports / adapters / pipeline / api), `AGENTS.md` |
|
||||
| AI application | `backend/app/prompts.py`, `backend/app/adapters/anthropic/llm.py`, `backend/app/pipeline/grounding.py` |
|
||||
| Spotify integration | `backend/app/adapters/spotify/` (PKCE, retry policy, mapping) |
|
||||
| Performance | `backend/app/pipeline/grounding.py` (fan-out, cache, early stop), `backend/app/observability/` |
|
||||
| Streaming contract | `backend/app/api/recommendations.py`, `frontend/src/lib/recommendationStream.ts` |
|
||||
| Evaluation | `eval/` (runner, scenarios, baseline arm, recorded fixtures) |
|
||||
| Pragmatic cuts over time | `docs/logboek.md` |
|
||||
*(to follow)*
|
||||
|
||||
## Method
|
||||
|
||||
About one hour of preparation (API research, a design spike) and about
|
||||
eight hours of hands-on build time across three days. The build was
|
||||
agent-assisted: coding agents implemented scoped tracks from briefs I
|
||||
wrote, while I owned the architecture, the API contracts, every code
|
||||
review, every merge and all live verification against the real APIs. Work
|
||||
landed in reviewed batches, which is why commits arrive in bursts. The
|
||||
logboek documents the process as it happened; the setup behind it (server,
|
||||
Forgejo/GitHub mirror, deployment, how the agents are used) is mapped in
|
||||
[docs/workflow.md](docs/workflow.md).
|
||||
*(to follow)*
|
||||
|
||||
## What I cut, and what would come next
|
||||
## What I cut / what I would do next
|
||||
|
||||
- A faster model on the intent call, which would cut most of the time to
|
||||
first card; the grounding stage already measures the trade-off to watch
|
||||
(fabrication rate), so the experiment is cheap to run safely.
|
||||
- Deeper discovery. A repair loop that re-prompts the model for candidates
|
||||
that failed verification would let the prompt chase less obvious work
|
||||
without losing the pool, and a novelty signal beyond exact-id exclusion
|
||||
(artist-level familiarity, recently played) would push results past
|
||||
"famous in an adjacent scene".
|
||||
- Refinement turns that re-ground; today turn 2 reranks the existing pool.
|
||||
- Chat history persistence. Conversations are client-side only and the
|
||||
server keeps no conversation state; sessions and caches are in-process,
|
||||
Redis is the named production step.
|
||||
- Playback queue control, an i18n framework (copy is already centralized),
|
||||
per-stage byte snapshots in the eval, and a semantic index over grounded
|
||||
candidates.
|
||||
*(to follow)*
|
||||
|
|
|
|||
|
|
@ -6,15 +6,11 @@ RUN npm ci
|
|||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS live
|
||||
WORKDIR /srv/backend
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
WORKDIR /srv
|
||||
COPY backend/pyproject.toml backend/uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
COPY backend/app ./app
|
||||
COPY eval/scenarios.yaml ../eval/scenarios.yaml
|
||||
COPY --from=frontend /build/dist ./app/static
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
FROM live AS demo
|
||||
COPY eval/fixtures ../eval/fixtures
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ class AnthropicRecommender:
|
|||
if parsed is None:
|
||||
raise RecommenderOutputError("Intent response contained no structured output")
|
||||
validated = IntentOutput.model_validate(parsed.model_dump())
|
||||
return to_intent(validated)
|
||||
return _to_intent(validated)
|
||||
|
||||
async def stream_rerank(
|
||||
self,
|
||||
|
|
@ -125,7 +125,7 @@ class AnthropicRecommender:
|
|||
) -> AsyncGenerator[RerankSelection]:
|
||||
"""Yield each complete valid selection while the JSON is streaming."""
|
||||
schema = transform_schema(RerankOutput.model_json_schema())
|
||||
parser = RecommendationObjectParser()
|
||||
parser = _RecommendationObjectParser()
|
||||
async with self.client.messages.stream(
|
||||
model=self.settings.llm_model,
|
||||
max_tokens=self.settings.rerank_max_tokens,
|
||||
|
|
@ -166,11 +166,8 @@ class AnthropicRecommender:
|
|||
raise RecommenderOutputError("Rerank response returned too many selections")
|
||||
|
||||
|
||||
class RecommendationObjectParser:
|
||||
"""Incrementally extract complete recommendation objects from JSON text."""
|
||||
|
||||
class _RecommendationObjectParser:
|
||||
def __init__(self) -> None:
|
||||
"""Create an empty parser for one rerank response."""
|
||||
self.complete_text = ""
|
||||
self._scan_index = 0
|
||||
self._object_start: int | None = None
|
||||
|
|
@ -180,7 +177,6 @@ class RecommendationObjectParser:
|
|||
self._has_found_array = False
|
||||
|
||||
def feed(self, text_delta: str) -> list[RerankSelectionOutput]:
|
||||
"""Consume a text delta and return newly completed selections."""
|
||||
self.complete_text += text_delta
|
||||
if not self._has_found_array:
|
||||
match = _RECOMMENDATION_ARRAY.search(self.complete_text)
|
||||
|
|
@ -225,8 +221,7 @@ class RecommendationObjectParser:
|
|||
return None
|
||||
|
||||
def _finish_object(self) -> RerankSelectionOutput:
|
||||
if self._object_start is None:
|
||||
raise RecommenderOutputError("Rerank parser lost the object start position")
|
||||
assert self._object_start is not None
|
||||
object_text = self.complete_text[self._object_start : self._scan_index + 1]
|
||||
self._object_start = None
|
||||
try:
|
||||
|
|
@ -235,8 +230,7 @@ class RecommendationObjectParser:
|
|||
raise RecommenderOutputError("Rerank item failed validation") from error
|
||||
|
||||
|
||||
def to_intent(output: IntentOutput) -> Intent:
|
||||
"""Map validated intent output to the application domain."""
|
||||
def _to_intent(output: IntentOutput) -> Intent:
|
||||
return Intent(
|
||||
mood=tuple(output.mood),
|
||||
activity=output.activity,
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
"""Decode recorded Spotify and Anthropic responses for demo replay."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
from app.adapters.demo.scenario import FIXTURE_ROOT
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecordedResponse:
|
||||
"""One decoded response retained from a recorded HTTP interaction."""
|
||||
|
||||
method: str
|
||||
url: str
|
||||
status: int
|
||||
response_body: bytes
|
||||
response_chunks: tuple[bytes, ...]
|
||||
|
||||
def json_body(self) -> object:
|
||||
"""Parse the decoded response body as JSON."""
|
||||
return json.loads(self.response_body)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemoCassette:
|
||||
"""The recorded service responses needed by one demo scenario."""
|
||||
|
||||
key: str
|
||||
spotify_responses: tuple[RecordedResponse, ...]
|
||||
intent_response_bodies: tuple[bytes, ...]
|
||||
rerank_response_chunks: tuple[bytes, ...]
|
||||
|
||||
@property
|
||||
def intent_response_body(self) -> bytes:
|
||||
"""Return the final intent response recorded for this scenario."""
|
||||
return self.intent_response_bodies[-1]
|
||||
|
||||
@property
|
||||
def spotify_search_responses(self) -> tuple[RecordedResponse, ...]:
|
||||
"""Return recorded Spotify search responses in capture order."""
|
||||
return tuple(response for response in self.spotify_responses if "/search?" in response.url)
|
||||
|
||||
@property
|
||||
def spotify_taste_responses(self) -> tuple[RecordedResponse, ...]:
|
||||
"""Return recorded Spotify taste responses in capture order."""
|
||||
return tuple(
|
||||
response
|
||||
for response in self.spotify_responses
|
||||
if any(
|
||||
endpoint in response.url
|
||||
for endpoint in ("/me/top/artists", "/me/top/tracks", "/me/tracks?")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def load_cassette(scenario_key: str) -> DemoCassette:
|
||||
"""Load and decode both service cassettes for one scenario."""
|
||||
scenario_root = FIXTURE_ROOT / scenario_key
|
||||
spotify = _load_responses(scenario_root / "spotify.json")
|
||||
anthropic = _load_responses(scenario_root / "anthropic.json")
|
||||
intent_responses = tuple(
|
||||
response for response in anthropic if _is_anthropic_message(response.response_body)
|
||||
)
|
||||
rerank_responses = tuple(
|
||||
response for response in anthropic if not _is_anthropic_message(response.response_body)
|
||||
)
|
||||
if not spotify or not intent_responses or not rerank_responses:
|
||||
raise ValueError(f"Scenario cassette is incomplete: {scenario_key}")
|
||||
return DemoCassette(
|
||||
key=scenario_key,
|
||||
spotify_responses=spotify,
|
||||
intent_response_bodies=tuple(response.response_body for response in intent_responses),
|
||||
rerank_response_chunks=rerank_responses[-1].response_chunks,
|
||||
)
|
||||
|
||||
|
||||
def _load_responses(path: Path) -> tuple[RecordedResponse, ...]:
|
||||
payload = json.loads(path.read_text(encoding="ascii"))
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError(f"Cassette must contain a response list: {path}")
|
||||
return tuple(_decode_response(entry) for entry in payload)
|
||||
|
||||
|
||||
def _decode_response(payload: object) -> RecordedResponse:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Cassette response must be a mapping")
|
||||
try:
|
||||
chunks_value = payload["response_chunks_base64"]
|
||||
if not isinstance(chunks_value, list):
|
||||
raise TypeError
|
||||
chunks = tuple(_decode_base64(value) for value in chunks_value)
|
||||
return RecordedResponse(
|
||||
method=_string(payload["method"]),
|
||||
url=_string(payload["url"]),
|
||||
status=_integer(payload["status"]),
|
||||
response_body=_decode_base64(payload["response_body_base64"]),
|
||||
response_chunks=chunks,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ValueError("Cassette response is invalid") from error
|
||||
|
||||
|
||||
def _decode_base64(value: object) -> bytes:
|
||||
return base64.b64decode(_string(value), validate=True)
|
||||
|
||||
|
||||
def _string(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object) -> int:
|
||||
if not isinstance(value, int):
|
||||
raise TypeError
|
||||
return value
|
||||
|
||||
|
||||
def _is_anthropic_message(body: bytes) -> bool:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return False
|
||||
return isinstance(payload, dict) and payload.get("type") == "message"
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
"""Spotify catalog adapter backed by one recorded demo cassette."""
|
||||
|
||||
from collections import defaultdict
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from app.adapters.demo.cassette import DemoCassette, RecordedResponse
|
||||
from app.adapters.demo.scenario import normalize_text
|
||||
from app.adapters.spotify.mapping import (
|
||||
parse_saved_track_page,
|
||||
parse_search_tracks,
|
||||
parse_top_artists,
|
||||
parse_track_page,
|
||||
)
|
||||
from app.domain.models import Track
|
||||
from app.ports.protocols import TimeRange
|
||||
|
||||
|
||||
class DemoCatalog:
|
||||
"""Replay recorded Spotify search and taste responses without HTTP."""
|
||||
|
||||
def __init__(self, cassette: DemoCassette) -> None:
|
||||
"""Index one scenario cassette for deterministic request replay."""
|
||||
self.cassette = cassette
|
||||
self._search_pages = _index_search_pages(cassette.spotify_search_responses)
|
||||
self._search_cursors: dict[str, int] = defaultdict(int)
|
||||
|
||||
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
|
||||
"""Return the next recorded search page for a normalized query."""
|
||||
normalized_query = normalize_text(query)
|
||||
pages = self._search_pages.get(normalized_query, ())
|
||||
cursor = self._search_cursors[normalized_query]
|
||||
if cursor >= len(pages):
|
||||
return []
|
||||
self._search_cursors[normalized_query] += 1
|
||||
return parse_search_tracks(pages[cursor].json_body())[:limit]
|
||||
|
||||
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
|
||||
"""Return the recorded synthetic top artists for a time range."""
|
||||
response = self._taste_response("/me/top/artists", time_range)
|
||||
return parse_top_artists(response.json_body())[:limit]
|
||||
|
||||
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
|
||||
"""Return the recorded synthetic top tracks for a time range."""
|
||||
response = self._taste_response("/me/top/tracks", time_range)
|
||||
return parse_track_page(response.json_body())[:limit]
|
||||
|
||||
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
|
||||
"""Return recorded synthetic saved-track pages in offset order."""
|
||||
responses = sorted(
|
||||
(
|
||||
response
|
||||
for response in self.cassette.spotify_taste_responses
|
||||
if urlparse(response.url).path.endswith("/me/tracks")
|
||||
),
|
||||
key=_saved_track_offset,
|
||||
)
|
||||
tracks: list[Track] = []
|
||||
for response in responses:
|
||||
tracks.extend(parse_saved_track_page(response.json_body()))
|
||||
if len(tracks) >= limit:
|
||||
break
|
||||
return tracks[:limit]
|
||||
|
||||
def _taste_response(self, endpoint: str, time_range: TimeRange) -> RecordedResponse:
|
||||
for response in self.cassette.spotify_taste_responses:
|
||||
parsed_url = urlparse(response.url)
|
||||
query = parse_qs(parsed_url.query)
|
||||
if parsed_url.path.endswith(endpoint) and query.get("time_range") == [time_range]:
|
||||
return response
|
||||
raise ValueError(f"Cassette lacks {endpoint} for {time_range}")
|
||||
|
||||
|
||||
def _index_search_pages(
|
||||
responses: tuple[RecordedResponse, ...],
|
||||
) -> dict[str, tuple[RecordedResponse, ...]]:
|
||||
pages: dict[str, list[RecordedResponse]] = defaultdict(list)
|
||||
for response in responses:
|
||||
query = parse_qs(urlparse(response.url).query).get("q")
|
||||
if query:
|
||||
pages[normalize_text(query[0])].append(response)
|
||||
return {key: tuple(value) for key, value in pages.items()}
|
||||
|
||||
|
||||
def _saved_track_offset(response: RecordedResponse) -> int:
|
||||
value = parse_qs(urlparse(response.url).query).get("offset", ["0"])[0]
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"""Demo-only pipeline decorator for honest fuzzy replay disclosure."""
|
||||
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import aclosing
|
||||
|
||||
from app.adapters.demo.catalog import DemoCatalog
|
||||
from app.adapters.demo.recommender import DemoRecommender, parse_recorded_intent
|
||||
from app.adapters.demo.scenario import select_replay_scenario
|
||||
from app.config import Settings
|
||||
from app.domain.models import ConversationTurn, PreviousRecommendation, Track
|
||||
from app.pipeline.event import PipelineEvent, PipelineMetadataEvent, PipelineWarningEvent
|
||||
from app.pipeline.grounding import Grounder
|
||||
from app.pipeline.orchestrator import RecommendationPipeline
|
||||
from app.ports.protocols import MusicCatalog
|
||||
|
||||
DEMO_REPLAY_CODE = "demo_replay"
|
||||
|
||||
|
||||
class DemoReplayPipeline:
|
||||
"""Decorate the real pipeline with a fuzzy-replay warning event."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Create one cache-preserving pipeline with a replay recommender."""
|
||||
self.settings = settings
|
||||
self.pipeline = RecommendationPipeline(DemoRecommender(settings), settings)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
session_id: str,
|
||||
request_id: str,
|
||||
catalog: MusicCatalog,
|
||||
query: str,
|
||||
history: tuple[ConversationTurn, ...],
|
||||
previous_recommendations: tuple[PreviousRecommendation, ...],
|
||||
) -> AsyncGenerator[PipelineEvent]:
|
||||
"""Stream the selected fixture and disclose non-exact selection."""
|
||||
match = select_replay_scenario(query, bool(previous_recommendations))
|
||||
seeded_pool = await self._prepare_refinement_pool(catalog)
|
||||
event_stream = self.pipeline.stream(
|
||||
session_id,
|
||||
request_id,
|
||||
catalog,
|
||||
query,
|
||||
history,
|
||||
previous_recommendations,
|
||||
seeded_pool=seeded_pool,
|
||||
)
|
||||
async with aclosing(event_stream) as events:
|
||||
async for event in events:
|
||||
yield event
|
||||
if isinstance(event, PipelineMetadataEvent) and not match.is_exact:
|
||||
yield PipelineWarningEvent(
|
||||
code=DEMO_REPLAY_CODE,
|
||||
message=f'Demo replay is showing the recorded "{match.chip}" scenario.',
|
||||
)
|
||||
|
||||
async def _prepare_refinement_pool(
|
||||
self,
|
||||
catalog: MusicCatalog,
|
||||
) -> tuple[Track, ...] | None:
|
||||
if not isinstance(catalog, DemoCatalog):
|
||||
return None
|
||||
intent_bodies = catalog.cassette.intent_response_bodies
|
||||
if len(intent_bodies) < 2:
|
||||
return None
|
||||
# Refinement cassettes record the parent intent first and refinement intent last.
|
||||
parent_intent = parse_recorded_intent(intent_bodies[0])
|
||||
result = await Grounder(self.settings).ground(
|
||||
catalog,
|
||||
parent_intent.candidates,
|
||||
# The recorded parent pool is listener-neutral, so replay has no known-track exclusions.
|
||||
frozenset(),
|
||||
parent_intent.familiarity,
|
||||
self.settings.rerank_count + self.settings.rerank_pool_buffer,
|
||||
time.monotonic() + self.settings.request_deadline_seconds,
|
||||
)
|
||||
return result.tracks
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
"""Playlist writer that makes demo saves explicit and network-free."""
|
||||
|
||||
import hashlib
|
||||
|
||||
import structlog
|
||||
|
||||
from app.domain.models import CreatedPlaylist
|
||||
|
||||
|
||||
class DemoPlaylistWriter:
|
||||
"""Simulate playlist writes with stable Spotify-shaped URLs."""
|
||||
|
||||
async def create_playlist(self, name: str, description: str) -> CreatedPlaylist:
|
||||
"""Return a deterministic fake playlist without an external write."""
|
||||
digest = hashlib.sha256(f"{name}\n{description}".encode()).hexdigest()[:12]
|
||||
playlist_id = f"demo-{digest}"
|
||||
return CreatedPlaylist(
|
||||
id=playlist_id,
|
||||
url=f"https://open.spotify.com/playlist/{playlist_id}",
|
||||
)
|
||||
|
||||
async def add_tracks_to_playlist(self, playlist_id: str, track_uris: list[str]) -> None:
|
||||
"""Log the simulated track addition without writing to Spotify."""
|
||||
structlog.get_logger().info(
|
||||
"demo_playlist_simulated",
|
||||
playlist_id=playlist_id,
|
||||
track_count=len(track_uris),
|
||||
)
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
"""Anthropic recommender adapter backed by recorded response chunks."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextvars import ContextVar
|
||||
|
||||
from app.adapters.anthropic.llm import (
|
||||
IntentOutput,
|
||||
RecommendationObjectParser,
|
||||
RerankOutput,
|
||||
to_intent,
|
||||
)
|
||||
from app.adapters.demo.cassette import DemoCassette, load_cassette
|
||||
from app.adapters.demo.scenario import select_replay_scenario
|
||||
from app.config import Settings
|
||||
from app.domain.models import (
|
||||
ConversationTurn,
|
||||
Intent,
|
||||
PreviousRecommendation,
|
||||
RerankSelection,
|
||||
Track,
|
||||
)
|
||||
from app.ports.protocols import RecommenderOutputError
|
||||
|
||||
|
||||
class DemoRecommender:
|
||||
"""Replay recorded intent and rerank output through live validation."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Bind replay pacing and task-local scenario state."""
|
||||
self.settings = settings
|
||||
self._cassette: ContextVar[DemoCassette | None] = ContextVar(
|
||||
"demo_recommender_cassette",
|
||||
default=None,
|
||||
)
|
||||
|
||||
async def create_intent(
|
||||
self,
|
||||
query: str,
|
||||
history: tuple[ConversationTurn, ...],
|
||||
previous_recommendations: tuple[PreviousRecommendation, ...],
|
||||
taste_summary: str,
|
||||
candidate_count: int,
|
||||
) -> Intent:
|
||||
"""Parse the selected scenario's recorded structured intent."""
|
||||
match = select_replay_scenario(query, bool(previous_recommendations))
|
||||
cassette = load_cassette(match.key)
|
||||
self._cassette.set(cassette)
|
||||
return parse_recorded_intent(cassette.intent_response_body)
|
||||
|
||||
async def stream_rerank(
|
||||
self,
|
||||
intent: Intent,
|
||||
grounded_tracks: tuple[Track, ...],
|
||||
taste_summary: str,
|
||||
history: tuple[ConversationTurn, ...],
|
||||
selection_count: int,
|
||||
correction: str | None = None,
|
||||
) -> AsyncGenerator[RerankSelection]:
|
||||
"""Replay recorded SSE chunks through the live incremental parser."""
|
||||
cassette = self._cassette.get()
|
||||
if cassette is None:
|
||||
raise RecommenderOutputError("Demo rerank has no selected scenario")
|
||||
parser = RecommendationObjectParser()
|
||||
async for text_delta in _text_deltas(
|
||||
cassette.rerank_response_chunks,
|
||||
self.settings.demo_chunk_delay_seconds,
|
||||
):
|
||||
for selection in parser.feed(text_delta):
|
||||
yield RerankSelection(selection.track_id, selection.justification)
|
||||
try:
|
||||
validated = RerankOutput.model_validate_json(parser.complete_text)
|
||||
except ValueError as error:
|
||||
raise RecommenderOutputError("Recorded rerank response is invalid") from error
|
||||
if len(validated.recommendations) > selection_count:
|
||||
raise RecommenderOutputError("Recorded rerank returned too many selections")
|
||||
|
||||
|
||||
def parse_recorded_intent(response_body: bytes) -> Intent:
|
||||
"""Parse an Anthropic message body through the live intent output model."""
|
||||
try:
|
||||
response = json.loads(response_body)
|
||||
content = response["content"]
|
||||
text = content[0]["text"]
|
||||
if not isinstance(text, str):
|
||||
raise TypeError
|
||||
return to_intent(IntentOutput.model_validate_json(text))
|
||||
except (IndexError, KeyError, TypeError, ValueError) as error:
|
||||
raise RecommenderOutputError("Recorded intent response is invalid") from error
|
||||
|
||||
|
||||
async def _text_deltas(
|
||||
chunks: tuple[bytes, ...],
|
||||
delay_seconds: float,
|
||||
) -> AsyncGenerator[str]:
|
||||
buffer = b""
|
||||
for chunk_index, chunk in enumerate(chunks):
|
||||
if chunk_index and delay_seconds > 0:
|
||||
await asyncio.sleep(delay_seconds)
|
||||
buffer += chunk
|
||||
buffer = buffer.replace(b"\r\n", b"\n")
|
||||
while b"\n\n" in buffer:
|
||||
event, buffer = buffer.split(b"\n\n", 1)
|
||||
text_delta = _event_text_delta(event)
|
||||
if text_delta is not None:
|
||||
yield text_delta
|
||||
if buffer:
|
||||
text_delta = _event_text_delta(buffer)
|
||||
if text_delta is not None:
|
||||
yield text_delta
|
||||
|
||||
|
||||
def _event_text_delta(event: bytes) -> str | None:
|
||||
data_lines = [line[5:].strip() for line in event.splitlines() if line.startswith(b"data:")]
|
||||
if not data_lines:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(b"\n".join(data_lines))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or payload.get("type") != "content_block_delta":
|
||||
return None
|
||||
delta = payload.get("delta")
|
||||
if not isinstance(delta, dict) or delta.get("type") != "text_delta":
|
||||
return None
|
||||
text = delta.get("text")
|
||||
return text if isinstance(text, str) else None
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
"""Select replayable demo scenarios from the shared scenario catalog."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
_REPOSITORY_ROOT = Path(__file__).parents[4]
|
||||
SCENARIO_PATH = _REPOSITORY_ROOT / "eval" / "scenarios.yaml"
|
||||
FIXTURE_ROOT = _REPOSITORY_ROOT / "eval" / "fixtures"
|
||||
_WORD_PATTERN = re.compile(r"[a-z0-9]+")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scenario:
|
||||
"""One shared suggestion and its optional parent scenario."""
|
||||
|
||||
key: str
|
||||
chip: str
|
||||
query: str
|
||||
after: str | None = None
|
||||
|
||||
@property
|
||||
def is_refinement(self) -> bool:
|
||||
"""Return whether this scenario refines an earlier result."""
|
||||
return self.after is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScenarioMatch:
|
||||
"""Describe which recorded scenario will answer a demo request."""
|
||||
|
||||
key: str
|
||||
is_exact: bool
|
||||
scenario_query: str
|
||||
chip: str
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_scenarios() -> tuple[Scenario, ...]:
|
||||
"""Load and validate the shared scenario catalog once."""
|
||||
payload = yaml.safe_load(SCENARIO_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(scenarios):
|
||||
raise ValueError("Scenario keys must be unique")
|
||||
if any(scenario.after not in keys for scenario in scenarios if scenario.after is not None):
|
||||
raise ValueError("Scenario catalog contains an unknown parent")
|
||||
return scenarios
|
||||
|
||||
|
||||
def select_scenario(query: str) -> ScenarioMatch:
|
||||
"""Select an exact scenario or the nearest available recorded fixture."""
|
||||
scenarios = _replayable_scenarios()
|
||||
normalized_query = normalize_text(query)
|
||||
for scenario in scenarios:
|
||||
if normalized_query in {normalize_text(scenario.query), normalize_text(scenario.chip)}:
|
||||
return _to_match(scenario, is_exact=True)
|
||||
|
||||
selected = max(
|
||||
scenarios,
|
||||
key=lambda scenario: max(
|
||||
SequenceMatcher(None, normalized_query, normalize_text(scenario.query)).ratio(),
|
||||
SequenceMatcher(None, normalized_query, normalize_text(scenario.chip)).ratio(),
|
||||
),
|
||||
)
|
||||
return _to_match(selected, is_exact=False)
|
||||
|
||||
|
||||
def select_replay_scenario(
|
||||
query: str,
|
||||
has_previous_recommendations: bool,
|
||||
) -> ScenarioMatch:
|
||||
"""Map a request with prior results to its recorded refinement when available."""
|
||||
selected = select_scenario(query)
|
||||
scenarios = _replayable_scenarios()
|
||||
selected_scenario = next(scenario for scenario in scenarios if scenario.key == selected.key)
|
||||
if selected_scenario.is_refinement or not has_previous_recommendations:
|
||||
return selected
|
||||
refinement = next(
|
||||
(scenario for scenario in scenarios if scenario.after == selected_scenario.key),
|
||||
None,
|
||||
)
|
||||
return _to_match(refinement, is_exact=False) if refinement is not None else selected
|
||||
|
||||
|
||||
def suggestion_items() -> list[dict[str, str]]:
|
||||
"""Return chip and query pairs for non-refinement scenarios."""
|
||||
return [
|
||||
{"chip": scenario.chip, "query": scenario.query}
|
||||
for scenario in load_scenarios()
|
||||
if not scenario.is_refinement
|
||||
]
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
"""Normalize text for exact and similarity matching."""
|
||||
decomposed = unicodedata.normalize("NFKD", value.casefold())
|
||||
ascii_text = decomposed.encode("ascii", errors="ignore").decode("ascii")
|
||||
return " ".join(_WORD_PATTERN.findall(ascii_text))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _replayable_scenarios() -> tuple[Scenario, ...]:
|
||||
fixture_keys = {path.name for path in FIXTURE_ROOT.iterdir() if path.is_dir()}
|
||||
scenarios = tuple(scenario for scenario in load_scenarios() if scenario.key in fixture_keys)
|
||||
if not scenarios:
|
||||
raise ValueError("Demo mode requires at least one scenario fixture")
|
||||
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"}
|
||||
if not required <= set(payload):
|
||||
raise ValueError("Scenario is missing a required field")
|
||||
after = payload.get("after")
|
||||
if after is not None and not isinstance(after, str):
|
||||
raise ValueError("Scenario parent must be a key")
|
||||
return Scenario(
|
||||
key=_required_string(payload, "key"),
|
||||
chip=_required_string(payload, "chip"),
|
||||
query=_required_string(payload, "query"),
|
||||
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
|
||||
|
||||
|
||||
def _to_match(scenario: Scenario, is_exact: bool) -> ScenarioMatch:
|
||||
return ScenarioMatch(
|
||||
key=scenario.key,
|
||||
is_exact=is_exact,
|
||||
scenario_query=scenario.query,
|
||||
chip=scenario.chip,
|
||||
)
|
||||
|
|
@ -13,7 +13,11 @@ from app.adapters.spotify.errors import SpotifyAuthenticationError
|
|||
|
||||
AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
|
||||
TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||
SCOPES = "user-top-read user-library-read playlist-modify-public playlist-modify-private"
|
||||
SCOPES = (
|
||||
"user-top-read user-library-read user-read-recently-played "
|
||||
"playlist-modify-public playlist-modify-private "
|
||||
"user-read-playback-state user-modify-playback-state"
|
||||
)
|
||||
TOKEN_EXPIRY_SKEW_SECONDS = 60.0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import httpx2
|
|||
from app.adapters.spotify.auth import refresh_access_token
|
||||
from app.adapters.spotify.errors import (
|
||||
SpotifyAuthenticationError,
|
||||
SpotifyQuotaExhaustedError,
|
||||
SpotifyRateLimitedError,
|
||||
SpotifyRequestError,
|
||||
SpotifyUnavailableError,
|
||||
|
|
@ -120,7 +119,7 @@ class SpotifyClient:
|
|||
params: dict[str, str | int] | None = None,
|
||||
json: dict[str, object] | None = None,
|
||||
) -> httpx2.Response:
|
||||
access_token, refresh_generation = await self._access_token()
|
||||
access_token = await self._access_token()
|
||||
response = await self._send(
|
||||
method,
|
||||
path,
|
||||
|
|
@ -128,27 +127,18 @@ class SpotifyClient:
|
|||
params=params,
|
||||
json=json,
|
||||
)
|
||||
response, refresh_generation = await self._retry_once_if_unauthorized(
|
||||
response = await self._retry_once_if_unauthorized(
|
||||
response,
|
||||
method,
|
||||
path,
|
||||
refresh_generation,
|
||||
access_token,
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
response, refresh_generation = await self._retry_once_if_rate_limited(
|
||||
response = await self._retry_once_if_rate_limited(
|
||||
response,
|
||||
method,
|
||||
path,
|
||||
refresh_generation,
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
response, _ = await self._retry_once_if_unauthorized(
|
||||
response,
|
||||
method,
|
||||
path,
|
||||
refresh_generation,
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
|
|
@ -164,7 +154,6 @@ class SpotifyClient:
|
|||
json: dict[str, object] | None,
|
||||
) -> httpx2.Response:
|
||||
increment_spotify_calls()
|
||||
try:
|
||||
return await self.http.request(
|
||||
method,
|
||||
f"{self.settings.spotify_api_base_url.rstrip('/')}{path}",
|
||||
|
|
@ -172,33 +161,27 @@ class SpotifyClient:
|
|||
json=json,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
except httpx2.HTTPError as error:
|
||||
raise SpotifyUnavailableError(504, "Spotify request failed") from error
|
||||
|
||||
async def _retry_once_if_unauthorized(
|
||||
self,
|
||||
response: httpx2.Response,
|
||||
method: str,
|
||||
path: str,
|
||||
refresh_generation: int,
|
||||
access_token: str,
|
||||
*,
|
||||
params: dict[str, str | int] | None,
|
||||
json: dict[str, object] | None,
|
||||
) -> tuple[httpx2.Response, int]:
|
||||
) -> httpx2.Response:
|
||||
if response.status_code != 401:
|
||||
return response, refresh_generation
|
||||
return response
|
||||
|
||||
await self._refresh_if_current(refresh_generation)
|
||||
access_token, retry_generation = self._token_snapshot()
|
||||
return (
|
||||
await self._send(
|
||||
await self._refresh_if_current(access_token)
|
||||
return await self._send(
|
||||
method,
|
||||
path,
|
||||
access_token,
|
||||
self.session.tokens.access_token,
|
||||
params=params,
|
||||
json=json,
|
||||
),
|
||||
retry_generation,
|
||||
)
|
||||
|
||||
async def _retry_once_if_rate_limited(
|
||||
|
|
@ -206,13 +189,12 @@ class SpotifyClient:
|
|||
response: httpx2.Response,
|
||||
method: str,
|
||||
path: str,
|
||||
refresh_generation: int,
|
||||
*,
|
||||
params: dict[str, str | int] | None,
|
||||
json: dict[str, object] | None,
|
||||
) -> tuple[httpx2.Response, int]:
|
||||
) -> httpx2.Response:
|
||||
if response.status_code != 429:
|
||||
return response, refresh_generation
|
||||
return response
|
||||
|
||||
retry_after_seconds = _parse_retry_after(response)
|
||||
_, reason = _parse_error_details(response)
|
||||
|
|
@ -222,19 +204,15 @@ class SpotifyClient:
|
|||
or retry_after_seconds is None
|
||||
or retry_after_seconds > self.settings.spotify_retry_after_cap_seconds
|
||||
):
|
||||
return response, refresh_generation
|
||||
return response
|
||||
|
||||
await asyncio.sleep(retry_after_seconds)
|
||||
access_token, retry_generation = self._token_snapshot()
|
||||
return (
|
||||
await self._send(
|
||||
return await self._send(
|
||||
method,
|
||||
path,
|
||||
access_token,
|
||||
self.session.tokens.access_token,
|
||||
params=params,
|
||||
json=json,
|
||||
),
|
||||
retry_generation,
|
||||
)
|
||||
|
||||
def _raise_for_error(self, response: httpx2.Response) -> httpx2.Response:
|
||||
|
|
@ -245,33 +223,26 @@ class SpotifyClient:
|
|||
if response.status_code == 401:
|
||||
raise SpotifyAuthenticationError("Spotify rejected refreshed authentication")
|
||||
if response.status_code == 429:
|
||||
if reason == "QUOTA_EXCEEDED":
|
||||
raise SpotifyQuotaExhaustedError(_parse_retry_after(response), reason)
|
||||
raise SpotifyRateLimitedError(_parse_retry_after(response), reason)
|
||||
if response.status_code >= 500:
|
||||
raise SpotifyUnavailableError(response.status_code, message)
|
||||
raise SpotifyRequestError(response.status_code, message)
|
||||
|
||||
async def _access_token(self) -> tuple[str, int]:
|
||||
tokens = self.session.tokens
|
||||
refresh_generation = self.session.refresh_generation
|
||||
if tokens.is_expired:
|
||||
await self._refresh_if_current(refresh_generation)
|
||||
return self._token_snapshot()
|
||||
async def _access_token(self) -> str:
|
||||
access_token = self.session.tokens.access_token
|
||||
if self.session.tokens.is_expired:
|
||||
await self._refresh_if_current(access_token)
|
||||
return self.session.tokens.access_token
|
||||
|
||||
async def _refresh_if_current(self, refresh_generation: int) -> None:
|
||||
async def _refresh_if_current(self, access_token: str) -> None:
|
||||
async with self.session.refresh_lock:
|
||||
if self.session.refresh_generation != refresh_generation:
|
||||
if self.session.tokens.access_token != access_token:
|
||||
return
|
||||
self.session.tokens = await refresh_access_token(
|
||||
self.http,
|
||||
client_id=self.settings.spotify_client_id,
|
||||
tokens=self.session.tokens,
|
||||
)
|
||||
self.session.refresh_generation += 1
|
||||
|
||||
def _token_snapshot(self) -> tuple[str, int]:
|
||||
return self.session.tokens.access_token, self.session.refresh_generation
|
||||
|
||||
|
||||
def _parse_retry_after(response: httpx2.Response) -> float | None:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class SpotifyAuthenticationError(SpotifyError):
|
|||
"""Spotify rejected authentication or token refresh."""
|
||||
|
||||
|
||||
class SpotifyRateLimitedError(SpotifyError):
|
||||
class SpotifyRateLimitedError(SpotifyError, CatalogQuotaExhaustedError):
|
||||
"""Spotify rate limited a request that could not be retried."""
|
||||
|
||||
def __init__(self, retry_after_seconds: float | None, reason: str | None = None) -> None:
|
||||
|
|
@ -21,10 +21,6 @@ class SpotifyRateLimitedError(SpotifyError):
|
|||
self.reason = reason
|
||||
|
||||
|
||||
class SpotifyQuotaExhaustedError(SpotifyRateLimitedError, CatalogQuotaExhaustedError):
|
||||
"""Spotify rejected a request because the application quota is exhausted."""
|
||||
|
||||
|
||||
class SpotifyUnavailableError(SpotifyError):
|
||||
"""Spotify returned a server-side failure."""
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ class SpotifySession:
|
|||
tokens: TokenSet
|
||||
account_id: str
|
||||
display_name: str
|
||||
refresh_generation: int = 0
|
||||
refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
|
|
@ -52,15 +51,7 @@ class PendingLogins:
|
|||
|
||||
def add(self, state: str, code_verifier: str) -> None:
|
||||
"""Store a PKCE verifier for a newly issued OAuth state."""
|
||||
now = time.monotonic()
|
||||
expired_states = (
|
||||
pending_state
|
||||
for pending_state, (_, created_at) in self._entries.items()
|
||||
if now - created_at >= PENDING_LOGIN_LIFETIME_SECONDS
|
||||
)
|
||||
for expired_state in tuple(expired_states):
|
||||
del self._entries[expired_state]
|
||||
self._entries[state] = (code_verifier, now)
|
||||
self._entries[state] = (code_verifier, time.monotonic())
|
||||
|
||||
def pop(self, state: str) -> str | None:
|
||||
"""Consume a verifier unless its OAuth state is unknown or expired."""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Recommendation streaming and playlist creation routes."""
|
||||
"""Authenticated recommendation streaming and playlist creation routes."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
|
|
@ -8,7 +8,7 @@ import structlog
|
|||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.adapters.spotify.errors import SpotifyAuthenticationError, SpotifyError
|
||||
from app.adapters.spotify.errors import SpotifyError
|
||||
from app.adapters.spotify.session import SpotifySession
|
||||
from app.api.routes import resolve_session
|
||||
from app.api.schemas import (
|
||||
|
|
@ -38,10 +38,6 @@ from app.pipeline.orchestrator import RecommendationPipeline
|
|||
from app.ports.protocols import MusicCatalog, PlaylistWriter
|
||||
|
||||
SpotifyClientFactory = Callable[[SpotifySession], MusicCatalog | PlaylistWriter]
|
||||
MusicCatalogFactory = Callable[
|
||||
[SpotifySession, str, tuple[PreviousRecommendation, ...]],
|
||||
MusicCatalog,
|
||||
]
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -51,12 +47,14 @@ async def recommendations(
|
|||
request: Request,
|
||||
payload: RecommendationRequest,
|
||||
) -> StreamingResponse:
|
||||
"""Stream one session-resolved discovery response as NDJSON."""
|
||||
"""Stream one authenticated discovery response as NDJSON."""
|
||||
resolved = resolve_session(request)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
pipeline = cast(RecommendationPipeline, request.app.state.recommendation_pipeline)
|
||||
factory = cast(SpotifyClientFactory, request.app.state.spotify_client_factory)
|
||||
catalog = cast(MusicCatalog, factory(resolved.session))
|
||||
request_id = cast(str, request.state.request_id)
|
||||
history = tuple(ConversationTurn(turn.role, turn.content) for turn in payload.history)
|
||||
previous = tuple(
|
||||
|
|
@ -68,11 +66,10 @@ async def recommendations(
|
|||
)
|
||||
for item in payload.prior_recommendations
|
||||
)
|
||||
factory = cast(MusicCatalogFactory, request.app.state.music_catalog_factory)
|
||||
catalog = factory(resolved.session, payload.query, previous)
|
||||
|
||||
return StreamingResponse(
|
||||
_stream_lines(
|
||||
request,
|
||||
pipeline,
|
||||
resolved.session_id,
|
||||
request_id,
|
||||
|
|
@ -90,7 +87,7 @@ async def create_playlist(
|
|||
request: Request,
|
||||
payload: PlaylistCreateRequest,
|
||||
) -> PlaylistCreateResponse:
|
||||
"""Create and fill one live or simulated playlist."""
|
||||
"""Create and fill one authenticated Spotify playlist."""
|
||||
resolved = resolve_session(request)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
|
@ -104,9 +101,6 @@ async def create_playlist(
|
|||
"Music discovery selected by the listener.",
|
||||
)
|
||||
await writer.add_tracks_to_playlist(playlist.id, payload.track_uris)
|
||||
except SpotifyAuthenticationError as error:
|
||||
structlog.get_logger().warning("playlist_write_failed", error_type=type(error).__name__)
|
||||
raise HTTPException(status_code=401, detail="Spotify authentication expired") from error
|
||||
except (SpotifyError, ValueError) as error:
|
||||
structlog.get_logger().warning("playlist_write_failed", error_type=type(error).__name__)
|
||||
raise HTTPException(status_code=502, detail="Spotify playlist creation failed") from error
|
||||
|
|
@ -114,6 +108,7 @@ async def create_playlist(
|
|||
|
||||
|
||||
async def _stream_lines(
|
||||
request: Request,
|
||||
pipeline: RecommendationPipeline,
|
||||
session_id: str,
|
||||
request_id: str,
|
||||
|
|
@ -132,6 +127,8 @@ async def _stream_lines(
|
|||
)
|
||||
try:
|
||||
async for event in event_stream:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
yield f"{_to_wire_event(event).model_dump_json()}\n"
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
|
@ -140,6 +137,7 @@ async def _stream_lines(
|
|||
"recommendation_stream_failed",
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
if not await request.is_disconnected():
|
||||
failure = ErrorEvent(
|
||||
code="recommendation_failed",
|
||||
message="Recommendation could not be completed.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""HTTP routes for login and session management."""
|
||||
"""HTTP routes for Spotify login and session management."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
|
@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response
|
|||
|
||||
from app.adapters.spotify.login import begin_login, complete_login
|
||||
from app.adapters.spotify.session import PendingLogins, SessionStore, SpotifySession
|
||||
from app.config import AppMode, Settings
|
||||
from app.config import Settings
|
||||
|
||||
SESSION_COOKIE_NAME = "discovery_session"
|
||||
|
||||
|
|
@ -26,10 +26,8 @@ class ResolvedSession:
|
|||
|
||||
@router.get("/api/auth/login")
|
||||
def login(request: Request) -> RedirectResponse:
|
||||
"""Start the configured live or demo login flow."""
|
||||
"""Start Spotify Authorization Code with PKCE login."""
|
||||
application_settings = cast(Settings, request.app.state.settings)
|
||||
if application_settings.app_mode is AppMode.DEMO:
|
||||
return RedirectResponse("/?login=demo", status_code=307)
|
||||
pending_logins = cast(PendingLogins, request.app.state.pending_logins)
|
||||
authorize_url = begin_login(application_settings, pending_logins)
|
||||
return RedirectResponse(authorize_url, status_code=307)
|
||||
|
|
@ -42,13 +40,11 @@ async def callback(
|
|||
state: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> RedirectResponse:
|
||||
"""Complete live login and establish an opaque cookie session."""
|
||||
application_settings = cast(Settings, request.app.state.settings)
|
||||
if application_settings.app_mode is AppMode.DEMO:
|
||||
return RedirectResponse("/?login=demo", status_code=307)
|
||||
"""Complete Spotify login and establish an opaque cookie session."""
|
||||
if error is not None or code is None or state is None:
|
||||
return _login_error_redirect()
|
||||
|
||||
application_settings = cast(Settings, request.app.state.settings)
|
||||
http = cast(httpx2.AsyncClient, request.app.state.http)
|
||||
pending_logins = cast(PendingLogins, request.app.state.pending_logins)
|
||||
session_store = cast(SessionStore, request.app.state.session_store)
|
||||
|
|
@ -84,12 +80,6 @@ def current_session(request: Request) -> JSONResponse:
|
|||
return JSONResponse({"display_name": resolved.session.display_name})
|
||||
|
||||
|
||||
@router.get("/api/suggestions")
|
||||
def suggestions(request: Request) -> list[dict[str, str]]:
|
||||
"""Return shared non-refinement suggestion chips and queries."""
|
||||
return cast(list[dict[str, str]], request.app.state.suggestions)
|
||||
|
||||
|
||||
@router.post("/api/auth/logout", status_code=204)
|
||||
def logout(request: Request) -> Response:
|
||||
"""Remove the current application session and clear its cookie."""
|
||||
|
|
@ -115,11 +105,7 @@ def _login_error_redirect() -> RedirectResponse:
|
|||
|
||||
|
||||
def resolve_session(request: Request) -> ResolvedSession | None:
|
||||
"""Resolve the stable demo identity, cookie, or live seed session."""
|
||||
application_settings = cast(Settings, request.app.state.settings)
|
||||
if application_settings.app_mode is AppMode.DEMO:
|
||||
demo_session = cast(SpotifySession, request.app.state.demo_session)
|
||||
return ResolvedSession("demo", demo_session)
|
||||
"""Resolve the cookie session or the installed live seed session."""
|
||||
session_store = cast(SessionStore, request.app.state.session_store)
|
||||
cookie_session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if cookie_session_id is not None:
|
||||
|
|
|
|||
|
|
@ -41,8 +41,6 @@ class Settings(BaseSettings):
|
|||
llm_model: str = "claude-sonnet-5"
|
||||
intent_effort: str = "low"
|
||||
rerank_effort: str = "medium"
|
||||
# Bound provider calls independently from the grounding deadline.
|
||||
llm_timeout_seconds: float = 120.0
|
||||
# Ceilings include adaptive thinking tokens, which is why they sit far
|
||||
# above the size of the structured output itself.
|
||||
intent_max_tokens: int = 16384
|
||||
|
|
@ -73,9 +71,6 @@ class Settings(BaseSettings):
|
|||
resolution_cache_max_entries: int = 2048
|
||||
taste_profile_ttl_seconds: float = 900.0
|
||||
|
||||
# Demo replay paces recorded stream chunks so cards appear as they did live.
|
||||
demo_chunk_delay_seconds: float = 0.05
|
||||
|
||||
# Taste profile fetch bounds: enough signal to describe a listener
|
||||
# without paging through an entire library on session start.
|
||||
top_items_limit: int = 50
|
||||
|
|
|
|||
|
|
@ -4,27 +4,18 @@ from collections.abc import AsyncIterator
|
|||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import structlog
|
||||
from anthropic import AsyncAnthropic
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.adapters.anthropic.llm import AnthropicRecommender
|
||||
from app.adapters.demo.cassette import load_cassette
|
||||
from app.adapters.demo.catalog import DemoCatalog
|
||||
from app.adapters.demo.pipeline import DemoReplayPipeline
|
||||
from app.adapters.demo.playlist import DemoPlaylistWriter
|
||||
from app.adapters.demo.scenario import select_replay_scenario, suggestion_items
|
||||
from app.adapters.spotify.auth import TokenSet, refresh_access_token
|
||||
from app.adapters.spotify.client import SpotifyClient
|
||||
from app.adapters.spotify.errors import SpotifyError
|
||||
from app.adapters.spotify.session import PendingLogins, SessionStore, SpotifySession
|
||||
from app.api.recommendations import router as recommendations_router
|
||||
from app.api.routes import router
|
||||
from app.config import AppMode, Settings, settings
|
||||
from app.domain.models import PreviousRecommendation
|
||||
from app.observability.logging import configure_logging
|
||||
from app.observability.timing import RequestTimingMiddleware
|
||||
from app.pipeline.orchestrator import RecommendationPipeline
|
||||
|
|
@ -35,7 +26,6 @@ FRONTEND_DIST = Path(__file__).parent / "static"
|
|||
def create_app(
|
||||
application_settings: Settings | None = None,
|
||||
http_transport: httpx2.AsyncBaseTransport | None = None,
|
||||
anthropic_http_client: httpx.AsyncClient | None = None,
|
||||
) -> FastAPI:
|
||||
"""Build the FastAPI app: API routes plus the built SPA on one port."""
|
||||
active_settings = application_settings or settings
|
||||
|
|
@ -44,37 +34,17 @@ def create_app(
|
|||
@asynccontextmanager
|
||||
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
|
||||
_validate_live_settings(active_settings)
|
||||
application.state.session_store = SessionStore()
|
||||
application.state.pending_logins = PendingLogins()
|
||||
application.state.settings = active_settings
|
||||
application.state.seed_session_id = None
|
||||
application.state.suggestions = suggestion_items()
|
||||
if active_settings.app_mode is AppMode.DEMO:
|
||||
application.state.demo_session = _demo_session()
|
||||
application.state.recommendation_pipeline = DemoReplayPipeline(active_settings)
|
||||
|
||||
def demo_catalog_factory(
|
||||
session: SpotifySession,
|
||||
query: str,
|
||||
previous_recommendations: tuple[PreviousRecommendation, ...],
|
||||
) -> DemoCatalog:
|
||||
match = select_replay_scenario(query, bool(previous_recommendations))
|
||||
return DemoCatalog(load_cassette(match.key))
|
||||
|
||||
application.state.music_catalog_factory = demo_catalog_factory
|
||||
application.state.spotify_client_factory = lambda session: DemoPlaylistWriter()
|
||||
yield
|
||||
return
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
timeout=active_settings.spotify_timeout_seconds,
|
||||
transport=http_transport,
|
||||
) as http:
|
||||
application.state.http = http
|
||||
application.state.session_store = SessionStore()
|
||||
application.state.pending_logins = PendingLogins()
|
||||
application.state.settings = active_settings
|
||||
application.state.seed_session_id = None
|
||||
anthropic_client = AsyncAnthropic(
|
||||
api_key=active_settings.anthropic_api_key,
|
||||
timeout=active_settings.llm_timeout_seconds,
|
||||
http_client=anthropic_http_client,
|
||||
api_key=active_settings.anthropic_api_key or "unused-demo-key"
|
||||
)
|
||||
application.state.anthropic = anthropic_client
|
||||
application.state.recommendation_pipeline = RecommendationPipeline(
|
||||
|
|
@ -85,30 +55,16 @@ def create_app(
|
|||
def spotify_client_factory(session: SpotifySession) -> SpotifyClient:
|
||||
return SpotifyClient(http, session, active_settings)
|
||||
|
||||
def music_catalog_factory(
|
||||
session: SpotifySession,
|
||||
query: str,
|
||||
previous_recommendations: tuple[PreviousRecommendation, ...],
|
||||
) -> SpotifyClient:
|
||||
return spotify_client_factory(session)
|
||||
|
||||
application.state.spotify_client_factory = spotify_client_factory
|
||||
application.state.music_catalog_factory = music_catalog_factory
|
||||
if (
|
||||
active_settings.app_mode is AppMode.LIVE
|
||||
and active_settings.spotify_seed_refresh_token
|
||||
):
|
||||
try:
|
||||
application.state.seed_session_id = await _install_seed_session(
|
||||
http,
|
||||
application.state.session_store,
|
||||
active_settings,
|
||||
)
|
||||
except (SpotifyError, ValueError) as error:
|
||||
structlog.get_logger().warning(
|
||||
"seed_session_install_failed",
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
|
|
@ -173,11 +129,3 @@ async def _install_seed_session(
|
|||
display_name=current_user.display_name,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _demo_session() -> SpotifySession:
|
||||
return SpotifySession(
|
||||
tokens=TokenSet(access_token="demo", refresh_token="demo", expires_at=float("inf")),
|
||||
account_id="demo-listener",
|
||||
display_name="Demo Listener",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ from collections.abc import Callable
|
|||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
import httpx2
|
||||
import structlog
|
||||
|
||||
from app.adapters.spotify.errors import SpotifyError
|
||||
from app.config import Settings
|
||||
from app.domain.matching import candidate_key, judge_candidate_match, track_key
|
||||
from app.domain.models import Familiarity, Track, TrackCandidate
|
||||
|
|
@ -24,7 +22,6 @@ class ResolutionStatus(StrEnum):
|
|||
RESOLVED = "resolved"
|
||||
MISS = "miss"
|
||||
MISMATCH = "mismatch"
|
||||
FAILED = "failed"
|
||||
QUOTA = "quota"
|
||||
|
||||
|
||||
|
|
@ -35,7 +32,6 @@ class GroundingMetrics:
|
|||
attempted_count: int
|
||||
miss_count: int
|
||||
mismatch_guard_count: int
|
||||
failed_count: int
|
||||
cache_hit_count: int
|
||||
did_reach_deadline: bool
|
||||
did_exhaust_quota: bool
|
||||
|
|
@ -115,7 +111,6 @@ class Grounder:
|
|||
settings.resolution_cache_ttl_seconds,
|
||||
settings.resolution_cache_max_entries,
|
||||
)
|
||||
self._semaphore = asyncio.Semaphore(settings.grounding_concurrency)
|
||||
|
||||
async def ground(
|
||||
self,
|
||||
|
|
@ -124,7 +119,6 @@ class Grounder:
|
|||
known_track_ids: frozenset[str],
|
||||
familiarity: Familiarity,
|
||||
pool_target: int,
|
||||
deadline_at: float,
|
||||
) -> GroundingResult:
|
||||
"""Resolve candidates until the target, deadline, or quota boundary."""
|
||||
accepted: dict[int, Track] = {}
|
||||
|
|
@ -133,18 +127,18 @@ class Grounder:
|
|||
metrics = _MutableMetrics()
|
||||
pending: dict[asyncio.Task[_ResolutionAttempt], int] = {}
|
||||
next_index = 0
|
||||
deadline_at = time.monotonic() + self.settings.request_deadline_seconds
|
||||
semaphore = asyncio.Semaphore(self.settings.grounding_concurrency)
|
||||
try:
|
||||
while next_index < len(candidates) or pending:
|
||||
if len(accepted) >= pool_target:
|
||||
break
|
||||
if time.monotonic() >= deadline_at:
|
||||
metrics.did_reach_deadline = True
|
||||
break
|
||||
next_index = self._launch_tasks(
|
||||
catalog,
|
||||
candidates,
|
||||
pending,
|
||||
next_index,
|
||||
semaphore,
|
||||
)
|
||||
if not pending:
|
||||
break
|
||||
|
|
@ -189,9 +183,12 @@ class Grounder:
|
|||
candidates: tuple[TrackCandidate, ...],
|
||||
pending: dict[asyncio.Task[_ResolutionAttempt], int],
|
||||
next_index: int,
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> int:
|
||||
while next_index < len(candidates) and len(pending) < self.settings.grounding_concurrency:
|
||||
task = asyncio.create_task(self._resolve(catalog, next_index, candidates[next_index]))
|
||||
task = asyncio.create_task(
|
||||
self._resolve(catalog, next_index, candidates[next_index], semaphore)
|
||||
)
|
||||
pending[task] = next_index
|
||||
next_index += 1
|
||||
return next_index
|
||||
|
|
@ -231,8 +228,9 @@ class Grounder:
|
|||
catalog: MusicCatalog,
|
||||
index: int,
|
||||
candidate: TrackCandidate,
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> _ResolutionAttempt:
|
||||
async with self._semaphore:
|
||||
async with semaphore:
|
||||
return await self._resolve_with_slot(catalog, index, candidate)
|
||||
|
||||
async def _resolve_with_slot(
|
||||
|
|
@ -267,15 +265,6 @@ class Grounder:
|
|||
bare_results = []
|
||||
except CatalogQuotaExhaustedError:
|
||||
return _ResolutionAttempt(index=index, status=ResolutionStatus.QUOTA)
|
||||
except (SpotifyError, httpx2.HTTPError) as error:
|
||||
structlog.get_logger().info(
|
||||
"candidate_unresolved",
|
||||
title=candidate.title,
|
||||
artist=candidate.artist,
|
||||
status=ResolutionStatus.FAILED,
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
return _ResolutionAttempt(index=index, status=ResolutionStatus.FAILED)
|
||||
|
||||
if matched_track is not None:
|
||||
self.cache.put(key, matched_track)
|
||||
|
|
@ -300,7 +289,6 @@ class _MutableMetrics:
|
|||
attempted_count: int = 0
|
||||
miss_count: int = 0
|
||||
mismatch_guard_count: int = 0
|
||||
failed_count: int = 0
|
||||
cache_hit_count: int = 0
|
||||
did_reach_deadline: bool = False
|
||||
did_exhaust_quota: bool = False
|
||||
|
|
@ -309,7 +297,6 @@ class _MutableMetrics:
|
|||
self.attempted_count += 1
|
||||
self.miss_count += attempt.status is ResolutionStatus.MISS
|
||||
self.mismatch_guard_count += attempt.status is ResolutionStatus.MISMATCH
|
||||
self.failed_count += attempt.status is ResolutionStatus.FAILED
|
||||
self.cache_hit_count += attempt.is_cache_hit
|
||||
self.did_exhaust_quota = self.did_exhaust_quota or attempt.status is ResolutionStatus.QUOTA
|
||||
|
||||
|
|
@ -318,7 +305,6 @@ class _MutableMetrics:
|
|||
attempted_count=self.attempted_count,
|
||||
miss_count=self.miss_count,
|
||||
mismatch_guard_count=self.mismatch_guard_count,
|
||||
failed_count=self.failed_count,
|
||||
cache_hit_count=self.cache_hit_count,
|
||||
did_reach_deadline=self.did_reach_deadline,
|
||||
did_exhaust_quota=self.did_exhaust_quota,
|
||||
|
|
@ -354,7 +340,6 @@ def _log_grounding(result: GroundingResult) -> None:
|
|||
attempted_count=metrics.attempted_count,
|
||||
miss_rate=metrics.miss_rate,
|
||||
mismatch_guard_rate=metrics.mismatch_guard_rate,
|
||||
failed_count=metrics.failed_count,
|
||||
cache_hits=metrics.cache_hit_count,
|
||||
deadline_reached=metrics.did_reach_deadline,
|
||||
quota_exhausted=metrics.did_exhaust_quota,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from contextlib import aclosing
|
|||
|
||||
import structlog
|
||||
|
||||
from app.adapters.spotify.errors import SpotifyError
|
||||
from app.config import Settings
|
||||
from app.domain.models import (
|
||||
CompressedTasteProfile,
|
||||
|
|
@ -28,12 +27,7 @@ from app.pipeline.event import (
|
|||
PipelineWarningEvent,
|
||||
)
|
||||
from app.pipeline.grounding import Grounder
|
||||
from app.ports.protocols import (
|
||||
CatalogQuotaExhaustedError,
|
||||
MusicCatalog,
|
||||
Recommender,
|
||||
RecommenderOutputError,
|
||||
)
|
||||
from app.ports.protocols import MusicCatalog, Recommender, RecommenderOutputError
|
||||
|
||||
RERANK_FALLBACK_CODE = "rerank_fallback"
|
||||
RERANK_FALLBACK_MESSAGE = "Ranking output was invalid, so grounded results are shown instead."
|
||||
|
|
@ -118,12 +112,9 @@ class RecommendationPipeline:
|
|||
query: str,
|
||||
history: tuple[ConversationTurn, ...],
|
||||
previous_recommendations: tuple[PreviousRecommendation, ...],
|
||||
seeded_pool: tuple[Track, ...] | None = None,
|
||||
) -> AsyncGenerator[PipelineEvent]:
|
||||
"""Yield ordered events for one recommendation request."""
|
||||
started_at = time.monotonic()
|
||||
deadline_at = started_at + self.settings.request_deadline_seconds
|
||||
try:
|
||||
taste = await self.taste_cache.get(session_id, catalog)
|
||||
intent = await self.recommender.create_intent(
|
||||
query,
|
||||
|
|
@ -132,35 +123,13 @@ class RecommendationPipeline:
|
|||
taste.text,
|
||||
self.settings.candidate_count,
|
||||
)
|
||||
except RecommenderOutputError:
|
||||
yield PipelineErrorEvent(
|
||||
code="intent_failed",
|
||||
message="Recommendation intent could not be generated from the model response.",
|
||||
)
|
||||
return
|
||||
except CatalogQuotaExhaustedError:
|
||||
yield PipelineErrorEvent(
|
||||
code="quota_exhausted",
|
||||
message=(
|
||||
"Spotify request quota was exhausted before recommendations could be prepared."
|
||||
),
|
||||
)
|
||||
return
|
||||
except SpotifyError:
|
||||
yield PipelineErrorEvent(
|
||||
code="spotify_unavailable",
|
||||
message="Spotify was unavailable while preparing recommendations.",
|
||||
)
|
||||
return
|
||||
yield PipelineMetadataEvent(
|
||||
request_id=request_id,
|
||||
intent_summary=intent.intent_summary,
|
||||
candidate_count=len(intent.candidates),
|
||||
)
|
||||
|
||||
pool = await self._grounded_pool(
|
||||
session_id, catalog, intent, taste, deadline_at, seeded_pool
|
||||
)
|
||||
pool = await self._grounded_pool(session_id, catalog, intent, taste)
|
||||
if not pool:
|
||||
yield PipelineErrorEvent(
|
||||
code="no_grounded_results",
|
||||
|
|
@ -191,14 +160,10 @@ class RecommendationPipeline:
|
|||
catalog: MusicCatalog,
|
||||
intent: Intent,
|
||||
taste: CompressedTasteProfile,
|
||||
deadline_at: float,
|
||||
seeded_pool: tuple[Track, ...] | None,
|
||||
) -> tuple[Track, ...]:
|
||||
"""Reuse the seeded or session pool on refinement, otherwise ground anew."""
|
||||
"""Reuse the session's pool on refinement, otherwise ground anew."""
|
||||
if intent.is_refinement:
|
||||
cached_pool = (
|
||||
seeded_pool if seeded_pool is not None else self.last_pools.get(session_id)
|
||||
)
|
||||
cached_pool = self.last_pools.get(session_id)
|
||||
if cached_pool:
|
||||
return cached_pool
|
||||
result = await self.grounder.ground(
|
||||
|
|
@ -207,7 +172,6 @@ class RecommendationPipeline:
|
|||
taste.known_track_ids,
|
||||
intent.familiarity,
|
||||
self.settings.rerank_count + self.settings.rerank_pool_buffer,
|
||||
deadline_at,
|
||||
)
|
||||
if result.tracks:
|
||||
self.last_pools[session_id] = result.tracks
|
||||
|
|
@ -326,31 +290,19 @@ class _TasteProfileCache:
|
|||
return profile
|
||||
|
||||
async def _fetch(self, catalog: MusicCatalog) -> CompressedTasteProfile:
|
||||
try:
|
||||
async with asyncio.TaskGroup() as task_group:
|
||||
short_artists_task = task_group.create_task(
|
||||
catalog.fetch_top_artists("short_term", self.settings.top_items_limit)
|
||||
short_artists, long_artists, short_tracks, long_tracks, saved_tracks = await asyncio.gather(
|
||||
catalog.fetch_top_artists("short_term", self.settings.top_items_limit),
|
||||
catalog.fetch_top_artists("long_term", self.settings.top_items_limit),
|
||||
catalog.fetch_top_tracks("short_term", self.settings.top_items_limit),
|
||||
catalog.fetch_top_tracks("long_term", self.settings.top_items_limit),
|
||||
catalog.fetch_saved_tracks(self.settings.saved_tracks_limit),
|
||||
)
|
||||
long_artists_task = task_group.create_task(
|
||||
catalog.fetch_top_artists("long_term", self.settings.top_items_limit)
|
||||
)
|
||||
short_tracks_task = task_group.create_task(
|
||||
catalog.fetch_top_tracks("short_term", self.settings.top_items_limit)
|
||||
)
|
||||
long_tracks_task = task_group.create_task(
|
||||
catalog.fetch_top_tracks("long_term", self.settings.top_items_limit)
|
||||
)
|
||||
saved_tracks_task = task_group.create_task(
|
||||
catalog.fetch_saved_tracks(self.settings.saved_tracks_limit)
|
||||
)
|
||||
except ExceptionGroup as errors:
|
||||
raise errors.exceptions[0] from None
|
||||
return compress_taste_profile(
|
||||
TasteProfile(
|
||||
short_term_artists=tuple(short_artists_task.result()),
|
||||
long_term_artists=tuple(long_artists_task.result()),
|
||||
short_term_tracks=tuple(short_tracks_task.result()),
|
||||
long_term_tracks=tuple(long_tracks_task.result()),
|
||||
saved_tracks=tuple(saved_tracks_task.result()),
|
||||
short_term_artists=tuple(short_artists),
|
||||
long_term_artists=tuple(long_artists),
|
||||
short_term_tracks=tuple(short_tracks),
|
||||
long_term_tracks=tuple(long_tracks),
|
||||
saved_tracks=tuple(saved_tracks),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,23 +49,15 @@ appears prominently in the taste profile. Avoid duplicate titles by the same art
|
|||
avoid multiple editions of the same recording. Use a range of strong fits so a later
|
||||
ranking step has meaningful choices rather than thirty near-identical songs.
|
||||
|
||||
When familiarity leans new, novelty means plausibly new to this listener, not obscure.
|
||||
Known tracks are excluded after grounding, so novelty relative to the taste profile is
|
||||
enforced downstream; do not chase rarity. Name each artist's signature track, biggest
|
||||
single, or a similarly famous recording from their catalog: a title you have seen written
|
||||
down many times, spelled exactly as released. Never guess a title that merely sounds like
|
||||
the artist; if no specific famous title comes to mind for an artist, pick a different
|
||||
artist whose hit you can name with certainty. Famous artists adjacent to the taste profile
|
||||
are welcome; their best-known work is still new to a listener who does not play them.
|
||||
Prefer the anthems of the profile's own scenes and neighboring scenes over global chart
|
||||
hits: the certainty bar stays the same, but a scene's signature tracks fit the taste far
|
||||
better than whatever topped the general charts.
|
||||
Retain understandable bridges through genre, scene, production style, instrumentation,
|
||||
energy, era, or songwriting. The profile is not exhaustive, so never claim that a
|
||||
candidate is definitely unknown. When familiarity is
|
||||
familiar, candidates may include supplied top or saved tracks, but remain responsive to the
|
||||
current request. When familiarity is mix, combine recognizable anchors with real,
|
||||
confidently identifiable adjacent discoveries rather than splitting into unrelated halves.
|
||||
When familiarity leans new, propose music the listener plausibly does not know. Move beyond
|
||||
the named top artists and tracks while retaining understandable bridges through genre,
|
||||
scene, production style, instrumentation, energy, era, or songwriting. Do not simply select
|
||||
deep cuts from every familiar artist. Prefer adjacent artists, overlooked catalogs,
|
||||
regional scenes, and credible cross-genre connections. The profile is not exhaustive, so
|
||||
never claim that a candidate is definitely unknown. When familiarity is familiar,
|
||||
candidates may include supplied top or saved tracks, but remain responsive to the current
|
||||
request. When familiarity is mix, combine recognizable anchors with adjacent discoveries
|
||||
rather than splitting into unrelated halves.
|
||||
|
||||
Use musical knowledge conservatively. Base selection on durable, commonly knowable
|
||||
attributes of recordings. Do not invent listening statistics, personal memories, release
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@ requires-python = "==3.13.*"
|
|||
dependencies = [
|
||||
"anthropic==0.121.0",
|
||||
"fastapi>=0.116",
|
||||
"httpx>=0.28",
|
||||
"httpx2>=2.10",
|
||||
"pydantic-settings>=2.10",
|
||||
"pyyaml>=6.0",
|
||||
"structlog>=25.4",
|
||||
"uvicorn[standard]>=0.35",
|
||||
]
|
||||
|
|
@ -19,7 +17,6 @@ dev = [
|
|||
"mypy>=1.17",
|
||||
"pytest>=8.4",
|
||||
"ruff>=0.12",
|
||||
"types-pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
|
@ -39,5 +36,4 @@ module = ["app.domain.*", "app.ports.*", "app.pipeline.*"]
|
|||
strict = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = [".", "../eval"]
|
||||
testpaths = ["tests", "../eval/tests"]
|
||||
testpaths = ["tests"]
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
"""Focused tests for Anthropic response parsing."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.anthropic.llm import RecommendationObjectParser
|
||||
from app.ports.protocols import RecommenderOutputError
|
||||
|
||||
|
||||
def test_parser_missing_object_start_raises_typed_output_error() -> None:
|
||||
parser = RecommendationObjectParser()
|
||||
|
||||
with pytest.raises(RecommenderOutputError, match="object start position"):
|
||||
parser._finish_object()
|
||||
|
|
@ -113,29 +113,6 @@ def test_seed_session_authenticates_requests_without_a_cookie() -> None:
|
|||
assert response.json() == {"display_name": "Seed Listener"}
|
||||
|
||||
|
||||
def test_seed_session_failure_keeps_application_serving() -> None:
|
||||
async def spotify_handler(request: httpx2.Request) -> httpx2.Response:
|
||||
return httpx2.Response(400)
|
||||
|
||||
app = create_app(
|
||||
application_settings=Settings(
|
||||
app_mode=AppMode.LIVE,
|
||||
spotify_client_id="client-id",
|
||||
anthropic_api_key="test-key",
|
||||
spotify_seed_refresh_token="seed-refresh",
|
||||
),
|
||||
http_transport=httpx2.MockTransport(spotify_handler),
|
||||
)
|
||||
with TestClient(app, follow_redirects=False) as client:
|
||||
health_response = client.get("/api/health")
|
||||
login_response = client.get("/api/auth/login")
|
||||
|
||||
assert app.state.seed_session_id is None
|
||||
|
||||
assert health_response.status_code == 200
|
||||
assert login_response.status_code == 307
|
||||
|
||||
|
||||
def _live_settings() -> Settings:
|
||||
return Settings(
|
||||
app_mode=AppMode.LIVE,
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
"""Offline replay tests for demo service adapters."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.adapters.demo.cassette import load_cassette
|
||||
from app.adapters.demo.catalog import DemoCatalog
|
||||
from app.adapters.demo.recommender import DemoRecommender
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_cassette_decodes_bodies_and_preserves_rerank_chunks() -> None:
|
||||
cassette = load_cassette("focus-coding")
|
||||
|
||||
assert cassette.intent_response_body.startswith(b'{"model"')
|
||||
assert len(cassette.spotify_search_responses) > 20
|
||||
assert len(cassette.spotify_taste_responses) == 6
|
||||
assert len(cassette.rerank_response_chunks) > 1
|
||||
assert b"event: content_block_delta" in b"".join(cassette.rerank_response_chunks)
|
||||
|
||||
|
||||
def test_demo_catalog_replays_search_and_synthetic_taste_pages() -> None:
|
||||
async def run() -> None:
|
||||
catalog = DemoCatalog(load_cassette("focus-coding"))
|
||||
|
||||
tracks = await catalog.search_tracks('track:"Stay" artist:"Hybrid Minds"')
|
||||
artists = await catalog.fetch_top_artists("short_term", 50)
|
||||
top_tracks = await catalog.fetch_top_tracks("long_term", 50)
|
||||
saved_tracks = await catalog.fetch_saved_tracks(100)
|
||||
|
||||
assert tracks[0].title == "Stay"
|
||||
assert artists[0] == "Synthetic Focus Artist"
|
||||
assert {track.title for track in top_tracks} >= {
|
||||
"Synthetic Focus Track",
|
||||
"Synthetic Jazz Track",
|
||||
}
|
||||
assert saved_tracks[0].title == "Synthetic Saved Track"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_demo_recommender_parses_intent_and_streams_recorded_rerank() -> None:
|
||||
async def run() -> None:
|
||||
recommender = DemoRecommender(Settings(demo_chunk_delay_seconds=0))
|
||||
intent = await recommender.create_intent(
|
||||
"Focus while coding",
|
||||
(),
|
||||
(),
|
||||
"Synthetic taste",
|
||||
35,
|
||||
)
|
||||
selections = [
|
||||
selection
|
||||
async for selection in recommender.stream_rerank(
|
||||
intent,
|
||||
(),
|
||||
"Synthetic taste",
|
||||
(),
|
||||
15,
|
||||
)
|
||||
]
|
||||
|
||||
assert intent.activity == "programming"
|
||||
assert len(intent.candidates) == 35
|
||||
assert len(selections) == 15
|
||||
assert selections[0].track_id == "2nIixNuuV5eHCJydG5aYIB"
|
||||
assert all(selection.justification for selection in selections)
|
||||
|
||||
asyncio.run(run())
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
"""Full HTTP contract tests for key-free demo mode."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import TypeAdapter
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from app.adapters.demo.scenario import load_scenarios
|
||||
from app.api.schemas import StreamEvent
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class _MetadataBarrier:
|
||||
"""Pause one marked ASGI response immediately after its metadata event."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
self.metadata_sent = asyncio.Event()
|
||||
self.resume_response = asyncio.Event()
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
is_refinement = dict(scope.get("headers", ())).get(b"x-demo-request") == b"refinement"
|
||||
|
||||
async def send_with_barrier(message: Message) -> None:
|
||||
await send(message)
|
||||
if message["type"] == "http.response.body" and b'"type":"metadata"' in message.get(
|
||||
"body", b""
|
||||
):
|
||||
self.metadata_sent.set()
|
||||
await self.resume_response.wait()
|
||||
|
||||
await self.app(scope, receive, send_with_barrier if is_refinement else send)
|
||||
|
||||
|
||||
def test_demo_request_streams_valid_events_without_a_session() -> None:
|
||||
app = create_app(Settings(demo_chunk_delay_seconds=0))
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/recommendations",
|
||||
json={"schema_version": 1, "query": "Focus while coding"},
|
||||
)
|
||||
|
||||
adapter: TypeAdapter[StreamEvent] = TypeAdapter(StreamEvent)
|
||||
events = [adapter.validate_json(line) for line in response.text.splitlines()]
|
||||
assert response.status_code == 200
|
||||
assert events[0].type == "metadata"
|
||||
assert events[-1].type == "done"
|
||||
assert sum(event.type == "track" for event in events) == 15
|
||||
|
||||
|
||||
def test_unknown_demo_query_discloses_the_replayed_scenario_before_tracks() -> None:
|
||||
app = create_app(Settings(demo_chunk_delay_seconds=0))
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/recommendations",
|
||||
json={"schema_version": 1, "query": "Focus while codign"},
|
||||
)
|
||||
|
||||
adapter: TypeAdapter[StreamEvent] = TypeAdapter(StreamEvent)
|
||||
events = [adapter.validate_json(line) for line in response.text.splitlines()]
|
||||
warning_index = next(
|
||||
index
|
||||
for index, event in enumerate(events)
|
||||
if event.type == "warning" and event.code == "demo_replay"
|
||||
)
|
||||
first_track_index = next(index for index, event in enumerate(events) if event.type == "track")
|
||||
warning = events[warning_index]
|
||||
assert warning_index < first_track_index
|
||||
assert warning.type == "warning"
|
||||
assert "Focus while coding" in warning.message
|
||||
|
||||
|
||||
def test_demo_refinement_reconstructs_the_pool_recorded_with_its_cassette() -> None:
|
||||
app = create_app(Settings(demo_chunk_delay_seconds=0))
|
||||
with TestClient(app) as client:
|
||||
initial_response = client.post(
|
||||
"/api/recommendations",
|
||||
json={"schema_version": 1, "query": "Focus while coding"},
|
||||
)
|
||||
initial_adapter: TypeAdapter[StreamEvent] = TypeAdapter(StreamEvent)
|
||||
initial_events: list[StreamEvent] = [
|
||||
initial_adapter.validate_json(line) for line in initial_response.text.splitlines()
|
||||
]
|
||||
prior_recommendations = [
|
||||
{
|
||||
"rank": event.rank,
|
||||
"track_id": event.track.id,
|
||||
"title": event.track.title,
|
||||
"artists": event.track.artists,
|
||||
}
|
||||
for event in initial_events
|
||||
if event.type == "track"
|
||||
]
|
||||
response = client.post(
|
||||
"/api/recommendations",
|
||||
json={
|
||||
"schema_version": 1,
|
||||
"query": "More electronic",
|
||||
"prior_recommendations": prior_recommendations,
|
||||
},
|
||||
)
|
||||
|
||||
adapter: TypeAdapter[StreamEvent] = TypeAdapter(StreamEvent)
|
||||
events = [adapter.validate_json(line) for line in response.text.splitlines()]
|
||||
warning_codes = [event.code for event in events if event.type == "warning"]
|
||||
assert "rerank_fallback" not in warning_codes
|
||||
assert sum(event.type == "track" for event in events) == 15
|
||||
|
||||
|
||||
def test_concurrent_demo_refinement_uses_its_request_local_recorded_pool() -> None:
|
||||
async def run() -> None:
|
||||
settings = Settings(demo_chunk_delay_seconds=0)
|
||||
previous = [
|
||||
{
|
||||
"rank": 1,
|
||||
"track_id": "previous",
|
||||
"title": "Previous track",
|
||||
"artists": ["Previous artist"],
|
||||
}
|
||||
]
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"query": "More electronic",
|
||||
"prior_recommendations": previous,
|
||||
}
|
||||
|
||||
baseline_app = create_app(settings)
|
||||
baseline_transport = httpx.ASGITransport(app=baseline_app)
|
||||
async with (
|
||||
baseline_app.router.lifespan_context(baseline_app),
|
||||
httpx.AsyncClient(
|
||||
transport=baseline_transport,
|
||||
base_url="http://test",
|
||||
) as client,
|
||||
):
|
||||
baseline_response = await client.post("/api/recommendations", json=payload)
|
||||
baseline_events = _stream_events(baseline_response)
|
||||
expected_track_ids = [event.track.id for event in baseline_events if event.type == "track"]
|
||||
|
||||
concurrent_app = create_app(settings)
|
||||
barrier = _MetadataBarrier(concurrent_app)
|
||||
transport = httpx.ASGITransport(app=barrier)
|
||||
async with (
|
||||
concurrent_app.router.lifespan_context(concurrent_app),
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as client,
|
||||
):
|
||||
refinement_task = asyncio.create_task(
|
||||
client.post(
|
||||
"/api/recommendations",
|
||||
json=payload,
|
||||
headers={"x-demo-request": "refinement"},
|
||||
)
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(barrier.metadata_sent.wait(), timeout=2)
|
||||
unrelated_response = await client.post(
|
||||
"/api/recommendations",
|
||||
json={"schema_version": 1, "query": "Energy for the gym"},
|
||||
)
|
||||
finally:
|
||||
barrier.resume_response.set()
|
||||
refinement_response = await refinement_task
|
||||
|
||||
events = _stream_events(refinement_response)
|
||||
warning_codes = [event.code for event in events if event.type == "warning"]
|
||||
track_ids = [event.track.id for event in events if event.type == "track"]
|
||||
assert unrelated_response.status_code == 200
|
||||
assert refinement_response.status_code == 200
|
||||
assert "rerank_fallback" not in warning_codes
|
||||
assert track_ids == expected_track_ids
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_demo_auth_playlist_and_suggestions_are_explicitly_simulated() -> None:
|
||||
app = create_app(Settings(demo_chunk_delay_seconds=0))
|
||||
with TestClient(app, follow_redirects=False) as client:
|
||||
current_user = client.get("/api/auth/me")
|
||||
login = client.get("/api/auth/login")
|
||||
playlist = client.post(
|
||||
"/api/playlists",
|
||||
json={
|
||||
"schema_version": 1,
|
||||
"name": "Night drive",
|
||||
"track_uris": ["spotify:track:demo"],
|
||||
},
|
||||
)
|
||||
suggestions = client.get("/api/suggestions")
|
||||
|
||||
expected_suggestions = [
|
||||
{"chip": scenario.chip, "query": scenario.query}
|
||||
for scenario in load_scenarios()
|
||||
if not scenario.is_refinement
|
||||
]
|
||||
assert current_user.json() == {"display_name": "Demo Listener"}
|
||||
assert login.headers["location"] == "/?login=demo"
|
||||
assert playlist.json()["url"].startswith("https://open.spotify.com/playlist/demo-")
|
||||
assert suggestions.json() == expected_suggestions
|
||||
assert not hasattr(app.state, "http")
|
||||
assert not hasattr(app.state, "anthropic")
|
||||
|
||||
|
||||
def _stream_events(response: httpx.Response) -> list[StreamEvent]:
|
||||
adapter: TypeAdapter[StreamEvent] = TypeAdapter(StreamEvent)
|
||||
return [adapter.validate_json(line) for line in response.text.splitlines()]
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
"""Tests for deterministic demo scenario selection."""
|
||||
|
||||
from app.adapters.demo.scenario import select_replay_scenario, select_scenario
|
||||
|
||||
|
||||
def test_scenario_selection_prefers_normalized_exact_query_or_chip() -> None:
|
||||
query_match = select_scenario("something calm for while I am programming, but not boring")
|
||||
chip_match = select_scenario(" FOCUS, while CODING! ")
|
||||
|
||||
assert query_match.key == "focus-coding"
|
||||
assert query_match.is_exact
|
||||
assert chip_match.key == "focus-coding"
|
||||
assert chip_match.is_exact
|
||||
|
||||
|
||||
def test_scenario_selection_falls_back_to_nearest_fixture() -> None:
|
||||
match = select_scenario("Focus while codign")
|
||||
|
||||
assert match.key == "focus-coding"
|
||||
assert not match.is_exact
|
||||
|
||||
|
||||
def test_prior_results_select_a_matching_refinement_or_same_scenario() -> None:
|
||||
refinement = select_replay_scenario("Focus while coding", True)
|
||||
direct_refinement = select_replay_scenario("More electronic", False)
|
||||
same_scenario = select_replay_scenario("Rainy Sunday", True)
|
||||
|
||||
assert refinement.key == "focus-coding-refine"
|
||||
assert not refinement.is_exact
|
||||
assert direct_refinement.key == "focus-coding-refine"
|
||||
assert direct_refinement.is_exact
|
||||
assert same_scenario.key == "rainy-sunday"
|
||||
|
|
@ -1,14 +1,8 @@
|
|||
"""Deterministic tests for bounded Spotify grounding."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from app.adapters.spotify.errors import (
|
||||
SpotifyQuotaExhaustedError,
|
||||
SpotifyRateLimitedError,
|
||||
SpotifyUnavailableError,
|
||||
)
|
||||
from app.config import Settings
|
||||
from app.domain.models import Familiarity, Track, TrackCandidate
|
||||
from app.pipeline.grounding import Grounder
|
||||
|
|
@ -53,14 +47,7 @@ def test_early_stop_honors_pool_target() -> None:
|
|||
grounder = Grounder(_settings(grounding_concurrency=2))
|
||||
candidates = tuple(TrackCandidate(f"track-{index}", "Artist") for index in range(6))
|
||||
|
||||
result = await grounder.ground(
|
||||
catalog,
|
||||
candidates,
|
||||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
2,
|
||||
_deadline(),
|
||||
)
|
||||
result = await grounder.ground(catalog, candidates, frozenset(), Familiarity.MIX, 2)
|
||||
|
||||
assert len(result.tracks) == 2
|
||||
assert len(catalog.search_queries) == 2
|
||||
|
|
@ -87,7 +74,6 @@ def test_miss_and_mismatch_are_counted_separately() -> None:
|
|||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
2,
|
||||
_deadline(),
|
||||
)
|
||||
|
||||
assert result.metrics.miss_count == 1
|
||||
|
|
@ -106,23 +92,9 @@ def test_resolution_cache_hit_skips_catalog() -> None:
|
|||
grounder = Grounder(_settings())
|
||||
candidates = (TrackCandidate("Cached Song", "Artist"),)
|
||||
|
||||
await grounder.ground(
|
||||
catalog,
|
||||
candidates,
|
||||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
1,
|
||||
_deadline(),
|
||||
)
|
||||
await grounder.ground(catalog, candidates, frozenset(), Familiarity.MIX, 1)
|
||||
first_call_count = len(catalog.search_queries)
|
||||
second = await grounder.ground(
|
||||
catalog,
|
||||
candidates,
|
||||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
1,
|
||||
_deadline(),
|
||||
)
|
||||
second = await grounder.ground(catalog, candidates, frozenset(), Familiarity.MIX, 1)
|
||||
|
||||
assert len(catalog.search_queries) == first_call_count
|
||||
assert second.metrics.cache_hit_count == 1
|
||||
|
|
@ -152,7 +124,6 @@ def test_deadline_returns_resolved_partial_pool() -> None:
|
|||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
2,
|
||||
time.monotonic() + settings.request_deadline_seconds,
|
||||
)
|
||||
|
||||
assert [track.id for track in result.tracks] == ["fast"]
|
||||
|
|
@ -161,127 +132,6 @@ def test_deadline_returns_resolved_partial_pool() -> None:
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_spotify_failure_is_counted_and_remaining_candidates_continue() -> None:
|
||||
async def run() -> None:
|
||||
async def search(query: str) -> list[Track]:
|
||||
if "Failing Song" in query:
|
||||
raise SpotifyUnavailableError(503)
|
||||
title = query.split('track:"', 1)[1].split('"', 1)[0]
|
||||
return [_track(title.lower().replace(" ", "-"), title, "Artist")]
|
||||
|
||||
catalog = FakeCatalog(search)
|
||||
candidates = (
|
||||
TrackCandidate("Failing Song", "Artist"),
|
||||
TrackCandidate("First Good Song", "Artist"),
|
||||
TrackCandidate("Second Good Song", "Artist"),
|
||||
)
|
||||
|
||||
result = await Grounder(_settings()).ground(
|
||||
catalog,
|
||||
candidates,
|
||||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
2,
|
||||
_deadline(),
|
||||
)
|
||||
|
||||
assert [track.title for track in result.tracks] == [
|
||||
"First Good Song",
|
||||
"Second Good Song",
|
||||
]
|
||||
assert result.metrics.failed_count == 1
|
||||
assert result.metrics.attempted_count == 3
|
||||
assert not result.metrics.did_exhaust_quota
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_plain_rate_limit_continues_but_quota_exhaustion_stops_fanout() -> None:
|
||||
async def run() -> None:
|
||||
async def plain_rate_limited_search(query: str) -> list[Track]:
|
||||
if "Rate Limited" in query:
|
||||
raise SpotifyRateLimitedError(6.0)
|
||||
return [_track("found", "Found Song", "Artist")]
|
||||
|
||||
plain_catalog = FakeCatalog(plain_rate_limited_search)
|
||||
candidates = (
|
||||
TrackCandidate("Rate Limited", "Artist"),
|
||||
TrackCandidate("Found Song", "Artist"),
|
||||
)
|
||||
plain_result = await Grounder(_settings()).ground(
|
||||
plain_catalog,
|
||||
candidates,
|
||||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
1,
|
||||
_deadline(),
|
||||
)
|
||||
|
||||
async def quota_search(query: str) -> list[Track]:
|
||||
raise SpotifyQuotaExhaustedError(0.0, "QUOTA_EXCEEDED")
|
||||
|
||||
quota_catalog = FakeCatalog(quota_search)
|
||||
quota_result = await Grounder(_settings()).ground(
|
||||
quota_catalog,
|
||||
candidates,
|
||||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
1,
|
||||
_deadline(),
|
||||
)
|
||||
|
||||
assert [track.id for track in plain_result.tracks] == ["found"]
|
||||
assert plain_result.metrics.failed_count == 1
|
||||
assert not plain_result.metrics.did_exhaust_quota
|
||||
assert quota_result.tracks == ()
|
||||
assert quota_result.metrics.did_exhaust_quota
|
||||
assert len(quota_catalog.search_queries) == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_grounding_concurrency_is_shared_across_requests() -> None:
|
||||
async def run() -> None:
|
||||
active_searches = 0
|
||||
maximum_active_searches = 0
|
||||
|
||||
async def search(query: str) -> list[Track]:
|
||||
nonlocal active_searches, maximum_active_searches
|
||||
active_searches += 1
|
||||
maximum_active_searches = max(maximum_active_searches, active_searches)
|
||||
await asyncio.sleep(0.01)
|
||||
active_searches -= 1
|
||||
title = query.split('track:"', 1)[1].split('"', 1)[0]
|
||||
return [_track(title, title, "Artist")]
|
||||
|
||||
catalog = FakeCatalog(search)
|
||||
grounder = Grounder(_settings(grounding_concurrency=2))
|
||||
candidates = tuple(TrackCandidate(f"Song {index}", "Artist") for index in range(2))
|
||||
|
||||
await asyncio.gather(
|
||||
grounder.ground(
|
||||
catalog,
|
||||
candidates,
|
||||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
2,
|
||||
_deadline(),
|
||||
),
|
||||
grounder.ground(
|
||||
catalog,
|
||||
candidates,
|
||||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
2,
|
||||
_deadline(),
|
||||
),
|
||||
)
|
||||
|
||||
assert maximum_active_searches == 2
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def _settings(**overrides: object) -> Settings:
|
||||
values: dict[str, object] = {
|
||||
"grounding_concurrency": 1,
|
||||
|
|
@ -291,10 +141,6 @@ def _settings(**overrides: object) -> Settings:
|
|||
return Settings.model_validate(values)
|
||||
|
||||
|
||||
def _deadline() -> float:
|
||||
return time.monotonic() + 1.0
|
||||
|
||||
|
||||
def _track(track_id: str, title: str, artist: str) -> Track:
|
||||
return Track(
|
||||
id=track_id,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
"""Smoke test for the application factory."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
|
|
@ -14,22 +10,3 @@ def test_health_reports_mode() -> None:
|
|||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["mode"] in ("live", "demo")
|
||||
|
||||
|
||||
def test_anthropic_client_uses_configured_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
anthropic_client = Mock()
|
||||
anthropic_client.close = AsyncMock()
|
||||
constructor = Mock(return_value=anthropic_client)
|
||||
monkeypatch.setattr("app.main.AsyncAnthropic", constructor)
|
||||
|
||||
live_settings = Settings(
|
||||
app_mode="live",
|
||||
spotify_client_id="client-id",
|
||||
anthropic_api_key="api-key",
|
||||
llm_timeout_seconds=42.0,
|
||||
)
|
||||
with TestClient(create_app(live_settings)) as client:
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
constructor.assert_called_once_with(api_key="api-key", timeout=42.0, http_client=None)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from app.adapters.spotify.errors import SpotifyQuotaExhaustedError, SpotifyUnavailableError
|
||||
from app.config import Settings
|
||||
from app.domain.models import (
|
||||
ConversationTurn,
|
||||
|
|
@ -14,7 +13,7 @@ from app.domain.models import (
|
|||
Track,
|
||||
TrackCandidate,
|
||||
)
|
||||
from app.pipeline.event import PipelineErrorEvent, PipelineEvent, PipelineTrackEvent
|
||||
from app.pipeline.event import PipelineEvent, PipelineTrackEvent
|
||||
from app.pipeline.orchestrator import RecommendationPipeline
|
||||
from app.ports.protocols import RecommenderOutputError, TimeRange
|
||||
|
||||
|
|
@ -62,15 +61,11 @@ class FakeRecommender:
|
|||
intents: list[Intent],
|
||||
selection_ids: tuple[str, ...] = (),
|
||||
failure_count: int = 0,
|
||||
intent_error: Exception | None = None,
|
||||
intent_delay_seconds: float = 0.0,
|
||||
) -> None:
|
||||
"""Store deterministic outputs for successive calls."""
|
||||
self.intents = intents
|
||||
self.selection_ids = selection_ids
|
||||
self.failure_count = failure_count
|
||||
self.intent_error = intent_error
|
||||
self.intent_delay_seconds = intent_delay_seconds
|
||||
self.rerank_call_count = 0
|
||||
|
||||
async def create_intent(
|
||||
|
|
@ -82,9 +77,6 @@ class FakeRecommender:
|
|||
candidate_count: int,
|
||||
) -> Intent:
|
||||
"""Return the next fixed intent."""
|
||||
await asyncio.sleep(self.intent_delay_seconds)
|
||||
if self.intent_error is not None:
|
||||
raise self.intent_error
|
||||
return self.intents.pop(0)
|
||||
|
||||
async def stream_rerank(
|
||||
|
|
@ -123,108 +115,6 @@ def test_event_order_and_rerank_ids_stay_inside_grounded_pool() -> None:
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_intent_stage_failures_yield_one_typed_error_event() -> None:
|
||||
async def run() -> None:
|
||||
cases = (
|
||||
(RecommenderOutputError("invalid intent"), "intent_failed"),
|
||||
(
|
||||
SpotifyQuotaExhaustedError(0.0, "QUOTA_EXCEEDED"),
|
||||
"quota_exhausted",
|
||||
),
|
||||
(SpotifyUnavailableError(503), "spotify_unavailable"),
|
||||
)
|
||||
for error, expected_code in cases:
|
||||
catalog = FakeCatalog(())
|
||||
recommender = FakeRecommender([], intent_error=error)
|
||||
|
||||
events = await _run_pipeline(catalog, recommender)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "error"
|
||||
assert events[0].code == expected_code
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_taste_failure_cancels_sibling_fetches() -> None:
|
||||
class FailingTasteCatalog(FakeCatalog):
|
||||
"""Fail one taste request after all sibling requests have started."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(())
|
||||
self.started_count = 0
|
||||
self.cancelled_count = 0
|
||||
self.all_started = asyncio.Event()
|
||||
self.never_finishes = asyncio.Event()
|
||||
|
||||
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
|
||||
await self._wait_for_all_fetches()
|
||||
await self._wait_until_cancelled()
|
||||
return []
|
||||
|
||||
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
|
||||
await self._wait_for_all_fetches()
|
||||
await self._wait_until_cancelled()
|
||||
return []
|
||||
|
||||
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
|
||||
await self._wait_for_all_fetches()
|
||||
raise SpotifyUnavailableError(503)
|
||||
|
||||
async def _wait_for_all_fetches(self) -> None:
|
||||
self.started_count += 1
|
||||
if self.started_count == 5:
|
||||
self.all_started.set()
|
||||
await self.all_started.wait()
|
||||
|
||||
async def _wait_until_cancelled(self) -> None:
|
||||
try:
|
||||
await self.never_finishes.wait()
|
||||
except asyncio.CancelledError:
|
||||
self.cancelled_count += 1
|
||||
raise
|
||||
|
||||
async def run() -> None:
|
||||
catalog = FailingTasteCatalog()
|
||||
events = await _run_pipeline(catalog, FakeRecommender([]))
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "error"
|
||||
assert events[0].code == "spotify_unavailable"
|
||||
assert catalog.cancelled_count == 4
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_grounding_deadline_starts_before_intent_generation() -> None:
|
||||
async def run() -> None:
|
||||
track = _track("found", "Found Song")
|
||||
catalog = FakeCatalog((track,))
|
||||
recommender = FakeRecommender(
|
||||
[_intent(track)],
|
||||
intent_delay_seconds=0.02,
|
||||
)
|
||||
pipeline = RecommendationPipeline(
|
||||
recommender,
|
||||
Settings(
|
||||
rerank_count=1,
|
||||
rerank_pool_buffer=0,
|
||||
grounding_floor=1,
|
||||
grounding_concurrency=1,
|
||||
request_deadline_seconds=0.01,
|
||||
),
|
||||
)
|
||||
|
||||
events = await _collect(pipeline, catalog, "query")
|
||||
|
||||
assert [event.type for event in events] == ["metadata", "error"]
|
||||
assert isinstance(events[-1], PipelineErrorEvent)
|
||||
assert events[-1].code == "no_grounded_results"
|
||||
assert catalog.search_call_count == 0
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_rerank_fallback_warns_then_streams_grounded_order() -> None:
|
||||
async def run() -> None:
|
||||
first = _track("first", "First Song")
|
||||
|
|
|
|||
|
|
@ -3,16 +3,13 @@
|
|||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from app.adapters.spotify.auth import TokenSet
|
||||
from app.adapters.spotify.errors import SpotifyAuthenticationError
|
||||
from app.adapters.spotify.session import SessionStore, SpotifySession
|
||||
from app.api.routes import SESSION_COOKIE_NAME
|
||||
from app.api.schemas import StreamEvent
|
||||
from app.config import AppMode, Settings
|
||||
from app.domain.models import ConversationTurn, CreatedPlaylist, PreviousRecommendation, Track
|
||||
from app.main import create_app
|
||||
from app.pipeline.event import PipelineDoneEvent, PipelineMetadataEvent, PipelineTrackEvent
|
||||
|
|
@ -40,16 +37,13 @@ class FakePipeline:
|
|||
class FakePlaylistWriter:
|
||||
"""Capture playlist writes without external calls."""
|
||||
|
||||
def __init__(self, error: Exception | None = None) -> None:
|
||||
def __init__(self) -> None:
|
||||
"""Create an empty write trace."""
|
||||
self.error = error
|
||||
self.name: str | None = None
|
||||
self.track_uris: list[str] = []
|
||||
|
||||
async def create_playlist(self, name: str, description: str) -> CreatedPlaylist:
|
||||
"""Record the prefixed name and return a stable playlist."""
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
self.name = name
|
||||
return CreatedPlaylist("playlist", "https://open.spotify.com/playlist/playlist")
|
||||
|
||||
|
|
@ -58,13 +52,7 @@ class FakePlaylistWriter:
|
|||
self.track_uris = track_uris
|
||||
|
||||
|
||||
def test_recommendations_stream_lines_validate_against_frozen_schemas(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fail_if_polled(request: object) -> bool:
|
||||
raise AssertionError("Request disconnect state must not be polled")
|
||||
|
||||
monkeypatch.setattr("starlette.requests.Request.is_disconnected", fail_if_polled)
|
||||
def test_recommendations_stream_lines_validate_against_frozen_schemas() -> None:
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
_authenticate(client, session_store=app.state.session_store)
|
||||
|
|
@ -82,13 +70,8 @@ def test_recommendations_stream_lines_validate_against_frozen_schemas(
|
|||
assert [event.type for event in events] == ["metadata", "track", "done"]
|
||||
|
||||
|
||||
def test_live_recommendations_require_a_valid_session_without_seed() -> None:
|
||||
application_settings = Settings(
|
||||
app_mode=AppMode.LIVE,
|
||||
spotify_client_id="client-id",
|
||||
anthropic_api_key="test-key",
|
||||
)
|
||||
with TestClient(create_app(application_settings)) as client:
|
||||
def test_recommendations_require_a_valid_session_without_seed() -> None:
|
||||
with TestClient(create_app()) as client:
|
||||
response = client.post(
|
||||
"/api/recommendations",
|
||||
json={"schema_version": 1, "query": "focused electronic music"},
|
||||
|
|
@ -120,26 +103,6 @@ def test_playlist_endpoint_prefixes_name_and_adds_tracks() -> None:
|
|||
assert writer.track_uris == ["spotify:track:track"]
|
||||
|
||||
|
||||
def test_playlist_authentication_failure_signals_relogin() -> None:
|
||||
app = create_app()
|
||||
writer = FakePlaylistWriter(SpotifyAuthenticationError("expired"))
|
||||
with TestClient(app) as client:
|
||||
_authenticate(client, session_store=app.state.session_store)
|
||||
app.state.spotify_client_factory = lambda session: writer
|
||||
|
||||
response = client.post(
|
||||
"/api/playlists",
|
||||
json={
|
||||
"schema_version": 1,
|
||||
"name": "Night drive",
|
||||
"track_uris": ["spotify:track:track"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Spotify authentication expired"}
|
||||
|
||||
|
||||
def _authenticate(client: TestClient, session_store: SessionStore) -> None:
|
||||
session_id = session_store.create(
|
||||
SpotifySession(
|
||||
|
|
|
|||
|
|
@ -6,14 +6,12 @@ import hashlib
|
|||
import time
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
|
||||
from app.adapters.spotify.auth import (
|
||||
TokenSet,
|
||||
derive_code_challenge,
|
||||
refresh_access_token,
|
||||
)
|
||||
from app.adapters.spotify.session import PendingLogins
|
||||
|
||||
|
||||
def test_code_challenge_is_unpadded_base64url_sha256() -> None:
|
||||
|
|
@ -45,18 +43,3 @@ def test_refresh_keeps_existing_refresh_token_when_omitted() -> None:
|
|||
def test_token_expiry_uses_sixty_second_skew() -> None:
|
||||
assert TokenSet("access", "refresh", time.monotonic() + 59).is_expired
|
||||
assert not TokenSet("access", "refresh", time.monotonic() + 61).is_expired
|
||||
|
||||
|
||||
def test_pending_login_add_sweeps_expired_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
current_time = 0.0
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.spotify.session.time.monotonic",
|
||||
lambda: current_time,
|
||||
)
|
||||
pending_logins = PendingLogins()
|
||||
pending_logins.add("expired", "old-verifier")
|
||||
|
||||
current_time = 601.0
|
||||
pending_logins.add("current", "new-verifier")
|
||||
|
||||
assert set(pending_logins._entries) == {"current"}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,10 @@ import pytest
|
|||
|
||||
from app.adapters.spotify.auth import TokenSet
|
||||
from app.adapters.spotify.client import SpotifyClient
|
||||
from app.adapters.spotify.errors import (
|
||||
SpotifyQuotaExhaustedError,
|
||||
SpotifyRateLimitedError,
|
||||
SpotifyRequestError,
|
||||
SpotifyUnavailableError,
|
||||
)
|
||||
from app.adapters.spotify.errors import SpotifyRateLimitedError, SpotifyRequestError
|
||||
from app.adapters.spotify.session import SpotifySession
|
||||
from app.config import Settings
|
||||
from app.domain.models import Track
|
||||
from app.ports.protocols import CatalogQuotaExhaustedError
|
||||
|
||||
TransportHandler = Callable[[httpx2.Request], Coroutine[None, None, httpx2.Response]]
|
||||
|
||||
|
|
@ -50,19 +44,19 @@ def test_unauthorized_response_refreshes_once_and_returns_result() -> None:
|
|||
def test_concurrent_unauthorized_responses_share_one_refresh() -> None:
|
||||
async def run() -> None:
|
||||
token_calls = 0
|
||||
api_calls = 0
|
||||
both_initial_requests_arrived = asyncio.Event()
|
||||
old_api_calls = 0
|
||||
both_old_requests_arrived = asyncio.Event()
|
||||
|
||||
async def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
nonlocal api_calls, token_calls
|
||||
nonlocal old_api_calls, token_calls
|
||||
if request.url.host == "accounts.spotify.com":
|
||||
token_calls += 1
|
||||
return _token_response("old-access")
|
||||
api_calls += 1
|
||||
if api_calls <= 2:
|
||||
if api_calls == 2:
|
||||
both_initial_requests_arrived.set()
|
||||
await asyncio.wait_for(both_initial_requests_arrived.wait(), timeout=1)
|
||||
return _token_response()
|
||||
if request.headers["Authorization"] == "Bearer old-access":
|
||||
old_api_calls += 1
|
||||
if old_api_calls == 2:
|
||||
both_old_requests_arrived.set()
|
||||
await asyncio.wait_for(both_old_requests_arrived.wait(), timeout=1)
|
||||
return httpx2.Response(401)
|
||||
return httpx2.Response(200, json=_search_payload())
|
||||
|
||||
|
|
@ -75,7 +69,6 @@ def test_concurrent_unauthorized_responses_share_one_refresh() -> None:
|
|||
|
||||
assert first == second
|
||||
assert token_calls == 1
|
||||
assert client.session.refresh_generation == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
|
@ -112,7 +105,6 @@ def test_get_rate_limit_above_cap_raises_without_retry() -> None:
|
|||
await _search_with_handler(handler)
|
||||
|
||||
assert error.value.retry_after_seconds == 6
|
||||
assert not isinstance(error.value, CatalogQuotaExhaustedError)
|
||||
assert api_calls == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
|
@ -144,11 +136,10 @@ def test_quota_exhaustion_raises_without_retry_or_sleep(
|
|||
)
|
||||
|
||||
monkeypatch.setattr("app.adapters.spotify.client.asyncio.sleep", fake_sleep)
|
||||
with pytest.raises(SpotifyQuotaExhaustedError) as error:
|
||||
with pytest.raises(SpotifyRateLimitedError) as error:
|
||||
await _search_with_handler(handler)
|
||||
|
||||
assert error.value.reason == "QUOTA_EXCEEDED"
|
||||
assert isinstance(error.value, CatalogQuotaExhaustedError)
|
||||
assert api_calls == 1
|
||||
assert sleep_calls == 0
|
||||
|
||||
|
|
@ -181,46 +172,6 @@ def test_authentication_then_rate_limit_retries_each_policy_once() -> None:
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_rate_limit_retry_landing_on_unauthorized_refreshes_once() -> None:
|
||||
async def run() -> None:
|
||||
token_calls = 0
|
||||
api_calls = 0
|
||||
|
||||
async def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
nonlocal api_calls, token_calls
|
||||
if request.url.host == "accounts.spotify.com":
|
||||
token_calls += 1
|
||||
return _token_response()
|
||||
api_calls += 1
|
||||
if api_calls == 1:
|
||||
return httpx2.Response(429, headers={"Retry-After": "0"})
|
||||
if api_calls == 2:
|
||||
return httpx2.Response(401)
|
||||
return httpx2.Response(200, json=_search_payload())
|
||||
|
||||
tracks = await _search_with_handler(handler)
|
||||
|
||||
assert len(tracks) == 1
|
||||
assert token_calls == 1
|
||||
assert api_calls == 3
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_transport_error_becomes_spotify_unavailable() -> None:
|
||||
async def run() -> None:
|
||||
async def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
raise httpx2.ConnectError("connection failed", request=request)
|
||||
|
||||
with pytest.raises(SpotifyUnavailableError) as error:
|
||||
await _search_with_handler(handler)
|
||||
|
||||
assert error.value.status_code == 504
|
||||
assert str(error.value) == "Spotify request failed"
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_request_error_includes_parsed_spotify_message() -> None:
|
||||
async def run() -> None:
|
||||
async def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
|
|
@ -316,8 +267,8 @@ def _client(http: httpx2.AsyncClient) -> SpotifyClient:
|
|||
return SpotifyClient(http, session, Settings(spotify_client_id="client"))
|
||||
|
||||
|
||||
def _token_response(access_token: str = "new-access") -> httpx2.Response:
|
||||
return httpx2.Response(200, json={"access_token": access_token, "expires_in": 3600})
|
||||
def _token_response() -> httpx2.Response:
|
||||
return httpx2.Response(200, json={"access_token": "new-access", "expires_in": 3600})
|
||||
|
||||
|
||||
def _search_payload() -> dict[str, object]:
|
||||
|
|
|
|||
15
backend/uv.lock
generated
15
backend/uv.lock
generated
|
|
@ -132,10 +132,8 @@ source = { virtual = "." }
|
|||
dependencies = [
|
||||
{ name = "anthropic" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "structlog" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
|
@ -145,17 +143,14 @@ dev = [
|
|||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-pyyaml" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "anthropic", specifier = "==0.121.0" },
|
||||
{ name = "fastapi", specifier = ">=0.116" },
|
||||
{ name = "httpx", specifier = ">=0.28" },
|
||||
{ name = "httpx2", specifier = ">=2.10" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.10" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "structlog", specifier = ">=25.4" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35" },
|
||||
]
|
||||
|
|
@ -165,7 +160,6 @@ dev = [
|
|||
{ name = "mypy", specifier = ">=1.17" },
|
||||
{ name = "pytest", specifier = ">=8.4" },
|
||||
{ name = "ruff", specifier = ">=0.12" },
|
||||
{ name = "types-pyyaml", specifier = ">=6.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -586,15 +580,6 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
target: demo
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
|
|
|
|||
199
docs/logboek.md
199
docs/logboek.md
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
Bijgehouden tijdens de bouw. Per stap: wat ik deed, waarom, wat ik heb laten
|
||||
vallen.
|
||||
|
||||
## Dag 1 - korte sessie in de avond
|
||||
|
||||
### Opzet
|
||||
|
||||
Wat ik deed:
|
||||
|
|
@ -37,7 +35,6 @@ Wat ik heb laten vallen of uitgesteld:
|
|||
- Geen apart beslisdocument. De motivering staat in de README en hier.
|
||||
|
||||
## Dag 2
|
||||
|
||||
### Spotify-koppeling
|
||||
|
||||
Wat ik deed:
|
||||
|
|
@ -276,199 +273,3 @@ Waarom:
|
|||
- Machine-geconverteerde UI-code krijgt dezelfde behandeling als de
|
||||
backend: een review-pass met concrete findings en een test-suite die de
|
||||
contractkritische randen vastzet, voordat het richting main gaat.
|
||||
|
||||
## Dag 3 - avond
|
||||
|
||||
### Live eval run
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- Alle 8 scenario's in 1 run live gedraaid, plus de baseline arm (bare
|
||||
search) voor de comparison table.
|
||||
- Het Spotify-dagquotum was eerder al een keer vol; daarom vooraf
|
||||
maatregelen genomen: scenario's sequentieel, geen retries, de server-boot
|
||||
zelf als quota-check, en live meegekeken op 429's (niet gehit).
|
||||
- Uitkomst: 4 scenario's pass, 3 vallen alleen op een te strenge
|
||||
synonym-check in de eval zelf (gefixt), en discover-new faalt echt:
|
||||
call 1 verzint bij familiarity=new titels die niet bestaan, dus er
|
||||
overleeft niets de grounding. Fix volgt in de intent prompt.
|
||||
- De refinement turn deed live 0 Spotify calls: pool reuse werkt.
|
||||
|
||||
Waarom:
|
||||
|
||||
- De comparison table in de README wil ik op echte metingen baseren, en het
|
||||
quotum maakt herhalen duur.
|
||||
|
||||
Wat ik heb laten vallen of uitgesteld:
|
||||
|
||||
- discover-new opnieuw opnemen wacht op de prompt fix.
|
||||
|
||||
### Frontend: presentatie
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- Cards komen rustig gestaggerd binnen (met reduced-motion pad), results
|
||||
en chips scrollen in eigen panelen zodat de summary zichtbaar blijft,
|
||||
klik op het logo start een nieuwe chat, fonts self-hosted.
|
||||
- Een error die voor de metadata binnenkomt is nu een echte foutmelding
|
||||
in de chat, geen transport failure. Playlist-namen hadden een dubbele
|
||||
prefix; de frontend stuurt nu de kale query. 10 nieuwe tests.
|
||||
|
||||
Waarom:
|
||||
|
||||
- Dit is wat de reviewer als eerste ziet; rustige presentatie en eerlijke
|
||||
foutmeldingen gaan voor extra features.
|
||||
|
||||
Wat ik heb laten vallen of uitgesteld:
|
||||
|
||||
- Verdere styling; de tijd gaat naar eval, demo mode en de README.
|
||||
|
||||
### Backend hardening
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- Een hardening pass over de backend randgevallen, deels gevonden via een
|
||||
adversarial review: fouten per candidate ingedamd zodat 1 kapotte
|
||||
candidate nooit de hele request breekt, quota exhaustion apart herkend
|
||||
van gewone 429's, token refresh single-flight per sessie-generatie,
|
||||
typed errors voor de intent stap, en de seed-sessie kan de boot niet
|
||||
meer laten crashen.
|
||||
- De request deadline start nu bij binnenkomst van de request en dekt
|
||||
alles tot en met grounding. De gestreamde rerank heeft bewust een eigen
|
||||
timeout: een totaalbudget zou een gezonde stream halverwege afkappen.
|
||||
- Concurrency op de grounding fan-out is nu process-wide begrensd in
|
||||
plaats van per request.
|
||||
|
||||
Waarom:
|
||||
|
||||
- Dit zijn precies de randgevallen die je in een demo niet wilt zien; ze
|
||||
nu dichtzetten is goedkoper dan er straks 1 in een review tegenkomen.
|
||||
|
||||
Wat ik heb laten vallen of uitgesteld:
|
||||
|
||||
- Een totaalbudget over de hele request heen; de afweging staat hierboven
|
||||
en komt ook in de README.
|
||||
|
||||
### Discover-new gefixt in de intent prompt
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- Het discover-new scenario faalde live steeds op de grounding: call 1
|
||||
verzon voor echte, goed gekozen artiesten generieke titels die niet
|
||||
bestaan ("Vibes", "Drift", "Shine"), en de matcher liet terecht niets
|
||||
door. Niet de matcher aangepast maar de prompt: bij familiarity=new
|
||||
alleen signature tracks en scene-anthems noemen die het model met
|
||||
zekerheid kan spellen; nieuw-voor-de-luisteraar wordt toch al
|
||||
afgedwongen door de exact-ID exclusion na grounding.
|
||||
- Live gevalideerd: het scenario levert nu 15 tracks van 16 artiesten.
|
||||
Van de 32 kandidaten vielen er 12 als verzinsel af en resolvede de
|
||||
rest; drop-over-substitute deed precies wat het moet doen.
|
||||
|
||||
Waarom:
|
||||
|
||||
- Titel-recall van een LLM stort in zodra je bewust om onbekend werk
|
||||
vraagt; om obscuriteit vragen is dan het verkeerde gereedschap. Nieuw
|
||||
en obscuur zijn geen synoniemen.
|
||||
|
||||
Wat ik heb laten vallen of uitgesteld:
|
||||
|
||||
- Een repair loop die gefaalde kandidaten opnieuw aan het model
|
||||
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.
|
||||
|
||||
## Dag 3 - afronding
|
||||
|
||||
### Demo mode
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- Demo mode af: zonder keys speelt de app de opgenomen cassettes af door
|
||||
dezelfde pipeline en hetzelfde streamingpad als live. Onbekende vragen
|
||||
krijgen het dichtstbijzijnde scenario, met een banner die dat eerlijk
|
||||
benoemt; playlist-acties zijn gesimuleerd en zo gelabeld.
|
||||
- Een eerste versie deelde replay-state tussen gelijktijdige requests;
|
||||
dat kon elkaars refinement verstoren. Nu krijgt elke request zijn
|
||||
eigen replay-pool, met een test die de botsing naspeelt.
|
||||
- Het docker image bestaat nu in twee smaken: live zonder fixtures, demo
|
||||
met. docker compose bouwt standaard de demo-smaak, dus een verse clone
|
||||
zonder keys werkt direct. Een .dockerignore bracht de build context
|
||||
van ~350 MB terug naar ~7 MB.
|
||||
|
||||
Waarom:
|
||||
|
||||
- De reviewer moet de app kunnen starten met alleen docker compose,
|
||||
zonder accounts of keys; demo mode is ook het bewijs dat de
|
||||
ports-and-adapters opzet echt is (dezelfde poorten, andere adapter).
|
||||
|
||||
Wat ik heb laten vallen of uitgesteld:
|
||||
|
||||
- Meer demo-scenario's; de 8 opgenomen zijn de suggestion chips en dat
|
||||
is genoeg voor het verhaal.
|
||||
|
||||
### Opruimen
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- De suggestion chips terug naar de wrapped look; de scrollbare box uit de
|
||||
vorige batch stond lelijker dan wat er eerst was.
|
||||
- Ongebruikte OAuth scopes weggehaald (player en recently-played; nergens
|
||||
in de code gebruikt), .env.example compleet gemaakt en AGENTS.md
|
||||
gelijkgetrokken met hoe de repo er echt uitziet.
|
||||
|
||||
Waarom:
|
||||
|
||||
- Minder scopes vragen dan je gebruikt is netter richting de reviewer en
|
||||
richting Spotify.
|
||||
|
||||
### Documentatie
|
||||
|
||||
Wat ik deed:
|
||||
|
||||
- De README geschreven: wat het is, drie manieren om het te draaien, hoe
|
||||
de pipeline werkt, de keuzes, eerlijke performance-cijfers (eerste kaart
|
||||
20 tot 30 s, gemeten via de UI en de eval), een stukje van de
|
||||
vergelijking met kale search, en een "where to look" tabel per
|
||||
beoordelingscriterium.
|
||||
- docs/workflow.md toegevoegd: hoe ik werk. De server, Forgejo met een
|
||||
GitHub-mirror, Komodo en Caddy voor de gehoste instantie, en hoe ik
|
||||
agents inzet: implementatie parallel en goedkoop, ontwerp, review en
|
||||
verificatie serieel en bij mij.
|
||||
- De gehoste preview bouwt nu bij elke merge opnieuw vanaf main.
|
||||
|
||||
Waarom:
|
||||
|
||||
- De reviewer leest de README het eerst; die moet kort zijn en naar de
|
||||
rest wijzen in plaats van alles zelf te vertellen. En de werkwijze
|
||||
uitleggen is eerlijker dan hem laten raden waarom commits in bursts
|
||||
binnenkomen.
|
||||
|
||||
Wat ik heb laten vallen of uitgesteld:
|
||||
|
||||
- Een sneller model op de intent call (zou de wachttijd flink verlagen;
|
||||
de fabrication rate is de afweging om dan te meten) staat als vervolg
|
||||
in de README, niet gebouwd.
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
# How I work
|
||||
|
||||
A short map of the setup behind this repo, because the process says as much
|
||||
as the code.
|
||||
|
||||
## The environment
|
||||
|
||||
Everything was built over SSH from a laptop (I was house-sitting for most
|
||||
of it) against my home server, a Debian box that runs my self-hosted
|
||||
infrastructure. The server carries the whole toolchain: uv and node for
|
||||
fast local feedback, Docker for the deliverable, and the deployment stack
|
||||
below. No cloud dev environment involved.
|
||||
|
||||
## Source control and CI
|
||||
|
||||
- Origin is my self-hosted Forgejo instance, with a push mirror to GitHub;
|
||||
the repo you are reading is the mirror and shows the same history and
|
||||
the same green CI.
|
||||
- CI runs on every push: ruff, mypy and pytest for the backend; typecheck,
|
||||
lint, build and vitest for the frontend. It has been green since the
|
||||
first commit.
|
||||
|
||||
## Deployment
|
||||
|
||||
- The hosted instance is a single container on the same server, built from
|
||||
this repo's Dockerfile and managed with Komodo (a self-hosted container
|
||||
control plane). It is rebuilt from main as the build advances.
|
||||
- Routing and TLS come from caddy-docker-proxy: the container carries its
|
||||
route as labels, Caddy picks them up. The streaming endpoint needed one
|
||||
deliberate setting there (no response buffering), verified through the
|
||||
public URL.
|
||||
- HTTP basic auth sits at the edge because the instance runs against my
|
||||
personal Spotify account.
|
||||
|
||||
## AI-assisted building
|
||||
|
||||
I work with coding agents, and this project was built that way end to end:
|
||||
|
||||
- One orchestrating session (Claude Code harness) holds the plan and the
|
||||
state. It briefs implementation agents (codex CLI, Opus 5 subagents) that
|
||||
build scoped tracks in their own git worktrees, in parallel.
|
||||
- Contracts come first: the API schemas and the event protocol were frozen
|
||||
before frontend and backend lanes ran in parallel against them.
|
||||
- Parallel lanes buy time where it matters. While I was working on the
|
||||
backend, a separate lane generated a ready-to-implement UI (design
|
||||
tokens, component structure, copy) against the frozen event protocol, so
|
||||
frontend implementation started from a settled design instead of a blank
|
||||
page.
|
||||
- Everything that lands is reviewed by me, commit by commit, in my editor
|
||||
before it goes in. Voice-carrying text (this file, the README, the
|
||||
logboek) I write or rewrite myself.
|
||||
- Verification against the real APIs is deliberate and budgeted: the
|
||||
Spotify development quota is a daily account-level budget, so live test
|
||||
runs are planned, sequential and measured rather than sprayed.
|
||||
- Review also runs as a tool: adversarial review passes over the diffs
|
||||
produced findings that became fix batches; the useful findings and the
|
||||
rejected ones are both traceable in the logboek.
|
||||
|
||||
The result is that implementation is cheap and parallel, while design,
|
||||
review and verification stay serial and human. The logboek
|
||||
([logboek.md](logboek.md)) records that rhythm as it happened, including
|
||||
what was dropped under time pressure.
|
||||
114
eval/baseline.py
114
eval/baseline.py
|
|
@ -1,114 +0,0 @@
|
|||
"""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",
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
284
eval/live.py
284
eval/live.py
|
|
@ -1,284 +0,0 @@
|
|||
"""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",
|
||||
)
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
"""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())
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
"""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",
|
||||
]
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
"""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",
|
||||
)
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
"""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))
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"""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())
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
"""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
46
eval/wire.py
|
|
@ -1,46 +0,0 @@
|
|||
"""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")
|
||||
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
|
|
@ -8,9 +8,6 @@
|
|||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource/public-sans": "^5.3.0",
|
||||
"vue": "^3.5.40"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -159,30 +156,6 @@
|
|||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bricolage-grotesque": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
|
||||
"integrity": "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/jetbrains-mono": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
|
||||
"integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/public-sans": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/public-sans/-/public-sans-5.3.0.tgz",
|
||||
"integrity": "sha512-kjODI0S3zdv0mBYCIQ8TbBayaiqszpc2UbhJiO3bjIqVVXzcWfHSt2o3WBCLOY3juaGaQoy4MoWCcgmfI5hCuA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
|
|
|
|||
|
|
@ -13,9 +13,6 @@
|
|||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource/public-sans": "^5.3.0",
|
||||
"vue": "^3.5.40"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const props = defineProps<{
|
|||
mode: AppMode | null
|
||||
healthFailed: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ logout: []; reset: []; toggleDev: [opener: HTMLElement] }>()
|
||||
const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>()
|
||||
|
||||
function openDevPanel(event: MouseEvent): void {
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
|
|
@ -29,18 +29,11 @@ const modeLabel = computed(() => {
|
|||
<header class="header">
|
||||
<div class="inner">
|
||||
<div class="brand">
|
||||
<button
|
||||
class="brand-control"
|
||||
type="button"
|
||||
:aria-label="messages.appHeaderBrandResetLabel"
|
||||
@click="$emit('reset')"
|
||||
>
|
||||
<span class="wordmark"
|
||||
>{{ messages.appHeaderBrandPrefix
|
||||
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
|
||||
>{{ messages.appHeaderBrandSuffix }}</span
|
||||
>
|
||||
</button>
|
||||
<span class="kicker">{{ messages.appHeaderKicker }}</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
|
|
@ -85,20 +78,6 @@ const modeLabel = computed(() => {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-control {
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.brand-control:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: var(--f-display);
|
||||
font-size: var(--t-brand);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ const isEmptyResult = computed(
|
|||
<div class="column">
|
||||
<p v-if="turn.intentSummary" class="intent">{{ turn.intentSummary }}</p>
|
||||
|
||||
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
|
||||
|
||||
<ThinkingIndicator v-if="turn.status === 'streaming'" :label="thinkingLabel" />
|
||||
|
||||
<StreamWarning
|
||||
|
|
@ -83,8 +85,6 @@ const isEmptyResult = computed(
|
|||
:url="turn.playlist.url"
|
||||
:track-count="turn.tracks.length"
|
||||
/>
|
||||
|
||||
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -112,7 +112,6 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
|
|||
:mode="mode"
|
||||
:health-failed="health.status === 'failed'"
|
||||
@logout="logout"
|
||||
@reset="reset"
|
||||
@toggle-dev="openDevPanel"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -69,13 +69,7 @@ async function scrollToLatest(): Promise<void> {
|
|||
if (!pinned.value) return
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => {
|
||||
const element = list.value
|
||||
if (!element) return
|
||||
if (latestAssistant.value?.status === 'done') {
|
||||
element.querySelector<HTMLElement>('.turn:last-child')?.scrollIntoView({ block: 'start' })
|
||||
return
|
||||
}
|
||||
element.scrollTop = element.scrollHeight
|
||||
if (list.value) list.value.scrollTop = list.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatMessage } from '../lib/messages'
|
||||
import { messages } from '../lib/messages'
|
||||
import type { TrackEvent } from '../lib/types'
|
||||
import TrackCard from './TrackCard.vue'
|
||||
|
||||
const props = defineProps<{ tracks: TrackEvent[] }>()
|
||||
const regionLabel = computed(() =>
|
||||
formatMessage(
|
||||
props.tracks.length === 1
|
||||
? 'resultSetRecommendedTrackLabel'
|
||||
: 'resultSetRecommendedTracksLabel',
|
||||
{ count: props.tracks.length },
|
||||
),
|
||||
)
|
||||
defineProps<{ tracks: TrackEvent[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="set" role="region" :aria-label="regionLabel" tabindex="0">
|
||||
<TrackCard
|
||||
v-for="(event, index) in tracks"
|
||||
:key="event.track.id"
|
||||
:event="event"
|
||||
:style="`--card-order: ${index}`"
|
||||
/>
|
||||
<div class="set" :aria-label="messages.resultSetRecommendedTracksLabel">
|
||||
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -31,18 +17,5 @@ const regionLabel = computed(() =>
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
max-height: min(42dvh, 480px);
|
||||
padding: var(--s-2) var(--s-3) var(--s-2) 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
border-block: 1px solid var(--c-line);
|
||||
scrollbar-color: var(--c-line-strong) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.set {
|
||||
max-height: min(34dvh, 360px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { messages } from '../lib/messages'
|
||||
|
||||
defineProps<{ suggestions: readonly string[] }>()
|
||||
defineEmits<{ pick: [suggestion: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chips" role="region" :aria-label="messages.suggestionChipsRegionLabel">
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
|
|
@ -27,12 +25,13 @@ defineEmits<{ pick: [suggestion: string] }>()
|
|||
}
|
||||
|
||||
.chip {
|
||||
flex: none;
|
||||
padding: var(--s-3) 15px;
|
||||
color: var(--c-text-chip);
|
||||
font-size: var(--t-small);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: var(--c-surface);
|
||||
background: var(--c-surface-raised);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: var(--r-pill);
|
||||
transition: all var(--dur-fast) ease;
|
||||
|
|
@ -43,4 +42,18 @@ defineEmits<{ pick: [suggestion: string] }>()
|
|||
border-color: var(--c-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.chips {
|
||||
flex-wrap: nowrap;
|
||||
padding: 0 var(--gutter);
|
||||
margin: 0 calc(-1 * var(--gutter));
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.chips::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ const artworkAlt = computed(() =>
|
|||
border-color var(--dur-fast) ease,
|
||||
background var(--dur-fast) ease;
|
||||
animation: card-in var(--dur-card) var(--ease) both;
|
||||
animation-delay: calc(var(--card-order, 0) * var(--dur-card-stagger));
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
PRIOR_RECOMMENDATIONS_MAX_TRACKS,
|
||||
QUERY_MAX_LENGTH,
|
||||
} from '../lib/constants'
|
||||
import { messages } from '../lib/messages'
|
||||
import { formatMessage, messages } from '../lib/messages'
|
||||
import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models'
|
||||
import { EMPTY_PLAYLIST_STATE } from '../lib/models'
|
||||
import {
|
||||
|
|
@ -101,7 +101,9 @@ function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
|
|||
}
|
||||
|
||||
function playlistName(query: string): string {
|
||||
return query.slice(0, PLAYLIST_NAME_MAX_LENGTH).trim()
|
||||
return formatMessage('useChatStreamPlaylistName', { query })
|
||||
.slice(0, PLAYLIST_NAME_MAX_LENGTH)
|
||||
.trim()
|
||||
}
|
||||
|
||||
function createTurnId(): string {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
export const messages = {
|
||||
appHeaderBrandPrefix: 'discovery',
|
||||
appHeaderBrandResetLabel: 'discovery-by-llm: start a new chat',
|
||||
appHeaderBrandAccent: '-by-',
|
||||
appHeaderBrandSuffix: 'llm',
|
||||
appHeaderKicker: 'proof of concept',
|
||||
|
|
@ -91,13 +90,10 @@ export const messages = {
|
|||
resultActionsConnectSpotify: 'Connect Spotify to save',
|
||||
resultActionsSummary: '{count} verified tracks ready for a private playlist',
|
||||
|
||||
resultSetRecommendedTrackLabel: '{count} recommended track',
|
||||
resultSetRecommendedTracksLabel: '{count} recommended tracks',
|
||||
resultSetRecommendedTracksLabel: 'Recommended tracks',
|
||||
|
||||
streamErrorRetry: 'Edit and retry',
|
||||
|
||||
suggestionChipsRegionLabel: 'Listening suggestions',
|
||||
|
||||
trackCardArtworkAlt: '{album} album artwork',
|
||||
trackCardOpenSpotify: 'Open in Spotify',
|
||||
|
||||
|
|
@ -114,6 +110,7 @@ export const messages = {
|
|||
useApiPlaylistCreationFailed: 'Playlist creation returned {status}.',
|
||||
|
||||
useChatStreamTrackCountMismatch: 'The final track count did not match the streamed results.',
|
||||
useChatStreamPlaylistName: '[discovery-by-llm] {query}',
|
||||
useChatStreamRequestReplaced: 'This request was replaced by a newer request.',
|
||||
useChatStreamUnexpectedFailure: 'The recommendation request failed unexpectedly.',
|
||||
useChatStreamTransportEvent: 'transport',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ function parseLine(line: string): StreamEvent {
|
|||
function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase {
|
||||
if (phase === 'metadata') {
|
||||
if (event.type === 'metadata') return 'events'
|
||||
if (event.type === 'error') return 'terminal'
|
||||
throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata)
|
||||
}
|
||||
if (phase === 'events') {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import { createApp } from 'vue'
|
||||
import '@fontsource/bricolage-grotesque/600.css'
|
||||
import '@fontsource/jetbrains-mono/400.css'
|
||||
import '@fontsource/public-sans/400.css'
|
||||
import '@fontsource/public-sans/500.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
|
|
|
|||
|
|
@ -74,11 +74,6 @@
|
|||
|
||||
--shadow-drawer: -24px 0 60px rgba(0, 0, 0, 0.45);
|
||||
--ease: cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
--dur-fast: 0.16s;
|
||||
--dur-card: 0.4s;
|
||||
--dur-card-stagger: 0.08s;
|
||||
--dur-reduced-motion: 0.001s;
|
||||
|
||||
color-scheme: dark;
|
||||
color: var(--c-text);
|
||||
background: var(--c-bg);
|
||||
|
|
@ -156,9 +151,7 @@ a:hover {
|
|||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-delay: var(--dur-reduced-motion) !important;
|
||||
animation-duration: var(--dur-reduced-motion) !important;
|
||||
transition-delay: var(--dur-reduced-motion) !important;
|
||||
transition-duration: var(--dur-reduced-motion) !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,23 +128,8 @@ describe('streamRecommendations', () => {
|
|||
expect(wasCancelled).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a terminal error as the only event', async () => {
|
||||
const error = { type: 'error', code: 'first_stage_failed', message: 'Planning failed.' }
|
||||
stubResponse(streamResponse([encoder.encode(`${JSON.stringify(error)}\n`)]))
|
||||
const events: StreamEvent[] = []
|
||||
|
||||
await streamRecommendations(
|
||||
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
|
||||
new AbortController().signal,
|
||||
(event) => events.push(event),
|
||||
)
|
||||
|
||||
expect(events).toEqual([error])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an event before metadata', [{ type: 'warning', code: 'early', message: 'No metadata.' }]],
|
||||
['done before metadata', [{ type: 'done', track_count: 0, total_ms: 4 }]],
|
||||
[
|
||||
'duplicate metadata',
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppHeader from '../src/components/AppHeader.vue'
|
||||
import AssistantMessage from '../src/components/AssistantMessage.vue'
|
||||
import MessageList from '../src/components/MessageList.vue'
|
||||
import SuggestionChips from '../src/components/SuggestionChips.vue'
|
||||
import type { AssistantTurn, ChatTurn } from '../src/lib/models'
|
||||
import type { TrackEvent } from '../src/lib/types'
|
||||
|
||||
const unmountCallbacks: Array<() => void> = []
|
||||
|
||||
function track(rank: number): TrackEvent {
|
||||
return {
|
||||
type: 'track',
|
||||
rank,
|
||||
track: {
|
||||
id: `track-${rank}`,
|
||||
uri: `spotify:track:${rank}`,
|
||||
title: `Track ${rank}`,
|
||||
artists: ['Artist'],
|
||||
album_name: 'Album',
|
||||
album_art_url: null,
|
||||
external_url: null,
|
||||
},
|
||||
justification: 'It fits.',
|
||||
}
|
||||
}
|
||||
|
||||
function doneTurn(): AssistantTurn {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
query: 'Focused listening',
|
||||
status: 'done',
|
||||
requestId: 'request-1',
|
||||
intentSummary: 'Calm music for focused work.',
|
||||
candidateCount: 20,
|
||||
tracks: [track(1), track(2)],
|
||||
warnings: [{ type: 'warning', code: 'limited_pool', message: 'The pool was limited.' }],
|
||||
error: null,
|
||||
transportFailure: null,
|
||||
completion: { type: 'done', track_count: 2, total_ms: 12 },
|
||||
playlist: { status: 'idle', name: null, url: null, message: null },
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(component: Component, props: Record<string, unknown>): HTMLElement {
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
render: () => h(component, props),
|
||||
}),
|
||||
)
|
||||
app.mount(root)
|
||||
unmountCallbacks.push(() => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (unmountCallbacks.length) unmountCallbacks.pop()?.()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('result presentation', () => {
|
||||
it('uses the app title as a fresh-chat control', () => {
|
||||
const onReset = vi.fn()
|
||||
const root = mountComponent(AppHeader, {
|
||||
auth: { status: 'anonymous', user: null, message: null },
|
||||
mode: 'demo',
|
||||
healthFailed: false,
|
||||
onReset,
|
||||
})
|
||||
const button = root.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="discovery-by-llm: start a new chat"]',
|
||||
)
|
||||
|
||||
expect(button?.type).toBe('button')
|
||||
expect(button?.textContent).toContain('discovery-by-llm')
|
||||
button?.click()
|
||||
expect(onReset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps completion context outside the focusable track region', () => {
|
||||
const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true })
|
||||
const region = root.querySelector<HTMLElement>(
|
||||
'[role="region"][aria-label="2 recommended tracks"]',
|
||||
)
|
||||
const summary = root.querySelector<HTMLElement>('.intent')
|
||||
const warning = root.querySelector<HTMLElement>('[data-warning-code="limited_pool"]')
|
||||
const action = root.querySelector<HTMLButtonElement>('button')
|
||||
|
||||
expect(region?.tabIndex).toBe(0)
|
||||
expect(region?.querySelectorAll('article')).toHaveLength(2)
|
||||
expect(summary?.textContent).toContain('Calm music')
|
||||
expect(warning?.textContent).toContain('pool was limited')
|
||||
expect(action?.textContent).toContain('Save as playlist')
|
||||
expect(region?.contains(summary ?? null)).toBe(false)
|
||||
expect(region?.contains(warning ?? null)).toBe(false)
|
||||
expect(region?.contains(action ?? null)).toBe(false)
|
||||
})
|
||||
|
||||
it('assigns a deliberate stagger order to arriving cards', () => {
|
||||
const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true })
|
||||
const cards = root.querySelectorAll<HTMLElement>('article')
|
||||
|
||||
expect(cards[0]?.style.getPropertyValue('--card-order')).toBe('0')
|
||||
expect(cards[1]?.style.getPropertyValue('--card-order')).toBe('1')
|
||||
})
|
||||
|
||||
it('anchors a completed response at the top of the conversation viewport', async () => {
|
||||
const scrollIntoView = vi
|
||||
.spyOn(Element.prototype, 'scrollIntoView')
|
||||
.mockImplementation(() => undefined)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
const complete = doneTurn()
|
||||
const turns = ref<ChatTurn[]>([
|
||||
{ id: 'user-1', role: 'user', text: 'Focused listening' },
|
||||
{ ...complete, status: 'streaming', completion: null },
|
||||
])
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
render: () =>
|
||||
h(MessageList, {
|
||||
turns: turns.value,
|
||||
suggestions: [],
|
||||
canSave: true,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
app.mount(root)
|
||||
unmountCallbacks.push(() => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
turns.value = [turns.value[0] as ChatTurn, complete]
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start' })
|
||||
})
|
||||
|
||||
it('keeps suggestions in a labeled region', () => {
|
||||
const onPick = vi.fn()
|
||||
const suggestions = ['Focus', 'Energy', 'Surprise']
|
||||
const root = mountComponent(SuggestionChips, { suggestions, onPick })
|
||||
const region = root.querySelector<HTMLElement>('[aria-label="Listening suggestions"]')
|
||||
const buttons = root.querySelectorAll<HTMLButtonElement>('button')
|
||||
|
||||
expect(region).not.toBeNull()
|
||||
expect(buttons).toHaveLength(suggestions.length)
|
||||
expect([...buttons].map((button) => button.textContent?.trim())).toEqual(suggestions)
|
||||
|
||||
buttons[0]?.click()
|
||||
expect(onPick).toHaveBeenCalledWith('Focus')
|
||||
})
|
||||
|
||||
it('renders a first-stage stream error with its code and message', () => {
|
||||
const turn: AssistantTurn = {
|
||||
...doneTurn(),
|
||||
status: 'error',
|
||||
requestId: null,
|
||||
intentSummary: '',
|
||||
tracks: [],
|
||||
warnings: [],
|
||||
error: { type: 'error', code: 'first_stage_failed', message: 'Planning failed.' },
|
||||
transportFailure: null,
|
||||
completion: null,
|
||||
}
|
||||
const root = mountComponent(AssistantMessage, { turn, canSave: false })
|
||||
const alert = root.querySelector<HTMLElement>('[role="alert"]')
|
||||
|
||||
expect(alert?.dataset.errorCode).toBe('first_stage_failed')
|
||||
expect(alert?.textContent).toContain('Planning failed.')
|
||||
})
|
||||
})
|
||||
|
|
@ -13,16 +13,14 @@ vi.mock('../src/lib/recommendationStream', async (importOriginal) => {
|
|||
const streamMock = vi.mocked(streamRecommendations)
|
||||
const unmountCallbacks: Array<() => void> = []
|
||||
|
||||
function mountChat(
|
||||
createPlaylist: Parameters<typeof useChatStream>[0] = vi.fn(async () => ({ url: null })),
|
||||
) {
|
||||
function mountChat() {
|
||||
let chat: ReturnType<typeof useChatStream> | undefined
|
||||
const root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
chat = useChatStream(createPlaylist)
|
||||
chat = useChatStream(vi.fn(async () => ({ url: null })))
|
||||
return () => h('div')
|
||||
},
|
||||
}),
|
||||
|
|
@ -108,22 +106,6 @@ describe('useChatStream', () => {
|
|||
expect(turn?.transportFailure).toBeNull()
|
||||
})
|
||||
|
||||
it('stores a metadata-free terminal error as a normal stream error', async () => {
|
||||
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
|
||||
onEvent({ type: 'error', code: 'first_stage_failed', message: 'Planning failed.' })
|
||||
})
|
||||
const chat = mountChat()
|
||||
|
||||
await chat.send('error')
|
||||
|
||||
expect(assistantTurns(chat)[0]).toMatchObject({
|
||||
status: 'error',
|
||||
requestId: null,
|
||||
error: { code: 'first_stage_failed', message: 'Planning failed.' },
|
||||
transportFailure: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('stores an incomplete stream as a transport failure', async () => {
|
||||
streamMock.mockRejectedValue(
|
||||
new StreamTransportError('unexpected_eof', 'The stream ended early.'),
|
||||
|
|
@ -257,26 +239,4 @@ describe('useChatStream', () => {
|
|||
|
||||
expect(receivedQuery).toHaveLength(1000)
|
||||
})
|
||||
|
||||
it('uses the bare query as the capped playlist name', async () => {
|
||||
const createPlaylist = vi.fn(async () => ({ url: null }))
|
||||
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
|
||||
onEvent(metadata('playlist'))
|
||||
onEvent(track(1))
|
||||
onEvent({ type: 'done', track_count: 1, total_ms: 5 })
|
||||
})
|
||||
const chat = mountChat(createPlaylist)
|
||||
const query = `Late-night instrumental focus ${'x'.repeat(100)}`
|
||||
|
||||
await chat.send(query)
|
||||
const turn = assistantTurns(chat)[0]
|
||||
if (!turn) throw new Error('Assistant turn was not created.')
|
||||
await chat.savePlaylist(turn.id)
|
||||
|
||||
expect(createPlaylist).toHaveBeenCalledWith({
|
||||
schema_version: 1,
|
||||
name: query.slice(0, 100),
|
||||
track_uris: ['spotify:track:1'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue