Compare commits
13 commits
fad7884c42
...
a3af8f45cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3af8f45cc | ||
|
|
036ef3cc6f | ||
|
|
96c69507ec | ||
|
|
47cbeac87a | ||
|
|
0ccc9a5d4e | ||
|
|
ce652d3114 | ||
|
|
1a0712144e | ||
|
|
41aa93c3c7 | ||
|
|
e8d20158e3 | ||
|
|
cead39edbc | ||
|
|
751391e6a2 | ||
|
|
e970bdf542 | ||
|
|
5c3ba8d6ec |
71 changed files with 7171 additions and 334 deletions
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
337
backend/app/adapters/anthropic/llm.py
Normal file
337
backend/app/adapters/anthropic/llm.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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}",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
parsed_tracks.append(parsed_track)
|
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)
|
||||||
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
|
||||||
|
|
|
||||||
178
backend/app/api/recommendations.py
Normal file
178
backend/app/api/recommendations.py
Normal 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")
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
85
backend/app/domain/matching.py
Normal file
85
backend/app/domain/matching.py
Normal 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)}"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
31
backend/app/domain/profile.py
Normal file
31
backend/app/domain/profile.py
Normal 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}"
|
||||||
|
|
@ -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
|
||||||
yield
|
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
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
||||||
30
backend/app/observability/logging.py
Normal file
30
backend/app/observability/logging.py
Normal 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,
|
||||||
|
)
|
||||||
101
backend/app/observability/timing.py
Normal file
101
backend/app/observability/timing.py
Normal 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,
|
||||||
|
)
|
||||||
62
backend/app/pipeline/event.py
Normal file
62
backend/app/pipeline/event.py
Normal 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
|
||||||
|
)
|
||||||
346
backend/app/pipeline/grounding.py
Normal file
346
backend/app/pipeline/grounding.py
Normal 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,
|
||||||
|
)
|
||||||
308
backend/app/pipeline/orchestrator.py
Normal file
308
backend/app/pipeline/orchestrator.py
Normal 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),
|
||||||
|
)
|
||||||
|
)
|
||||||
82
backend/app/ports/protocols.py
Normal file
82
backend/app/ports/protocols.py
Normal 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
161
backend/app/prompts.py
Normal 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.
|
||||||
|
"""
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
|
||||||
153
backend/tests/test_grounding.py
Normal file
153
backend/tests/test_grounding.py
Normal 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,
|
||||||
|
)
|
||||||
65
backend/tests/test_matching.py
Normal file
65
backend/tests/test_matching.py
Normal 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
|
||||||
288
backend/tests/test_orchestrator.py
Normal file
288
backend/tests/test_orchestrator.py
Normal 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())
|
||||||
46
backend/tests/test_profile.py
Normal file
46
backend/tests/test_profile.py
Normal 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,
|
||||||
|
)
|
||||||
126
backend/tests/test_recommendation_api.py
Normal file
126
backend/tests/test_recommendation_api.py
Normal 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",
|
||||||
|
)
|
||||||
|
|
@ -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,18 +276,22 @@ def _search_payload() -> dict[str, object]:
|
||||||
"tracks": {
|
"tracks": {
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"items": [
|
"items": [
|
||||||
{
|
_track_payload(),
|
||||||
"id": "track-1",
|
|
||||||
"uri": "spotify:track:1",
|
|
||||||
"name": "Mapped song",
|
|
||||||
"artists": [{"name": "First artist"}, {"name": "Second artist"}],
|
|
||||||
"album": {
|
|
||||||
"name": "Mapped album",
|
|
||||||
"images": [{"url": "https://images.example/cover.jpg"}],
|
|
||||||
},
|
|
||||||
"external_urls": {"spotify": "https://open.spotify.com/track/track-1"},
|
|
||||||
},
|
|
||||||
{"id": "missing-required-fields"},
|
{"id": "missing-required-fields"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _track_payload() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": "track-1",
|
||||||
|
"uri": "spotify:track:1",
|
||||||
|
"name": "Mapped song",
|
||||||
|
"artists": [{"name": "First artist"}, {"name": "Second artist"}],
|
||||||
|
"album": {
|
||||||
|
"name": "Mapped album",
|
||||||
|
"images": [{"url": "https://images.example/cover.jpg"}],
|
||||||
|
},
|
||||||
|
"external_urls": {"spotify": "https://open.spotify.com/track/track-1"},
|
||||||
|
}
|
||||||
|
|
|
||||||
118
backend/uv.lock
generated
118
backend/uv.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
226
docs/logboek.md
226
docs/logboek.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
419
frontend/package-lock.json
generated
419
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
171
frontend/src/components/AppHeader.vue
Normal file
171
frontend/src/components/AppHeader.vue
Normal 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>
|
||||||
136
frontend/src/components/AssistantMessage.vue
Normal file
136
frontend/src/components/AssistantMessage.vue
Normal 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>
|
||||||
138
frontend/src/components/AuthStatus.vue
Normal file
138
frontend/src/components/AuthStatus.vue
Normal 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>
|
||||||
169
frontend/src/components/ChatView.vue
Normal file
169
frontend/src/components/ChatView.vue
Normal 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>
|
||||||
299
frontend/src/components/DevPanel.vue
Normal file
299
frontend/src/components/DevPanel.vue
Normal 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>
|
||||||
32
frontend/src/components/EmptyResults.vue
Normal file
32
frontend/src/components/EmptyResults.vue
Normal 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>
|
||||||
75
frontend/src/components/EmptyState.vue
Normal file
75
frontend/src/components/EmptyState.vue
Normal 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>
|
||||||
149
frontend/src/components/MessageInput.vue
Normal file
149
frontend/src/components/MessageInput.vue
Normal 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>
|
||||||
149
frontend/src/components/MessageList.vue
Normal file
149
frontend/src/components/MessageList.vue
Normal 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>
|
||||||
40
frontend/src/components/ModeBanner.vue
Normal file
40
frontend/src/components/ModeBanner.vue
Normal 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>
|
||||||
17
frontend/src/components/PlaylistError.vue
Normal file
17
frontend/src/components/PlaylistError.vue
Normal 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>
|
||||||
68
frontend/src/components/PlaylistSaved.vue
Normal file
68
frontend/src/components/PlaylistSaved.vue
Normal 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">↗</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>
|
||||||
37
frontend/src/components/RequestMetadata.vue
Normal file
37
frontend/src/components/RequestMetadata.vue
Normal 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>
|
||||||
76
frontend/src/components/ResultActions.vue
Normal file
76
frontend/src/components/ResultActions.vue
Normal 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>
|
||||||
21
frontend/src/components/ResultSet.vue
Normal file
21
frontend/src/components/ResultSet.vue
Normal 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>
|
||||||
39
frontend/src/components/StreamError.vue
Normal file
39
frontend/src/components/StreamError.vue
Normal 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>
|
||||||
20
frontend/src/components/StreamWarning.vue
Normal file
20
frontend/src/components/StreamWarning.vue
Normal 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>
|
||||||
59
frontend/src/components/SuggestionChips.vue
Normal file
59
frontend/src/components/SuggestionChips.vue
Normal 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>
|
||||||
54
frontend/src/components/ThinkingIndicator.vue
Normal file
54
frontend/src/components/ThinkingIndicator.vue
Normal 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>
|
||||||
168
frontend/src/components/TrackCard.vue
Normal file
168
frontend/src/components/TrackCard.vue
Normal 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">↗</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>
|
||||||
26
frontend/src/components/UserMessage.vue
Normal file
26
frontend/src/components/UserMessage.vue
Normal 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>
|
||||||
133
frontend/src/composables/useApi.ts
Normal file
133
frontend/src/composables/useApi.ts
Normal 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 }
|
||||||
|
}
|
||||||
295
frontend/src/composables/useChatStream.ts
Normal file
295
frontend/src/composables/useChatStream.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
46
frontend/src/lib/constants.ts
Normal file
46
frontend/src/lib/constants.ts
Normal 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
|
||||||
149
frontend/src/lib/messages.ts
Normal file
149
frontend/src/lib/messages.ts
Normal 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
|
||||||
|
}
|
||||||
67
frontend/src/lib/models.ts
Normal file
67
frontend/src/lib/models.ts
Normal 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,
|
||||||
|
}
|
||||||
157
frontend/src/lib/recommendationStream.ts
Normal file
157
frontend/src/lib/recommendationStream.ts
Normal 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')
|
||||||
|
}
|
||||||
13
frontend/src/lib/spotifyUrl.ts
Normal file
13
frontend/src/lib/spotifyUrl.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
109
frontend/src/lib/streamParser.ts
Normal file
109
frontend/src/lib/streamParser.ts
Normal 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 }))
|
||||||
|
}
|
||||||
|
}
|
||||||
9
frontend/src/lib/suggestions.ts
Normal file
9
frontend/src/lib/suggestions.ts
Normal 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
|
||||||
|
|
@ -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 {
|
* {
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2 {
|
|
||||||
font-family: var(--heading);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 56px;
|
|
||||||
letter-spacing: -1.68px;
|
|
||||||
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,
|
|
||||||
.counter {
|
|
||||||
font-family: var(--mono);
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 135%;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
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);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.base,
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
inset-inline: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.base {
|
|
||||||
width: 170px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
#center {
|
html,
|
||||||
display: flex;
|
body,
|
||||||
flex-direction: column;
|
#app {
|
||||||
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;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-h);
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--social-bg);
|
|
||||||
display: flex;
|
|
||||||
padding: 6px 12px;
|
|
||||||
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%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--c-bg);
|
||||||
|
}
|
||||||
|
|
||||||
&::before,
|
button,
|
||||||
&::after {
|
input {
|
||||||
content: '';
|
font: inherit;
|
||||||
position: absolute;
|
}
|
||||||
top: -4.5px;
|
|
||||||
border: 5px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
button:disabled {
|
||||||
left: 0;
|
cursor: not-allowed;
|
||||||
border-left-color: var(--border);
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
&::after {
|
|
||||||
right: 0;
|
a {
|
||||||
border-right-color: var(--border);
|
color: var(--c-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--c-accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--c-accent);
|
||||||
|
color: var(--c-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--c-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
animation-duration: var(--dur-reduced-motion) !important;
|
||||||
|
transition-duration: var(--dur-reduced-motion) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
202
frontend/tests/recommendationStream.test.ts
Normal file
202
frontend/tests/recommendationStream.test.ts
Normal 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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
79
frontend/tests/spotifyUrl.test.ts
Normal file
79
frontend/tests/spotifyUrl.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
242
frontend/tests/useChatStream.test.ts
Normal file
242
frontend/tests/useChatStream.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue