fix: tolerate spotify version suffixes and surface rerank failures

This commit is contained in:
Justin Visser 2026-08-10 14:02:54 +02:00
parent ce652d3114
commit 0ccc9a5d4e
6 changed files with 92 additions and 29 deletions

View file

@ -41,8 +41,10 @@ class Settings(BaseSettings):
llm_model: str = "claude-sonnet-5"
intent_effort: str = "low"
rerank_effort: str = "medium"
intent_max_tokens: int = 8192
rerank_max_tokens: int = 4096
# 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

View file

@ -8,6 +8,7 @@ from difflib import SequenceMatcher
from app.domain.models import Track, TrackCandidate
_SUFFIX_PATTERN = re.compile(r"(?:\s*(?:\([^)]*\)|\[[^]]*\]))+\s*$")
_DASH_SUFFIX_PATTERN = re.compile(r"\s+-\s+[^-]+$")
@dataclass(frozen=True)
@ -35,12 +36,19 @@ def normalize_text(value: str) -> str:
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()
"""Return normalized title similarity, tolerating version dash suffixes."""
normalized_candidate = normalize_text(candidate_title)
full_similarity = _ratio(normalized_candidate, normalize_text(track_title))
# Spotify appends version info as "Title - Remaster 2023"; some tracks
# only exist in suffixed releases. The tiny penalty keeps an exact
# original title ahead of a suffixed release at equal similarity.
stripped_title = _DASH_SUFFIX_PATTERN.sub("", track_title)
stripped_similarity = _ratio(normalized_candidate, normalize_text(stripped_title)) - 0.001
return max(full_similarity, stripped_similarity)
def _ratio(left: str, right: str) -> float:
return SequenceMatcher(None, left, right).ratio()
def artist_matches(candidate_artist: str, track_artists: tuple[str, ...]) -> bool:

View file

@ -274,6 +274,13 @@ class Grounder:
status = (
ResolutionStatus.MISMATCH if field_results or bare_results else ResolutionStatus.MISS
)
structlog.get_logger().info(
"candidate_unresolved",
title=candidate.title,
artist=candidate.artist,
status=status,
result_count=len(field_results) + len(bare_results),
)
return _ResolutionAttempt(index=index, status=status)

View file

@ -177,25 +177,42 @@ class RecommendationPipeline:
history: tuple[ConversationTurn, ...],
selection: _TrackSelection,
) -> AsyncGenerator[PipelineTrackEvent | PipelineWarningEvent]:
"""Stream one rerank, retry once on invalid output, then fall back."""
correction: str | None = None
for _ in range(2):
if selection.is_full:
return
"""Stream the rerank with one corrected retry, then fall back."""
try:
async for event in self._rerank_once(
intent, taste_summary, history, selection, correction
async for event in self._rerank_with_one_retry(
intent, taste_summary, history, selection
):
yield event
return
except RecommenderOutputError as error:
_log_rerank_failure(attempt=2, error=error)
yield PipelineWarningEvent(code=RERANK_FALLBACK_CODE, message=RERANK_FALLBACK_MESSAGE)
for event in selection.fill_from_pool(RERANK_FALLBACK_JUSTIFICATION):
yield event
async def _rerank_with_one_retry(
self,
intent: Intent,
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection: _TrackSelection,
) -> AsyncGenerator[PipelineTrackEvent]:
"""Rerank once; on invalid output, retry once with a correction."""
try:
async for event in self._rerank_once(intent, taste_summary, history, selection, None):
yield event
return
except RecommenderOutputError as error:
_log_rerank_failure(attempt=1, error=error)
if selection.is_full:
return
correction = (
f"Validation failed: {error}."
f" Already emitted track ids: {selection.describe_selected_ids()}."
)
yield PipelineWarningEvent(code=RERANK_FALLBACK_CODE, message=RERANK_FALLBACK_MESSAGE)
for event in selection.fill_from_pool(RERANK_FALLBACK_JUSTIFICATION):
async for event in self._rerank_once(intent, taste_summary, history, selection, correction):
yield event
async def _rerank_once(
@ -219,6 +236,11 @@ class RecommendationPipeline:
yield selection.select(item.track_id, item.justification)
def _log_rerank_failure(attempt: int, error: RecommenderOutputError) -> None:
"""Log one invalid rerank attempt with its validation reason."""
structlog.get_logger().warning("rerank_attempt_failed", attempt=attempt, error=str(error))
def _log_completion(selection: _TrackSelection, taste: CompressedTasteProfile) -> None:
"""Log how many recommendations were served and how many are new."""
new_track_count = sum(track.id not in taste.known_track_ids for track in selection.selected)

View file

@ -52,3 +52,14 @@ def _track(*, title: str, artist: str) -> Track:
album_art_url=None,
external_url=None,
)
def test_title_similarity_tolerates_version_dash_suffix() -> None:
assert title_similarity("Immunity", "Immunity - Remaster 2023") > 0.95
assert title_similarity("Nightcall", "Nightcall - Breakbot Remix") > 0.95
def test_title_similarity_prefers_the_unsuffixed_release() -> None:
plain = title_similarity("Immunity", "Immunity")
suffixed = title_similarity("Immunity", "Immunity - Remaster 2023")
assert plain > suffixed

View file

@ -119,7 +119,8 @@ Wat ik deed:
NDJSON events (metadata / track / warning / error / done); een
client-disconnect stopt de stream en de grounding.
- Playlist-endpoint dat schrijft met een vaste naam-prefix, zodat
aangemaakte playlists later in bulk op te ruimen zijn.
aangemaakte playlists later in bulk op te ruimen zijn (ik gebruik mijn
persoonlijke spotify account/abbonement voor de demo)..
- Request-timing middleware met per-request counters (Spotify calls, cache
hits, LLM tokens) in gestructureerde logs.
- Seed session voor gehost draaien: in live mode installeert een refresh
@ -128,8 +129,7 @@ Wat ik deed:
Waarom:
- Streamen maakt de wachttijd eerlijk: de eerste kaart telt, niet de
laatste. Een afgebroken request mag geen werk laten doorlopen.
- Streamen maakt de wachttijd eerlijk: de eerste kaart telt. Een afgebroken request mag geen werk laten doorlopen.
### Review-fixes en de account-quota
@ -149,5 +149,18 @@ Waarom:
- De quota is per developer-account en per dag; bulk-werk zoals fixtures
opnemen moet dus gebudgetteerd, en het cache-ontwerp (naam-naar-id,
smaakprofiel) is geen optimalisatie-garnituur maar
noodzakelijk om binnen de quota te blijven.
smaakprofiel) is noodzakelijk om binnen de quota te blijven.
### Meer live-test fixes
Wat ik deed:
- Matching accepteert nu ook nummers die alleen als release met een
versie-suffix bestaan ("Immunity - Remaster 2023"), met een lichte
voorkeur voor de originele release bij gelijke score; de live-test liet
zien dat echte nummers hierdoor onterecht afvielen.
- Mislukte rerank-pogingen worden met reden gelogd, en de token-ceilings
van beide LLM-calls zijn verhoogd: adaptive thinking telt mee in
max_tokens en kon de gestructureerde output afkappen.
- Per unresolved kandidaat wordt titel, artiest en status gelogd, zodat
zichtbaar is WAT het model verzon in plaats van alleen hoeveel.