"""Typed application settings: the only reader of environment variables.""" from enum import StrEnum from pydantic_settings import BaseSettings, SettingsConfigDict class AppMode(StrEnum): """Runtime mode: live Spotify + Anthropic, or fixture-replay demo.""" LIVE = "live" DEMO = "demo" class Settings(BaseSettings): """Application settings, loaded from the environment or a .env file.""" model_config = SettingsConfigDict(env_file=".env", extra="ignore") app_mode: AppMode = AppMode.DEMO # Optional public URL of a fixture-replay twin of this deployment; the UI # offers it as an escape hatch when the live Spotify quota runs out. demo_instance_url: str = "" # 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_redirect_uri: str = "http://127.0.0.1:8888/callback" 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_retry_after_cap_seconds: float = 5.0 # 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" # Bound provider calls independently from the grounding deadline. llm_timeout_seconds: float = 120.0 # Ceilings include adaptive thinking tokens, which is why they sit far # above the size of the structured output itself. intent_max_tokens: int = 16384 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 # Demo replay paces recorded stream chunks so cards appear as they did live. demo_chunk_delay_seconds: float = 0.05 # Taste profile fetch bounds: enough signal to describe a listener # without paging through an entire library on session start. top_items_limit: int = 50 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()