diff --git a/backend/app/config.py b/backend/app/config.py index 40f7923..7f02ee5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/domain/matching.py b/backend/app/domain/matching.py index 9b855b6..fe2de12 100644 --- a/backend/app/domain/matching.py +++ b/backend/app/domain/matching.py @@ -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: diff --git a/backend/app/pipeline/grounding.py b/backend/app/pipeline/grounding.py index 32ea2b8..7ecfba6 100644 --- a/backend/app/pipeline/grounding.py +++ b/backend/app/pipeline/grounding.py @@ -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) diff --git a/backend/app/pipeline/orchestrator.py b/backend/app/pipeline/orchestrator.py index f1f07cb..1d93038 100644 --- a/backend/app/pipeline/orchestrator.py +++ b/backend/app/pipeline/orchestrator.py @@ -177,27 +177,44 @@ 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 - try: - async for event in self._rerank_once( - intent, taste_summary, history, selection, correction - ): - yield event - return - except RecommenderOutputError as error: - correction = ( - f"Validation failed: {error}." - f" Already emitted track ids: {selection.describe_selected_ids()}." - ) + """Stream the rerank with one corrected retry, then fall back.""" + try: + 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()}." + ) + + async for event in self._rerank_once(intent, taste_summary, history, selection, correction): + yield event + async def _rerank_once( self, intent: Intent, @@ -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) diff --git a/backend/tests/test_matching.py b/backend/tests/test_matching.py index f9d9262..074af55 100644 --- a/backend/tests/test_matching.py +++ b/backend/tests/test_matching.py @@ -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 diff --git a/docs/logboek.md b/docs/logboek.md index f18c84a..6d633d3 100644 --- a/docs/logboek.md +++ b/docs/logboek.md @@ -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.