feat: add key-free demo mode replaying recorded fixtures

This commit is contained in:
Justin Visser 2026-08-11 09:14:22 +02:00
parent 0664cc2d27
commit 401a0ddea7
21 changed files with 1040 additions and 31 deletions

View file

@ -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,

View 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"

View 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

View 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

View 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),
)

View 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

View 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,
)

View file

@ -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")

View file

@ -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:

View file

@ -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

View file

@ -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",
)

View file

@ -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(