From 1e1c40f103d11f94c5412396857aaeb09175b22a Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Mon, 10 Aug 2026 10:55:26 +0200 Subject: [PATCH] feat: define the discovery api wire contract --- backend/app/api/schemas.py | 103 +++++++++++++++++++++++++++++++++++++ frontend/src/lib/types.ts | 79 ++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 backend/app/api/schemas.py create mode 100644 frontend/src/lib/types.ts diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..d82573d --- /dev/null +++ b/backend/app/api/schemas.py @@ -0,0 +1,103 @@ +"""Wire schemas for the discovery API: requests and streamed events.""" + +from typing import Literal + +from pydantic import BaseModel, Field + +NDJSON_CONTENT_TYPE = "application/x-ndjson" + + +class HistoryTurn(BaseModel): + """One prior chat turn; conversation state lives client-side.""" + + role: Literal["user", "assistant"] + content: str = Field(min_length=1, max_length=2000) + + +class PriorRecommendation(BaseModel): + """A track from an earlier response that follow-up turns can refer to.""" + + rank: int = Field(ge=1, le=50) + track_id: str + title: str + artists: list[str] = Field(max_length=10) + + +class RecommendationRequest(BaseModel): + """A discovery query with bounded client-side conversation state.""" + + schema_version: Literal[1] = 1 + query: str = Field(min_length=1, max_length=1000) + history: list[HistoryTurn] = Field(default_factory=list, max_length=12) + prior_recommendations: list[PriorRecommendation] = Field(default_factory=list, max_length=50) + + +class TrackCard(BaseModel): + """The wire shape of one recommended track.""" + + id: str + uri: str + title: str + artists: list[str] + album_name: str + album_art_url: str | None + external_url: str | None + + +class MetadataEvent(BaseModel): + """First stream event: how the query was understood.""" + + type: Literal["metadata"] = "metadata" + request_id: str + intent_summary: str + candidate_count: int + + +class TrackEvent(BaseModel): + """One recommended track, streamed as soon as it validates.""" + + type: Literal["track"] = "track" + rank: int + track: TrackCard + justification: str + + +class WarningEvent(BaseModel): + """A non-fatal degradation, surfaced honestly instead of hidden.""" + + type: Literal["warning"] = "warning" + code: str + message: str + + +class ErrorEvent(BaseModel): + """A terminal failure; no further events follow it.""" + + type: Literal["error"] = "error" + code: str + message: str + + +class DoneEvent(BaseModel): + """Final stream event with response-level counters.""" + + type: Literal["done"] = "done" + track_count: int + total_ms: int + + +StreamEvent = MetadataEvent | TrackEvent | WarningEvent | ErrorEvent | DoneEvent + + +class PlaylistCreateRequest(BaseModel): + """A request to save recommended tracks as a real Spotify playlist.""" + + schema_version: Literal[1] = 1 + name: str = Field(min_length=1, max_length=100) + track_uris: list[str] = Field(min_length=1, max_length=50) + + +class PlaylistCreateResponse(BaseModel): + """The created playlist's public location.""" + + url: str diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..7f70bde --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,79 @@ +// Wire types mirrored against backend/app/api/schemas.py. +// Field names match the JSON payloads exactly, so they stay snake_case. + +export interface HistoryTurn { + role: 'user' | 'assistant' + content: string +} + +export interface PriorRecommendation { + rank: number + track_id: string + title: string + artists: string[] +} + +export interface RecommendationRequest { + schema_version: 1 + query: string + history: HistoryTurn[] + prior_recommendations: PriorRecommendation[] +} + +export interface TrackCard { + id: string + uri: string + title: string + artists: string[] + album_name: string + album_art_url: string | null + external_url: string | null +} + +export interface MetadataEvent { + type: 'metadata' + request_id: string + intent_summary: string + candidate_count: number +} + +export interface TrackEvent { + type: 'track' + rank: number + track: TrackCard + justification: string +} + +export interface WarningEvent { + type: 'warning' + code: string + message: string +} + +export interface ErrorEvent { + type: 'error' + code: string + message: string +} + +export interface DoneEvent { + type: 'done' + track_count: number + total_ms: number +} + +export type StreamEvent = MetadataEvent | TrackEvent | WarningEvent | ErrorEvent | DoneEvent + +export interface PlaylistCreateRequest { + schema_version: 1 + name: string + track_uris: string[] +} + +export interface PlaylistCreateResponse { + url: string +} + +export interface CurrentUser { + display_name: string +}