feat: add recommendation domain foundations

This commit is contained in:
Justin Visser 2026-08-10 11:59:02 +02:00
parent fad7884c42
commit 5c3ba8d6ec
10 changed files with 446 additions and 0 deletions

View file

@ -24,6 +24,24 @@ class Settings(BaseSettings):
spotify_timeout_seconds: float = 10.0
spotify_retry_after_cap_seconds: float = 5.0
session_cookie_secure: bool = False
anthropic_api_key: str = ""
llm_model: str = "claude-sonnet-5"
intent_effort: str = "low"
rerank_effort: str = "medium"
candidate_count: int = 35
rerank_count: int = 15
rerank_pool_buffer: int = 5
grounding_concurrency: int = 6
grounding_floor: int = 8
title_similarity_threshold: float = 0.82
request_deadline_seconds: float = 25.0
resolution_cache_ttl_seconds: float = 3600.0
resolution_cache_max_entries: int = 2048
taste_profile_ttl_seconds: float = 900.0
playlist_name_prefix: str = "discovery-by-llm"
spotify_seed_refresh_token: str = ""
top_items_limit: int = 50
saved_tracks_limit: int = 100
settings = Settings()

View file

@ -0,0 +1,73 @@
"""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*$")
@dataclass(frozen=True)
class MatchVerdict:
"""Explain whether a Spotify track safely matches a proposed candidate."""
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 sequence similarity for two track titles."""
return SequenceMatcher(
None,
normalize_text(candidate_title),
normalize_text(track_title),
).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)}"

View file

@ -1,6 +1,7 @@
"""Pure domain models shared across application boundaries."""
from dataclasses import dataclass
from enum import StrEnum
@dataclass(frozen=True)
@ -14,3 +15,69 @@ class Track:
album_name: str
album_art_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
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

View file

@ -0,0 +1,28 @@
"""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."""
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}"

View file

@ -0,0 +1,19 @@
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.

View file

@ -0,0 +1,21 @@
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.