feat: add key-free demo mode replaying recorded fixtures
This commit is contained in:
parent
0664cc2d27
commit
401a0ddea7
21 changed files with 1040 additions and 31 deletions
|
|
@ -6,11 +6,15 @@ RUN npm ci
|
|||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
WORKDIR /srv
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS live
|
||||
WORKDIR /srv/backend
|
||||
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,8 +166,11 @@ class AnthropicRecommender:
|
|||
raise RecommenderOutputError("Rerank response returned too many selections")
|
||||
|
||||
|
||||
class _RecommendationObjectParser:
|
||||
class RecommendationObjectParser:
|
||||
"""Incrementally extract complete recommendation objects from JSON text."""
|
||||
|
||||
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
|
||||
|
|
@ -177,6 +180,7 @@ 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)
|
||||
|
|
@ -231,7 +235,8 @@ class _RecommendationObjectParser:
|
|||
raise RecommenderOutputError("Rerank item failed validation") from error
|
||||
|
||||
|
||||
def _to_intent(output: IntentOutput) -> Intent:
|
||||
def to_intent(output: IntentOutput) -> Intent:
|
||||
"""Map validated intent output to the application domain."""
|
||||
return Intent(
|
||||
mood=tuple(output.mood),
|
||||
activity=output.activity,
|
||||
|
|
|
|||
128
backend/app/adapters/demo/cassette.py
Normal file
128
backend/app/adapters/demo/cassette.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""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"
|
||||
89
backend/app/adapters/demo/catalog.py
Normal file
89
backend/app/adapters/demo/catalog.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""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
|
||||
78
backend/app/adapters/demo/pipeline.py
Normal file
78
backend/app/adapters/demo/pipeline.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""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
|
||||
28
backend/app/adapters/demo/playlist.py
Normal file
28
backend/app/adapters/demo/playlist.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""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),
|
||||
)
|
||||
128
backend/app/adapters/demo/recommender.py
Normal file
128
backend/app/adapters/demo/recommender.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""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
|
||||
151
backend/app/adapters/demo/scenario.py
Normal file
151
backend/app/adapters/demo/scenario.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""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,
|
||||
)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""Authenticated recommendation streaming and playlist creation routes."""
|
||||
"""Recommendation streaming and playlist creation routes."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
|
|
@ -38,6 +38,10 @@ 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()
|
||||
|
||||
|
|
@ -47,14 +51,12 @@ async def recommendations(
|
|||
request: Request,
|
||||
payload: RecommendationRequest,
|
||||
) -> StreamingResponse:
|
||||
"""Stream one authenticated discovery response as NDJSON."""
|
||||
"""Stream one session-resolved 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(
|
||||
|
|
@ -66,6 +68,8 @@ 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(
|
||||
|
|
@ -86,7 +90,7 @@ async def create_playlist(
|
|||
request: Request,
|
||||
payload: PlaylistCreateRequest,
|
||||
) -> PlaylistCreateResponse:
|
||||
"""Create and fill one authenticated Spotify playlist."""
|
||||
"""Create and fill one live or simulated playlist."""
|
||||
resolved = resolve_session(request)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""HTTP routes for Spotify login and session management."""
|
||||
"""HTTP routes for 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 Settings
|
||||
from app.config import AppMode, Settings
|
||||
|
||||
SESSION_COOKIE_NAME = "discovery_session"
|
||||
|
||||
|
|
@ -26,8 +26,10 @@ class ResolvedSession:
|
|||
|
||||
@router.get("/api/auth/login")
|
||||
def login(request: Request) -> RedirectResponse:
|
||||
"""Start Spotify Authorization Code with PKCE login."""
|
||||
"""Start the configured live or demo login flow."""
|
||||
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)
|
||||
|
|
@ -40,11 +42,13 @@ async def callback(
|
|||
state: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> RedirectResponse:
|
||||
"""Complete Spotify login and establish an opaque cookie session."""
|
||||
"""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)
|
||||
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)
|
||||
|
|
@ -80,6 +84,12 @@ 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."""
|
||||
|
|
@ -105,7 +115,11 @@ def _login_error_redirect() -> RedirectResponse:
|
|||
|
||||
|
||||
def resolve_session(request: Request) -> ResolvedSession | None:
|
||||
"""Resolve the cookie session or the installed live seed session."""
|
||||
"""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)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ 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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ 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
|
||||
|
|
@ -19,6 +24,7 @@ from app.adapters.spotify.session import PendingLogins, SessionStore, SpotifySes
|
|||
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
|
||||
|
|
@ -38,17 +44,35 @@ 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 or "unused-demo-key",
|
||||
api_key=active_settings.anthropic_api_key,
|
||||
timeout=active_settings.llm_timeout_seconds,
|
||||
http_client=anthropic_http_client,
|
||||
)
|
||||
|
|
@ -61,7 +85,15 @@ 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
|
||||
|
|
@ -141,3 +173,11 @@ 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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ 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()
|
||||
|
|
@ -157,7 +158,9 @@ class RecommendationPipeline:
|
|||
candidate_count=len(intent.candidates),
|
||||
)
|
||||
|
||||
pool = await self._grounded_pool(session_id, catalog, intent, taste, deadline_at)
|
||||
pool = await self._grounded_pool(
|
||||
session_id, catalog, intent, taste, deadline_at, seeded_pool
|
||||
)
|
||||
if not pool:
|
||||
yield PipelineErrorEvent(
|
||||
code="no_grounded_results",
|
||||
|
|
@ -189,10 +192,13 @@ class RecommendationPipeline:
|
|||
intent: Intent,
|
||||
taste: CompressedTasteProfile,
|
||||
deadline_at: float,
|
||||
seeded_pool: tuple[Track, ...] | None,
|
||||
) -> tuple[Track, ...]:
|
||||
"""Reuse the session's pool on refinement, otherwise ground anew."""
|
||||
"""Reuse the seeded or session pool on refinement, otherwise ground anew."""
|
||||
if intent.is_refinement:
|
||||
cached_pool = self.last_pools.get(session_id)
|
||||
cached_pool = (
|
||||
seeded_pool if seeded_pool is not None else self.last_pools.get(session_id)
|
||||
)
|
||||
if cached_pool:
|
||||
return cached_pool
|
||||
result = await self.grounder.ground(
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import pytest
|
||||
|
||||
from app.adapters.anthropic.llm import _RecommendationObjectParser
|
||||
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()
|
||||
parser = RecommendationObjectParser()
|
||||
|
||||
with pytest.raises(RecommenderOutputError, match="object start position"):
|
||||
parser._finish_object()
|
||||
|
|
|
|||
68
backend/tests/test_demo_adapters.py
Normal file
68
backend/tests/test_demo_adapters.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""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())
|
||||
208
backend/tests/test_demo_app.py
Normal file
208
backend/tests/test_demo_app.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""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()]
|
||||
32
backend/tests/test_demo_scenario.py
Normal file
32
backend/tests/test_demo_scenario.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""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"
|
||||
|
|
@ -22,8 +22,14 @@ def test_anthropic_client_uses_configured_timeout(monkeypatch: pytest.MonkeyPatc
|
|||
constructor = Mock(return_value=anthropic_client)
|
||||
monkeypatch.setattr("app.main.AsyncAnthropic", constructor)
|
||||
|
||||
with TestClient(create_app(Settings(llm_timeout_seconds=42.0))) as client:
|
||||
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="unused-demo-key", timeout=42.0, http_client=None)
|
||||
constructor.assert_called_once_with(api_key="api-key", timeout=42.0, http_client=None)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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
|
||||
|
|
@ -81,8 +82,13 @@ def test_recommendations_stream_lines_validate_against_frozen_schemas(
|
|||
assert [event.type for event in events] == ["metadata", "track", "done"]
|
||||
|
||||
|
||||
def test_recommendations_require_a_valid_session_without_seed() -> None:
|
||||
with TestClient(create_app()) as client:
|
||||
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:
|
||||
response = client.post(
|
||||
"/api/recommendations",
|
||||
json={"schema_version": 1, "query": "focused electronic music"},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue