feat: scaffold FastAPI backend with ports-and-adapters layout
This commit is contained in:
parent
adcf4b9df6
commit
1f40d064ad
25 changed files with 973 additions and 22 deletions
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/adapters/__init__.py
Normal file
0
backend/app/adapters/__init__.py
Normal file
0
backend/app/adapters/anthropic/__init__.py
Normal file
0
backend/app/adapters/anthropic/__init__.py
Normal file
0
backend/app/adapters/demo/__init__.py
Normal file
0
backend/app/adapters/demo/__init__.py
Normal file
0
backend/app/adapters/spotify/__init__.py
Normal file
0
backend/app/adapters/spotify/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
53
backend/app/config.py
Normal file
53
backend/app/config.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""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):
|
||||
"""All pipeline tunables in one place so the eval harness can sweep them."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
app_mode: AppMode = AppMode.DEMO
|
||||
host_port: int = 8000
|
||||
|
||||
# credentials (empty in demo mode; incomplete live config fails fast)
|
||||
spotify_client_id: str = ""
|
||||
spotify_client_secret: str = ""
|
||||
spotify_redirect_uri: str = "http://127.0.0.1:8000/callback"
|
||||
anthropic_api_key: str = ""
|
||||
# hosted instance only: installs a session at startup (never set locally)
|
||||
spotify_refresh_token: str = ""
|
||||
|
||||
# LLM
|
||||
llm_model: str = "claude-sonnet-5"
|
||||
llm_effort_intent: str = "low"
|
||||
llm_effort_rerank: str = "medium"
|
||||
llm_max_tokens: int = 4096
|
||||
|
||||
# pipeline shape
|
||||
candidate_count: int = 30
|
||||
recommendation_count: int = 15
|
||||
rerank_buffer: int = 5
|
||||
|
||||
# grounding
|
||||
grounding_fanout_width: int = 8
|
||||
grounding_floor: int = 10
|
||||
title_similarity_threshold: float = 0.82
|
||||
request_deadline_seconds: float = 20.0
|
||||
|
||||
# caches
|
||||
resolution_cache_ttl_seconds: int = 3600
|
||||
taste_profile_ttl_seconds: int = 1800
|
||||
|
||||
|
||||
settings = Settings()
|
||||
0
backend/app/domain/__init__.py
Normal file
0
backend/app/domain/__init__.py
Normal file
13
backend/app/domain/matching.py
Normal file
13
backend/app/domain/matching.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Fuzzy grounding: decide whether a search hit matches an LLM candidate."""
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""Lowercase and strip decorations that vary across releases."""
|
||||
return text.lower().strip()
|
||||
|
||||
|
||||
def title_similarity(candidate_title: str, catalog_title: str) -> float:
|
||||
"""Ratio in [0, 1] between a proposed title and a catalog title."""
|
||||
return SequenceMatcher(None, normalize(candidate_title), normalize(catalog_title)).ratio()
|
||||
30
backend/app/domain/models.py
Normal file
30
backend/app/domain/models.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Core domain models — pure data, no app-level imports."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Track(BaseModel):
|
||||
"""A resolved, verified track from the catalog."""
|
||||
|
||||
spotify_id: str
|
||||
title: str
|
||||
artists: list[str]
|
||||
album: str
|
||||
album_art_url: str | None = None
|
||||
|
||||
|
||||
class TasteProfile(BaseModel):
|
||||
"""Compressed listening profile feeding both LLM calls."""
|
||||
|
||||
top_artists_long_term: list[str]
|
||||
top_artists_short_term: list[str]
|
||||
top_tracks_short_term: list[str]
|
||||
saved_track_ids: set[str]
|
||||
|
||||
|
||||
class Recommendation(BaseModel):
|
||||
"""A ranked track with its one-line justification."""
|
||||
|
||||
track: Track
|
||||
justification: str
|
||||
rank: int
|
||||
12
backend/app/domain/profile.py
Normal file
12
backend/app/domain/profile.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""Taste-profile compression: raw listening data to a prompt-sized summary."""
|
||||
|
||||
from app.domain.models import TasteProfile
|
||||
|
||||
|
||||
def compress_taste_profile(profile: TasteProfile, artist_limit: int = 15) -> str:
|
||||
"""Render a profile as compact prompt text, bounded in size."""
|
||||
return (
|
||||
f"Top artists (long term): {', '.join(profile.top_artists_long_term[:artist_limit])}. "
|
||||
f"Top artists (recent): {', '.join(profile.top_artists_short_term[:artist_limit])}. "
|
||||
f"Recent favourite tracks: {', '.join(profile.top_tracks_short_term[:artist_limit])}."
|
||||
)
|
||||
27
backend/app/main.py
Normal file
27
backend/app/main.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Application factory and wiring — no logic lives here."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import settings
|
||||
|
||||
FRONTEND_DIST = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Build the FastAPI app: API routes plus the built SPA on one port."""
|
||||
app = FastAPI(title="discovery-by-llm", docs_url=None, redoc_url=None)
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok", "mode": settings.app_mode}
|
||||
|
||||
if FRONTEND_DIST.is_dir():
|
||||
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="spa")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
0
backend/app/observability/__init__.py
Normal file
0
backend/app/observability/__init__.py
Normal file
8
backend/app/observability/logging.py
Normal file
8
backend/app/observability/logging.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""Structured logging configuration."""
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure structlog for JSON output with per-request counters."""
|
||||
structlog.configure(processors=[structlog.processors.JSONRenderer()])
|
||||
0
backend/app/pipeline/__init__.py
Normal file
0
backend/app/pipeline/__init__.py
Normal file
11
backend/app/pipeline/orchestrator.py
Normal file
11
backend/app/pipeline/orchestrator.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Pipeline orchestrator: the five stages composed and timed, nothing else."""
|
||||
|
||||
from app.ports.protocols import MusicCatalog, Recommender
|
||||
|
||||
|
||||
class DiscoveryPipeline:
|
||||
"""Session context -> intent+candidates -> grounding -> rerank -> action."""
|
||||
|
||||
def __init__(self, catalog: MusicCatalog, recommender: Recommender) -> None:
|
||||
self._catalog = catalog
|
||||
self._recommender = recommender
|
||||
0
backend/app/ports/__init__.py
Normal file
0
backend/app/ports/__init__.py
Normal file
32
backend/app/ports/protocols.py
Normal file
32
backend/app/ports/protocols.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Port definitions — every adapter implements one of these protocols."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.models import Recommendation, TasteProfile, Track
|
||||
|
||||
|
||||
class MusicCatalog(Protocol):
|
||||
"""Resolve, verify, and personalise against a music catalog."""
|
||||
|
||||
async def search_track(self, title: str, artist: str) -> Track | None: ...
|
||||
|
||||
async def fetch_taste_profile(self) -> TasteProfile: ...
|
||||
|
||||
|
||||
class Recommender(Protocol):
|
||||
"""The two LLM calls: intent + candidates, then streamed rerank."""
|
||||
|
||||
async def propose_candidates(
|
||||
self, query: str, profile_summary: str
|
||||
) -> list[tuple[str, str]]: ...
|
||||
|
||||
def rerank(
|
||||
self, query: str, profile_summary: str, grounded: list[Track]
|
||||
) -> AsyncIterator[Recommendation]: ...
|
||||
|
||||
|
||||
class PlaylistWriter(Protocol):
|
||||
"""Persist a recommendation set as a playlist."""
|
||||
|
||||
async def create_playlist(self, name: str, tracks: list[Track]) -> str: ...
|
||||
10
backend/app/prompts/intent_and_candidates.md
Normal file
10
backend/app/prompts/intent_and_candidates.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
You are a music recommendation engine. Ground every suggestion in the user's
|
||||
taste profile.
|
||||
|
||||
Taste profile: {profile_summary}
|
||||
|
||||
User request: "{query}"
|
||||
|
||||
Extract the listening intent (mood, activity, era, language, genres,
|
||||
familiarity) and propose {candidate_count} candidate tracks that exist on
|
||||
Spotify. For "new" familiarity, prefer tracks the user is unlikely to know.
|
||||
10
backend/app/prompts/rerank.md
Normal file
10
backend/app/prompts/rerank.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
You are a music recommendation engine.
|
||||
|
||||
Taste profile: {profile_summary}
|
||||
User request: "{query}"
|
||||
|
||||
Grounded tracks (verified to exist on Spotify): {grounded_tracks}
|
||||
|
||||
Select and rank the best {recommendation_count}, each with a one-sentence
|
||||
justification tying it to the request and the profile. Use the given
|
||||
spotify_id values exactly.
|
||||
Loading…
Add table
Add a link
Reference in a new issue