discovery-by-llm/backend/app/main.py
Justin Visser b60a492417
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
feat: offer a demo instance link from the live deployment
2026-08-11 10:21:18 +02:00

186 lines
7 KiB
Python

"""Application factory and dependency wiring."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
import httpx
import httpx2
import structlog
from anthropic import AsyncAnthropic
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.adapters.anthropic.llm import AnthropicRecommender
from app.adapters.demo.cassette import load_cassette
from app.adapters.demo.catalog import DemoCatalog
from app.adapters.demo.pipeline import DemoReplayPipeline
from app.adapters.demo.playlist import DemoPlaylistWriter
from app.adapters.demo.scenario import select_replay_scenario, suggestion_items
from app.adapters.spotify.auth import TokenSet, refresh_access_token
from app.adapters.spotify.client import SpotifyClient
from app.adapters.spotify.errors import SpotifyError
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.config import AppMode, Settings, settings
from app.domain.models import PreviousRecommendation
from app.observability.logging import configure_logging
from app.observability.timing import RequestTimingMiddleware
from app.pipeline.orchestrator import RecommendationPipeline
FRONTEND_DIST = Path(__file__).parent / "static"
def create_app(
application_settings: Settings | None = None,
http_transport: httpx2.AsyncBaseTransport | None = None,
anthropic_http_client: httpx.AsyncClient | None = None,
) -> FastAPI:
"""Build the FastAPI app: API routes plus the built SPA on one port."""
active_settings = application_settings or settings
configure_logging(active_settings.app_mode)
@asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
_validate_live_settings(active_settings)
application.state.session_store = SessionStore()
application.state.pending_logins = PendingLogins()
application.state.settings = active_settings
application.state.seed_session_id = None
application.state.suggestions = suggestion_items()
if active_settings.app_mode is AppMode.DEMO:
application.state.demo_session = _demo_session()
application.state.recommendation_pipeline = DemoReplayPipeline(active_settings)
def demo_catalog_factory(
session: SpotifySession,
query: str,
previous_recommendations: tuple[PreviousRecommendation, ...],
) -> DemoCatalog:
match = select_replay_scenario(query, bool(previous_recommendations))
return DemoCatalog(load_cassette(match.key))
application.state.music_catalog_factory = demo_catalog_factory
application.state.spotify_client_factory = lambda session: DemoPlaylistWriter()
yield
return
async with httpx2.AsyncClient(
timeout=active_settings.spotify_timeout_seconds,
transport=http_transport,
) as http:
application.state.http = http
anthropic_client = AsyncAnthropic(
api_key=active_settings.anthropic_api_key,
timeout=active_settings.llm_timeout_seconds,
http_client=anthropic_http_client,
)
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)
def music_catalog_factory(
session: SpotifySession,
query: str,
previous_recommendations: tuple[PreviousRecommendation, ...],
) -> SpotifyClient:
return spotify_client_factory(session)
application.state.spotify_client_factory = spotify_client_factory
application.state.music_catalog_factory = music_catalog_factory
if (
active_settings.app_mode is AppMode.LIVE
and active_settings.spotify_seed_refresh_token
):
try:
application.state.seed_session_id = await _install_seed_session(
http,
application.state.session_store,
active_settings,
)
except (SpotifyError, ValueError) as error:
structlog.get_logger().warning(
"seed_session_install_failed",
error_type=type(error).__name__,
)
try:
yield
finally:
await anthropic_client.close()
app = FastAPI(
title="discovery-by-llm",
docs_url=None,
redoc_url=None,
lifespan=lifespan,
)
app.add_middleware(RequestTimingMiddleware)
@app.get("/api/health")
def health() -> dict[str, str]:
payload = {"status": "ok", "mode": active_settings.app_mode}
if active_settings.demo_instance_url:
payload["demo_url"] = active_settings.demo_instance_url
return payload
app.include_router(router)
app.include_router(recommendations_router)
if FRONTEND_DIST.is_dir():
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="spa")
return 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,
)
)
def _demo_session() -> SpotifySession:
return SpotifySession(
tokens=TokenSet(access_token="demo", refresh_token="demo", expires_at=float("inf")),
account_id="demo-listener",
display_name="Demo Listener",
)