Compare commits

...

13 commits

Author SHA1 Message Date
Justin Visser
a3af8f45cc fix(frontend): harden the streaming ui and extract copy and constants
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
2026-08-10 15:42:18 +02:00
Justin Visser
036ef3cc6f feat(frontend): assemble accessible application shell 2026-08-10 15:04:17 +02:00
Justin Visser
96c69507ec feat(frontend): add session and playlist API client 2026-08-10 14:46:10 +02:00
Justin Visser
47cbeac87a fix: degrade to partial results below the grounding floor 2026-08-10 14:16:59 +02:00
Justin Visser
0ccc9a5d4e fix: tolerate spotify version suffixes and surface rerank failures 2026-08-10 14:02:54 +02:00
Justin Visser
ce652d3114 fix: key users by account_id and tolerate candidate count variance 2026-08-10 13:54:36 +02:00
Justin Visser
1a0712144e docs: log the streaming api 2026-08-10 13:46:56 +02:00
Justin Visser
41aa93c3c7 feat: expose recommendation streaming api 2026-08-10 13:41:43 +02:00
Justin Visser
e8d20158e3 refactor: rebuild orchestration around a track selection 2026-08-10 13:17:33 +02:00
Justin Visser
cead39edbc feat: orchestrate grounded recommendations 2026-08-10 13:09:08 +02:00
Justin Visser
751391e6a2 feat: add recommendation service adapters 2026-08-10 12:05:59 +02:00
Justin Visser
e970bdf542 refactor: section the config and make prompts a module 2026-08-10 11:59:02 +02:00
Justin Visser
5c3ba8d6ec feat: add recommendation domain foundations 2026-08-10 11:59:02 +02:00
71 changed files with 7171 additions and 334 deletions

View file

@ -35,4 +35,5 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run typecheck - run: npm run typecheck
- run: npm run lint - run: npm run lint
- run: npm test
- run: npm run build - run: npm run build

View file

@ -0,0 +1,337 @@
"""Anthropic implementation of structured intent and streamed reranking."""
import json
import re
from collections.abc import AsyncGenerator
from typing import Literal, cast
from anthropic import AsyncAnthropic
from anthropic.lib._parse._transform import transform_schema
from anthropic.types import Message, TextBlockParam, Usage
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from app.config import Settings
from app.domain.models import (
ConversationTurn,
Familiarity,
Intent,
PreviousRecommendation,
RerankSelection,
Track,
TrackCandidate,
)
from app.observability.timing import record_llm_tokens
from app.ports.protocols import RecommenderOutputError
from app.prompts import INTENT_SYSTEM_PROMPT, RERANK_SYSTEM_PROMPT
Effort = Literal["low", "medium", "high", "xhigh", "max"]
_RECOMMENDATION_ARRAY = re.compile(r'"recommendations"\s*:\s*\[')
class CandidateOutput(BaseModel):
"""One bounded candidate in the intent response."""
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=200)
artist: str = Field(min_length=1, max_length=200)
class IntentOutput(BaseModel):
"""Bounded structured output for the intent call."""
model_config = ConfigDict(extra="forbid")
mood: list[str] = Field(default_factory=list, max_length=6)
activity: str | None = Field(default=None, max_length=100)
era: list[str] = Field(default_factory=list, max_length=5)
languages: list[str] = Field(default_factory=list, max_length=8)
genres: list[str] = Field(default_factory=list, max_length=8)
familiarity: Familiarity
is_refinement: bool
intent_summary: str = Field(min_length=1, max_length=300, pattern=r"^[^\r\n]+$")
candidates: list[CandidateOutput] = Field(min_length=10, max_length=50)
class RerankSelectionOutput(BaseModel):
"""One validated object extracted from the rerank stream."""
model_config = ConfigDict(extra="forbid")
track_id: str = Field(min_length=1, max_length=200)
justification: str = Field(min_length=1, max_length=300, pattern=r"^[^\r\n]+$")
class RerankOutput(BaseModel):
"""Complete bounded rerank output used for final validation."""
model_config = ConfigDict(extra="forbid")
recommendations: list[RerankSelectionOutput] = Field(min_length=1, max_length=50)
class AnthropicRecommender:
"""Use two Anthropic calls for intent generation and grounded ranking."""
def __init__(self, client: AsyncAnthropic, settings: Settings) -> None:
"""Bind the shared asynchronous client and immutable settings."""
self.client = client
self.settings = settings
async def create_intent(
self,
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
taste_summary: str,
candidate_count: int,
) -> Intent:
"""Interpret a request through Anthropic structured output."""
message = await self.client.messages.parse(
model=self.settings.llm_model,
max_tokens=self.settings.intent_max_tokens,
output_config={"effort": _parse_effort(self.settings.intent_effort)},
output_format=IntentOutput,
system=[_system_block(INTENT_SYSTEM_PROMPT)],
messages=[
{
"role": "user",
"content": _render_intent_input(
query,
history,
previous_recommendations,
taste_summary,
candidate_count,
),
}
],
)
_validate_stop_reason(message)
_record_usage(message.usage)
parsed = message.parsed_output
if parsed is None:
raise RecommenderOutputError("Intent response contained no structured output")
validated = IntentOutput.model_validate(parsed.model_dump())
return _to_intent(validated)
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]:
"""Yield each complete valid selection while the JSON is streaming."""
schema = transform_schema(RerankOutput.model_json_schema())
parser = _RecommendationObjectParser()
async with self.client.messages.stream(
model=self.settings.llm_model,
max_tokens=self.settings.rerank_max_tokens,
output_config={
"effort": _parse_effort(self.settings.rerank_effort),
"format": {"type": "json_schema", "schema": schema},
},
system=[_system_block(RERANK_SYSTEM_PROMPT)],
messages=[
{
"role": "user",
"content": _render_rerank_input(
intent,
grounded_tracks,
taste_summary,
history,
selection_count,
correction,
),
}
],
) as stream:
async for text_delta in stream.text_stream:
for selection in parser.feed(text_delta):
yield RerankSelection(
track_id=selection.track_id,
justification=selection.justification,
)
final_message = await stream.get_final_message()
_validate_stop_reason(final_message)
_record_usage(final_message.usage)
try:
validated = RerankOutput.model_validate_json(parser.complete_text)
except ValidationError as error:
raise RecommenderOutputError("Rerank response failed final validation") from error
if len(validated.recommendations) > selection_count:
raise RecommenderOutputError("Rerank response returned too many selections")
class _RecommendationObjectParser:
def __init__(self) -> None:
self.complete_text = ""
self._scan_index = 0
self._object_start: int | None = None
self._object_depth = 0
self._is_in_string = False
self._is_escaped = False
self._has_found_array = False
def feed(self, text_delta: str) -> list[RerankSelectionOutput]:
self.complete_text += text_delta
if not self._has_found_array:
match = _RECOMMENDATION_ARRAY.search(self.complete_text)
if match is None:
return []
self._has_found_array = True
self._scan_index = match.end()
selections: list[RerankSelectionOutput] = []
while self._scan_index < len(self.complete_text):
character = self.complete_text[self._scan_index]
completed = self._scan_character(character)
self._scan_index += 1
if completed is not None:
selections.append(completed)
return selections
def _scan_character(self, character: str) -> RerankSelectionOutput | None:
if self._object_start is None:
if character == "{":
self._object_start = self._scan_index
self._object_depth = 1
return None
if self._is_in_string:
if self._is_escaped:
self._is_escaped = False
elif character == "\\":
self._is_escaped = True
elif character == '"':
self._is_in_string = False
return None
if character == '"':
self._is_in_string = True
elif character == "{":
self._object_depth += 1
elif character == "}":
self._object_depth -= 1
if self._object_depth == 0:
return self._finish_object()
return None
def _finish_object(self) -> RerankSelectionOutput:
assert self._object_start is not None
object_text = self.complete_text[self._object_start : self._scan_index + 1]
self._object_start = None
try:
return RerankSelectionOutput.model_validate_json(object_text)
except ValidationError as error:
raise RecommenderOutputError("Rerank item failed validation") from error
def _to_intent(output: IntentOutput) -> Intent:
return Intent(
mood=tuple(output.mood),
activity=output.activity,
era=tuple(output.era),
languages=tuple(output.languages),
genres=tuple(output.genres),
familiarity=output.familiarity,
is_refinement=output.is_refinement,
intent_summary=output.intent_summary,
candidates=tuple(
TrackCandidate(title=candidate.title, artist=candidate.artist)
for candidate in output.candidates
),
)
def _render_intent_input(
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
taste_summary: str,
candidate_count: int,
) -> str:
payload = {
"query": query,
"history": [turn.__dict__ for turn in history],
"prior_recommendations": [
recommendation.__dict__ for recommendation in previous_recommendations
],
"taste_profile": taste_summary,
"required_candidate_count": candidate_count,
}
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
def _render_rerank_input(
intent: Intent,
grounded_tracks: tuple[Track, ...],
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection_count: int,
correction: str | None,
) -> str:
payload = {
"intent": {
"mood": intent.mood,
"activity": intent.activity,
"era": intent.era,
"languages": intent.languages,
"genres": intent.genres,
"familiarity": intent.familiarity,
"intent_summary": intent.intent_summary,
},
"grounded_pool": [
{"track_id": track.id, "title": track.title, "artists": track.artists}
for track in grounded_tracks
],
"taste_profile": taste_summary,
"history": [turn.__dict__ for turn in history],
"requested_selection_count": selection_count,
"correction": correction,
}
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
def _system_block(prompt: str) -> TextBlockParam:
return {
"type": "text",
"text": prompt,
"cache_control": {"type": "ephemeral"},
}
def _parse_effort(value: str) -> Effort:
allowed = {"low", "medium", "high", "xhigh", "max"}
if value not in allowed:
raise ValueError(f"Unsupported Anthropic effort: {value}")
return cast(Effort, value)
def _validate_stop_reason(message: Message) -> None:
stop_reason_messages = {
None: "Anthropic response had no stop reason",
"max_tokens": "Anthropic response reached its token limit",
"stop_sequence": "Anthropic response hit an unexpected stop sequence",
"tool_use": "Anthropic response attempted tool use",
"pause_turn": "Anthropic response paused before completion",
"refusal": "Anthropic response was refused",
"model_context_window_exceeded": "Anthropic context window was exceeded",
}
if message.stop_reason == "end_turn":
return
raise RecommenderOutputError(
stop_reason_messages.get(message.stop_reason, "Anthropic response stopped unexpectedly")
)
def _record_usage(usage: Usage) -> None:
cache_creation_tokens = usage.cache_creation_input_tokens or 0
cache_read_tokens = usage.cache_read_input_tokens or 0
record_llm_tokens(
usage.input_tokens + cache_creation_tokens + cache_read_tokens,
usage.output_tokens,
)

View file

@ -12,15 +12,22 @@ from app.adapters.spotify.errors import (
SpotifyUnavailableError, SpotifyUnavailableError,
) )
from app.adapters.spotify.mapping import ( from app.adapters.spotify.mapping import (
CreatedPlaylist,
CurrentUser, CurrentUser,
parse_created_playlist, parse_created_playlist,
parse_current_user, parse_current_user,
parse_saved_track_page,
parse_search_tracks, parse_search_tracks,
parse_top_artists,
parse_track_page,
) )
from app.adapters.spotify.session import SpotifySession from app.adapters.spotify.session import SpotifySession
from app.config import Settings from app.config import Settings
from app.domain.models import Track from app.domain.models import CreatedPlaylist, Track
from app.observability.timing import increment_spotify_calls
from app.ports.protocols import TimeRange
SPOTIFY_SEARCH_LIMIT = 10
SPOTIFY_PAGE_LIMIT = 50
class SpotifyClient: class SpotifyClient:
@ -39,6 +46,8 @@ class SpotifyClient:
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]: async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Search Spotify tracks and return only valid mapped results.""" """Search Spotify tracks and return only valid mapped results."""
if not 1 <= limit <= SPOTIFY_SEARCH_LIMIT:
raise ValueError("Spotify search limit must be between 1 and 10")
response = await self._request( response = await self._request(
"GET", "GET",
"/search", "/search",
@ -46,6 +55,40 @@ class SpotifyClient:
) )
return parse_search_tracks(response.json()) return parse_search_tracks(response.json())
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
"""Fetch the user's top artists for a supported time range."""
response = await self._request(
"GET",
"/me/top/artists",
params={"time_range": time_range, "limit": limit},
)
return parse_top_artists(response.json())
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
"""Fetch the user's top tracks for a supported time range."""
response = await self._request(
"GET",
"/me/top/tracks",
params={"time_range": time_range, "limit": limit},
)
return parse_track_page(response.json())
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
"""Fetch a bounded saved-track sample across Spotify pages."""
tracks: list[Track] = []
while len(tracks) < limit:
page_limit = min(SPOTIFY_PAGE_LIMIT, limit - len(tracks))
response = await self._request(
"GET",
"/me/tracks",
params={"limit": page_limit, "offset": len(tracks)},
)
page = parse_saved_track_page(response.json())
tracks.extend(page)
if len(page) < page_limit:
break
return tracks
async def fetch_current_user(self) -> CurrentUser: async def fetch_current_user(self) -> CurrentUser:
"""Fetch the authenticated Spotify user's stable identity.""" """Fetch the authenticated Spotify user's stable identity."""
response = await self._request("GET", "/me") response = await self._request("GET", "/me")
@ -110,6 +153,7 @@ class SpotifyClient:
params: dict[str, str | int] | None, params: dict[str, str | int] | None,
json: dict[str, object] | None, json: dict[str, object] | None,
) -> httpx2.Response: ) -> httpx2.Response:
increment_spotify_calls()
return await self.http.request( return await self.http.request(
method, method,
f"{self.settings.spotify_api_base_url.rstrip('/')}{path}", f"{self.settings.spotify_api_base_url.rstrip('/')}{path}",

View file

@ -1,5 +1,7 @@
"""Typed failures raised by the Spotify adapter.""" """Typed failures raised by the Spotify adapter."""
from app.ports.protocols import CatalogQuotaExhaustedError
class SpotifyError(Exception): class SpotifyError(Exception):
"""Base class for Spotify adapter failures.""" """Base class for Spotify adapter failures."""
@ -9,7 +11,7 @@ class SpotifyAuthenticationError(SpotifyError):
"""Spotify rejected authentication or token refresh.""" """Spotify rejected authentication or token refresh."""
class SpotifyRateLimitedError(SpotifyError): class SpotifyRateLimitedError(SpotifyError, CatalogQuotaExhaustedError):
"""Spotify rate limited a request that could not be retried.""" """Spotify rate limited a request that could not be retried."""
def __init__(self, retry_after_seconds: float | None, reason: str | None = None) -> None: def __init__(self, retry_after_seconds: float | None, reason: str | None = None) -> None:

View file

@ -4,7 +4,8 @@ from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import cast from typing import cast
from app.domain.models import Track from app.domain.matching import track_key
from app.domain.models import CreatedPlaylist, Track
@dataclass(frozen=True) @dataclass(frozen=True)
@ -15,14 +16,6 @@ class CurrentUser:
display_name: str display_name: str
@dataclass(frozen=True)
class CreatedPlaylist:
"""The application-owned result of creating a Spotify playlist."""
id: str
url: str
def parse_search_tracks(payload: object) -> list[Track]: def parse_search_tracks(payload: object) -> list[Track]:
"""Map valid Spotify search items and discard malformed entries.""" """Map valid Spotify search items and discard malformed entries."""
root = _as_mapping(payload) root = _as_mapping(payload)
@ -32,17 +25,54 @@ def parse_search_tracks(payload: object) -> list[Track]:
return [] return []
parsed_tracks: list[Track] = [] parsed_tracks: list[Track] = []
seen_ids: set[str] = set()
seen_keys: set[str] = set()
for item in items: for item in items:
parsed_track = _parse_track(item) parsed_track = _parse_track(item)
if parsed_track is not None: if parsed_track is None:
continue
normalized_key = track_key(parsed_track)
if parsed_track.id in seen_ids or normalized_key in seen_keys:
continue
seen_ids.add(parsed_track.id)
seen_keys.add(normalized_key)
parsed_tracks.append(parsed_track) parsed_tracks.append(parsed_track)
return parsed_tracks return parsed_tracks
def parse_top_artists(payload: object) -> list[str]:
"""Map a top-artists page to valid artist names."""
root = _as_mapping(payload)
items = root.get("items") if root is not None else None
if not isinstance(items, list):
return []
return [
name for item in items if (name := _required_string(_as_mapping(item), "name")) is not None
]
def parse_track_page(payload: object) -> list[Track]:
"""Map a direct Spotify track page to domain tracks."""
root = _as_mapping(payload)
items = root.get("items") if root is not None else None
return _parse_track_items(items)
def parse_saved_track_page(payload: object) -> list[Track]:
"""Map a saved-track wrapper page to domain tracks."""
root = _as_mapping(payload)
items = root.get("items") if root is not None else None
if not isinstance(items, list):
return []
return _parse_track_items(
[wrapper.get("track") for item in items if (wrapper := _as_mapping(item)) is not None]
)
def parse_current_user(payload: object) -> CurrentUser: def parse_current_user(payload: object) -> CurrentUser:
"""Map a Spotify current-user response into stable identity fields.""" """Map a Spotify current-user response into stable identity fields."""
root = _as_mapping(payload) root = _as_mapping(payload)
account_id = _required_string(root, "account_id") account_id = _required_string(root, "account_id") or _required_string(root, "id")
display_name = _required_string(root, "display_name") display_name = _required_string(root, "display_name")
if account_id is None or display_name is None: if account_id is None or display_name is None:
raise ValueError("Spotify returned an invalid current-user response") raise ValueError("Spotify returned an invalid current-user response")
@ -90,6 +120,12 @@ def _parse_track(payload: object) -> Track | None:
) )
def _parse_track_items(payload: object) -> list[Track]:
if not isinstance(payload, list):
return []
return [track for item in payload if (track := _parse_track(item)) is not None]
def _parse_artists(payload: object) -> tuple[str, ...] | None: def _parse_artists(payload: object) -> tuple[str, ...] | None:
if not isinstance(payload, list) or not payload: if not isinstance(payload, list) or not payload:
return None return None

View file

@ -0,0 +1,178 @@
"""Authenticated recommendation streaming and playlist creation routes."""
import asyncio
from collections.abc import AsyncIterator, Callable
from typing import cast
import structlog
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
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 (
NDJSON_CONTENT_TYPE,
DoneEvent,
ErrorEvent,
MetadataEvent,
PlaylistCreateRequest,
PlaylistCreateResponse,
RecommendationRequest,
StreamEvent,
TrackCard,
TrackEvent,
WarningEvent,
)
from app.config import Settings
from app.domain.models import ConversationTurn, PreviousRecommendation
from app.pipeline.event import (
PipelineDoneEvent,
PipelineErrorEvent,
PipelineEvent,
PipelineMetadataEvent,
PipelineTrackEvent,
PipelineWarningEvent,
)
from app.pipeline.orchestrator import RecommendationPipeline
from app.ports.protocols import MusicCatalog, PlaylistWriter
SpotifyClientFactory = Callable[[SpotifySession], MusicCatalog | PlaylistWriter]
router = APIRouter()
@router.post("/api/recommendations")
async def recommendations(
request: Request,
payload: RecommendationRequest,
) -> StreamingResponse:
"""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(
PreviousRecommendation(
item.rank,
item.track_id,
item.title,
tuple(item.artists),
)
for item in payload.prior_recommendations
)
return StreamingResponse(
_stream_lines(
request,
pipeline,
resolved.session_id,
request_id,
catalog,
payload.query,
history,
previous,
),
media_type=NDJSON_CONTENT_TYPE,
)
@router.post("/api/playlists", response_model=PlaylistCreateResponse)
async def create_playlist(
request: Request,
payload: PlaylistCreateRequest,
) -> PlaylistCreateResponse:
"""Create and fill one authenticated Spotify playlist."""
resolved = resolve_session(request)
if resolved is None:
raise HTTPException(status_code=401, detail="Not authenticated")
settings = cast(Settings, request.app.state.settings)
factory = cast(SpotifyClientFactory, request.app.state.spotify_client_factory)
writer = cast(PlaylistWriter, factory(resolved.session))
playlist_name = f"{settings.playlist_name_prefix} {payload.name}"
try:
playlist = await writer.create_playlist(
playlist_name,
"Music discovery selected by the listener.",
)
await writer.add_tracks_to_playlist(playlist.id, payload.track_uris)
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
return PlaylistCreateResponse(url=playlist.url)
async def _stream_lines(
request: Request,
pipeline: RecommendationPipeline,
session_id: str,
request_id: str,
catalog: MusicCatalog,
query: str,
history: tuple[ConversationTurn, ...],
previous: tuple[PreviousRecommendation, ...],
) -> AsyncIterator[str]:
event_stream = pipeline.stream(
session_id,
request_id,
catalog,
query,
history,
previous,
)
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
except Exception as error:
structlog.get_logger().exception(
"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.",
)
yield f"{failure.model_dump_json()}\n"
finally:
await event_stream.aclose()
def _to_wire_event(event: PipelineEvent) -> StreamEvent:
if isinstance(event, PipelineMetadataEvent):
return MetadataEvent(
request_id=event.request_id,
intent_summary=event.intent_summary,
candidate_count=event.candidate_count,
)
if isinstance(event, PipelineTrackEvent):
track = event.track
return TrackEvent(
rank=event.rank,
track=TrackCard(
id=track.id,
uri=track.uri,
title=track.title,
artists=list(track.artists),
album_name=track.album_name,
album_art_url=track.album_art_url,
external_url=track.external_url,
),
justification=event.justification,
)
if isinstance(event, PipelineWarningEvent):
return WarningEvent(code=event.code, message=event.message)
if isinstance(event, PipelineErrorEvent):
return ErrorEvent(code=event.code, message=event.message)
if isinstance(event, PipelineDoneEvent):
return DoneEvent(track_count=event.track_count, total_ms=event.total_ms)
raise AssertionError("Unhandled pipeline event")

View file

@ -1,5 +1,6 @@
"""HTTP routes for Spotify login and session management.""" """HTTP routes for Spotify login and session management."""
from dataclasses import dataclass
from typing import cast from typing import cast
import httpx2 import httpx2
@ -7,7 +8,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, RedirectResponse, Response from fastapi.responses import JSONResponse, RedirectResponse, Response
from app.adapters.spotify.login import begin_login, complete_login from app.adapters.spotify.login import begin_login, complete_login
from app.adapters.spotify.session import PendingLogins, SessionStore from app.adapters.spotify.session import PendingLogins, SessionStore, SpotifySession
from app.config import Settings from app.config import Settings
SESSION_COOKIE_NAME = "discovery_session" SESSION_COOKIE_NAME = "discovery_session"
@ -15,6 +16,14 @@ SESSION_COOKIE_NAME = "discovery_session"
router = APIRouter() router = APIRouter()
@dataclass(frozen=True)
class ResolvedSession:
"""A session and stable cache key selected for one request."""
session_id: str
session: SpotifySession
@router.get("/api/auth/login") @router.get("/api/auth/login")
def login(request: Request) -> RedirectResponse: def login(request: Request) -> RedirectResponse:
"""Start Spotify Authorization Code with PKCE login.""" """Start Spotify Authorization Code with PKCE login."""
@ -65,12 +74,10 @@ async def callback(
@router.get("/api/auth/me") @router.get("/api/auth/me")
def current_session(request: Request) -> JSONResponse: def current_session(request: Request) -> JSONResponse:
"""Return the display name for a valid application session.""" """Return the display name for a valid application session."""
session_id = request.cookies.get(SESSION_COOKIE_NAME) resolved = resolve_session(request)
session_store = cast(SessionStore, request.app.state.session_store) if resolved is None:
session = session_store.get(session_id) if session_id is not None else None
if session is None:
return JSONResponse({"detail": "Not authenticated"}, status_code=401) return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return JSONResponse({"display_name": session.display_name}) return JSONResponse({"display_name": resolved.session.display_name})
@router.post("/api/auth/logout", status_code=204) @router.post("/api/auth/logout", status_code=204)
@ -95,3 +102,19 @@ def logout(request: Request) -> Response:
def _login_error_redirect() -> RedirectResponse: def _login_error_redirect() -> RedirectResponse:
return RedirectResponse("/?login=error", status_code=307) return RedirectResponse("/?login=error", status_code=307)
def resolve_session(request: Request) -> ResolvedSession | None:
"""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:
cookie_session = session_store.get(cookie_session_id)
if cookie_session is not None:
return ResolvedSession(cookie_session_id, cookie_session)
seed_session_id = cast(str | None, getattr(request.app.state, "seed_session_id", None))
seed_session = session_store.get(seed_session_id) if seed_session_id is not None else None
if seed_session is None or seed_session_id is None:
return None
return ResolvedSession(seed_session_id, seed_session)

View file

@ -18,12 +18,67 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_mode: AppMode = AppMode.DEMO app_mode: AppMode = AppMode.DEMO
# Spotify application and OAuth. The redirect URI must exactly match a
# URI registered in the Spotify developer dashboard, port included.
spotify_client_id: str = "" spotify_client_id: str = ""
spotify_redirect_uri: str = "http://127.0.0.1:8888/callback" spotify_redirect_uri: str = "http://127.0.0.1:8888/callback"
spotify_api_base_url: str = "https://api.spotify.com/v1" spotify_api_base_url: str = "https://api.spotify.com/v1"
# Set behind TLS so the session cookie is never sent over plain HTTP.
session_cookie_secure: bool = False
# A pre-authorized refresh token installs a session at startup, so a
# hosted instance works without an interactive login.
spotify_seed_refresh_token: str = ""
# Spotify transport. A Retry-After above the cap fails the request
# instead of silently holding it open for seconds.
spotify_timeout_seconds: float = 10.0 spotify_timeout_seconds: float = 10.0
spotify_retry_after_cap_seconds: float = 5.0 spotify_retry_after_cap_seconds: float = 5.0
session_cookie_secure: bool = False
# LLM provider. Effort steers reasoning depth per call: intent is a
# recall task, reranking benefits from more deliberation.
anthropic_api_key: str = ""
llm_model: str = "claude-sonnet-5"
intent_effort: str = "low"
rerank_effort: str = "medium"
# Ceilings include adaptive thinking tokens, which is why they sit far
# above the size of the structured output itself.
intent_max_tokens: int = 16384
rerank_max_tokens: int = 16384
# Pipeline shape. candidate_count is the main call-1 latency lever and
# the hallucination budget: at "new to you" familiarity a large share of
# proposed tracks fails verification, so breadth keeps the pool filled.
# The buffer gives the reranker real choices beyond the shown count.
candidate_count: int = 35
rerank_count: int = 15
rerank_pool_buffer: int = 5
# Grounding. Search is capped at 10 results per call, so resolving is a
# fan-out; concurrency 6 stays far under the limiter (40 wide drew no
# 429s when measured). Below the floor the response is an honest error
# instead of a thin list. The similarity threshold rejects wrong tracks
# while tolerating punctuation and edition noise.
grounding_concurrency: int = 6
grounding_floor: int = 8
title_similarity_threshold: float = 0.82
request_deadline_seconds: float = 25.0
# In-process caches, single instance by design; a shared store is the
# first production step. Resolution entries are small, so the bound is
# generous; taste rarely shifts within a session.
resolution_cache_ttl_seconds: float = 3600.0
resolution_cache_max_entries: int = 2048
taste_profile_ttl_seconds: float = 900.0
# Taste profile fetch bounds: enough signal to describe a listener
# without paging through an entire library on session start.
top_items_limit: int = 50
saved_tracks_limit: int = 100
# Created playlists carry a fixed prefix so they can be found and
# removed in bulk afterwards.
playlist_name_prefix: str = "discovery-by-llm"
settings = Settings() settings = Settings()

View file

@ -0,0 +1,85 @@
"""Pure normalization and conservative candidate matching."""
import re
import unicodedata
from dataclasses import dataclass
from difflib import SequenceMatcher
from app.domain.models import Track, TrackCandidate
_SUFFIX_PATTERN = re.compile(r"(?:\s*(?:\([^)]*\)|\[[^]]*\]))+\s*$")
_DASH_SUFFIX_PATTERN = re.compile(r"\s+-\s+[^-]+$")
@dataclass(frozen=True)
class MatchVerdict:
"""Match decision plus the two signals it was derived from.
A rejection is attributable: either title_similarity fell below the
caller's threshold, or is_artist_match is false, or both.
"""
is_match: bool
title_similarity: float
is_artist_match: bool
def normalize_text(value: str) -> str:
"""Normalize names for stable comparisons without transport knowledge."""
decomposed = unicodedata.normalize("NFKD", value.casefold())
without_marks = "".join(
character for character in decomposed if not unicodedata.combining(character)
)
without_suffix = _SUFFIX_PATTERN.sub("", without_marks)
words = "".join(character if character.isalnum() else " " for character in without_suffix)
return " ".join(words.split())
def title_similarity(candidate_title: str, track_title: str) -> float:
"""Return normalized title similarity, tolerating version dash suffixes."""
normalized_candidate = normalize_text(candidate_title)
full_similarity = _ratio(normalized_candidate, normalize_text(track_title))
# Spotify appends version info as "Title - Remaster 2023"; some tracks
# only exist in suffixed releases. The tiny penalty keeps an exact
# original title ahead of a suffixed release at equal similarity.
stripped_title = _DASH_SUFFIX_PATTERN.sub("", track_title)
stripped_similarity = _ratio(normalized_candidate, normalize_text(stripped_title)) - 0.001
return max(full_similarity, stripped_similarity)
def _ratio(left: str, right: str) -> float:
return SequenceMatcher(None, left, right).ratio()
def artist_matches(candidate_artist: str, track_artists: tuple[str, ...]) -> bool:
"""Require one normalized Spotify artist to equal the proposed artist."""
normalized_candidate = normalize_text(candidate_artist)
return bool(normalized_candidate) and any(
normalize_text(track_artist) == normalized_candidate for track_artist in track_artists
)
def judge_candidate_match(
candidate: TrackCandidate,
track: Track,
title_threshold: float,
) -> MatchVerdict:
"""Accept only a similar title paired with a near-exact artist."""
similarity = title_similarity(candidate.title, track.title)
is_artist_match = artist_matches(candidate.artist, track.artists)
return MatchVerdict(
is_match=similarity >= title_threshold and is_artist_match,
title_similarity=similarity,
is_artist_match=is_artist_match,
)
def candidate_key(candidate: TrackCandidate) -> str:
"""Build the normalized cache key for a proposed title and artist."""
return f"{normalize_text(candidate.title)}\x00{normalize_text(candidate.artist)}"
def track_key(track: Track) -> str:
"""Build the normalized title and primary-artist deduplication key."""
primary_artist = track.artists[0] if track.artists else ""
return f"{normalize_text(track.title)}\x00{normalize_text(primary_artist)}"

View file

@ -1,6 +1,7 @@
"""Pure domain models shared across application boundaries.""" """Pure domain models shared across application boundaries."""
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum
@dataclass(frozen=True) @dataclass(frozen=True)
@ -14,3 +15,87 @@ class Track:
album_name: str album_name: str
album_art_url: str | None album_art_url: str | None
external_url: str | None external_url: str | None
@dataclass(frozen=True)
class TrackCandidate:
"""A title and artist pair proposed for Spotify resolution."""
title: str
artist: str
@dataclass(frozen=True)
class ConversationTurn:
"""One bounded user or assistant message supplied by the client."""
role: str
content: str
@dataclass(frozen=True)
class PreviousRecommendation:
"""One earlier recommendation available to a refinement request."""
rank: int
track_id: str
title: str
artists: tuple[str, ...]
class Familiarity(StrEnum):
"""How strongly a request should favor known or unknown music."""
FAMILIAR = "familiar"
MIX = "mix"
NEW = "new"
@dataclass(frozen=True)
class Intent:
"""Structured interpretation and candidate set for a discovery query."""
mood: tuple[str, ...]
activity: str | None
era: tuple[str, ...]
languages: tuple[str, ...]
genres: tuple[str, ...]
familiarity: Familiarity
is_refinement: bool
intent_summary: str
candidates: tuple[TrackCandidate, ...]
@dataclass(frozen=True)
class RerankSelection:
"""One grounded track selected by the recommender."""
track_id: str
justification: str
@dataclass(frozen=True)
class TasteProfile:
"""Bounded Spotify taste signals collected for one session."""
short_term_artists: tuple[str, ...]
long_term_artists: tuple[str, ...]
short_term_tracks: tuple[Track, ...]
long_term_tracks: tuple[Track, ...]
saved_tracks: tuple[Track, ...]
@dataclass(frozen=True)
class CompressedTasteProfile:
"""Prompt-ready taste text plus exact known Spotify track identifiers."""
text: str
known_track_ids: frozenset[str]
@dataclass(frozen=True)
class CreatedPlaylist:
"""The application-owned result of creating a Spotify playlist."""
id: str
url: str

View file

@ -0,0 +1,31 @@
"""Compress Spotify taste signals for prompts and known-track filtering."""
from app.domain.models import CompressedTasteProfile, TasteProfile, Track
def compress_taste_profile(profile: TasteProfile) -> CompressedTasteProfile:
"""Build compact prompt text and the complete known track identifier set."""
# No genre section on purpose: Spotify deprecated the artist genres
# field for new apps, so there is no reliable genre source left in the
# API. Artist and track names are the genre evidence the model reads.
sections = (
_line("Short-term top artists", profile.short_term_artists),
_line("Long-term top artists", profile.long_term_artists),
_line("Short-term top tracks", _track_labels(profile.short_term_tracks)),
_line("Long-term top tracks", _track_labels(profile.long_term_tracks)),
_line("Saved-track sample", _track_labels(profile.saved_tracks)),
)
known_tracks = (*profile.short_term_tracks, *profile.long_term_tracks, *profile.saved_tracks)
return CompressedTasteProfile(
text="\n".join(sections),
known_track_ids=frozenset(track.id for track in known_tracks),
)
def _track_labels(tracks: tuple[Track, ...]) -> tuple[str, ...]:
return tuple(f"{track.title} by {', '.join(track.artists)}" for track in tracks)
def _line(label: str, values: tuple[str, ...]) -> str:
rendered_values = "; ".join(values) if values else "none"
return f"{label}: {rendered_values}"

View file

@ -1,16 +1,24 @@
"""Application factory and wiring. No logic lives here.""" """Application factory and dependency wiring."""
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import httpx2 import httpx2
from anthropic import AsyncAnthropic
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.adapters.spotify.session import PendingLogins, SessionStore from app.adapters.anthropic.llm import AnthropicRecommender
from app.adapters.spotify.auth import TokenSet, refresh_access_token
from app.adapters.spotify.client import SpotifyClient
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.api.routes import router
from app.config import AppMode, Settings, settings from app.config import AppMode, Settings, settings
from app.observability.logging import configure_logging
from app.observability.timing import RequestTimingMiddleware
from app.pipeline.orchestrator import RecommendationPipeline
FRONTEND_DIST = Path(__file__).parent / "static" FRONTEND_DIST = Path(__file__).parent / "static"
@ -21,11 +29,11 @@ def create_app(
) -> FastAPI: ) -> FastAPI:
"""Build the FastAPI app: API routes plus the built SPA on one port.""" """Build the FastAPI app: API routes plus the built SPA on one port."""
active_settings = application_settings or settings active_settings = application_settings or settings
configure_logging(active_settings.app_mode)
@asynccontextmanager @asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncIterator[None]: async def lifespan(application: FastAPI) -> AsyncIterator[None]:
if active_settings.app_mode is AppMode.LIVE and not active_settings.spotify_client_id: _validate_live_settings(active_settings)
raise RuntimeError("SPOTIFY_CLIENT_ID is required in live mode")
async with httpx2.AsyncClient( async with httpx2.AsyncClient(
timeout=active_settings.spotify_timeout_seconds, timeout=active_settings.spotify_timeout_seconds,
transport=http_transport, transport=http_transport,
@ -34,7 +42,33 @@ def create_app(
application.state.session_store = SessionStore() application.state.session_store = SessionStore()
application.state.pending_logins = PendingLogins() application.state.pending_logins = PendingLogins()
application.state.settings = active_settings 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"
)
application.state.anthropic = anthropic_client
application.state.recommendation_pipeline = RecommendationPipeline(
AnthropicRecommender(anthropic_client, active_settings),
active_settings,
)
def spotify_client_factory(session: SpotifySession) -> SpotifyClient:
return SpotifyClient(http, session, active_settings)
application.state.spotify_client_factory = spotify_client_factory
if (
active_settings.app_mode is AppMode.LIVE
and active_settings.spotify_seed_refresh_token
):
application.state.seed_session_id = await _install_seed_session(
http,
application.state.session_store,
active_settings,
)
try:
yield yield
finally:
await anthropic_client.close()
app = FastAPI( app = FastAPI(
title="discovery-by-llm", title="discovery-by-llm",
@ -42,12 +76,14 @@ def create_app(
redoc_url=None, redoc_url=None,
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware(RequestTimingMiddleware)
@app.get("/api/health") @app.get("/api/health")
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok", "mode": active_settings.app_mode} return {"status": "ok", "mode": active_settings.app_mode}
app.include_router(router) app.include_router(router)
app.include_router(recommendations_router)
if FRONTEND_DIST.is_dir(): if FRONTEND_DIST.is_dir():
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="spa") app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="spa")
@ -55,3 +91,41 @@ def create_app(
app = create_app() app = create_app()
def _validate_live_settings(application_settings: Settings) -> None:
if application_settings.app_mode is not AppMode.LIVE:
return
if not application_settings.spotify_client_id:
raise RuntimeError("SPOTIFY_CLIENT_ID is required in live mode")
if not application_settings.anthropic_api_key:
raise RuntimeError("ANTHROPIC_API_KEY is required in live mode")
async def _install_seed_session(
http: httpx2.AsyncClient,
session_store: SessionStore,
application_settings: Settings,
) -> str:
tokens = await refresh_access_token(
http,
client_id=application_settings.spotify_client_id,
tokens=TokenSet(
access_token="seed-bootstrap",
refresh_token=application_settings.spotify_seed_refresh_token,
expires_at=0.0,
),
)
bootstrap_session = SpotifySession(tokens=tokens, account_id="", display_name="")
current_user = await SpotifyClient(
http,
bootstrap_session,
application_settings,
).fetch_current_user()
return session_store.create(
SpotifySession(
tokens=bootstrap_session.tokens,
account_id=current_user.account_id,
display_name=current_user.display_name,
)
)

View file

@ -0,0 +1,30 @@
"""Configure structlog for machine-readable live logs and readable development logs."""
import logging
import structlog
from app.config import AppMode
def configure_logging(app_mode: AppMode) -> None:
"""Install the process logging pipeline for the selected runtime mode."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
renderer: structlog.types.Processor
if app_mode is AppMode.LIVE:
renderer = structlog.processors.JSONRenderer()
else:
renderer = structlog.dev.ConsoleRenderer(colors=False)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
renderer,
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)

View file

@ -0,0 +1,101 @@
"""Request timing context and the counters emitted with completion logs."""
import time
import uuid
from contextvars import ContextVar
from dataclasses import dataclass
from typing import cast
import structlog
from starlette.types import ASGIApp, Receive, Scope, Send
from structlog.contextvars import bind_contextvars, reset_contextvars
@dataclass
class RequestCounters:
"""Mutable counters scoped to one ASGI request context."""
spotify_calls: int = 0
cache_hits: int = 0
llm_input_tokens: int = 0
llm_output_tokens: int = 0
_COUNTERS: ContextVar[RequestCounters | None] = ContextVar("request_counters", default=None)
_REQUEST_ID: ContextVar[str | None] = ContextVar("request_id", default=None)
def increment_spotify_calls() -> None:
"""Count one Spotify HTTP request when a request context is active."""
counters = _COUNTERS.get()
if counters is not None:
counters.spotify_calls += 1
def increment_cache_hits() -> None:
"""Count one in-process cache hit when a request context is active."""
counters = _COUNTERS.get()
if counters is not None:
counters.cache_hits += 1
def record_llm_tokens(input_tokens: int, output_tokens: int) -> None:
"""Accumulate model token usage when the provider reports it."""
counters = _COUNTERS.get()
if counters is not None:
counters.llm_input_tokens += input_tokens
counters.llm_output_tokens += output_tokens
def current_request_id() -> str | None:
"""Return the active request identifier when one exists."""
return _REQUEST_ID.get()
class RequestTimingMiddleware:
"""Log duration and counters after the complete response body is sent."""
def __init__(self, app: ASGIApp) -> None:
"""Wrap an ASGI application."""
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Install request context and measure an HTTP exchange."""
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request_id = uuid.uuid4().hex
state = scope.setdefault("state", {})
cast(dict[str, object], state)["request_id"] = request_id
started_at = time.monotonic()
counters = RequestCounters()
counter_token = _COUNTERS.set(counters)
request_token = _REQUEST_ID.set(request_id)
logging_tokens = bind_contextvars(request_id=request_id)
try:
await self.app(scope, receive, send)
finally:
self._log_completion(scope, request_id, started_at, counters)
reset_contextvars(**logging_tokens)
_COUNTERS.reset(counter_token)
_REQUEST_ID.reset(request_token)
@staticmethod
def _log_completion(
scope: Scope,
request_id: str,
started_at: float,
counters: RequestCounters,
) -> None:
structlog.get_logger().info(
"request_complete",
request_id=request_id,
method=scope.get("method"),
path=scope.get("path"),
duration_ms=round((time.monotonic() - started_at) * 1000),
spotify_calls=counters.spotify_calls,
cache_hits=counters.cache_hits,
llm_input_tokens=counters.llm_input_tokens,
llm_output_tokens=counters.llm_output_tokens,
)

View file

@ -0,0 +1,62 @@
"""Application-owned events emitted by the recommendation pipeline."""
from dataclasses import dataclass, field
from typing import Literal
from app.domain.models import Track
@dataclass(frozen=True)
class PipelineMetadataEvent:
"""Describe the interpreted request before track results."""
request_id: str
intent_summary: str
candidate_count: int
type: Literal["metadata"] = field(default="metadata", init=False)
@dataclass(frozen=True)
class PipelineTrackEvent:
"""Carry one ranked, grounded recommendation."""
rank: int
track: Track
justification: str
type: Literal["track"] = field(default="track", init=False)
@dataclass(frozen=True)
class PipelineWarningEvent:
"""Report a non-terminal degradation."""
code: str
message: str
type: Literal["warning"] = field(default="warning", init=False)
@dataclass(frozen=True)
class PipelineErrorEvent:
"""Report a terminal recommendation failure."""
code: str
message: str
type: Literal["error"] = field(default="error", init=False)
@dataclass(frozen=True)
class PipelineDoneEvent:
"""Report final track count and elapsed time."""
track_count: int
total_ms: int
type: Literal["done"] = field(default="done", init=False)
type PipelineEvent = (
PipelineMetadataEvent
| PipelineTrackEvent
| PipelineWarningEvent
| PipelineErrorEvent
| PipelineDoneEvent
)

View file

@ -0,0 +1,346 @@
"""Resolve proposed tracks with bounded concurrency and conservative matching."""
import asyncio
import time
from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
import structlog
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
from app.observability.timing import increment_cache_hits
from app.ports.protocols import CatalogQuotaExhaustedError, MusicCatalog
class ResolutionStatus(StrEnum):
"""Terminal outcome of one candidate resolution attempt."""
RESOLVED = "resolved"
MISS = "miss"
MISMATCH = "mismatch"
QUOTA = "quota"
@dataclass(frozen=True)
class GroundingMetrics:
"""Separate resolver outcomes for operational visibility."""
attempted_count: int
miss_count: int
mismatch_guard_count: int
cache_hit_count: int
did_reach_deadline: bool
did_exhaust_quota: bool
@property
def miss_rate(self) -> float:
"""Return the share of attempted candidates with no search result."""
return self.miss_count / self.attempted_count if self.attempted_count else 0.0
@property
def mismatch_guard_rate(self) -> float:
"""Return the share rejected by client-side identity checks."""
return self.mismatch_guard_count / self.attempted_count if self.attempted_count else 0.0
@dataclass(frozen=True)
class GroundingResult:
"""Resolved pool and its operational metrics."""
tracks: tuple[Track, ...]
metrics: GroundingMetrics
@dataclass(frozen=True)
class _ResolutionAttempt:
index: int
status: ResolutionStatus
track: Track | None = None
is_cache_hit: bool = False
class ResolutionCache:
"""Bound successful name resolutions by age and least-recent use."""
def __init__(
self,
ttl_seconds: float,
max_entries: int,
clock: Callable[[], float] = time.monotonic,
) -> None:
"""Create an empty successful-resolution cache."""
self.ttl_seconds = ttl_seconds
self.max_entries = max_entries
self.clock = clock
self._entries: OrderedDict[str, tuple[float, Track]] = OrderedDict()
def get(self, key: str) -> Track | None:
"""Return a fresh cached track and refresh its recency."""
entry = self._entries.get(key)
if entry is None:
return None
created_at, track = entry
if self.clock() - created_at >= self.ttl_seconds:
del self._entries[key]
return None
self._entries.move_to_end(key)
increment_cache_hits()
return track
def put(self, key: str, track: Track) -> None:
"""Cache a successful unambiguous resolution."""
if self.max_entries <= 0 or self.ttl_seconds <= 0:
return
self._entries[key] = (self.clock(), track)
self._entries.move_to_end(key)
while len(self._entries) > self.max_entries:
self._entries.popitem(last=False)
class Grounder:
"""Build a safe Spotify pool with early stop and bounded fan-out."""
def __init__(self, settings: Settings, cache: ResolutionCache | None = None) -> None:
"""Bind resolver settings and a process-local resolution cache."""
self.settings = settings
self.cache = cache or ResolutionCache(
settings.resolution_cache_ttl_seconds,
settings.resolution_cache_max_entries,
)
async def ground(
self,
catalog: MusicCatalog,
candidates: tuple[TrackCandidate, ...],
known_track_ids: frozenset[str],
familiarity: Familiarity,
pool_target: int,
) -> GroundingResult:
"""Resolve candidates until the target, deadline, or quota boundary."""
accepted: dict[int, Track] = {}
seen_ids: set[str] = set()
seen_keys: set[str] = set()
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
next_index = self._launch_tasks(
catalog,
candidates,
pending,
next_index,
semaphore,
)
if not pending:
break
remaining_seconds = deadline_at - time.monotonic()
if remaining_seconds <= 0:
metrics.did_reach_deadline = True
break
done, _ = await asyncio.wait(
pending,
timeout=remaining_seconds,
return_when=asyncio.FIRST_COMPLETED,
)
if not done:
metrics.did_reach_deadline = True
break
self._collect_done(
done,
pending,
accepted,
seen_ids,
seen_keys,
known_track_ids,
familiarity,
pool_target,
metrics,
)
if metrics.did_exhaust_quota:
break
finally:
await _cancel_tasks(tuple(pending))
tracks = tuple(track for _, track in sorted(accepted.items()))
if familiarity is Familiarity.FAMILIAR:
tracks = tuple(sorted(tracks, key=lambda track: track.id not in known_track_ids))
result = GroundingResult(tracks=tracks, metrics=metrics.freeze())
_log_grounding(result)
return result
def _launch_tasks(
self,
catalog: MusicCatalog,
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], semaphore)
)
pending[task] = next_index
next_index += 1
return next_index
def _collect_done(
self,
done: set[asyncio.Task[_ResolutionAttempt]],
pending: dict[asyncio.Task[_ResolutionAttempt], int],
accepted: dict[int, Track],
seen_ids: set[str],
seen_keys: set[str],
known_track_ids: frozenset[str],
familiarity: Familiarity,
pool_target: int,
metrics: "_MutableMetrics",
) -> None:
for task in sorted(done, key=pending.__getitem__):
del pending[task]
attempt = task.result()
metrics.record(attempt)
if attempt.status is ResolutionStatus.QUOTA:
continue
track = attempt.track
if track is None or len(accepted) >= pool_target:
continue
if familiarity is Familiarity.NEW and track.id in known_track_ids:
continue
normalized_key = track_key(track)
if track.id in seen_ids or normalized_key in seen_keys:
continue
seen_ids.add(track.id)
seen_keys.add(normalized_key)
accepted[attempt.index] = track
async def _resolve(
self,
catalog: MusicCatalog,
index: int,
candidate: TrackCandidate,
semaphore: asyncio.Semaphore,
) -> _ResolutionAttempt:
async with semaphore:
return await self._resolve_with_slot(catalog, index, candidate)
async def _resolve_with_slot(
self,
catalog: MusicCatalog,
index: int,
candidate: TrackCandidate,
) -> _ResolutionAttempt:
key = candidate_key(candidate)
cached_track = self.cache.get(key)
if cached_track is not None:
return _ResolutionAttempt(
index=index,
status=ResolutionStatus.RESOLVED,
track=cached_track,
is_cache_hit=True,
)
try:
field_results = await catalog.search_tracks(_field_query(candidate))
matched_track = _best_match(
candidate, field_results, self.settings.title_similarity_threshold
)
if matched_track is None:
bare_results = await catalog.search_tracks(f"{candidate.title} {candidate.artist}")
matched_track = _best_match(
candidate,
bare_results,
self.settings.title_similarity_threshold,
)
else:
bare_results = []
except CatalogQuotaExhaustedError:
return _ResolutionAttempt(index=index, status=ResolutionStatus.QUOTA)
if matched_track is not None:
self.cache.put(key, matched_track)
return _ResolutionAttempt(
index=index, status=ResolutionStatus.RESOLVED, track=matched_track
)
status = (
ResolutionStatus.MISMATCH if field_results or bare_results else ResolutionStatus.MISS
)
structlog.get_logger().info(
"candidate_unresolved",
title=candidate.title,
artist=candidate.artist,
status=status,
result_count=len(field_results) + len(bare_results),
)
return _ResolutionAttempt(index=index, status=status)
@dataclass
class _MutableMetrics:
attempted_count: int = 0
miss_count: int = 0
mismatch_guard_count: int = 0
cache_hit_count: int = 0
did_reach_deadline: bool = False
did_exhaust_quota: bool = False
def record(self, attempt: _ResolutionAttempt) -> None:
self.attempted_count += 1
self.miss_count += attempt.status is ResolutionStatus.MISS
self.mismatch_guard_count += attempt.status is ResolutionStatus.MISMATCH
self.cache_hit_count += attempt.is_cache_hit
self.did_exhaust_quota = self.did_exhaust_quota or attempt.status is ResolutionStatus.QUOTA
def freeze(self) -> GroundingMetrics:
return GroundingMetrics(
attempted_count=self.attempted_count,
miss_count=self.miss_count,
mismatch_guard_count=self.mismatch_guard_count,
cache_hit_count=self.cache_hit_count,
did_reach_deadline=self.did_reach_deadline,
did_exhaust_quota=self.did_exhaust_quota,
)
def _best_match(candidate: TrackCandidate, tracks: list[Track], threshold: float) -> Track | None:
verdicts = ((judge_candidate_match(candidate, track, threshold), track) for track in tracks)
accepted = [
(verdict.title_similarity, track) for verdict, track in verdicts if verdict.is_match
]
return max(accepted, key=lambda item: item[0])[1] if accepted else None
def _field_query(candidate: TrackCandidate) -> str:
title = candidate.title.replace('"', " ")
artist = candidate.artist.replace('"', " ")
return f'track:"{title}" artist:"{artist}"'
async def _cancel_tasks(tasks: tuple[asyncio.Task[_ResolutionAttempt], ...]) -> None:
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
def _log_grounding(result: GroundingResult) -> None:
metrics = result.metrics
structlog.get_logger().info(
"grounding_complete",
track_count=len(result.tracks),
attempted_count=metrics.attempted_count,
miss_rate=metrics.miss_rate,
mismatch_guard_rate=metrics.mismatch_guard_rate,
cache_hits=metrics.cache_hit_count,
deadline_reached=metrics.did_reach_deadline,
quota_exhausted=metrics.did_exhaust_quota,
)

View file

@ -0,0 +1,308 @@
"""Compose taste, intent, grounding, and reranking into streamed events."""
import asyncio
import time
from collections.abc import AsyncGenerator, Iterator
from contextlib import aclosing
import structlog
from app.config import Settings
from app.domain.models import (
CompressedTasteProfile,
ConversationTurn,
Intent,
PreviousRecommendation,
TasteProfile,
Track,
)
from app.domain.profile import compress_taste_profile
from app.observability.timing import increment_cache_hits
from app.pipeline.event import (
PipelineDoneEvent,
PipelineErrorEvent,
PipelineEvent,
PipelineMetadataEvent,
PipelineTrackEvent,
PipelineWarningEvent,
)
from app.pipeline.grounding import Grounder
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."
RERANK_FALLBACK_JUSTIFICATION = "Selected as a grounded match for your request."
class _TrackSelection:
"""Rank tracks from one grounded pool, enforcing bound and uniqueness."""
def __init__(self, pool: tuple[Track, ...], limit: int) -> None:
"""Bind the only tracks that may ever be selected."""
self.pool = pool
self.limit = limit
self.selected: list[Track] = []
self._tracks_by_id = {track.id: track for track in pool}
self._selected_ids: set[str] = set()
@property
def is_full(self) -> bool:
"""Return whether the selection reached its limit."""
return len(self.selected) >= self.limit
@property
def remaining_count(self) -> int:
"""Return how many further selections are allowed."""
return self.limit - len(self.selected)
def describe_selected_ids(self) -> str:
"""Render the ids already emitted, for a correction instruction."""
return ", ".join(sorted(self._selected_ids)) or "none"
def select(self, track_id: str, justification: str) -> PipelineTrackEvent:
"""Accept one recommender selection or reject it as invalid output."""
if self.is_full:
raise RecommenderOutputError("Rerank returned too many track ids")
track = self._tracks_by_id.get(track_id)
if track is None:
raise RecommenderOutputError("Rerank selected an out-of-pool track id")
if track_id in self._selected_ids:
raise RecommenderOutputError("Rerank selected a duplicate track id")
return self._emit(track, justification)
def fill_from_pool(self, justification: str) -> Iterator[PipelineTrackEvent]:
"""Complete the selection in pool order after a failed rerank."""
for track in self.pool:
if self.is_full:
return
if track.id not in self._selected_ids:
yield self._emit(track, justification)
def _emit(self, track: Track, justification: str) -> PipelineTrackEvent:
self._selected_ids.add(track.id)
self.selected.append(track)
return PipelineTrackEvent(
rank=len(self.selected),
track=track,
justification=justification,
)
class RecommendationPipeline:
"""Orchestrate the code-defined recommendation stages."""
def __init__(
self,
recommender: Recommender,
settings: Settings,
grounder: Grounder | None = None,
) -> None:
"""Create process-local caches around the provided service ports."""
self.recommender = recommender
self.settings = settings
self.grounder = grounder or Grounder(settings)
self.taste_cache = _TasteProfileCache(settings)
self.last_pools: dict[str, tuple[Track, ...]] = {}
async def stream(
self,
session_id: str,
request_id: str,
catalog: MusicCatalog,
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
) -> AsyncGenerator[PipelineEvent]:
"""Yield ordered events for one recommendation request."""
started_at = time.monotonic()
taste = await self.taste_cache.get(session_id, catalog)
intent = await self.recommender.create_intent(
query,
history,
previous_recommendations,
taste.text,
self.settings.candidate_count,
)
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)
if not pool:
yield PipelineErrorEvent(
code="no_grounded_results",
message="None of the proposed tracks could be verified on Spotify.",
)
return
if len(pool) < self.settings.grounding_floor:
# Fewer verified tracks than promised is still an answer; an
# empty error in its place would hide real results.
yield PipelineWarningEvent(
code="partial_results",
message="Fewer tracks than usual could be verified; showing what held up.",
)
selection = _TrackSelection(pool, self.settings.rerank_count)
async for event in self._ranked_events(intent, taste.text, history, selection):
yield event
_log_completion(selection, taste)
yield PipelineDoneEvent(
track_count=len(selection.selected),
total_ms=round((time.monotonic() - started_at) * 1000),
)
async def _grounded_pool(
self,
session_id: str,
catalog: MusicCatalog,
intent: Intent,
taste: CompressedTasteProfile,
) -> tuple[Track, ...]:
"""Reuse the session's pool on refinement, otherwise ground anew."""
if intent.is_refinement:
cached_pool = self.last_pools.get(session_id)
if cached_pool:
return cached_pool
result = await self.grounder.ground(
catalog,
intent.candidates,
taste.known_track_ids,
intent.familiarity,
self.settings.rerank_count + self.settings.rerank_pool_buffer,
)
if result.tracks:
self.last_pools[session_id] = result.tracks
return result.tracks
async def _ranked_events(
self,
intent: Intent,
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection: _TrackSelection,
) -> AsyncGenerator[PipelineTrackEvent | PipelineWarningEvent]:
"""Stream the rerank with one corrected retry, then fall back."""
try:
async for event in self._rerank_with_one_retry(
intent, taste_summary, history, selection
):
yield event
return
except RecommenderOutputError as error:
_log_rerank_failure(attempt=2, error=error)
yield PipelineWarningEvent(code=RERANK_FALLBACK_CODE, message=RERANK_FALLBACK_MESSAGE)
for event in selection.fill_from_pool(RERANK_FALLBACK_JUSTIFICATION):
yield event
async def _rerank_with_one_retry(
self,
intent: Intent,
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection: _TrackSelection,
) -> AsyncGenerator[PipelineTrackEvent]:
"""Rerank once; on invalid output, retry once with a correction."""
try:
async for event in self._rerank_once(intent, taste_summary, history, selection, None):
yield event
return
except RecommenderOutputError as error:
_log_rerank_failure(attempt=1, error=error)
if selection.is_full:
return
correction = (
f"Validation failed: {error}."
f" Already emitted track ids: {selection.describe_selected_ids()}."
)
async for event in self._rerank_once(intent, taste_summary, history, selection, correction):
yield event
async def _rerank_once(
self,
intent: Intent,
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection: _TrackSelection,
correction: str | None,
) -> AsyncGenerator[PipelineTrackEvent]:
stream = self.recommender.stream_rerank(
intent,
selection.pool,
taste_summary,
history,
selection.remaining_count,
correction,
)
async with aclosing(stream) as selections:
async for item in selections:
yield selection.select(item.track_id, item.justification)
def _log_rerank_failure(attempt: int, error: RecommenderOutputError) -> None:
"""Log one invalid rerank attempt with its validation reason."""
structlog.get_logger().warning("rerank_attempt_failed", attempt=attempt, error=str(error))
def _log_completion(selection: _TrackSelection, taste: CompressedTasteProfile) -> None:
"""Log how many recommendations were served and how many are new."""
new_track_count = sum(track.id not in taste.known_track_ids for track in selection.selected)
structlog.get_logger().info(
"recommendations_complete",
track_count=len(selection.selected),
new_track_count=new_track_count,
)
class _TasteProfileCache:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._entries: dict[str, tuple[float, CompressedTasteProfile]] = {}
self._locks: dict[str, asyncio.Lock] = {}
async def get(self, session_id: str, catalog: MusicCatalog) -> CompressedTasteProfile:
cached = self._fresh_entry(session_id)
if cached is not None:
increment_cache_hits()
return cached
lock = self._locks.setdefault(session_id, asyncio.Lock())
async with lock:
cached = self._fresh_entry(session_id)
if cached is not None:
increment_cache_hits()
return cached
compressed = await self._fetch(catalog)
self._entries[session_id] = (time.monotonic(), compressed)
return compressed
def _fresh_entry(self, session_id: str) -> CompressedTasteProfile | None:
entry = self._entries.get(session_id)
if entry is None:
return None
created_at, profile = entry
if time.monotonic() - created_at >= self.settings.taste_profile_ttl_seconds:
del self._entries[session_id]
return None
return profile
async def _fetch(self, catalog: MusicCatalog) -> CompressedTasteProfile:
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),
)
return compress_taste_profile(
TasteProfile(
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),
)
)

View file

@ -0,0 +1,82 @@
"""Structural ports implemented by external service adapters."""
from collections.abc import AsyncGenerator
from typing import Literal, Protocol
from app.domain.models import (
ConversationTurn,
CreatedPlaylist,
Intent,
PreviousRecommendation,
RerankSelection,
Track,
)
TimeRange = Literal["short_term", "long_term"]
class CatalogQuotaExhaustedError(Exception):
"""A catalog quota stopped further resolution attempts."""
class RecommenderOutputError(Exception):
"""The recommender returned unusable structured output."""
class MusicCatalog(Protocol):
"""Read the restricted Spotify surface used by discovery."""
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Search tracks by fielded or bare-text query."""
...
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
"""Fetch bounded top artist names for one time range."""
...
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
"""Fetch bounded top tracks for one time range."""
...
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
"""Fetch a bounded sample of saved tracks."""
...
class Recommender(Protocol):
"""Interpret discovery intent and stream a grounded reranking."""
async def create_intent(
self,
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
taste_summary: str,
candidate_count: int,
) -> Intent:
"""Interpret a query and propose a bounded candidate set."""
...
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]:
"""Stream validated selections from the grounded pool."""
...
class PlaylistWriter(Protocol):
"""Write a Spotify playlist without retrying ambiguous mutations."""
async def create_playlist(self, name: str, description: str) -> CreatedPlaylist:
"""Create a private playlist."""
...
async def add_tracks_to_playlist(self, playlist_id: str, track_uris: list[str]) -> None:
"""Add ordered Spotify track URIs to a playlist."""
...

161
backend/app/prompts.py Normal file
View file

@ -0,0 +1,161 @@
"""System prompts for the two LLM calls. Prompts are data: edit them here."""
INTENT_SYSTEM_PROMPT = """\
You are the intent interpreter and candidate generator for a music discovery system. Your
job is to understand one listener request, use the supplied taste evidence carefully, and
propose real recordings that another service can resolve against Spotify. Return only data
that conforms to the provided output schema. Do not add prose before or after the
structured response.
Interpret the request in context. The variable input can contain a current query, a bounded
conversation history, recommendations from an earlier response, and a compact taste
profile. Treat the current query as authoritative. Use history only to resolve references,
continuity, or explicit changes such as "more like the third one" or "less energetic."
Prior recommendations are evidence about what the listener has just seen, not proof that
the listener likes every item. The Spotify taste profile is evidence of listening behavior,
not a complete identity and not permission to stereotype the listener.
Populate every schema field honestly. Mood is a short bounded list of useful musical or
emotional qualities. Activity is a concise listening context when one is stated or strongly
implied, otherwise null. Era contains only time periods that matter to the request.
Languages contains requested or strongly implied vocal languages; use an empty list when
language is irrelevant or the music can be instrumental. Genres should be specific enough
to guide selection without inventing a false precision. Familiarity must be familiar, mix,
or new. Use familiar when the listener asks for comfort, favorites, known songs, or
reliable crowd recognition. Use new when the listener asks for discovery, obscurity,
unfamiliar music, or a departure from their habits. Use mix for a balanced bridge or when
the request does not justify either extreme.
Set is_refinement to true only when the listener is revising, narrowing, extending, or
referring to an earlier recommendation result in the supplied conversation. A standalone
request is not a refinement merely because history exists. The intent summary must be one
plain-English sentence that tells the listener how the request was understood. It must not
mention internal models, candidate generation, schemas, retrieval, or Spotify search
behavior.
Generate the exact candidate count requested in the variable input. Each candidate contains
only a track title and the credited artist name most likely to identify the recording.
Choose recordings that plausibly exist in Spotify's catalog. Use canonical spellings. Do
not fabricate tracks, mash up titles and artists, translate titles, or use descriptive
placeholders. Avoid remixes, live versions, remasters, edits, sped-up versions, slowed
versions, karaoke versions, covers, and tribute recordings unless the user explicitly asks
for them. When several recordings share a title, choose the artist credit that makes the
intended recording unambiguous.
Candidate quality matters more than superficial variety. Every candidate should fit the
interpreted mood, activity, era, language, genre, and familiarity. Still, spread the set
across artists. Do not let one artist dominate the candidate list, even when that artist
appears prominently in the taste profile. Avoid duplicate titles by the same artist and
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, 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
stories, chart facts, cultural identities, lyrical meanings, or audio features. Do not
infer sensitive traits from taste. Explicit safety or content constraints in the request
are binding. If a request is broad, create a coherent interpretation instead of asking a
question. If constraints conflict, prioritize explicit exclusions, then the current query,
then history, then taste evidence.
The downstream resolver first tries an exact field-filtered search and then a fuzzy
bare-text search. Help it succeed with correct title and artist spelling. It will reject
weak title or artist matches, so substituting a vaguely related track wastes a candidate.
Prefer a confidently identifiable recording over an obscure item whose title or credit you
cannot state accurately. Do not include Spotify identifiers, album names, explanations,
scores, or justifications in candidate objects.
Before returning, silently check that the response matches the schema, the candidate list
has exactly the requested size, familiarity uses the allowed value, is_refinement reflects
conversation continuity, the intent summary is one line, spellings are credible, artist
distribution is broad, and no candidate violates an explicit exclusion. Return strictly the
structured response and nothing else.
"""
RERANK_SYSTEM_PROMPT = """\
You are the final ranking component of a music discovery system. Select and order tracks
only from the grounded Spotify pool supplied in the variable input. Return only data that
conforms to the provided JSON schema. Output strictly the JSON object required by that
schema, with no markdown, no code fence, no introductory sentence, and no trailing
commentary.
The variable input contains the interpreted intent, a compact listener taste summary,
bounded conversation history, and a grounded pool. Every pool entry includes a Spotify
track id, title, and artist names. The pool is the complete set of allowed choices. Copy
track_id values exactly. Never create, alter, guess, shorten, or normalize an id. Never
select a title that is absent from the pool, even if it would be a better recommendation.
Never return the same track id twice.
Choose up to the requested selection count, ordered from strongest to weakest
recommendation. Prefer a shorter set of honest strong fits over padding with clearly
unsuitable material, but normally fill the requested count when the pool contains enough
relevant choices. Ranking should respond to the current intent first. Use the taste summary
to personalize among plausible fits, not to override an explicit request. Use history to
understand refinements and references. Treat prior assistant statements as conversation
context rather than verified facts about a recording.
Respect the interpreted mood, activity, era, languages, genres, and familiarity together. A
track need not satisfy every soft descriptor equally, but the ordering should form a
coherent listening path. Put the clearest overall matches early. Consider transitions in
energy, texture, and familiarity when that creates a more useful sequence, while avoiding a
mechanical pattern. Spread selections across artists where the grounded pool permits it. Do
not rank several editions of the same recording merely to fill space.
Familiarity affects ordering, but it does not grant access to information outside the
input. For familiar requests, favor grounded tracks that visibly connect to the supplied
taste evidence or prior recommendations. For new requests, favor credible adjacent
discoveries and avoid leaning entirely on artists named in the taste summary. For mix
requests, use recognizable anchors and exploratory choices in a coherent balance. The
application may already have filtered known tracks, so do not claim that any selection is
definitely new or previously unheard.
Write one concise, plain-English justification for each selection. It should connect the
track to the stated intent and, only when supported, to an explicit item or pattern in the
supplied taste summary. Keep it to one line. Make the reason useful to the listener rather
than describing internal ranking operations. Good reasons identify a grounded connection
such as pacing for the activity, a genre bridge, a requested era, a compatible vocal
language, a mood transition, or continuity with a named preference.
Be exact and modest. Do not invent audio measurements, tempo values, key signatures,
instrumentation, lyrical subjects, release dates, artist biographies, chart history,
cultural significance, collaborations, popularity, or claims about what the listener has
heard. A title and artist name alone do not prove detailed sonic or lyrical facts. You may
use stable general musical knowledge when confident, but phrase the justification around
the supplied intent and visible evidence. If the inputs do not support a specific fact, use
a restrained reason such as "A focused fit for the requested late-night electronic mood"
rather than manufacturing detail.
Do not mention Spotify search, grounding, the candidate generator, language models,
schemas, hidden scores, safety filters, cache state, or missing data in a justification. Do
not tell the listener that a track was selected because it was available in the pool. Do
not compare a selection with tracks that are not in the pool. Do not repeat the same
generic sentence for every item. Avoid promotional language and absolute claims such as
"perfect," "guaranteed," or "the best."
When the variable input says this is a correction attempt, treat the stated validation
failure as a strict constraint. Return a fresh complete JSON object, not a patch or
explanation. Correct invalid ids, duplicates, malformed fields, count problems, and
formatting errors using only the supplied pool. The correction instruction never allows an
out-of-pool id.
The response must be one JSON object with the exact top-level field required by the schema.
Each array item must contain exactly a track_id and justification in the required types.
Preserve the requested ranking order in the array. Use valid JSON quoting and escaping. Do
not emit comments, dangling commas, alternate keys, null justifications, numeric ids, or
additional properties.
Before returning, silently verify every track_id against the supplied pool, ensure all ids
are unique, ensure the number of items does not exceed the requested count, confirm each
justification is honest and one line, confirm the ranking follows the current intent, and
confirm the entire response is valid against the provided JSON schema. Return only the
JSON object.
"""

View file

@ -4,9 +4,11 @@ version = "0.1.0"
description = "LLM-driven music discovery on the Spotify Web API" description = "LLM-driven music discovery on the Spotify Web API"
requires-python = "==3.13.*" requires-python = "==3.13.*"
dependencies = [ dependencies = [
"anthropic==0.121.0",
"fastapi>=0.116", "fastapi>=0.116",
"httpx2>=2.10", "httpx2>=2.10",
"pydantic-settings>=2.10", "pydantic-settings>=2.10",
"structlog>=25.4",
"uvicorn[standard]>=0.35", "uvicorn[standard]>=0.35",
] ]

View file

@ -90,5 +90,32 @@ def test_spotify_failure_during_callback_redirects_to_login_error() -> None:
assert response.headers["location"] == "/?login=error" assert response.headers["location"] == "/?login=error"
def test_seed_session_authenticates_requests_without_a_cookie() -> None:
async def spotify_handler(request: httpx2.Request) -> httpx2.Response:
if request.url.host == "accounts.spotify.com":
return httpx2.Response(200, json={"access_token": "seed-access", "expires_in": 3600})
assert request.url.path == "/v1/me"
return httpx2.Response(200, json={"id": "seed-account", "display_name": "Seed Listener"})
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) as client:
response = client.get("/api/auth/me")
assert response.status_code == 200
assert response.json() == {"display_name": "Seed Listener"}
def _live_settings() -> Settings: def _live_settings() -> Settings:
return Settings(app_mode=AppMode.LIVE, spotify_client_id="client-id") return Settings(
app_mode=AppMode.LIVE,
spotify_client_id="client-id",
anthropic_api_key="test-key",
)

View file

@ -0,0 +1,153 @@
"""Deterministic tests for bounded Spotify grounding."""
import asyncio
from collections.abc import Awaitable, Callable
from app.config import Settings
from app.domain.models import Familiarity, Track, TrackCandidate
from app.pipeline.grounding import Grounder
from app.ports.protocols import TimeRange
SearchHandler = Callable[[str], Awaitable[list[Track]]]
class FakeCatalog:
"""Expose a programmable search surface for grounding tests."""
def __init__(self, search_handler: SearchHandler) -> None:
"""Store the search behavior and call trace."""
self.search_handler = search_handler
self.search_queries: list[str] = []
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Record and delegate one fake search."""
self.search_queries.append(query)
return await self.search_handler(query)
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
"""Return no top artists."""
return []
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
"""Return no top tracks."""
return []
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
"""Return no saved tracks."""
return []
def test_early_stop_honors_pool_target() -> None:
async def run() -> None:
async def search(query: str) -> list[Track]:
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"track-{index}", "Artist") for index in range(6))
result = await grounder.ground(catalog, candidates, frozenset(), Familiarity.MIX, 2)
assert len(result.tracks) == 2
assert len(catalog.search_queries) == 2
asyncio.run(run())
def test_miss_and_mismatch_are_counted_separately() -> None:
async def run() -> None:
async def search(query: str) -> list[Track]:
if "Missing" in query:
return []
return [_track("wrong", "Different Song", "Different Artist")]
catalog = FakeCatalog(search)
candidates = (
TrackCandidate("Missing", "Artist"),
TrackCandidate("Rejected", "Artist"),
)
result = await Grounder(_settings()).ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
2,
)
assert result.metrics.miss_count == 1
assert result.metrics.mismatch_guard_count == 1
assert result.metrics.attempted_count == 2
asyncio.run(run())
def test_resolution_cache_hit_skips_catalog() -> None:
async def run() -> None:
async def search(query: str) -> list[Track]:
return [_track("cached", "Cached Song", "Artist")]
catalog = FakeCatalog(search)
grounder = Grounder(_settings())
candidates = (TrackCandidate("Cached Song", "Artist"),)
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)
assert len(catalog.search_queries) == first_call_count
assert second.metrics.cache_hit_count == 1
asyncio.run(run())
def test_deadline_returns_resolved_partial_pool() -> None:
async def run() -> None:
never_finishes = asyncio.Event()
async def search(query: str) -> list[Track]:
if "Slow Song" in query:
await never_finishes.wait()
return [_track("fast", "Fast Song", "Artist")]
catalog = FakeCatalog(search)
settings = _settings(grounding_concurrency=2, request_deadline_seconds=0.02)
candidates = (
TrackCandidate("Fast Song", "Artist"),
TrackCandidate("Slow Song", "Artist"),
)
result = await Grounder(settings).ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
2,
)
assert [track.id for track in result.tracks] == ["fast"]
assert result.metrics.did_reach_deadline
asyncio.run(run())
def _settings(**overrides: object) -> Settings:
values: dict[str, object] = {
"grounding_concurrency": 1,
"request_deadline_seconds": 1.0,
}
values.update(overrides)
return Settings.model_validate(values)
def _track(track_id: str, title: str, artist: str) -> Track:
return Track(
id=track_id,
uri=f"spotify:track:{track_id}",
title=title,
artists=(artist,),
album_name="Album",
album_art_url=None,
external_url=None,
)

View file

@ -0,0 +1,65 @@
"""Tests for conservative Spotify candidate matching."""
from app.domain.matching import judge_candidate_match, normalize_text, title_similarity
from app.domain.models import Track, TrackCandidate
def test_normalization_removes_case_punctuation_marks_and_suffixes() -> None:
value = " H\u00e9llo, WORLD! (2011 Remaster) [Deluxe] "
assert normalize_text(value) == "hello world"
def test_title_similarity_uses_normalized_values() -> None:
assert title_similarity("Signal Fire", "Signal Fire (Remastered)") == 1.0
def test_matching_accepts_a_similar_title_with_the_requested_artist() -> None:
candidate = TrackCandidate(title="The Night Drive", artist="Example Artist")
track = _track(title="The Night Drive (Radio Edit)", artist="Example Artist")
verdict = judge_candidate_match(candidate, track, title_threshold=0.82)
assert verdict.is_match
assert verdict.is_artist_match
def test_matching_drops_a_title_substitution_from_another_artist() -> None:
candidate = TrackCandidate(title="The Night Drive", artist="Requested Artist")
substitution = _track(title="The Night Drive", artist="Different Artist")
verdict = judge_candidate_match(candidate, substitution, title_threshold=0.82)
assert not verdict.is_match
assert verdict.title_similarity == 1.0
assert not verdict.is_artist_match
def test_matching_drops_a_weak_title_even_for_the_right_artist() -> None:
candidate = TrackCandidate(title="The Night Drive", artist="Requested Artist")
substitution = _track(title="Morning Train", artist="Requested Artist")
assert not judge_candidate_match(candidate, substitution, title_threshold=0.82).is_match
def _track(*, title: str, artist: str) -> Track:
return Track(
id="track-id",
uri="spotify:track:track-id",
title=title,
artists=(artist,),
album_name="Album",
album_art_url=None,
external_url=None,
)
def test_title_similarity_tolerates_version_dash_suffix() -> None:
assert title_similarity("Immunity", "Immunity - Remaster 2023") > 0.95
assert title_similarity("Nightcall", "Nightcall - Breakbot Remix") > 0.95
def test_title_similarity_prefers_the_unsuffixed_release() -> None:
plain = title_similarity("Immunity", "Immunity")
suffixed = title_similarity("Immunity", "Immunity - Remaster 2023")
assert plain > suffixed

View file

@ -0,0 +1,288 @@
"""End-to-end pipeline tests using deterministic service fakes."""
import asyncio
from collections.abc import AsyncGenerator
from app.config import Settings
from app.domain.models import (
ConversationTurn,
Familiarity,
Intent,
PreviousRecommendation,
RerankSelection,
Track,
TrackCandidate,
)
from app.pipeline.event import PipelineEvent, PipelineTrackEvent
from app.pipeline.orchestrator import RecommendationPipeline
from app.ports.protocols import RecommenderOutputError, TimeRange
class FakeCatalog:
"""Return exact tracks and a configurable known-track sample."""
def __init__(self, tracks: tuple[Track, ...], known_tracks: tuple[Track, ...] = ()) -> None:
"""Index tracks by title and expose taste-call counters."""
self.tracks_by_title = {track.title: track for track in tracks}
self.known_tracks = known_tracks
self.search_call_count = 0
self.taste_call_count = 0
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Resolve an exact fielded title and miss bare fallbacks."""
self.search_call_count += 1
if 'track:"' not in query:
return []
title = query.split('track:"', 1)[1].split('"', 1)[0]
track = self.tracks_by_title.get(title)
return [track] if track is not None else []
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
"""Return one stable taste artist."""
self.taste_call_count += 1
return ["Taste Artist"]
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
"""Return no top tracks."""
self.taste_call_count += 1
return []
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
"""Return the configured known tracks."""
self.taste_call_count += 1
return list(self.known_tracks)
class FakeRecommender:
"""Return fixed intents and either selections or structured failures."""
def __init__(
self,
intents: list[Intent],
selection_ids: tuple[str, ...] = (),
failure_count: int = 0,
) -> None:
"""Store deterministic outputs for successive calls."""
self.intents = intents
self.selection_ids = selection_ids
self.failure_count = failure_count
self.rerank_call_count = 0
async def create_intent(
self,
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
taste_summary: str,
candidate_count: int,
) -> Intent:
"""Return the next fixed intent."""
return self.intents.pop(0)
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]:
"""Stream configured ids or fail before yielding."""
self.rerank_call_count += 1
if self.failure_count:
self.failure_count -= 1
raise RecommenderOutputError("invalid test output")
selected_ids = self.selection_ids or tuple(track.id for track in grounded_tracks)
for track_id in selected_ids[:selection_count]:
yield RerankSelection(track_id, f"Reason for {track_id}")
def test_event_order_and_rerank_ids_stay_inside_grounded_pool() -> None:
async def run() -> None:
first = _track("first", "First Song")
second = _track("second", "Second Song")
catalog = FakeCatalog((first, second))
recommender = FakeRecommender([_intent(first, second)], ("second", "first"))
events = await _run_pipeline(catalog, recommender)
assert [event.type for event in events] == ["metadata", "track", "track", "done"]
track_events = [event for event in events if isinstance(event, PipelineTrackEvent)]
assert [event.track.id for event in track_events] == ["second", "first"]
assert {event.track.id for event in track_events} <= {"first", "second"}
asyncio.run(run())
def test_rerank_fallback_warns_then_streams_grounded_order() -> None:
async def run() -> None:
first = _track("first", "First Song")
second = _track("second", "Second Song")
catalog = FakeCatalog((first, second))
recommender = FakeRecommender([_intent(first, second)], failure_count=2)
events = await _run_pipeline(catalog, recommender)
assert [event.type for event in events] == [
"metadata",
"warning",
"track",
"track",
"done",
]
assert recommender.rerank_call_count == 2
track_events = [event for event in events if isinstance(event, PipelineTrackEvent)]
assert [event.track.id for event in track_events] == ["first", "second"]
asyncio.run(run())
def test_new_familiarity_excludes_known_track_ids() -> None:
async def run() -> None:
known = _track("known", "Known Song")
new = _track("new", "New Song")
catalog = FakeCatalog((known, new), known_tracks=(known,))
intent = _intent(known, new, familiarity=Familiarity.NEW)
recommender = FakeRecommender([intent])
events = await _run_pipeline(catalog, recommender, rerank_count=1)
track_events = [event for event in events if isinstance(event, PipelineTrackEvent)]
assert [event.track.id for event in track_events] == ["new"]
asyncio.run(run())
def test_refinement_reuses_last_grounded_pool_and_cached_taste() -> None:
async def run() -> None:
track = _track("first", "First Song")
initial = _intent(track)
refinement = _intent(track, is_refinement=True)
catalog = FakeCatalog((track,))
recommender = FakeRecommender([initial, refinement])
pipeline = _pipeline(recommender, rerank_count=1)
await _collect(pipeline, catalog, "first request")
initial_search_calls = catalog.search_call_count
initial_taste_calls = catalog.taste_call_count
await _collect(pipeline, catalog, "refine it")
assert catalog.search_call_count == initial_search_calls
assert catalog.taste_call_count == initial_taste_calls
asyncio.run(run())
async def _run_pipeline(
catalog: FakeCatalog,
recommender: FakeRecommender,
rerank_count: int = 2,
) -> list[PipelineEvent]:
return await _collect(_pipeline(recommender, rerank_count=rerank_count), catalog, "query")
def _pipeline(recommender: FakeRecommender, rerank_count: int) -> RecommendationPipeline:
return RecommendationPipeline(
recommender,
Settings(
rerank_count=rerank_count,
rerank_pool_buffer=0,
grounding_floor=1,
grounding_concurrency=2,
request_deadline_seconds=1.0,
),
)
async def _collect(
pipeline: RecommendationPipeline,
catalog: FakeCatalog,
query: str,
) -> list[PipelineEvent]:
return [
event
async for event in pipeline.stream(
"session",
"request",
catalog,
query,
(),
(),
)
]
def _intent(
*tracks: Track,
familiarity: Familiarity = Familiarity.MIX,
is_refinement: bool = False,
) -> Intent:
return Intent(
mood=("focused",),
activity=None,
era=(),
languages=(),
genres=("electronic",),
familiarity=familiarity,
is_refinement=is_refinement,
intent_summary="Focused electronic discovery.",
candidates=tuple(
TrackCandidate(title=track.title, artist=track.artists[0]) for track in tracks
),
)
def _track(track_id: str, title: str) -> Track:
return Track(
id=track_id,
uri=f"spotify:track:{track_id}",
title=title,
artists=("Artist",),
album_name="Album",
album_art_url=None,
external_url=None,
)
def test_below_floor_pool_streams_partial_results_after_warning() -> None:
async def run() -> None:
found = _track("found", "Found Song")
missing = _track("missing", "Missing Song")
catalog = FakeCatalog((found,))
recommender = FakeRecommender([_intent(found, missing)])
pipeline = RecommendationPipeline(
recommender,
Settings(
rerank_count=2,
rerank_pool_buffer=0,
grounding_floor=2,
grounding_concurrency=2,
request_deadline_seconds=1.0,
),
)
events = await _collect(pipeline, catalog, "query")
assert [event.type for event in events] == ["metadata", "warning", "track", "done"]
warning = events[1]
assert warning.type == "warning"
assert warning.code == "partial_results"
asyncio.run(run())
def test_empty_pool_is_a_terminal_error() -> None:
async def run() -> None:
missing = _track("missing", "Missing Song")
catalog = FakeCatalog(())
recommender = FakeRecommender([_intent(missing)])
events = await _run_pipeline(catalog, recommender)
assert [event.type for event in events] == ["metadata", "error"]
error = events[-1]
assert error.type == "error"
assert error.code == "no_grounded_results"
asyncio.run(run())

View file

@ -0,0 +1,46 @@
"""Tests for compact taste profile construction."""
from app.domain.models import TasteProfile, Track
from app.domain.profile import compress_taste_profile
def test_profile_compression_includes_bounded_signals_and_known_ids() -> None:
short_track = _track("short", "Quick Song", "Quick Artist")
long_track = _track("long", "Lasting Song", "Lasting Artist")
saved_track = _track("saved", "Saved Song", "Saved Artist")
profile = TasteProfile(
short_term_artists=("Current Artist",),
long_term_artists=("Enduring Artist",),
short_term_tracks=(short_track,),
long_term_tracks=(long_track,),
saved_tracks=(saved_track,),
)
compressed = compress_taste_profile(profile)
assert compressed.text.splitlines() == [
"Short-term top artists: Current Artist",
"Long-term top artists: Enduring Artist",
"Short-term top tracks: Quick Song by Quick Artist",
"Long-term top tracks: Lasting Song by Lasting Artist",
"Saved-track sample: Saved Song by Saved Artist",
]
assert compressed.known_track_ids == frozenset({"short", "long", "saved"})
def test_empty_profile_sections_are_explicit() -> None:
profile = TasteProfile((), (), (), (), ())
assert compress_taste_profile(profile).text.count(": none") == 5
def _track(track_id: str, title: str, artist: str) -> Track:
return Track(
id=track_id,
uri=f"spotify:track:{track_id}",
title=title,
artists=(artist,),
album_name="Album",
album_art_url=None,
external_url=None,
)

View file

@ -0,0 +1,126 @@
"""HTTP contract tests for recommendation and playlist routes."""
import time
from collections.abc import AsyncGenerator
from fastapi.testclient import TestClient
from pydantic import TypeAdapter
from app.adapters.spotify.auth import TokenSet
from app.adapters.spotify.session import SessionStore, SpotifySession
from app.api.routes import SESSION_COOKIE_NAME
from app.api.schemas import StreamEvent
from app.domain.models import ConversationTurn, CreatedPlaylist, PreviousRecommendation, Track
from app.main import create_app
from app.pipeline.event import PipelineDoneEvent, PipelineMetadataEvent, PipelineTrackEvent
from app.ports.protocols import MusicCatalog
class FakePipeline:
"""Emit one complete deterministic stream."""
async def stream(
self,
session_id: str,
request_id: str,
catalog: MusicCatalog,
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
) -> AsyncGenerator[PipelineMetadataEvent | PipelineTrackEvent | PipelineDoneEvent]:
"""Yield metadata, one track, and completion."""
yield PipelineMetadataEvent(request_id, "A focused test request.", 35)
yield PipelineTrackEvent(1, _track(), "It fits the requested focus.")
yield PipelineDoneEvent(1, 4)
class FakePlaylistWriter:
"""Capture playlist writes without external calls."""
def __init__(self) -> None:
"""Create an empty write trace."""
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."""
self.name = name
return CreatedPlaylist("playlist", "https://open.spotify.com/playlist/playlist")
async def add_tracks_to_playlist(self, playlist_id: str, track_uris: list[str]) -> None:
"""Record the ordered track URIs."""
self.track_uris = track_uris
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)
app.state.recommendation_pipeline = FakePipeline()
response = client.post(
"/api/recommendations",
json={"schema_version": 1, "query": "focused electronic music"},
)
adapter: TypeAdapter[StreamEvent] = TypeAdapter(StreamEvent)
events = [adapter.validate_json(line) for line in response.text.splitlines()]
assert response.status_code == 200
assert response.headers["content-type"] == "application/x-ndjson"
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:
response = client.post(
"/api/recommendations",
json={"schema_version": 1, "query": "focused electronic music"},
)
assert response.status_code == 401
assert response.json() == {"detail": "Not authenticated"}
def test_playlist_endpoint_prefixes_name_and_adds_tracks() -> None:
app = create_app()
writer = FakePlaylistWriter()
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 == 200
assert response.json() == {"url": "https://open.spotify.com/playlist/playlist"}
assert writer.name == "discovery-by-llm Night drive"
assert writer.track_uris == ["spotify:track:track"]
def _authenticate(client: TestClient, session_store: SessionStore) -> None:
session_id = session_store.create(
SpotifySession(
tokens=TokenSet("access", "refresh", time.monotonic() + 3600),
account_id="account",
display_name="Listener",
)
)
client.cookies.set(SESSION_COOKIE_NAME, session_id)
def _track() -> Track:
return Track(
id="track",
uri="spotify:track:track",
title="Test Track",
artists=("Test Artist",),
album_name="Test Album",
album_art_url=None,
external_url="https://open.spotify.com/track/track",
)

View file

@ -228,6 +228,31 @@ def test_search_maps_valid_fields_and_drops_malformed_item() -> None:
asyncio.run(run()) asyncio.run(run())
def test_taste_endpoints_map_supported_response_shapes() -> None:
async def run() -> None:
async def handler(request: httpx2.Request) -> httpx2.Response:
if request.url.path == "/v1/me/top/artists":
assert request.url.params["time_range"] == "short_term"
return httpx2.Response(200, json={"items": [{"name": "Top Artist"}]})
if request.url.path == "/v1/me/top/tracks":
return httpx2.Response(200, json={"items": [_track_payload()]})
assert request.url.path == "/v1/me/tracks"
assert request.url.params["offset"] == "0"
return httpx2.Response(200, json={"items": [{"track": _track_payload()}]})
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
client = _client(http)
artists = await client.fetch_top_artists("short_term", 50)
top_tracks = await client.fetch_top_tracks("long_term", 50)
saved_tracks = await client.fetch_saved_tracks(100)
assert artists == ["Top Artist"]
assert [track.id for track in top_tracks] == ["track-1"]
assert [track.id for track in saved_tracks] == ["track-1"]
asyncio.run(run())
async def _search_with_handler(handler: TransportHandler) -> list[Track]: async def _search_with_handler(handler: TransportHandler) -> list[Track]:
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
return list(await _client(http).search_tracks("mapped")) return list(await _client(http).search_tracks("mapped"))
@ -251,7 +276,15 @@ def _search_payload() -> dict[str, object]:
"tracks": { "tracks": {
"total": 0, "total": 0,
"items": [ "items": [
{ _track_payload(),
{"id": "missing-required-fields"},
],
}
}
def _track_payload() -> dict[str, object]:
return {
"id": "track-1", "id": "track-1",
"uri": "spotify:track:1", "uri": "spotify:track:1",
"name": "Mapped song", "name": "Mapped song",
@ -261,8 +294,4 @@ def _search_payload() -> dict[str, object]:
"images": [{"url": "https://images.example/cover.jpg"}], "images": [{"url": "https://images.example/cover.jpg"}],
}, },
"external_urls": {"spotify": "https://open.spotify.com/track/track-1"}, "external_urls": {"spotify": "https://open.spotify.com/track/track-1"},
},
{"id": "missing-required-fields"},
],
}
} }

118
backend/uv.lock generated
View file

@ -20,6 +20,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
] ]
[[package]]
name = "anthropic"
version = "0.121.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "docstring-parser" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/ca/3cb2c20ee729736fbd4546d5d8b67e818288529fe70cb7a80dbf80aef70b/anthropic-0.121.0.tar.gz", hash = "sha256:e79d6e08ab3376602fc9a70d4d5ea3540817c76cf7e16658bed790834e1833d6", size = 1013292, upload-time = "2026-08-07T17:11:07.241Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/91/b3d41643f1f639927e8c5fb02c3bd8bffe6f1f29e219b3bd4c61e267b15c/anthropic-0.121.0-py3-none-any.whl", hash = "sha256:6048713fa441e59e1cba8363171cd2a86273b25bd213e9c7ac70a523af88b011", size = 1035493, upload-time = "2026-08-07T17:11:08.508Z" },
]
[[package]] [[package]]
name = "anyio" name = "anyio"
version = "4.14.2" version = "4.14.2"
@ -76,6 +95,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" },
] ]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.4.2" version = "8.4.2"
@ -102,9 +130,11 @@ name = "discovery-backend"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "anthropic" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx2" }, { name = "httpx2" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "structlog" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@ -117,9 +147,11 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "anthropic", specifier = "==0.121.0" },
{ name = "fastapi", specifier = ">=0.116" }, { name = "fastapi", specifier = ">=0.116" },
{ name = "httpx2", specifier = ">=2.10" }, { name = "httpx2", specifier = ">=2.10" },
{ name = "pydantic-settings", specifier = ">=2.10" }, { name = "pydantic-settings", specifier = ">=2.10" },
{ name = "structlog", specifier = ">=25.4" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35" },
] ]
@ -130,6 +162,24 @@ dev = [
{ name = "ruff", specifier = ">=0.12" }, { name = "ruff", specifier = ">=0.12" },
] ]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "docstring-parser"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.141.1" version = "0.141.1"
@ -155,6 +205,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
] ]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]] [[package]]
name = "httpcore2" name = "httpcore2"
version = "2.10.0" version = "2.10.0"
@ -183,6 +246,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
] ]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]] [[package]]
name = "httpx2" name = "httpx2"
version = "2.10.0" version = "2.10.0"
@ -226,6 +304,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "jiter"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" },
{ url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" },
{ url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" },
{ url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" },
{ url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" },
{ url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" },
{ url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" },
{ url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" },
{ url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" },
{ url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" },
{ url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" },
{ url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" },
]
[[package]] [[package]]
name = "librt" name = "librt"
version = "0.15.0" version = "0.15.0"
@ -441,6 +541,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" },
] ]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "1.6.0" version = "1.6.0"
@ -453,6 +562,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
] ]
[[package]]
name = "structlog"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" },
]
[[package]] [[package]]
name = "truststore" name = "truststore"
version = "0.10.4" version = "0.10.4"

View file

@ -1,9 +1,9 @@
# Logboek # Logboek
Bijgehouden tijdens de bouw. Per blok: wat ik deed, waarom, wat ik heb laten Bijgehouden tijdens de bouw. Per stap: wat ik deed, waarom, wat ik heb laten
vallen. vallen.
## Dag 1 - korte sessie in de avond
## Opzet (avond dag 1) ### Opzet
Wat ik deed: Wat ik deed:
@ -29,29 +29,30 @@ Waarom:
Wat ik heb laten vallen of uitgesteld: Wat ik heb laten vallen of uitgesteld:
- Domainmodellen, protocollen, prompts, pipeline-instellingen en het - Domainmodellen, protocols, prompts, pipeline-instellingen en het
frontend-typecontract bewust nog niet neergezet. Die ontstaan in de stap frontend-typecontract bewust nog niet neergezet. Die ontstaan in de stap
waar ze horen. Ik probeer op die manier bewust vroeg drift en dode code te voorkomen. waar ze horen. Ik probeer op die manier bewust vroeg drift en dode code te voorkomen.
- Geen apart beslisdocument. De motivering staat in de README en hier. - Geen apart beslisdocument. De motivering staat in de README en hier.
## Spotify-koppeling (ochtend dag 2) ## Dag 2
### Spotify-koppeling
Wat ik deed: Wat ik deed:
- Login via Spotify met PKCE en cookie-sessies: tokens blijven server-side, de browser krijgt alleen een opaque HttpOnly cookie. - Login via Spotify met PKCE en cookie-sessies: tokens blijven server-side, de browser krijgt alleen een opaque HttpOnly cookie.
- Dunne async client op de Spotify Web API met per-endpoint retrybeleid: - Dunne async client op de Spotify Web API met per-endpoint retry policy:
leesacties herhalen maximaal 1 keer en alleen binnen een grens leesacties herhalen maximaal 1 keer en alleen binnen een grens
(Retry-After), schrijfacties op playlists nooit. (Retry-After), schrijfacties op playlists nooit.
- Token-refresh is single-flight: parallelle requests delen 1 refresh in - Token-refresh is single-flight: parallelle requests delen 1 refresh in
plaats van er allemaal zelf een te starten. plaats van er allemaal zelf een te starten.
- Mapping van Spotify-JSON naar eigen modellen op 1 plek; kapotte items - Mapping van Spotify-JSON naar eigen modellen op 1 plek; kapotte items
vallen weg in plaats van dat ze de app breken. vallen weg in plaats van dat ze de app breken.
- Getypeerde fouten en 12 transport- en routetests op een mock transport. - Getypeerde errors en 12 transport- en routetests op een mock transport.
- Na een eerste review de login-flow uit de routes getrokken naar een eigen - Na een eerste review de login-flow uit de routes getrokken naar een eigen
module (routes zijn nu dunne doorgeefluiken) en het retrybeleid herschreven module (routes zijn nu dunne passthroughs) en de retry policy herschreven
naar een lineaire keten van losse regels in plaats van een loop met flags; naar een lineaire keten van losse regels in plaats van een loop met flags;
foutdetails uit de Spotify-body worden meegenomen in de getypeerde fouten foutdetails uit de Spotify-body worden meegenomen in de getypeerde errors
en quota-uitputting wordt apart herkend en nooit opnieuw geprobeerd. en quota exhaustion (QUOTA_EXCEEDED) wordt apart herkend en nooit opnieuw geprobeerd.
- Daarna het API-contract vastgelegd: request-schema en de gestreamde - Daarna het API-contract vastgelegd: request-schema en de gestreamde
events (metadata / track / warning / error / done), gespiegeld in events (metadata / track / warning / error / done), gespiegeld in
TypeScript. TypeScript.
@ -59,7 +60,7 @@ Wat ik deed:
Waarom: Waarom:
- Schrijfacties blind herhalen kan dubbele playlist-items opleveren; dat - Schrijfacties blind herhalen kan dubbele playlist-items opleveren; dat
risico sluit ik structureel uit in het retrybeleid. risico sluit ik structureel uit in de retry policy.
- Het contract eerst bevriezen maakt parallel werken aan frontend en - Het contract eerst bevriezen maakt parallel werken aan frontend en
pipeline mogelijk zonder elkaar te breken. pipeline mogelijk zonder elkaar te breken.
@ -69,3 +70,206 @@ Wat ik heb laten vallen of uitgesteld:
pipeline-stap; daar bestaat het ontwerp pas echt. pipeline-stap; daar bestaat het ontwerp pas echt.
- OpenAPI-codegen voor het contract overwogen en afgewezen: de kern van dit - OpenAPI-codegen voor het contract overwogen en afgewezen: de kern van dit
contract is de event-stream en die modelleert OpenAPI niet. contract is de event-stream en die modelleert OpenAPI niet.
### Pipeline
Wat ik deed:
- Domeinbasis: titel/artiest-matching (normalisatie, met tolerantie voor
Spotify's versie-suffixen zoals "- Remaster 2023"), compressie van het
Spotify taste profile naar prompttekst plus een set bekende track-ids,
prompts als data in een eigen module, en alle instelbare waarden
gesectioneerd in de config met per waarde het waarom.
- Twee LLM-calls achter een eigen interface: call 1 interpreteert de
vraag (mood, activity, language, familiarity) en stelt 30-40 echte
nummers voor als gestructureerde output; call 2 herordent uitsluitend
geverifieerde nummers en streamt per nummer een eerlijke justification.
Elke output wordt gevalideerd, met hooguit 1 correctie-retry.
- Grounding: begrensde parallelle search fan-out met early stop, een deadline,
een naam-naar-id cache en twee aparte metrieken: niet gevonden versus
wel gevonden maar afgekeurd door de match-check. Een track-id dat niet in
de geverifieerde pool zit kan nooit bij de gebruiker terechtkomen.
- Na review de orkestratie herschreven: de stream-functie leest nu als de
pipeline-stappen zelf, en de selectie-logica (alleen pool-ids, geen
duplicaten, begrensd aantal, ranking) zit in 1 kleine klasse die zowel
het normale pad als de fallback bedient.
Waarom:
- De LLM is hier de recommender, maar mag alleen creatief zijn tussen twee
deterministische muren: alles wat hij ziet is echte data, alles wat de
gebruiker ziet is geverifieerd op Spotify. Een verzonnen nummer valt
stilletjes af en verschijnt nooit.
- Zoeken geeft maximaal 10 resultaten per call, dus resolutie is per
definitie een fan-out; liever een kandidaat laten vallen dan het
verkeerde nummer aanbevelen.
Wat ik heb laten vallen of uitgesteld:
- Refinements doen geen nieuwe search-ronde: turn 2 herordent de bestaande
geverifieerde pool. Sneller en consistent, maar een refinement haalt
geen nieuwe nummers op. Dit is een bewuste afweging, mocht er tijd over
zijn is dit 1 van de uitbreidingen die ik op zou kunnen pakken.
### Streaming API
Wat ik deed:
- De pipeline als streamend endpoint: POST /api/recommendations levert
NDJSON events (metadata / track / warning / error / done); een
client-disconnect stopt de stream en de grounding.
- Playlist-endpoint dat schrijft met een vaste naam-prefix, zodat
aangemaakte playlists later in bulk op te ruimen zijn (ik gebruik mijn
persoonlijke spotify account/abonnement voor de demo).
- Request-timing middleware met per-request counters (Spotify calls, cache
hits, LLM tokens) in gestructureerde logs.
- Seed session voor gehost draaien: in live mode installeert een refresh
token bij startup een sessie, zodat een publieke instantie werkt zonder
interactieve login.
Waarom:
- Streamen maakt de wachttijd eerlijk: de eerste kaart telt. Een afgebroken request mag geen werk laten doorlopen.
### Review-fixes en de account-quota
Wat ik deed:
- Twee fixes uit de live-test: gebruikers-id key op het stabiele account_id
veld (met id als fallback), en de intent-call accepteert nu een
afwijkend kandidaten-aantal in plaats van hard te falen als het model er
34 in plaats van 35 teruggeeft.
- Tijdens het opnemen van demo-fixtures de dagelijkse development-quota
van de Spotify-app geraakt: honderden searches in enkele minuten, daarna
QUOTA_EXCEEDED met een Retry-After van bijna 7 uur. Het systeem
degradeerde zoals ontworpen: een eerlijke foutmelding, geen stille
fallback naar verzonnen resultaten.
Waarom:
- De quota is per developer-account en per dag; bulk-werk zoals fixtures
opnemen moet dus gebudgetteerd, en het cache-ontwerp (naam-naar-id,
smaakprofiel) is noodzakelijk om binnen de quota te blijven.
### Meer live-test fixes
Wat ik deed:
- Matching accepteert nu ook nummers die alleen als release met een
versie-suffix bestaan ("Immunity - Remaster 2023"), met een lichte
voorkeur voor de originele release bij gelijke score; de live-test liet
zien dat echte nummers hierdoor onterecht afvielen.
- Mislukte rerank-pogingen worden met reden gelogd, en de token-ceilings
van beide LLM-calls zijn verhoogd: adaptive thinking telt mee in
max_tokens en kon de gestructureerde output afkappen.
- Per unresolved kandidaat wordt titel, artiest en status gelogd, zodat
zichtbaar is WAT het model verzon in plaats van alleen hoeveel.
### Eerlijke partial results
Wat ik deed:
- Onder de grounding-floor geeft de pipeline nu een warning plus de nummers
die wel geverifieerd zijn, in plaats van een lege foutmelding; alleen een
volledig lege pool is nog een terminal error.
Waarom:
- Vijf goede, geverifieerde nummers zijn een bruikbaar antwoord; een
foutmelding die echte resultaten verbergt is dat niet. Bij "verras me
met iets nieuws" vragen valt een groot deel van de kandidaten af bij de
verificatie, dus juist daar telt dit. Een weg om dit potentieel te voorkomen/verbeteren in de toekomst is het verbeteren van de prompt, of de LLM met behulp van een derde partij API die zonder de Spotify API te overbelasten gebruikt kan worden om echte nummers te vinden.
### Frontend: fundament
Voor de frontend heb ik gedurende de eerste paar uur op de achtergrond Open Design (een
design-tool) laten lopen. Ik ben geen visual designer, maar op deze manier lukt het mij om met minimale
effort een geschikt UI bouwpakket te ontwikkelen. De oplevering heeft zo'n vorm dat ik het
gemakkelijk door een cli agent kan laten uitbouwen in Vue.
Wat ik deed:
- Design tokens uit het ontwerppakket (donker thema, typografie, spacing,
motion) als basis voor alle componenten.
- Typed stream client: fetch met een ReadableStream, regelgebaseerde
NDJSON-parsing naar de contract-events, en AbortController-cancellation
zodra een nieuwe vraag start.
- API client voor sessie-status, logout en playlist-save.
Waarom:
- De frontend is de tweede consument van het bevroren contract: elke event
wordt tegen de TypeScript-union gevalideerd in plaats van los geparst;
wat niet valideert is een transport failure, geen gok.
### Frontend: componenten en app shell
Dit deel is grotendeels geautomatiseerde omzetting: het UI bouwpakket uit
Open Design heb ik door een cli agent laten converteren naar Vue single file components
tegen het bevroren contract. De transportlaag eronder (stream client,
validatie) komt uit de vorige stap. Review- en hardeningpassen op dit
resultaat volgen hierna als eigen stappen; wat hier staat is de ruwe
conversie.
Wat ik deed:
- Alle componenten uit het bouwpakket laten omzetten: chat view,
composer met suggestion chips, streamende track cards met album covers
en een justification per nummer, warning banners, error states, login-status,
mode banner en een dev panel.
- App shell met keyboard-navigatie, focus management en reduced-motion
support.
Waarom:
- Het bouwpakket was hierop ontworpen; de componenten volgen de tokens en
states daaruit, en elke regel gaat alsnog door review voordat die op
main landt.
- Styling is scoped CSS per component bovenop globale design tokens
(custom properties). Scoped omdat machine-geconverteerde componenten
om style leakage te voorkomen, en omdat het bouwpakket al gewone CSS
per component leverde: een utility
framework had een rewrite plus een extra dependency gekost. De
tokens houden het thema op 1 plek, dus het bekende scoped-CSS risico
(waarden die per component uiteenlopen) is afgedekt.
Wat ik heb laten vallen of uitgesteld:
- Een lijst met eerdere chats. Gesprekken leven bewust alleen client-side
en de server bewaart niets; een history-lijst kan later contract-schoon
via localStorage, zonder server-state. Potentiele extra als er tijd
over is, geen onderdeel van de kern.
### Frontend: review-pass op de conversie
De findings hieronder komen uit twee richtingen: een adversarial review
die ik op de conversie heb laten draaien, en mijn eigen review van de
code. Beide sets zijn in dezelfde pass verwerkt.
Wat ik deed:
- Uit de adversarial review. De grootste finding: API-strings gingen
ongefilterd een href in (URL-injectie mogelijk!); links worden nu alleen
gezet voor https-URLs op open.spotify.com.
- Verder: een expliciete phase machine op de event-stream (metadata, dan
tracks/warnings, dan precies 1 keer done of error; al het andere is een
transport failure), een race gefixt waarbij een snelle nieuwe vraag een
afgeronde beurt als cancelled kon markeren, response-body cancellation
op parse-fouten, query-invoer begrensd op de request-limiet, warnings
in een live region voor screenreaders, focus-herstel van het dev panel,
en contrast op WCAG AA gebracht.
- Een vitest-suite toegevoegd (26 tests) die de NDJSON-parser op
gefragmenteerde chunks, error-terminaliteit, de request-caps en
abort-races vastlegt; draait mee in CI.
- Alle user-facing tekst uit de componenten geextraheerd naar 1 getypeerde
messages-module (i18n-klaar zonder er nu een framework voor mee te nemen),
alle magic numbers vervangen door
benoemde constants met per waarde een reden (de request-caps verwijzen
expliciet naar de backend schema-bounds), en dichtgeschreven TypeScript
herschreven naar leesbare code zonder gedragsverandering.
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.

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b0c0b" />
<title>discovery-by-llm</title> <title>discovery-by-llm</title>
</head> </head>
<body> <body>

View file

@ -18,10 +18,12 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"eslint": "^10.8.1", "eslint": "^10.8.1",
"eslint-plugin-vue": "^10.10.0", "eslint-plugin-vue": "^10.10.0",
"happy-dom": "^20.11.2",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.2.0", "vite": "^8.2.0",
"vitest": "^4.1.10",
"vue-tsc": "^3.3.8" "vue-tsc": "^3.3.8"
} }
}, },
@ -506,6 +508,28 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true "dev": true
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true
},
"node_modules/@types/esrecurse": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -533,6 +557,21 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.66.0", "version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
@ -780,6 +819,121 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": { "node_modules/@volar/language-core": {
"version": "2.4.28", "version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
@ -1013,6 +1167,15 @@
"integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
"dev": true "dev": true
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -1052,6 +1215,33 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"engines": {
"node": ">=18"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -1126,6 +1316,12 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@ -1387,6 +1583,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -1550,6 +1755,24 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/happy-dom": {
"version": "20.11.2",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz",
"integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==",
"dev": true,
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -2021,6 +2244,19 @@
"url": "https://github.com/fb55/nth-check?sponsor=1" "url": "https://github.com/fb55/nth-check?sponsor=1"
} }
}, },
"node_modules/obug": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -2092,6 +2328,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2312,6 +2554,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2320,6 +2568,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true
},
"node_modules/synckit": { "node_modules/synckit": {
"version": "0.11.13", "version": "0.11.13",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
@ -2335,6 +2595,21 @@
"url": "https://opencollective.com/synckit" "url": "https://opencollective.com/synckit"
} }
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true
},
"node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -2351,6 +2626,15 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -2521,6 +2805,95 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vscode-uri": { "node_modules/vscode-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@ -2598,6 +2971,15 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -2613,6 +2995,22 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -2622,6 +3020,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View file

@ -8,6 +8,7 @@
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src", "lint": "eslint src",
"test": "vitest run",
"format": "prettier --write src", "format": "prettier --write src",
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json" "typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
}, },
@ -22,10 +23,12 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"eslint": "^10.8.1", "eslint": "^10.8.1",
"eslint-plugin-vue": "^10.10.0", "eslint-plugin-vue": "^10.10.0",
"happy-dom": "^20.11.2",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.2.0", "vite": "^8.2.0",
"vitest": "^4.1.10",
"vue-tsc": "^3.3.8" "vue-tsc": "^3.3.8"
} }
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 324 B

Before After
Before After

View file

@ -1,8 +1,7 @@
<script setup lang="ts"></script> <script setup lang="ts">
import ChatView from './components/ChatView.vue'
</script>
<template> <template>
<main> <ChatView />
<h1>discovery-by-llm</h1>
<p>Chat interface under construction.</p>
</main>
</template> </template>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,171 @@
<script setup lang="ts">
import { computed } from 'vue'
import { messages } from '../lib/messages'
import type { AppMode, AuthState } from '../lib/models'
import AuthStatus from './AuthStatus.vue'
const props = defineProps<{
auth: AuthState
mode: AppMode | null
healthFailed: boolean
}>()
const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>()
function openDevPanel(event: MouseEvent): void {
if (event.currentTarget instanceof HTMLElement) {
emit('toggleDev', event.currentTarget)
}
}
const modeLabel = computed(() => {
if (props.mode === 'demo') return messages.appHeaderDemoMode
if (props.mode === 'live') return messages.appHeaderLiveMode
if (props.healthFailed) return messages.appHeaderModeUnavailable
return messages.appHeaderCheckingMode
})
</script>
<template>
<header class="header">
<div class="inner">
<div class="brand">
<span class="wordmark"
>{{ messages.appHeaderBrandPrefix
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
>{{ messages.appHeaderBrandSuffix }}</span
>
<span class="kicker">{{ messages.appHeaderKicker }}</span>
</div>
<div class="controls">
<span class="mode" :class="{ failed: healthFailed }">
{{ modeLabel }}
</span>
<AuthStatus :auth="auth" @logout="$emit('logout')" />
<button class="button" type="button" data-dev-panel-opener @click="openDevPanel">
<span class="full-label">
{{ messages.appHeaderDevPanel }}
<span class="key">{{ messages.appHeaderDevPanelShortcut }}</span>
</span>
<span class="short-label">{{ messages.appHeaderDevPanelShort }}</span>
</button>
</div>
</div>
</header>
</template>
<style scoped>
.header {
z-index: 20;
flex: none;
padding: var(--s-5) var(--gutter);
background: var(--c-bg);
border-bottom: 1px solid var(--c-line);
}
.inner {
display: flex;
gap: var(--s-6);
align-items: center;
justify-content: space-between;
max-width: var(--measure);
margin: 0 auto;
}
.brand {
display: flex;
gap: var(--s-5);
align-items: baseline;
min-width: 0;
}
.wordmark {
font-family: var(--f-display);
font-size: var(--t-brand);
font-weight: 600;
letter-spacing: -0.02em;
}
.accent {
color: var(--c-accent);
}
.kicker,
.mode,
.button {
font-family: var(--f-mono);
font-size: var(--t-micro);
}
.kicker {
color: var(--c-text-faint);
font-size: var(--t-caps);
letter-spacing: var(--ls-caps);
text-transform: uppercase;
}
.controls {
display: flex;
flex: none;
gap: var(--s-4);
align-items: center;
white-space: nowrap;
}
.mode {
color: var(--c-accent-soft);
text-transform: uppercase;
}
.mode.failed {
color: var(--c-error);
}
.button {
min-height: 31px;
padding: 6px 11px;
color: var(--c-text-muted);
cursor: pointer;
background: transparent;
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.button:hover {
color: var(--c-text);
border-color: var(--c-focus-border);
}
.key {
color: var(--c-text-ghost);
}
.short-label {
display: none;
}
@media (max-width: 760px) {
.kicker,
.mode,
.key {
display: none;
}
}
@media (max-width: 560px) {
.button {
min-width: var(--tap-min);
min-height: var(--tap-min);
padding: 8px;
font-size: 0;
}
.full-label {
display: none;
}
.short-label {
display: inline;
font-size: var(--t-micro);
}
}
</style>

View file

@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatMessage, messages } from '../lib/messages'
import type { AssistantTurn } from '../lib/models'
import EmptyResults from './EmptyResults.vue'
import PlaylistError from './PlaylistError.vue'
import PlaylistSaved from './PlaylistSaved.vue'
import RequestMetadata from './RequestMetadata.vue'
import ResultActions from './ResultActions.vue'
import ResultSet from './ResultSet.vue'
import StreamError from './StreamError.vue'
import StreamWarning from './StreamWarning.vue'
import ThinkingIndicator from './ThinkingIndicator.vue'
const props = defineProps<{ turn: AssistantTurn; canSave: boolean }>()
defineEmits<{ save: []; retry: [query: string] }>()
const thinkingLabel = computed(() => {
if (props.turn.requestId === null) return messages.assistantMessageReadingRequest
if (props.turn.tracks.length === 0) {
if (props.turn.candidateCount === null) {
return messages.assistantMessageCheckingCandidates
}
return formatMessage('assistantMessageCheckingCandidateCount', {
count: props.turn.candidateCount,
})
}
return messages.assistantMessageStreamingTracks
})
const isEmptyResult = computed(
() =>
props.turn.status === 'done' &&
props.turn.completion?.track_count === 0 &&
props.turn.tracks.length === 0,
)
</script>
<template>
<div class="assistant">
<div class="avatar" aria-hidden="true">{{ messages.assistantMessageAvatar }}</div>
<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
v-for="(warning, index) in turn.warnings"
:key="`${warning.code}-${index}`"
:warning="warning"
/>
<StreamError
v-if="turn.error"
:code="turn.error.code"
:message="turn.error.message"
@retry="$emit('retry', turn.query)"
/>
<StreamError
v-else-if="turn.transportFailure"
:code="turn.transportFailure.kind"
:message="turn.transportFailure.message"
@retry="$emit('retry', turn.query)"
/>
<EmptyResults v-if="isEmptyResult" />
<RequestMetadata :turn="turn" />
<ResultActions
v-if="turn.status === 'done' && turn.tracks.length && turn.playlist.status !== 'saved'"
:state="turn.playlist"
:track-count="turn.tracks.length"
:can-save="canSave"
@save="$emit('save')"
/>
<PlaylistError
v-if="turn.playlist.status === 'error' && turn.playlist.message"
:message="turn.playlist.message"
/>
<PlaylistSaved
v-if="turn.playlist.status === 'saved' && turn.playlist.name"
:name="turn.playlist.name"
:url="turn.playlist.url"
:track-count="turn.tracks.length"
/>
</div>
</div>
</template>
<style scoped>
.assistant {
display: flex;
gap: var(--s-5);
}
.avatar {
display: grid;
flex: none;
place-items: center;
width: 26px;
height: 26px;
margin-top: 1px;
color: var(--c-accent);
font-family: var(--f-display);
font-size: 13px;
font-weight: 600;
border: 1px solid var(--c-line);
border-radius: 50%;
}
.column {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--s-7);
min-width: 0;
}
.intent {
margin: 0;
font-size: var(--t-lead);
text-wrap: pretty;
}
@media (max-width: 560px) {
.assistant {
gap: 0;
}
.avatar {
display: none;
}
}
</style>

View file

@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed } from 'vue'
import { messages } from '../lib/messages'
import type { AuthState } from '../lib/models'
const props = defineProps<{ auth: AuthState }>()
defineEmits<{ logout: [] }>()
const logoutLabel = computed(() => {
if (props.auth.status === 'logging_out') return messages.authStatusLoggingOut
return messages.authStatusLogOut
})
</script>
<template>
<span
v-if="auth.status === 'checking'"
class="checking"
:aria-label="messages.authStatusCheckingConnection"
/>
<a v-else-if="auth.status === 'anonymous'" class="button" href="/api/auth/login">
<span class="full-label">{{ messages.authStatusConnectSpotify }}</span
><span class="short-label">{{ messages.authStatusConnect }}</span>
</a>
<span v-else-if="auth.status === 'failed'" class="failed" role="alert">
<span class="failure-message">{{ auth.message }}</span>
<a class="button" href="/api/auth/login">
<span class="full-label">{{ messages.authStatusTryAgain }}</span
><span class="short-label">{{ messages.authStatusRetry }}</span>
</a>
</span>
<span v-else class="authenticated">
<span class="user">
<span class="dot" aria-hidden="true" />
<span class="connected">{{ messages.authStatusConnectedAs }}</span
>{{ auth.user.display_name }}
</span>
<button
class="button"
type="button"
:disabled="auth.status === 'logging_out'"
@click="$emit('logout')"
>
{{ logoutLabel }}
</button>
</span>
</template>
<style scoped>
.checking {
width: 116px;
height: 31px;
border: 1px solid var(--c-line);
border-radius: var(--r-pill);
opacity: 0.55;
}
.authenticated,
.failed {
display: inline-flex;
gap: var(--s-3);
align-items: center;
}
.user {
display: inline-flex;
gap: 7px;
align-items: center;
padding: 5px 11px;
color: var(--c-text-muted);
font-family: var(--f-mono);
font-size: var(--t-micro);
border: 1px solid var(--c-line);
border-radius: var(--r-pill);
}
.dot {
width: 6px;
height: 6px;
background: var(--c-accent);
border-radius: 50%;
}
.failure-message {
max-width: 24ch;
color: var(--c-error);
font-size: var(--t-micro);
white-space: normal;
}
.button {
display: inline-flex;
flex: none;
align-items: center;
min-height: 31px;
padding: 6px 11px;
color: var(--c-text-muted);
font-family: var(--f-mono);
font-size: var(--t-micro);
white-space: nowrap;
cursor: pointer;
background: transparent;
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.short-label {
display: none;
}
.button:hover {
color: var(--c-text);
text-decoration: none;
border-color: var(--c-focus-border);
}
@media (max-width: 760px) {
.user,
.failure-message {
display: none;
}
}
@media (max-width: 560px) {
.checking,
.button {
min-height: var(--tap-min);
}
.full-label {
display: none;
}
.short-label {
display: inline;
}
}
</style>

View file

@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useApi } from '../composables/useApi'
import { useChatStream } from '../composables/useChatStream'
import {
FAST_TRANSITION_DURATION_MS,
MESSAGE_ENTRANCE_ANIMATION_DURATION_MS,
PLAYLIST_SAVED_ANIMATION_DURATION_MS,
REDUCED_MOTION_DURATION_MS,
THINKING_PULSE_ANIMATION_DURATION_MS,
THINKING_PULSE_STAGGER_MS,
TRACK_CARD_ANIMATION_DURATION_MS,
} from '../lib/constants'
import { formatMessage, messages } from '../lib/messages'
import { DISCOVERY_SUGGESTIONS } from '../lib/suggestions'
import AppHeader from './AppHeader.vue'
import DevPanel from './DevPanel.vue'
import MessageInput from './MessageInput.vue'
import MessageList from './MessageList.vue'
import ModeBanner from './ModeBanner.vue'
const draft = ref('')
const devOpen = ref(false)
const devOpener = ref<HTMLElement | null>(null)
const input = ref<InstanceType<typeof MessageInput> | null>(null)
const { auth, health, bootstrap, logout, createPlaylist } = useApi()
const { turns, eventLog, isStreaming, turnCount, latestAssistant, send, savePlaylist, reset } =
useChatStream(createPlaylist)
const timingStyles: Record<string, string> = {
'--dur-fast': `${FAST_TRANSITION_DURATION_MS}ms`,
'--dur-card': `${TRACK_CARD_ANIMATION_DURATION_MS}ms`,
'--dur-message': `${MESSAGE_ENTRANCE_ANIMATION_DURATION_MS}ms`,
'--dur-playlist-saved': `${PLAYLIST_SAVED_ANIMATION_DURATION_MS}ms`,
'--dur-thinking-pulse': `${THINKING_PULSE_ANIMATION_DURATION_MS}ms`,
'--delay-thinking-pulse': `${THINKING_PULSE_STAGGER_MS}ms`,
'--dur-reduced-motion': `${REDUCED_MOTION_DURATION_MS}ms`,
}
const mode = computed(() => {
if (health.value.status === 'ready') return health.value.mode
return null
})
const canSave = computed(() => auth.value.status === 'authenticated')
const turnCounter = computed(() => {
if (turnCount.value === 0) return messages.chatViewNoTurns
if (turnCount.value === 1) {
return formatMessage('chatViewOneTurn', { count: turnCount.value })
}
return formatMessage('chatViewManyTurns', { count: turnCount.value })
})
async function focusInput(): Promise<void> {
await nextTick()
input.value?.focus()
}
function pickSuggestion(suggestion: string): void {
draft.value = suggestion
void focusInput()
}
function retryQuery(query: string): void {
draft.value = query
void focusInput()
}
function submit(query: string): void {
if (isStreaming.value) return
draft.value = ''
void send(query)
}
function openDevPanel(opener?: HTMLElement): void {
if (opener) {
devOpener.value = opener
devOpen.value = true
return
}
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement) {
devOpener.value = activeElement
} else {
devOpener.value = null
}
devOpen.value = true
}
function onShortcut(event: KeyboardEvent): void {
if (event.ctrlKey && event.key.toLowerCase() === 'd') {
event.preventDefault()
if (devOpen.value) {
devOpen.value = false
} else {
openDevPanel()
}
}
}
onMounted(() => {
void bootstrap()
window.addEventListener('keydown', onShortcut)
})
onUnmounted(() => window.removeEventListener('keydown', onShortcut))
</script>
<template>
<div class="chat-view" :style="timingStyles">
<div class="shell" :inert="devOpen">
<AppHeader
:auth="auth"
:mode="mode"
:health-failed="health.status === 'failed'"
@logout="logout"
@toggle-dev="openDevPanel"
/>
<ModeBanner v-if="mode === 'demo'" />
<MessageList
:turns="turns"
:suggestions="DISCOVERY_SUGGESTIONS"
:can-save="canSave"
@pick="pickSuggestion"
@save="savePlaylist"
@retry="retryQuery"
/>
<MessageInput
ref="input"
v-model="draft"
:disabled="isStreaming"
:turn-counter="turnCounter"
@send="submit"
/>
</div>
<DevPanel
v-if="devOpen"
:latest="latestAssistant"
:log="eventLog"
:opener="devOpener"
@close="devOpen = false"
@reset="reset"
/>
</div>
</template>
<style scoped>
.chat-view,
.shell {
width: 100%;
height: 100dvh;
overflow: hidden;
}
.chat-view {
color: var(--c-text);
font-family: var(--f-body);
font-size: var(--t-body);
line-height: var(--lh-body);
background: var(--c-bg);
}
.shell {
display: flex;
flex-direction: column;
}
</style>

View file

@ -0,0 +1,299 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { messages } from '../lib/messages'
import type { AssistantTurn, EventLogEntry } from '../lib/models'
const props = defineProps<{
latest: AssistantTurn | undefined
log: EventLogEntry[]
opener: HTMLElement | null
}>()
const emit = defineEmits<{ close: []; reset: [] }>()
const panel = ref<HTMLElement | null>(null)
const closeButton = ref<HTMLButtonElement | null>(null)
function latestValue(value: string | null | undefined): string {
return value ?? messages.devPanelWaiting
}
const rows = computed(() => [
{ label: messages.devPanelRequestId, value: latestValue(props.latest?.requestId) },
{
label: messages.devPanelCandidateCount,
value: latestValue(props.latest?.candidateCount?.toString()),
},
{
label: messages.devPanelTrackCount,
value: latestValue(
props.latest?.completion?.track_count.toString() ?? props.latest?.tracks.length.toString(),
),
},
{
label: messages.devPanelTotalMilliseconds,
value: latestValue(props.latest?.completion?.total_ms.toString()),
},
])
const entries = computed(() => {
if (props.log.length) return [...props.log].reverse()
return [
{
timestamp: '--:--:--',
type: messages.devPanelIdle,
detail: messages.devPanelWaitingForRequest,
},
]
})
function focusableElements(): HTMLElement[] {
if (!panel.value) return []
return Array.from(
panel.value.querySelectorAll<HTMLElement>(
'button:not(:disabled), a[href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
),
)
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
if (event.key !== 'Tab') return
const focusable = focusableElements()
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first?.focus()
}
}
onMounted(async () => {
await nextTick()
closeButton.value?.focus()
})
onUnmounted(() => {
const fallback = document.querySelector<HTMLElement>('[data-dev-panel-opener]')
const target = props.opener?.isConnected ? props.opener : fallback
target?.focus()
})
</script>
<template>
<div class="backdrop" @click.self="$emit('close')">
<aside
ref="panel"
class="panel"
role="dialog"
aria-modal="true"
aria-labelledby="dev-panel-title"
@keydown="onKeydown"
>
<div class="bar">
<h2 id="dev-panel-title" class="caps">{{ messages.devPanelTitle }}</h2>
<button
ref="closeButton"
class="close"
type="button"
:aria-label="messages.devPanelCloseLabel"
@click="$emit('close')"
>
<span aria-hidden="true">{{ messages.devPanelCloseSymbol }}</span>
</button>
</div>
<div class="body">
<section>
<h3 class="caps">{{ messages.devPanelLatestRequest }}</h3>
<p class="caption">{{ messages.devPanelContractCaption }}</p>
<div class="table">
<div v-for="row in rows" :key="row.label" class="row">
<span class="key">{{ row.label }}</span
><span class="value">{{ row.value }}</span>
</div>
</div>
</section>
<section>
<h3 class="caps section-heading">{{ messages.devPanelConversation }}</h3>
<button class="reset" type="button" @click="$emit('reset')">
{{ messages.devPanelClearConversation }}
</button>
</section>
<section>
<h3 class="caps section-heading">{{ messages.devPanelEventStream }}</h3>
<div class="log">
<div v-for="(entry, index) in entries" :key="index" class="entry">
<span class="time">{{ entry.timestamp }}</span>
<span class="type">{{ entry.type }}</span>
<span class="detail" :title="entry.detail">{{ entry.detail }}</span>
</div>
</div>
</section>
</div>
</aside>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.5);
}
.panel {
position: absolute;
inset: 0 0 0 auto;
display: flex;
flex-direction: column;
width: var(--dev-width);
max-width: 100vw;
overflow-y: auto;
background: var(--c-dev-bg);
border-left: 1px solid var(--c-line);
box-shadow: var(--shadow-drawer);
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-6) var(--s-7);
border-bottom: 1px solid var(--c-line);
}
.caps {
margin: 0;
color: var(--c-text-muted);
font-family: var(--f-mono);
font-size: var(--t-micro);
font-weight: 400;
letter-spacing: var(--ls-caps);
text-transform: uppercase;
}
.section-heading {
margin-bottom: var(--s-4);
}
.close {
min-width: var(--tap-min);
min-height: var(--tap-min);
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-body);
line-height: 1;
cursor: pointer;
background: none;
border: 0;
}
.body {
display: flex;
flex-direction: column;
gap: var(--s-8);
padding: var(--s-7);
}
.caption {
margin: var(--s-1) 0 var(--s-5);
color: var(--c-text-dim);
font-size: 12.5px;
}
.table {
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
background: var(--c-line);
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.row {
display: flex;
gap: var(--s-5);
justify-content: space-between;
padding: 9px var(--s-5);
font-family: var(--f-mono);
font-size: var(--t-meta);
background: var(--c-surface);
}
.key {
color: var(--c-text-dim);
}
.value {
max-width: 55%;
overflow: hidden;
color: var(--c-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.reset {
width: 100%;
min-height: var(--tap-min);
padding: 11px var(--s-5);
color: var(--c-text-chip);
font-size: 13px;
text-align: left;
cursor: pointer;
background: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.reset:hover {
color: var(--c-text);
border-color: var(--c-accent);
}
.log {
display: flex;
flex-direction: column;
gap: 3px;
max-height: 260px;
overflow-y: auto;
font-family: var(--f-mono);
font-size: var(--t-micro);
}
.entry {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
gap: var(--s-4);
}
.time {
color: var(--c-text-ghost);
}
.type {
color: var(--c-accent);
}
.detail {
min-width: 0;
overflow: hidden;
color: var(--c-text-dim);
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 560px) {
.panel {
width: 100vw;
}
}
</style>

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
</script>
<template>
<div class="empty-results">
<strong>{{ messages.emptyResultsTitle }}</strong>
<span>{{ messages.emptyResultsGuidance }}</span>
</div>
</template>
<style scoped>
.empty-results {
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-6);
color: var(--c-text-muted);
background: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.empty-results strong {
color: var(--c-text);
font-weight: 500;
}
.empty-results span {
font-size: var(--t-small);
}
</style>

View file

@ -0,0 +1,75 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
import SuggestionChips from './SuggestionChips.vue'
defineProps<{ suggestions: readonly string[] }>()
defineEmits<{ pick: [suggestion: string] }>()
</script>
<template>
<div class="empty">
<h1>
{{ messages.emptyStateHeading
}}<span class="accent">{{ messages.emptyStateHeadingAccent }}</span>
</h1>
<p class="lede">
{{ messages.emptyStateDescription }}
</p>
<template v-if="suggestions.length">
<div class="label">{{ messages.emptyStateSuggestionLabel }}</div>
<SuggestionChips :suggestions="suggestions" @pick="$emit('pick', $event)" />
</template>
</div>
</template>
<style scoped>
.empty {
padding: var(--s-5) 0 var(--s-3);
}
h1 {
max-width: 16ch;
margin: 0 0 var(--s-5);
font-family: var(--f-display);
font-size: var(--t-hero);
font-weight: 600;
line-height: var(--lh-hero);
letter-spacing: var(--ls-hero);
}
.accent {
color: var(--c-accent);
}
.lede {
max-width: 52ch;
margin: 0 0 var(--s-9);
color: var(--c-text-muted);
text-wrap: pretty;
}
.label {
margin-bottom: var(--s-5);
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-caps);
letter-spacing: var(--ls-caps);
text-transform: uppercase;
}
@media (max-width: 760px) {
h1 {
font-size: 38px;
}
}
@media (max-width: 560px) {
.empty {
padding-top: var(--s-1);
}
h1 {
font-size: 32px;
}
}
</style>

View file

@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { NARROW_COMPOSER_MAX_WIDTH_PX, QUERY_MAX_LENGTH } from '../lib/constants'
import { messages } from '../lib/messages'
const model = defineModel<string>({ required: true })
const props = defineProps<{ disabled: boolean; turnCounter: string }>()
const emit = defineEmits<{ send: [query: string] }>()
const input = ref<HTMLInputElement | null>(null)
const narrow = ref(false)
let mediaQuery: MediaQueryList | null = null
const sendDisabled = computed(() => props.disabled || model.value.trim().length === 0)
const placeholder = computed(() => {
if (narrow.value) return messages.messageInputNarrowPlaceholder
return messages.messageInputPlaceholder
})
const buttonLabel = computed(() => {
if (props.disabled) return messages.messageInputWorking
return messages.messageInputSend
})
function updateWidth(event?: MediaQueryListEvent): void {
narrow.value = event?.matches ?? mediaQuery?.matches ?? false
}
function submit(event?: KeyboardEvent): void {
if (event?.isComposing || props.disabled) return
const query = model.value.trim()
if (query) emit('send', query)
}
function focus(): void {
input.value?.focus()
}
onMounted(() => {
mediaQuery = window.matchMedia(`(max-width: ${NARROW_COMPOSER_MAX_WIDTH_PX}px)`)
updateWidth()
mediaQuery.addEventListener('change', updateWidth)
})
onUnmounted(() => mediaQuery?.removeEventListener('change', updateWidth))
defineExpose({ focus })
</script>
<template>
<div class="composer">
<div class="inner">
<div class="field">
<input
ref="input"
v-model="model"
:aria-label="messages.messageInputAriaLabel"
:maxlength="QUERY_MAX_LENGTH"
:placeholder="placeholder"
@keydown.enter.prevent="submit($event)"
/>
<button class="send" type="button" :disabled="sendDisabled" @click="submit()">
{{ buttonLabel }}
</button>
</div>
<div class="hints">
<span class="hint">
{{ messages.messageInputHint }}
</span>
<span>{{ turnCounter }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.composer {
flex: none;
padding: var(--s-5) var(--gutter) calc(20px + env(safe-area-inset-bottom));
background: var(--c-bg);
border-top: 1px solid var(--c-line);
}
.inner {
max-width: var(--measure);
margin: 0 auto;
}
.field {
display: flex;
gap: var(--s-4);
align-items: flex-end;
padding: var(--s-3) var(--s-3) var(--s-3) var(--s-6);
background: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: var(--r-lg);
transition: border-color var(--dur-fast) ease;
}
.field:focus-within {
border-color: var(--c-focus-border);
}
input {
flex: 1;
min-width: 0;
padding: var(--s-3) 0;
color: var(--c-text);
background: transparent;
border: 0;
outline: none;
}
input::placeholder {
color: var(--c-text-faint);
}
.send {
flex: none;
min-height: var(--tap-min);
padding: var(--s-4) var(--s-7);
color: var(--c-on-accent);
font-size: 14px;
font-weight: 500;
cursor: pointer;
background: var(--c-accent);
border: 0;
border-radius: var(--r-md);
transition: background var(--dur-fast) ease;
}
.send:hover:not(:disabled) {
background: var(--c-accent-hover);
}
.hints {
display: flex;
gap: var(--s-6);
justify-content: space-between;
margin-top: 9px;
color: var(--c-text-ghost);
font-family: var(--f-mono);
font-size: 10.5px;
}
@media (max-width: 760px) {
.hint {
display: none;
}
}
</style>

View file

@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { MESSAGE_LIST_PIN_THRESHOLD_PX } from '../lib/constants'
import { formatMessage, messages } from '../lib/messages'
import type { AssistantTurn, ChatTurn } from '../lib/models'
import AssistantMessage from './AssistantMessage.vue'
import EmptyState from './EmptyState.vue'
import UserMessage from './UserMessage.vue'
const props = defineProps<{
turns: ChatTurn[]
suggestions: readonly string[]
canSave: boolean
}>()
defineEmits<{
pick: [suggestion: string]
save: [turnId: string]
retry: [query: string]
}>()
const list = ref<HTMLElement | null>(null)
const pinned = ref(true)
function turnFingerprint(turn: ChatTurn): string {
if (turn.role === 'user') return turn.id
return `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`
}
function findLatestAssistant(): AssistantTurn | undefined {
for (let index = props.turns.length - 1; index >= 0; index -= 1) {
const turn = props.turns[index]
if (turn?.role === 'assistant') return turn
}
return undefined
}
const fingerprint = computed(() => props.turns.map(turnFingerprint).join('|'))
const streaming = computed(() =>
props.turns.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'),
)
const latestAssistant = computed(findLatestAssistant)
const announcement = computed(() => {
const turn = latestAssistant.value
if (!turn) return ''
if (turn.status === 'error') return messages.messageListRequestFailed
if (turn.status === 'done') {
const trackCount = turn.completion?.track_count ?? turn.tracks.length
return formatMessage('messageListTracksReady', { count: trackCount })
}
const warning = turn.warnings.at(-1)
if (warning) {
return formatMessage('messageListWarning', {
count: turn.warnings.length,
message: warning.message,
})
}
if (turn.requestId === null) return messages.messageListReadingRequest
if (turn.tracks.length === 0) return messages.messageListCheckingCandidates
return formatMessage('messageListVerifiedTracksReceived', { count: turn.tracks.length })
})
function onScroll(): void {
const element = list.value
if (!element) return
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight
pinned.value = distanceFromBottom < MESSAGE_LIST_PIN_THRESHOLD_PX
}
async function scrollToLatest(): Promise<void> {
if (!pinned.value) return
await nextTick()
requestAnimationFrame(() => {
if (list.value) list.value.scrollTop = list.value.scrollHeight
})
}
watch(fingerprint, scrollToLatest, { flush: 'post' })
watch(streaming, (value) => {
if (value) pinned.value = true
})
</script>
<template>
<main
ref="list"
class="list"
:aria-label="messages.messageListConversationLabel"
@scroll="onScroll"
>
<p class="visually-hidden" aria-live="polite" aria-atomic="true">
{{ announcement }}
</p>
<div class="column">
<EmptyState
v-if="turns.length === 0"
:suggestions="suggestions"
@pick="$emit('pick', $event)"
/>
<div v-for="turn in turns" :key="turn.id" class="turn">
<UserMessage v-if="turn.role === 'user'" :text="turn.text" />
<AssistantMessage
v-else
:turn="turn"
:can-save="canSave"
@save="$emit('save', turn.id)"
@retry="$emit('retry', $event)"
/>
</div>
</div>
</main>
</template>
<style scoped>
.list {
flex: 1 1 auto;
min-height: 0;
padding: 32px var(--gutter) 28px;
overflow-y: auto;
}
.column {
display: flex;
flex-direction: column;
gap: var(--s-9);
max-width: var(--measure);
margin: 0 auto;
}
.turn {
animation: message-in var(--dur-message) ease both;
}
@keyframes message-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@media (max-width: 560px) {
.list {
padding-top: 24px;
}
}
</style>

View file

@ -0,0 +1,40 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
</script>
<template>
<div class="banner" role="status">
<div class="inner">
<span class="dot" aria-hidden="true" />
<span>{{ messages.modeBannerDemo }}</span>
</div>
</div>
</template>
<style scoped>
.banner {
flex: none;
padding: 9px var(--gutter);
background: var(--c-banner-bg);
border-bottom: 1px solid var(--c-banner-line);
}
.inner {
display: flex;
gap: var(--s-5);
align-items: center;
max-width: var(--measure);
margin: 0 auto;
color: var(--c-accent-soft);
font-family: var(--f-mono);
font-size: var(--t-meta);
}
.dot {
flex: none;
width: 6px;
height: 6px;
background: var(--c-accent);
border-radius: 50%;
}
</style>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
defineProps<{ message: string }>()
</script>
<template>
<p class="error" role="alert">{{ message }}</p>
</template>
<style scoped>
.error {
margin: 0;
padding-left: var(--s-5);
color: var(--c-error);
font-size: var(--t-small);
border-left: 2px solid var(--c-error);
}
</style>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatMessage, messages } from '../lib/messages'
const props = defineProps<{ name: string; url: string | null; trackCount: number }>()
const metadata = computed(() => formatMessage('playlistSavedMetadata', { count: props.trackCount }))
</script>
<template>
<div class="saved" role="status">
<div>
<div class="line">
{{ messages.playlistSavedConfirmationPrefix }}<span class="name">{{ name }}</span>
</div>
<div class="meta">{{ metadata }}</div>
</div>
<a v-if="url" class="link" :href="url" target="_blank" rel="noreferrer">
{{ messages.playlistSavedOpenSpotify }} <span aria-hidden="true">&nearr;</span>
</a>
</div>
</template>
<style scoped>
.saved {
display: flex;
flex-wrap: wrap;
gap: var(--s-6);
align-items: center;
justify-content: space-between;
padding: 13px var(--s-6);
background: var(--c-saved-bg);
border: 1px solid var(--c-saved-line);
border-radius: var(--r-md);
animation: save-pop var(--dur-playlist-saved) ease both;
}
.line {
font-size: 14.5px;
}
.name,
.link {
color: var(--c-saved);
}
.meta,
.link {
font-family: var(--f-mono);
font-size: var(--t-micro);
}
.meta {
margin-top: 2px;
color: var(--c-text-faint);
}
@keyframes save-pop {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
</style>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatMessage } from '../lib/messages'
import type { AssistantTurn } from '../lib/models'
const props = defineProps<{ turn: AssistantTurn }>()
const parts = computed(() => {
const values: string[] = []
if (props.turn.candidateCount !== null) {
values.push(formatMessage('requestMetadataCandidates', { count: props.turn.candidateCount }))
}
if (props.turn.completion) {
values.push(
formatMessage('requestMetadataVerified', { count: props.turn.completion.track_count }),
)
values.push(
formatMessage('requestMetadataDuration', {
milliseconds: props.turn.completion.total_ms,
}),
)
}
return values
})
</script>
<template>
<p v-if="parts.length" class="metadata">{{ parts.join(' / ') }}</p>
</template>
<style scoped>
.metadata {
margin: 0;
color: var(--c-text-ghost);
font-family: var(--f-mono);
font-size: var(--t-micro);
}
</style>

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatMessage, messages } from '../lib/messages'
import type { PlaylistState } from '../lib/models'
const props = defineProps<{
state: PlaylistState
trackCount: number
canSave: boolean
}>()
defineEmits<{ save: [] }>()
const buttonLabel = computed(() => {
if (props.state.status === 'saving') return messages.resultActionsSaving
if (props.state.status === 'error') return messages.resultActionsTryAgain
return messages.resultActionsSavePlaylist
})
const summary = computed(() => formatMessage('resultActionsSummary', { count: props.trackCount }))
</script>
<template>
<div class="actions">
<button
v-if="canSave"
class="save"
type="button"
:disabled="state.status === 'saving'"
@click="$emit('save')"
>
{{ buttonLabel }}
</button>
<a v-else class="connect" href="/api/auth/login">{{ messages.resultActionsConnectSpotify }}</a>
<span class="summary">{{ summary }}</span>
</div>
</template>
<style scoped>
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--s-5);
align-items: center;
}
.save,
.connect {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--tap-min);
padding: 11px var(--s-6);
color: var(--c-on-accent);
font-size: var(--t-small);
font-weight: 500;
text-decoration: none;
cursor: pointer;
background: var(--c-accent);
border: 1px solid var(--c-accent);
border-radius: var(--r-md);
transition: all var(--dur-fast) ease;
}
.save:hover,
.connect:hover {
color: var(--c-on-accent);
text-decoration: none;
background: var(--c-accent-hover);
border-color: var(--c-accent-hover);
}
.summary {
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-micro);
}
</style>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
import type { TrackEvent } from '../lib/types'
import TrackCard from './TrackCard.vue'
defineProps<{ tracks: TrackEvent[] }>()
</script>
<template>
<div class="set" :aria-label="messages.resultSetRecommendedTracksLabel">
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
</div>
</template>
<style scoped>
.set {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
</style>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
defineProps<{ code: string; message: string }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<div class="error" role="alert" :data-error-code="code">
<p>{{ message }}</p>
<button type="button" @click="$emit('retry')">{{ messages.streamErrorRetry }}</button>
</div>
</template>
<style scoped>
.error {
padding-left: var(--s-5);
color: var(--c-error);
border-left: 2px solid var(--c-error);
}
.error p {
margin: 0;
text-wrap: pretty;
}
.error button {
min-height: var(--tap-min);
margin-top: var(--s-3);
padding: 8px var(--s-4);
color: var(--c-error);
font-family: var(--f-mono);
font-size: var(--t-micro);
background: transparent;
border: 1px solid currentcolor;
border-radius: var(--r-md);
cursor: pointer;
}
</style>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { WarningEvent } from '../lib/types'
defineProps<{ warning: WarningEvent }>()
</script>
<template>
<p class="warning" :data-warning-code="warning.code">{{ warning.message }}</p>
</template>
<style scoped>
.warning {
margin: 0;
padding-left: var(--s-5);
color: var(--c-accent-soft);
font-size: var(--t-small);
text-wrap: pretty;
border-left: 2px solid var(--c-accent-line);
}
</style>

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
defineProps<{ suggestions: readonly string[] }>()
defineEmits<{ pick: [suggestion: string] }>()
</script>
<template>
<div class="chips">
<button
v-for="suggestion in suggestions"
:key="suggestion"
class="chip"
type="button"
@click="$emit('pick', suggestion)"
>
{{ suggestion }}
</button>
</div>
</template>
<style scoped>
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--s-3);
}
.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-raised);
border: 1px solid var(--c-line);
border-radius: var(--r-pill);
transition: all var(--dur-fast) ease;
}
.chip:hover {
color: var(--c-text);
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>

View file

@ -0,0 +1,54 @@
<script setup lang="ts">
defineProps<{ label: string }>()
</script>
<template>
<div class="thinking" aria-hidden="true">
<span class="dot" /><span class="dot" /><span class="dot" />
<span class="label">{{ label }}</span>
</div>
</template>
<style scoped>
.thinking {
display: flex;
align-items: center;
gap: var(--s-3);
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-micro);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.dot {
width: 5px;
height: 5px;
background: var(--c-accent);
border-radius: 50%;
animation: pulse-dot var(--dur-thinking-pulse) infinite;
}
.dot:nth-child(2) {
animation-delay: var(--delay-thinking-pulse);
}
.dot:nth-child(3) {
animation-delay: calc(2 * var(--delay-thinking-pulse));
}
.label {
margin-left: var(--s-1);
}
@keyframes pulse-dot {
0%,
100% {
opacity: 0.25;
}
50% {
opacity: 1;
}
}
</style>

View file

@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { formatMessage, messages } from '../lib/messages'
import type { TrackEvent } from '../lib/types'
const props = defineProps<{ event: TrackEvent }>()
const artworkFailed = ref(false)
const artists = computed(() => props.event.track.artists.join(', '))
const spotifyUrl = computed(() => props.event.track.external_url)
const artworkAlt = computed(() =>
formatMessage('trackCardArtworkAlt', { album: props.event.track.album_name }),
)
</script>
<template>
<article class="card">
<div class="rank">{{ event.rank }}</div>
<div class="art">
<img
v-if="event.track.album_art_url && !artworkFailed"
:src="event.track.album_art_url"
:alt="artworkAlt"
@error="artworkFailed = true"
/>
</div>
<div class="track-meta">
<div class="head">
<span class="title">{{ event.track.title }}</span>
<span class="artist">{{ artists }}</span>
</div>
<div class="album">{{ event.track.album_name }}</div>
<div class="why">{{ event.justification }}</div>
</div>
<a v-if="spotifyUrl" class="link" :href="spotifyUrl" target="_blank" rel="noreferrer">
{{ messages.trackCardOpenSpotify }}
<span aria-hidden="true">&nearr;</span>
</a>
</article>
</template>
<style scoped>
.card {
display: grid;
grid-template-columns: 26px 52px 1fr auto;
row-gap: var(--s-4);
column-gap: var(--s-6);
align-items: center;
padding: 11px var(--s-5);
background: var(--c-surface);
border: 1px solid var(--c-card-border);
border-radius: var(--r-md);
transition:
border-color var(--dur-fast) ease,
background var(--dur-fast) ease;
animation: card-in var(--dur-card) var(--ease) both;
}
.card:hover {
background: var(--c-surface-hover);
border-color: var(--c-line-strong);
}
.rank {
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: 12px;
text-align: right;
}
.art {
width: 52px;
height: 52px;
overflow: hidden;
background: repeating-linear-gradient(135deg, var(--c-art-a) 0 6px, var(--c-art-b) 6px 12px);
border: 1px solid var(--c-line);
border-radius: var(--r-sm);
}
.art img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.track-meta {
min-width: 0;
}
.head {
display: flex;
flex-wrap: wrap;
gap: 0 9px;
align-items: baseline;
}
.title {
font-size: var(--t-title);
font-weight: 500;
}
.artist,
.album,
.why {
color: var(--c-text-muted);
font-size: 13px;
}
.album {
margin-top: 1px;
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-micro);
}
.why {
margin-top: 3px;
color: var(--c-text-dim);
font-size: var(--t-small);
text-wrap: pretty;
}
.link {
display: inline-flex;
gap: var(--s-1);
align-items: center;
justify-content: center;
justify-self: end;
padding: 6px var(--s-4);
color: var(--c-text-muted);
font-family: var(--f-mono);
font-size: var(--t-micro);
text-decoration: none;
white-space: nowrap;
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.link:hover {
color: var(--c-accent);
text-decoration: none;
border-color: var(--c-accent);
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
@media (max-width: 560px) {
.card {
grid-template-columns: 26px 52px 1fr;
}
.link {
grid-column: 1 / -1;
justify-self: stretch;
min-height: var(--tap-min);
}
}
</style>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
defineProps<{ text: string }>()
</script>
<template>
<div class="row">
<div class="bubble">{{ text }}</div>
</div>
</template>
<style scoped>
.row {
display: flex;
justify-content: flex-end;
}
.bubble {
max-width: 78%;
padding: 11px var(--s-6);
color: var(--c-text);
overflow-wrap: anywhere;
background: var(--c-surface-user);
border: 1px solid var(--c-line-user);
border-radius: var(--r-md) var(--r-md) var(--r-md) 12px;
}
</style>

View file

@ -0,0 +1,133 @@
import { ref } from 'vue'
import { formatMessage, messages } from '../lib/messages'
import type { AuthState, HealthState } from '../lib/models'
import { parseSpotifyUrl } from '../lib/spotifyUrl'
import type { CurrentUser, PlaylistCreateRequest } from '../lib/types'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseCurrentUser(value: unknown): CurrentUser {
if (!isRecord(value) || typeof value.display_name !== 'string') {
throw new Error(messages.useApiInvalidCurrentUser)
}
return { display_name: value.display_name }
}
function parsePlaylistResponse(value: unknown): { url: string | null } {
if (!isRecord(value) || typeof value.url !== 'string') {
throw new Error(messages.useApiInvalidPlaylist)
}
return { url: parseSpotifyUrl(value.url) }
}
function loginFailed(): boolean {
const url = new URL(window.location.href)
const failed = url.searchParams.get('login') === 'error'
if (failed) {
url.searchParams.delete('login')
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
}
return failed
}
/** Manage authentication, service mode, and playlist API operations. */
export function useApi() {
const auth = ref<AuthState>({ status: 'checking', user: null, message: null })
const health = ref<HealthState>({ status: 'checking', mode: null, message: null })
async function loadAuth(hadLoginError: boolean): Promise<void> {
try {
const response = await fetch('/api/auth/me', { credentials: 'same-origin' })
if (response.status === 401) {
if (hadLoginError) {
auth.value = {
status: 'failed',
user: null,
message: messages.useApiLoginFailed,
}
} else {
auth.value = { status: 'anonymous', user: null, message: null }
}
return
}
if (!response.ok) throw new Error(messages.useApiAuthenticationCheckFailed)
auth.value = {
status: 'authenticated',
user: parseCurrentUser(await response.json()),
message: null,
}
} catch {
let message: string = messages.useApiConnectionUnavailable
if (hadLoginError) message = messages.useApiLoginFailed
auth.value = {
status: 'failed',
user: null,
message,
}
}
}
async function loadHealth(): Promise<void> {
try {
const response = await fetch('/api/health', { credentials: 'same-origin' })
if (!response.ok) throw new Error(messages.useApiHealthCheckFailed)
const body: unknown = await response.json()
if (
!isRecord(body) ||
body.status !== 'ok' ||
(body.mode !== 'demo' && body.mode !== 'live')
) {
throw new Error(messages.useApiInvalidHealth)
}
health.value = { status: 'ready', mode: body.mode, message: null }
} catch {
health.value = {
status: 'failed',
mode: null,
message: messages.useApiModeUnavailable,
}
}
}
async function bootstrap(): Promise<void> {
const hadLoginError = loginFailed()
await Promise.all([loadAuth(hadLoginError), loadHealth()])
}
async function logout(): Promise<void> {
if (auth.value.status !== 'authenticated') return
const user = auth.value.user
auth.value = { status: 'logging_out', user, message: null }
try {
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin',
})
if (!response.ok) throw new Error(messages.useApiLogoutFailed)
auth.value = { status: 'anonymous', user: null, message: null }
} catch {
auth.value = {
status: 'failed',
user: null,
message: messages.useApiSpotifyLogoutFailed,
}
}
}
async function createPlaylist(request: PlaylistCreateRequest): Promise<{ url: string | null }> {
const response = await fetch('/api/playlists', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
if (!response.ok) {
throw new Error(formatMessage('useApiPlaylistCreationFailed', { status: response.status }))
}
return parsePlaylistResponse(await response.json())
}
return { auth, health, bootstrap, logout, createPlaylist }
}

View file

@ -0,0 +1,295 @@
import { computed, onUnmounted, ref } from 'vue'
import {
EVENT_LOG_MAX_ENTRIES,
HISTORY_CONTENT_MAX_LENGTH,
HISTORY_MAX_TURNS,
PLAYLIST_NAME_MAX_LENGTH,
PRIOR_RECOMMENDATIONS_MAX_TRACKS,
QUERY_MAX_LENGTH,
} from '../lib/constants'
import { formatMessage, messages } from '../lib/messages'
import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models'
import { EMPTY_PLAYLIST_STATE } from '../lib/models'
import {
isAbortError,
streamRecommendations,
StreamTransportError,
} from '../lib/recommendationStream'
import type {
HistoryTurn,
PlaylistCreateRequest,
PriorRecommendation,
RecommendationRequest,
StreamEvent,
} from '../lib/types'
type PlaylistCreator = (request: PlaylistCreateRequest) => Promise<{ url: string | null }>
interface ActiveRequest {
controller: AbortController
turnId: string
terminalReceived: boolean
}
function assistantContent(turn: AssistantTurn): string {
const trackLines = turn.tracks.map(
(event) => `${event.rank}. ${event.track.title} by ${event.track.artists.join(', ')}`,
)
const content = [turn.intentSummary, ...trackLines].filter(Boolean).join('\n')
return content.slice(0, HISTORY_CONTENT_MAX_LENGTH)
}
function buildHistory(turns: ChatTurn[]): HistoryTurn[] {
return turns
.map((turn): HistoryTurn | null => {
if (turn.role === 'user') return { role: 'user', content: turn.text }
const content = assistantContent(turn)
return content ? { role: 'assistant', content } : null
})
.filter((turn): turn is HistoryTurn => turn !== null)
.slice(-HISTORY_MAX_TURNS)
}
function findLatestCompletedAssistant(turns: ChatTurn[]): AssistantTurn | undefined {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index]
if (turn?.role === 'assistant' && turn.status === 'done') return turn
}
return undefined
}
function buildPriorRecommendations(turns: ChatTurn[]): PriorRecommendation[] {
const latest = findLatestCompletedAssistant(turns)
if (!latest) return []
return latest.tracks.slice(0, PRIOR_RECOMMENDATIONS_MAX_TRACKS).map((event) => ({
rank: event.rank,
track_id: event.track.id,
title: event.track.title,
artists: event.track.artists,
}))
}
function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
switch (event.type) {
case 'metadata':
return {
...turn,
requestId: event.request_id,
intentSummary: event.intent_summary,
candidateCount: event.candidate_count,
}
case 'track':
return { ...turn, tracks: [...turn.tracks, event] }
case 'warning':
return { ...turn, warnings: [...turn.warnings, event] }
case 'error':
return { ...turn, status: 'error', error: event }
case 'done':
if (event.track_count !== turn.tracks.length) {
return {
...turn,
status: 'error',
completion: event,
transportFailure: {
kind: 'protocol',
message: messages.useChatStreamTrackCountMismatch,
},
}
}
return { ...turn, status: 'done', completion: event }
}
}
function playlistName(query: string): string {
return formatMessage('useChatStreamPlaylistName', { query })
.slice(0, PLAYLIST_NAME_MAX_LENGTH)
.trim()
}
function createTurnId(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
function findLatestAssistant(turns: ChatTurn[]): AssistantTurn | undefined {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index]
if (turn?.role === 'assistant') return turn
}
return undefined
}
/** Reduce recommendation streams into conversation view state. */
export function useChatStream(createPlaylist: PlaylistCreator) {
const turns = ref<ChatTurn[]>([])
const eventLog = ref<EventLogEntry[]>([])
let activeRequest: ActiveRequest | null = null
const isStreaming = computed(() =>
turns.value.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'),
)
const turnCount = computed(() => turns.value.filter((turn) => turn.role === 'user').length)
const latestAssistant = computed(() => findLatestAssistant(turns.value))
function updateAssistant(id: string, update: (turn: AssistantTurn) => AssistantTurn): void {
turns.value = turns.value.map((turn) => {
if (turn.role === 'assistant' && turn.id === id) return update(turn)
return turn
})
}
function addLog(type: string, detail: string): void {
eventLog.value = [
...eventLog.value,
{ timestamp: new Date().toLocaleTimeString(), type, detail },
].slice(-EVENT_LOG_MAX_ENTRIES)
}
function cancelActive(shouldMarkTurn: boolean): void {
const request = activeRequest
if (!request) return
request.controller.abort()
if (shouldMarkTurn && !request.terminalReceived) {
const failure: TransportFailure = {
kind: 'cancelled',
message: messages.useChatStreamRequestReplaced,
}
updateAssistant(request.turnId, (turn) => {
if (turn.status !== 'streaming') return turn
return { ...turn, status: 'error', transportFailure: failure }
})
}
activeRequest = null
}
async function send(query: string): Promise<void> {
const text = query.trim().slice(0, QUERY_MAX_LENGTH)
if (!text) return
cancelActive(true)
const request: RecommendationRequest = {
schema_version: 1,
query: text,
history: buildHistory(turns.value),
prior_recommendations: buildPriorRecommendations(turns.value),
}
const userTurn: ChatTurn = { id: createTurnId(), role: 'user', text }
const assistantTurn: AssistantTurn = {
id: createTurnId(),
role: 'assistant',
query: text,
status: 'streaming',
requestId: null,
intentSummary: '',
candidateCount: null,
tracks: [],
warnings: [],
error: null,
transportFailure: null,
completion: null,
playlist: { ...EMPTY_PLAYLIST_STATE },
}
turns.value = [...turns.value, userTurn, assistantTurn]
const controller = new AbortController()
const requestState: ActiveRequest = {
controller,
turnId: assistantTurn.id,
terminalReceived: false,
}
activeRequest = requestState
try {
await streamRecommendations(request, controller.signal, (event) => {
if (activeRequest !== requestState || requestState.terminalReceived) return
if (event.type === 'done' || event.type === 'error') {
requestState.terminalReceived = true
}
updateAssistant(assistantTurn.id, (turn) => reduceEvent(turn, event))
addLog(event.type, JSON.stringify(event))
})
} catch (error) {
if (activeRequest !== requestState || requestState.terminalReceived) return
if (isAbortError(error, controller.signal)) return
let failure: TransportFailure
if (error instanceof StreamTransportError) {
failure = { kind: error.kind, message: error.message }
} else {
failure = { kind: 'network', message: messages.useChatStreamUnexpectedFailure }
}
updateAssistant(assistantTurn.id, (turn) => ({
...turn,
status: 'error',
transportFailure: failure,
}))
addLog(messages.useChatStreamTransportEvent, `${failure.kind}: ${failure.message}`)
} finally {
if (activeRequest === requestState) {
activeRequest = null
}
}
}
async function savePlaylist(turnId: string): Promise<void> {
const turn = turns.value.find(
(candidate): candidate is AssistantTurn =>
candidate.role === 'assistant' && candidate.id === turnId,
)
if (
!turn ||
turn.status !== 'done' ||
turn.tracks.length === 0 ||
turn.playlist.status === 'saving' ||
turn.playlist.status === 'saved'
) {
return
}
const name = playlistName(turn.query)
updateAssistant(turnId, (current) => ({
...current,
playlist: { status: 'saving', name, url: null, message: null },
}))
try {
const response = await createPlaylist({
schema_version: 1,
name,
track_uris: turn.tracks.map((event) => event.track.uri),
})
updateAssistant(turnId, (current) => ({
...current,
playlist: { status: 'saved', name, url: response.url, message: null },
}))
} catch {
updateAssistant(turnId, (current) => ({
...current,
playlist: {
status: 'error',
name,
url: null,
message: messages.useChatStreamPlaylistFailure,
},
}))
}
}
function reset(): void {
cancelActive(false)
turns.value = []
eventLog.value = []
}
onUnmounted(() => cancelActive(false))
return {
turns,
eventLog,
isStreaming,
turnCount,
latestAssistant,
send,
savePlaylist,
reset,
}
}

View file

@ -0,0 +1,46 @@
/** Shared frontend limits and timing values. */
// Mirrors the backend RecommendationRequest query max_length bound.
export const QUERY_MAX_LENGTH = 1000
// Mirrors the backend RecommendationRequest history max_length bound.
export const HISTORY_MAX_TURNS = 12
// Mirrors the backend RecommendationRequest prior_recommendations max_length bound.
export const PRIOR_RECOMMENDATIONS_MAX_TRACKS = 50
// Mirrors the backend HistoryTurn content max_length bound.
export const HISTORY_CONTENT_MAX_LENGTH = 2000
// Mirrors the backend PlaylistCreateRequest name max_length bound.
export const PLAYLIST_NAME_MAX_LENGTH = 100
// Keeps the developer event log useful without allowing unbounded growth.
export const EVENT_LOG_MAX_ENTRIES = 100
// Treats the message list as pinned when it is within this distance of the bottom.
export const MESSAGE_LIST_PIN_THRESHOLD_PX = 48
// Matches the composer placeholder switch to the mobile layout breakpoint.
export const NARROW_COMPOSER_MAX_WIDTH_PX = 559
// Keeps routine hover and focus transitions responsive.
export const FAST_TRANSITION_DURATION_MS = 160
// Gives track cards enough time to settle without slowing streamed results.
export const TRACK_CARD_ANIMATION_DURATION_MS = 340
// Makes new conversation turns noticeable without delaying interaction.
export const MESSAGE_ENTRANCE_ANIMATION_DURATION_MS = 300
// Gives playlist confirmation a brief visual acknowledgement.
export const PLAYLIST_SAVED_ANIMATION_DURATION_MS = 260
// Keeps the thinking pulse calm while work is in progress.
export const THINKING_PULSE_ANIMATION_DURATION_MS = 1100
// Separates thinking dots enough to make their sequence legible.
export const THINKING_PULSE_STAGGER_MS = 180
// Retains an effectively instant duration when reduced motion is requested.
export const REDUCED_MOTION_DURATION_MS = 1

View file

@ -0,0 +1,149 @@
/** Central English copy for future localization. */
export const messages = {
appHeaderBrandPrefix: 'discovery',
appHeaderBrandAccent: '-by-',
appHeaderBrandSuffix: 'llm',
appHeaderKicker: 'proof of concept',
appHeaderDemoMode: 'demo mode',
appHeaderLiveMode: 'live mode',
appHeaderModeUnavailable: 'mode unavailable',
appHeaderCheckingMode: 'checking mode',
appHeaderDevPanel: 'Dev panel',
appHeaderDevPanelShort: 'Dev',
appHeaderDevPanelShortcut: 'Ctrl+D',
assistantMessageReadingRequest: 'Reading the request',
assistantMessageCheckingCandidates: 'Checking candidates',
assistantMessageCheckingCandidateCount: 'Checking {count} candidates',
assistantMessageStreamingTracks: 'Streaming verified tracks',
assistantMessageAvatar: 'd',
authStatusCheckingConnection: 'Checking Spotify connection',
authStatusConnectSpotify: 'Connect Spotify',
authStatusConnect: 'Connect',
authStatusTryAgain: 'Try Spotify again',
authStatusRetry: 'Retry',
authStatusConnectedAs: 'Connected as ',
authStatusLoggingOut: 'Logging out...',
authStatusLogOut: 'Log out',
chatViewNoTurns: 'no turns yet',
chatViewOneTurn: '{count} turn in this session',
chatViewManyTurns: '{count} turns in this session',
devPanelTitle: 'Dev panel',
devPanelCloseLabel: 'Close dev panel',
devPanelCloseSymbol: 'X',
devPanelLatestRequest: 'Latest request',
devPanelContractCaption: 'Only counters supplied by the recommendation contract are shown.',
devPanelRequestId: 'request id',
devPanelCandidateCount: 'candidate count',
devPanelTrackCount: 'track count',
devPanelTotalMilliseconds: 'total ms',
devPanelWaiting: 'waiting',
devPanelConversation: 'Conversation',
devPanelClearConversation: 'Clear conversation',
devPanelEventStream: 'Event stream',
devPanelIdle: 'idle',
devPanelWaitingForRequest: 'waiting for a request',
emptyResultsTitle: 'No verified tracks matched this request.',
emptyResultsGuidance: 'Try broadening the moment or removing one constraint.',
emptyStateHeading: 'What should be playing',
emptyStateHeadingAccent: ' right now?',
emptyStateDescription:
'Describe the moment, not the genre. The assistant reads the request, proposes candidates, verifies each one on Spotify and explains why it picked them.',
emptyStateSuggestionLabel: 'Try one of these',
messageInputAriaLabel: 'Describe what you want to listen to',
messageInputPlaceholder:
'Describe the moment: "something calm while I am programming, but not boring"',
messageInputNarrowPlaceholder: 'Describe the moment...',
messageInputWorking: 'Working...',
messageInputSend: 'Send',
messageInputHint:
'Enter to send / refine with follow-ups like "more electronic and drop number 3"',
messageListConversationLabel: 'Conversation',
messageListRequestFailed: 'The recommendation request failed.',
messageListTracksReady: '{count} tracks ready.',
messageListWarning: 'Warning {count}: {message}',
messageListReadingRequest: 'Reading the request.',
messageListCheckingCandidates: 'Checking recommendation candidates.',
messageListVerifiedTracksReceived: '{count} verified tracks received.',
modeBannerDemo: 'Demo mode: replaying recorded recommendation sessions',
playlistSavedConfirmationPrefix: 'Saved ',
playlistSavedMetadata: '{count} tracks / private / created just now',
playlistSavedOpenSpotify: 'Open in Spotify',
requestMetadataCandidates: '{count} candidates considered',
requestMetadataVerified: '{count} verified',
requestMetadataDuration: '{milliseconds} ms',
resultActionsSaving: 'Saving...',
resultActionsTryAgain: 'Try saving again',
resultActionsSavePlaylist: 'Save as playlist',
resultActionsConnectSpotify: 'Connect Spotify to save',
resultActionsSummary: '{count} verified tracks ready for a private playlist',
resultSetRecommendedTracksLabel: 'Recommended tracks',
streamErrorRetry: 'Edit and retry',
trackCardArtworkAlt: '{album} album artwork',
trackCardOpenSpotify: 'Open in Spotify',
useApiInvalidCurrentUser: 'Invalid current user response.',
useApiInvalidPlaylist: 'Invalid playlist response.',
useApiLoginFailed: 'Spotify login did not complete. Please try again.',
useApiAuthenticationCheckFailed: 'Authentication check failed.',
useApiConnectionUnavailable: 'Spotify connection status is unavailable.',
useApiHealthCheckFailed: 'Health check failed.',
useApiInvalidHealth: 'Invalid health response.',
useApiModeUnavailable: 'Service mode is unavailable.',
useApiLogoutFailed: 'Logout failed.',
useApiSpotifyLogoutFailed: 'Spotify logout failed. Refresh before trying again.',
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',
useChatStreamPlaylistFailure: 'Spotify could not create the playlist. Nothing was retried.',
recommendationStreamInvalidJson: 'The response contained invalid JSON.',
recommendationStreamMissingMetadata: 'The stream did not begin with metadata.',
recommendationStreamDuplicateMetadata: 'The stream contained duplicate metadata.',
recommendationStreamAfterFinalEvent: 'The stream continued after its final event.',
recommendationStreamUnavailable: 'The recommendation service could not be reached.',
recommendationStreamHttpFailure: 'The recommendation service returned {status}.',
recommendationStreamInvalidContentType: 'The response was not an NDJSON stream.',
recommendationStreamEmpty: 'The response stream was empty.',
recommendationStreamUnexpectedEnd:
'The recommendation stream ended before a final event arrived.',
recommendationStreamInterrupted: 'The recommendation stream was interrupted.',
streamParserInvalidField: 'Invalid {field} field.',
streamParserInvalidTrack: 'Invalid track field.',
streamParserMissingType: 'Stream record has no valid type.',
streamParserUnknownEvent: 'Unknown stream event type: {type}.',
} as const satisfies Readonly<Record<string, string>>
export type MessageKey = keyof typeof messages
/** Fill named placeholders in one extracted message. */
export function formatMessage(
key: MessageKey,
values: Readonly<Record<string, string | number>>,
): string {
let message: string = messages[key]
for (const [name, value] of Object.entries(values)) {
message = message.replaceAll(`{${name}}`, String(value))
}
return message
}

View file

@ -0,0 +1,67 @@
import type { CurrentUser, DoneEvent, ErrorEvent, TrackEvent, WarningEvent } from './types'
type AssistantStatus = 'streaming' | 'done' | 'error'
export type AppMode = 'demo' | 'live'
export type TransportFailureKind =
'cancelled' | 'http' | 'network' | 'parse' | 'protocol' | 'unexpected_eof'
export interface TransportFailure {
kind: TransportFailureKind
message: string
}
export interface PlaylistState {
status: 'idle' | 'saving' | 'saved' | 'error'
name: string | null
url: string | null
message: string | null
}
interface UserTurn {
id: string
role: 'user'
text: string
}
export interface AssistantTurn {
id: string
role: 'assistant'
query: string
status: AssistantStatus
requestId: string | null
intentSummary: string
candidateCount: number | null
tracks: TrackEvent[]
warnings: WarningEvent[]
error: ErrorEvent | null
transportFailure: TransportFailure | null
completion: DoneEvent | null
playlist: PlaylistState
}
export type ChatTurn = UserTurn | AssistantTurn
export type AuthState =
| { status: 'checking'; user: null; message: null }
| { status: 'anonymous'; user: null; message: null }
| { status: 'authenticated'; user: CurrentUser; message: null }
| { status: 'logging_out'; user: CurrentUser; message: null }
| { status: 'failed'; user: null; message: string }
export type HealthState =
| { status: 'checking'; mode: null; message: null }
| { status: 'ready'; mode: AppMode; message: null }
| { status: 'failed'; mode: null; message: string }
export interface EventLogEntry {
timestamp: string
type: string
detail: string
}
export const EMPTY_PLAYLIST_STATE: PlaylistState = {
status: 'idle',
name: null,
url: null,
message: null,
}

View file

@ -0,0 +1,157 @@
import { formatMessage, messages } from './messages'
import type { RecommendationRequest, StreamEvent } from './types'
import type { TransportFailureKind } from './models'
import { parseStreamEvent, StreamParseError } from './streamParser'
type StreamPhase = 'metadata' | 'events' | 'terminal'
export class StreamTransportError extends Error {
readonly kind: TransportFailureKind
constructor(kind: TransportFailureKind, message: string) {
super(message)
this.name = 'StreamTransportError'
this.kind = kind
}
}
function parseLine(line: string): StreamEvent {
let value: unknown
try {
value = JSON.parse(line)
} catch {
throw new StreamTransportError('parse', messages.recommendationStreamInvalidJson)
}
try {
return parseStreamEvent(value)
} catch (error) {
if (error instanceof StreamParseError) {
throw new StreamTransportError('parse', error.message)
}
throw error
}
}
function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase {
if (phase === 'metadata') {
if (event.type === 'metadata') return 'events'
throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata)
}
if (phase === 'events') {
if (event.type === 'track' || event.type === 'warning') return 'events'
if (event.type === 'done' || event.type === 'error') return 'terminal'
throw new StreamTransportError('protocol', messages.recommendationStreamDuplicateMetadata)
}
throw new StreamTransportError('protocol', messages.recommendationStreamAfterFinalEvent)
}
async function cancelBody(body: ReadableStream<Uint8Array> | null): Promise<void> {
try {
await body?.cancel()
} catch {
return
}
}
async function cancelReader(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
try {
await reader.cancel()
} catch {
return
}
}
function releaseReader(reader: ReadableStreamDefaultReader<Uint8Array>): void {
try {
reader.releaseLock()
} catch {
return
}
}
/** Post a recommendation request and consume its NDJSON event stream. */
export async function streamRecommendations(
request: RecommendationRequest,
signal: AbortSignal,
onEvent: (event: StreamEvent) => void,
): Promise<void> {
let response: Response
try {
response = await fetch('/api/recommendations', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal,
})
} catch (error) {
if (signal.aborted) throw error
throw new StreamTransportError('network', messages.recommendationStreamUnavailable)
}
if (!response.ok) {
await cancelBody(response.body)
throw new StreamTransportError(
'http',
formatMessage('recommendationStreamHttpFailure', { status: response.status }),
)
}
const contentType = response.headers.get('content-type')?.split(';')[0].trim()
if (contentType !== 'application/x-ndjson') {
await cancelBody(response.body)
throw new StreamTransportError('protocol', messages.recommendationStreamInvalidContentType)
}
if (!response.body) {
throw new StreamTransportError('protocol', messages.recommendationStreamEmpty)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let phase: StreamPhase = 'metadata'
try {
while (true) {
const { done, value } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line) continue
const event = parseLine(line)
phase = advancePhase(phase, event)
onEvent(event)
if (phase === 'terminal') {
await cancelReader(reader)
return
}
}
if (done) break
}
const finalLine = buffer.trim()
if (finalLine) {
const event = parseLine(finalLine)
phase = advancePhase(phase, event)
onEvent(event)
if (phase === 'terminal') return
}
throw new StreamTransportError('unexpected_eof', messages.recommendationStreamUnexpectedEnd)
} catch (error) {
await cancelReader(reader)
if (signal.aborted || error instanceof StreamTransportError) throw error
throw new StreamTransportError('network', messages.recommendationStreamInterrupted)
} finally {
releaseReader(reader)
}
}
/** Return whether a rejected stream operation was intentionally aborted. */
export function isAbortError(error: unknown, signal: AbortSignal): boolean {
return signal.aborted || (error instanceof DOMException && error.name === 'AbortError')
}

View file

@ -0,0 +1,13 @@
/** Return a normalized Spotify web URL when the untrusted value is safe to link. */
export function parseSpotifyUrl(value: string): string | null {
try {
const url = new URL(value)
const usesHttps = url.protocol === 'https:'
const usesSpotifyHost = url.host === 'open.spotify.com'
const hasCredentials = Boolean(url.username || url.password)
if (!usesHttps || !usesSpotifyHost || hasCredentials) return null
return url.href
} catch {
return null
}
}

View file

@ -0,0 +1,109 @@
import { formatMessage, messages } from './messages'
import type { StreamEvent, TrackCard } from './types'
import { parseSpotifyUrl } from './spotifyUrl'
export class StreamParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'StreamParseError'
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringField(value: Record<string, unknown>, key: string): string {
const field = value[key]
if (typeof field !== 'string') {
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
}
return field
}
function numberField(value: Record<string, unknown>, key: string): number {
const field = value[key]
if (typeof field !== 'number' || !Number.isFinite(field)) {
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
}
return field
}
function stringArrayField(value: Record<string, unknown>, key: string): string[] {
const field = value[key]
if (!Array.isArray(field) || !field.every((entry) => typeof entry === 'string')) {
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
}
return field
}
function nullableStringField(value: Record<string, unknown>, key: string): string | null {
const field = value[key]
if (field !== null && typeof field !== 'string') {
throw new StreamParseError(formatMessage('streamParserInvalidField', { field: key }))
}
return field
}
function spotifyUrlField(value: Record<string, unknown>, key: string): string | null {
const field = nullableStringField(value, key)
if (field === null) return null
return parseSpotifyUrl(field)
}
function parseTrackCard(value: unknown): TrackCard {
if (!isRecord(value)) throw new StreamParseError(messages.streamParserInvalidTrack)
return {
id: stringField(value, 'id'),
uri: stringField(value, 'uri'),
title: stringField(value, 'title'),
artists: stringArrayField(value, 'artists'),
album_name: stringField(value, 'album_name'),
album_art_url: nullableStringField(value, 'album_art_url'),
external_url: spotifyUrlField(value, 'external_url'),
}
}
/** Parse one untrusted NDJSON value into the frozen event union. */
export function parseStreamEvent(value: unknown): StreamEvent {
if (!isRecord(value) || typeof value.type !== 'string') {
throw new StreamParseError(messages.streamParserMissingType)
}
switch (value.type) {
case 'metadata':
return {
type: 'metadata',
request_id: stringField(value, 'request_id'),
intent_summary: stringField(value, 'intent_summary'),
candidate_count: numberField(value, 'candidate_count'),
}
case 'track':
return {
type: 'track',
rank: numberField(value, 'rank'),
track: parseTrackCard(value.track),
justification: stringField(value, 'justification'),
}
case 'warning':
return {
type: 'warning',
code: stringField(value, 'code'),
message: stringField(value, 'message'),
}
case 'error':
return {
type: 'error',
code: stringField(value, 'code'),
message: stringField(value, 'message'),
}
case 'done':
return {
type: 'done',
track_count: numberField(value, 'track_count'),
total_ms: numberField(value, 'total_ms'),
}
default:
throw new StreamParseError(formatMessage('streamParserUnknownEvent', { type: value.type }))
}
}

View file

@ -0,0 +1,9 @@
export const DISCOVERY_SUGGESTIONS = [
'Focus while coding',
'Energy for the gym',
'Dutch-language and relaxed',
'90s nostalgia',
'Surprise me with something new',
'Rainy Sunday',
'Background for dinner',
] as const

View file

@ -1,296 +1,157 @@
:root { :root {
--text: #6b6375; --c-bg: #0b0c0b;
--text-h: #08060d; --c-surface: #131513;
--bg: #fff; --c-surface-raised: #161816;
--border: #e5e4e7; --c-surface-user: #1e211e;
--code-bg: #f4f3ec; --c-surface-hover: #171a18;
--accent: #aa3bff; --c-line: #262a26;
--accent-bg: rgba(170, 59, 255, 0.1); --c-line-strong: #38403a;
--accent-border: rgba(170, 59, 255, 0.5); --c-line-user: #2e332e;
--social-bg: rgba(244, 243, 236, 0.5); --c-focus-border: #444b46;
--shadow: --c-card-border: #232722;
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; --c-art-a: #1e231f;
--c-art-b: #171a17;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; --c-text: #e8ede8;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif; --c-text-muted: #8b948c;
--mono: ui-monospace, Consolas, monospace; --c-text-dim: #858e86;
--c-text-faint: #808981;
--c-text-ghost: #7d857e;
--c-text-chip: #bec5bf;
font: 18px/145% var(--sans); --c-accent: #7fc96b;
letter-spacing: 0.18px; --c-accent-hover: #9bdb88;
color-scheme: light dark; --c-accent-soft: #a6be95;
color: var(--text); --c-accent-line: #3b4f2e;
background: var(--bg); --c-banner-bg: #111510;
--c-banner-line: #26331e;
--c-on-accent: #0b0c0b;
--c-saved: #c9a96b;
--c-saved-bg: #191712;
--c-saved-line: #37301f;
--c-error: #e0795c;
--c-dev-bg: #14120f;
--f-display: 'Bricolage Grotesque', system-ui, sans-serif;
--f-body: 'Public Sans', system-ui, sans-serif;
--f-mono: 'JetBrains Mono', ui-monospace, monospace;
--t-hero: 44px;
--t-brand: 21px;
--t-lead: 16px;
--t-body: 15px;
--t-title: 15.5px;
--t-small: 13.5px;
--t-meta: 11.5px;
--t-micro: 11px;
--t-caps: 10px;
--lh-body: 1.55;
--lh-hero: 1.08;
--ls-hero: -0.025em;
--ls-caps: 0.16em;
--s-1: 4px;
--s-2: 6px;
--s-3: 8px;
--s-4: 10px;
--s-5: 14px;
--s-6: 16px;
--s-7: 18px;
--s-8: 22px;
--s-9: 34px;
--s-10: 44px;
--gutter: 24px;
--measure: 820px;
--dev-width: 372px;
--tap-min: 44px;
--r-sm: 2px;
--r-md: 3px;
--r-lg: 4px;
--r-pill: 999px;
--shadow-drawer: -24px 0 60px rgba(0, 0, 0, 0.45);
--ease: cubic-bezier(0.2, 0.8, 0.3, 1);
color-scheme: dark;
color: var(--c-text);
background: var(--c-bg);
font-family: var(--f-body);
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
} }
@media (prefers-color-scheme: dark) { @media (max-width: 560px) {
:root { :root {
--text: #9ca3af; --gutter: 16px;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
} }
} }
body { * {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0; margin: 0;
padding: 0;
background: var(--c-bg);
} }
h1, button,
h2 { input {
font-family: var(--heading); font: inherit;
font-weight: 500;
color: var(--text-h);
} }
h1 { button:disabled {
font-size: 56px; cursor: not-allowed;
letter-spacing: -1.68px; opacity: 0.55;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
} }
code, a {
.counter { color: var(--c-accent);
font-family: var(--mono); text-decoration: none;
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
} }
code { a:hover {
font-size: 15px; color: var(--c-accent-hover);
line-height: 135%; text-decoration: underline;
padding: 4px 8px;
background: var(--code-bg);
} }
.counter { ::selection {
font-size: 16px; background: var(--c-accent);
padding: 5px 10px; color: var(--c-on-accent);
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
} }
&:focus-visible {
outline: 2px solid var(--accent); :focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px; outline-offset: 2px;
} }
}
.hero { .visually-hidden {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute; position: absolute;
} width: 1px;
height: 1px;
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0; padding: 0;
display: flex; margin: -1px;
gap: 8px; overflow: hidden;
margin: 32px 0 0; clip: rect(0, 0, 0, 0);
white-space: nowrap;
.logo { border: 0;
height: 18px;
} }
a { @media (prefers-reduced-motion: reduce) {
color: var(--text-h); *,
font-size: 16px; *::before,
border-radius: 6px; *::after {
background: var(--social-bg); scroll-behavior: auto !important;
display: flex; animation-duration: var(--dur-reduced-motion) !important;
padding: 6px 12px; transition-duration: var(--dur-reduced-motion) !important;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
} }
} }

View file

@ -0,0 +1,202 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { streamRecommendations, StreamTransportError } from '../src/lib/recommendationStream'
import type { StreamEvent } from '../src/lib/types'
const encoder = new TextEncoder()
function streamResponse(
chunks: Uint8Array[],
options: { close?: boolean; cancel?: () => void } = {},
): Response {
let index = 0
const body = new ReadableStream<Uint8Array>({
pull(controller) {
const chunk = chunks[index]
if (chunk) {
controller.enqueue(chunk)
index += 1
}
if (options.close !== false && index === chunks.length) controller.close()
},
cancel() {
options.cancel?.()
},
})
return new Response(body, {
headers: { 'Content-Type': 'application/x-ndjson; charset=utf-8' },
})
}
function stubResponse(response: Response): void {
vi.stubGlobal(
'fetch',
vi.fn(async () => response),
)
}
afterEach(() => vi.unstubAllGlobals())
describe('streamRecommendations', () => {
it('decodes fragmented NDJSON across JSON and multi-byte boundaries', async () => {
const symbol = String.fromCodePoint(0x1f3b5)
const records = [
{
type: 'metadata',
request_id: 'request-1',
intent_summary: `Focused ${symbol} listening`,
candidate_count: 4,
},
{
type: 'track',
rank: 1,
track: {
id: 'track-1',
uri: 'spotify:track:1',
title: `Signal ${symbol}`,
artists: ['Artist'],
album_name: 'Album',
album_art_url: null,
external_url: 'https://open.spotify.com/track/1',
},
justification: 'It fits.',
},
{ type: 'warning', code: 'limited_pool', message: 'The pool was limited.' },
{ type: 'warning', code: 'limited_pool', message: 'The pool stayed limited.' },
{ type: 'done', track_count: 1, total_ms: 12 },
]
const payload = `${records.map((record) => JSON.stringify(record)).join('\n')}\n`
const bytes = encoder.encode(payload)
const jsonCut = encoder.encode(payload.slice(0, payload.indexOf('candidate_count') + 5)).length
const symbolIndex = payload.indexOf(symbol)
const symbolCut = encoder.encode(payload.slice(0, symbolIndex)).length + 2
const cuts = [jsonCut, symbolCut, bytes.length - 8].sort((left, right) => left - right)
const chunks = [
bytes.slice(0, cuts[0]),
bytes.slice(cuts[0], cuts[1]),
bytes.slice(cuts[1], cuts[2]),
bytes.slice(cuts[2]),
]
stubResponse(streamResponse(chunks))
const events: StreamEvent[] = []
await streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
(event) => events.push(event),
)
expect(events).toHaveLength(5)
expect(events[0]).toMatchObject({
type: 'metadata',
intent_summary: `Focused ${symbol} listening`,
})
expect(events[1]).toMatchObject({ type: 'track', track: { title: `Signal ${symbol}` } })
expect(events.filter((event) => event.type === 'warning')).toHaveLength(2)
expect(events.at(-1)).toMatchObject({ type: 'done', track_count: 1 })
})
it('treats an error as terminal without waiting for done', async () => {
let wasCancelled = false
const payload = [
JSON.stringify({
type: 'metadata',
request_id: 'request-1',
intent_summary: 'Intent',
candidate_count: 0,
}),
JSON.stringify({ type: 'error', code: 'upstream', message: 'Upstream failed.' }),
JSON.stringify({ type: 'done', track_count: 0, total_ms: 4 }),
].join('\n')
stubResponse(
streamResponse([encoder.encode(payload)], {
close: false,
cancel: () => {
wasCancelled = true
throw new Error('Cleanup failed.')
},
}),
)
const events: StreamEvent[] = []
await streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
(event) => events.push(event),
)
expect(events.map((event) => event.type)).toEqual(['metadata', 'error'])
expect(wasCancelled).toBe(true)
})
it.each([
['an event before metadata', [{ type: 'warning', code: 'early', message: 'No metadata.' }]],
[
'duplicate metadata',
[
{ type: 'metadata', request_id: 'one', intent_summary: 'First', candidate_count: 1 },
{ type: 'metadata', request_id: 'two', intent_summary: 'Second', candidate_count: 1 },
],
],
])('rejects %s and cancels the reader', async (_label, records) => {
let wasCancelled = false
const payload = `${records.map((record) => JSON.stringify(record)).join('\n')}\n`
stubResponse(
streamResponse([encoder.encode(payload)], {
close: false,
cancel: () => {
wasCancelled = true
},
}),
)
const result = streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
() => undefined,
)
await expect(result).rejects.toMatchObject<Partial<StreamTransportError>>({ kind: 'protocol' })
expect(wasCancelled).toBe(true)
})
it('cancels the reader after a parse failure', async () => {
let wasCancelled = false
stubResponse(
streamResponse([encoder.encode('{not-json}\n')], {
close: false,
cancel: () => {
wasCancelled = true
},
}),
)
const result = streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
() => undefined,
)
await expect(result).rejects.toMatchObject<Partial<StreamTransportError>>({ kind: 'parse' })
expect(wasCancelled).toBe(true)
})
it('reports a transport failure when the body ends before a terminal event', async () => {
const payload = `${JSON.stringify({
type: 'metadata',
request_id: 'request-1',
intent_summary: 'Intent',
candidate_count: 0,
})}\n`
stubResponse(streamResponse([encoder.encode(payload)]))
const result = streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
() => undefined,
)
await expect(result).rejects.toMatchObject<Partial<StreamTransportError>>({
kind: 'unexpected_eof',
})
})
})

View file

@ -0,0 +1,79 @@
import { createApp, h } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import PlaylistSaved from '../src/components/PlaylistSaved.vue'
import { useApi } from '../src/composables/useApi'
import { parseSpotifyUrl } from '../src/lib/spotifyUrl'
import { parseStreamEvent } from '../src/lib/streamParser'
afterEach(() => vi.unstubAllGlobals())
describe('parseSpotifyUrl', () => {
it.each([
['https://open.spotify.com/track/123', 'https://open.spotify.com/track/123'],
[
'https://open.spotify.com/playlist/123?si=abc',
'https://open.spotify.com/playlist/123?si=abc',
],
])('accepts %s', (value, expected) => {
expect(parseSpotifyUrl(value)).toBe(expected)
})
it.each([
'http://open.spotify.com/track/123',
'https://embed.spotify.com/track/123',
'https://open.spotify.com:8443/track/123',
'https://open.spotify.com.evil.example/track/123',
'https://user@open.spotify.com/track/123',
'javascript:alert(1)',
'/track/123',
'not a url',
])('rejects %s', (value) => {
expect(parseSpotifyUrl(value)).toBeNull()
})
it('removes an unsafe track link at the event parsing boundary', () => {
const event = parseStreamEvent({
type: 'track',
rank: 1,
track: {
id: 'track-1',
uri: 'spotify:track:1',
title: 'Track',
artists: ['Artist'],
album_name: 'Album',
album_art_url: null,
external_url: 'https://evil.example/track/1',
},
justification: 'It fits.',
})
expect(event).toMatchObject({ type: 'track', track: { external_url: null } })
})
it('keeps playlist confirmation but removes an unsafe API link', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(JSON.stringify({ url: 'https://evil.example/playlist/1' }), {
headers: { 'Content-Type': 'application/json' },
}),
),
)
const { createPlaylist } = useApi()
const result = await createPlaylist({
schema_version: 1,
name: 'Playlist',
track_uris: ['spotify:track:1'],
})
const root = document.createElement('div')
const app = createApp({
render: () => h(PlaylistSaved, { name: 'Playlist', url: result.url, trackCount: 1 }),
})
app.mount(root)
expect(root.textContent).toContain('Saved Playlist')
expect(root.querySelector('a')).toBeNull()
app.unmount()
})
})

View file

@ -0,0 +1,242 @@
import { createApp, defineComponent, h } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useChatStream } from '../src/composables/useChatStream'
import { streamRecommendations, StreamTransportError } from '../src/lib/recommendationStream'
import type { AssistantTurn } from '../src/lib/models'
import type { RecommendationRequest, StreamEvent, TrackEvent } from '../src/lib/types'
vi.mock('../src/lib/recommendationStream', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/lib/recommendationStream')>()
return { ...actual, streamRecommendations: vi.fn() }
})
const streamMock = vi.mocked(streamRecommendations)
const unmountCallbacks: Array<() => void> = []
function mountChat() {
let chat: ReturnType<typeof useChatStream> | undefined
const root = document.createElement('div')
document.body.append(root)
const app = createApp(
defineComponent({
setup() {
chat = useChatStream(vi.fn(async () => ({ url: null })))
return () => h('div')
},
}),
)
app.mount(root)
unmountCallbacks.push(() => {
app.unmount()
root.remove()
})
if (!chat) throw new Error('Chat composable did not mount.')
return chat
}
function metadata(requestId: string): StreamEvent {
return {
type: 'metadata',
request_id: requestId,
intent_summary: `Intent for ${requestId}`,
candidate_count: 100,
}
}
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 assistantTurns(chat: ReturnType<typeof useChatStream>): AssistantTurn[] {
return chat.turns.value.filter((turn): turn is AssistantTurn => turn.role === 'assistant')
}
beforeEach(() => {
streamMock.mockReset()
})
afterEach(() => {
while (unmountCallbacks.length) unmountCallbacks.pop()?.()
})
describe('useChatStream', () => {
it('retains repeated warnings that share a code', async () => {
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
onEvent(metadata('warnings'))
onEvent({ type: 'warning', code: 'limited_pool', message: 'First warning.' })
onEvent({ type: 'warning', code: 'limited_pool', message: 'Second warning.' })
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
})
const chat = mountChat()
await chat.send('warnings')
expect(assistantTurns(chat)[0]?.warnings).toEqual([
{ type: 'warning', code: 'limited_pool', message: 'First warning.' },
{ type: 'warning', code: 'limited_pool', message: 'Second warning.' },
])
})
it('keeps error terminal when a late done callback arrives', async () => {
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
onEvent(metadata('error'))
onEvent({ type: 'error', code: 'upstream', message: 'Upstream failed.' })
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
})
const chat = mountChat()
await chat.send('error')
const turn = assistantTurns(chat)[0]
expect(turn).toMatchObject({ status: 'error', error: { code: 'upstream' } })
expect(turn?.completion).toBeNull()
expect(turn?.transportFailure).toBeNull()
})
it('stores an incomplete stream as a transport failure', async () => {
streamMock.mockRejectedValue(
new StreamTransportError('unexpected_eof', 'The stream ended early.'),
)
const chat = mountChat()
await chat.send('failure')
expect(assistantTurns(chat)[0]).toMatchObject({
status: 'error',
transportFailure: { kind: 'unexpected_eof' },
})
})
it('caps request history at 12 turns', async () => {
const requests: RecommendationRequest[] = []
streamMock.mockImplementation(async (request, _signal, onEvent) => {
requests.push(request)
onEvent(metadata(request.query))
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
})
const chat = mountChat()
for (let index = 0; index < 8; index += 1) {
await chat.send(`query ${index}`)
}
expect(requests.at(-1)?.history).toHaveLength(12)
expect(requests.at(-1)?.history[0]?.role).toBe('user')
})
it('caps prior recommendations at 50 tracks', async () => {
const requests: RecommendationRequest[] = []
streamMock.mockImplementation(async (request, _signal, onEvent) => {
requests.push(request)
onEvent(metadata(request.query))
if (requests.length === 1) {
for (let rank = 1; rank <= 60; rank += 1) onEvent(track(rank))
onEvent({ type: 'done', track_count: 60, total_ms: 5 })
} else {
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
}
})
const chat = mountChat()
await chat.send('first')
await chat.send('second')
expect(requests[1]?.prior_recommendations).toHaveLength(50)
expect(requests[1]?.prior_recommendations.at(-1)?.rank).toBe(50)
})
it('aborts a replaced stream and ignores its late callbacks and failure', async () => {
interface PendingStream {
signal: AbortSignal
onEvent: (event: StreamEvent) => void
resolve: () => void
reject: (error: unknown) => void
}
const pending: PendingStream[] = []
streamMock.mockImplementation(
(_request, signal, onEvent) =>
new Promise<void>((resolve, reject) => {
pending.push({ signal, onEvent, resolve, reject })
}),
)
const chat = mountChat()
const firstSend = chat.send('first')
const secondSend = chat.send('second')
expect(pending[0]?.signal.aborted).toBe(true)
pending[0]?.onEvent(metadata('late-first'))
pending[0]?.onEvent(track(1))
pending[0]?.reject(new DOMException('Aborted', 'AbortError'))
pending[1]?.onEvent(metadata('second'))
pending[1]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
pending[1]?.resolve()
await Promise.all([firstSend, secondSend])
const [firstTurn, secondTurn] = assistantTurns(chat)
expect(firstTurn).toMatchObject({
status: 'error',
requestId: null,
tracks: [],
transportFailure: { kind: 'cancelled' },
})
expect(secondTurn).toMatchObject({ status: 'done', requestId: 'second' })
})
it('does not relabel a terminal turn while its reader is cleaning up', async () => {
interface PendingStream {
signal: AbortSignal
onEvent: (event: StreamEvent) => void
resolve: () => void
}
const pending: PendingStream[] = []
streamMock.mockImplementation(
(_request, signal, onEvent) =>
new Promise<void>((resolve) => {
pending.push({ signal, onEvent, resolve })
}),
)
const chat = mountChat()
const firstSend = chat.send('first')
pending[0]?.onEvent(metadata('first'))
pending[0]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
const secondSend = chat.send('second')
expect(pending[0]?.signal.aborted).toBe(true)
pending[0]?.resolve()
pending[1]?.onEvent(metadata('second'))
pending[1]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
pending[1]?.resolve()
await Promise.all([firstSend, secondSend])
expect(assistantTurns(chat).map((turn) => turn.status)).toEqual(['done', 'done'])
})
it('caps a programmatic query at 1000 characters', async () => {
let receivedQuery = ''
streamMock.mockImplementation(async (request, _signal, onEvent) => {
receivedQuery = request.query
onEvent(metadata('query-limit'))
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
})
const chat = mountChat()
await chat.send('x'.repeat(1200))
expect(receivedQuery).toHaveLength(1000)
})
})

View file

@ -1,7 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
test: {
environment: 'happy-dom',
},
server: {
proxy: {
'/api': 'http://127.0.0.1:8888',
'/callback': 'http://127.0.0.1:8888',
},
},
}) })