fix: tolerate spotify version suffixes and surface rerank failures
This commit is contained in:
parent
ce652d3114
commit
0ccc9a5d4e
6 changed files with 92 additions and 29 deletions
|
|
@ -41,8 +41,10 @@ class Settings(BaseSettings):
|
||||||
llm_model: str = "claude-sonnet-5"
|
llm_model: str = "claude-sonnet-5"
|
||||||
intent_effort: str = "low"
|
intent_effort: str = "low"
|
||||||
rerank_effort: str = "medium"
|
rerank_effort: str = "medium"
|
||||||
intent_max_tokens: int = 8192
|
# Ceilings include adaptive thinking tokens, which is why they sit far
|
||||||
rerank_max_tokens: int = 4096
|
# 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
|
# Pipeline shape. candidate_count is the main call-1 latency lever and
|
||||||
# the hallucination budget: at "new to you" familiarity a large share of
|
# the hallucination budget: at "new to you" familiarity a large share of
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from difflib import SequenceMatcher
|
||||||
from app.domain.models import Track, TrackCandidate
|
from app.domain.models import Track, TrackCandidate
|
||||||
|
|
||||||
_SUFFIX_PATTERN = re.compile(r"(?:\s*(?:\([^)]*\)|\[[^]]*\]))+\s*$")
|
_SUFFIX_PATTERN = re.compile(r"(?:\s*(?:\([^)]*\)|\[[^]]*\]))+\s*$")
|
||||||
|
_DASH_SUFFIX_PATTERN = re.compile(r"\s+-\s+[^-]+$")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -35,12 +36,19 @@ def normalize_text(value: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def title_similarity(candidate_title: str, track_title: str) -> float:
|
def title_similarity(candidate_title: str, track_title: str) -> float:
|
||||||
"""Return normalized sequence similarity for two track titles."""
|
"""Return normalized title similarity, tolerating version dash suffixes."""
|
||||||
return SequenceMatcher(
|
normalized_candidate = normalize_text(candidate_title)
|
||||||
None,
|
full_similarity = _ratio(normalized_candidate, normalize_text(track_title))
|
||||||
normalize_text(candidate_title),
|
# Spotify appends version info as "Title - Remaster 2023"; some tracks
|
||||||
normalize_text(track_title),
|
# only exist in suffixed releases. The tiny penalty keeps an exact
|
||||||
).ratio()
|
# 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:
|
def artist_matches(candidate_artist: str, track_artists: tuple[str, ...]) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,13 @@ class Grounder:
|
||||||
status = (
|
status = (
|
||||||
ResolutionStatus.MISMATCH if field_results or bare_results else ResolutionStatus.MISS
|
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)
|
return _ResolutionAttempt(index=index, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -177,25 +177,42 @@ class RecommendationPipeline:
|
||||||
history: tuple[ConversationTurn, ...],
|
history: tuple[ConversationTurn, ...],
|
||||||
selection: _TrackSelection,
|
selection: _TrackSelection,
|
||||||
) -> AsyncGenerator[PipelineTrackEvent | PipelineWarningEvent]:
|
) -> AsyncGenerator[PipelineTrackEvent | PipelineWarningEvent]:
|
||||||
"""Stream one rerank, retry once on invalid output, then fall back."""
|
"""Stream the rerank with one corrected retry, then fall back."""
|
||||||
correction: str | None = None
|
|
||||||
for _ in range(2):
|
|
||||||
if selection.is_full:
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
async for event in self._rerank_once(
|
async for event in self._rerank_with_one_retry(
|
||||||
intent, taste_summary, history, selection, correction
|
intent, taste_summary, history, selection
|
||||||
):
|
):
|
||||||
yield event
|
yield event
|
||||||
return
|
return
|
||||||
except RecommenderOutputError as error:
|
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 = (
|
correction = (
|
||||||
f"Validation failed: {error}."
|
f"Validation failed: {error}."
|
||||||
f" Already emitted track ids: {selection.describe_selected_ids()}."
|
f" Already emitted track ids: {selection.describe_selected_ids()}."
|
||||||
)
|
)
|
||||||
|
|
||||||
yield PipelineWarningEvent(code=RERANK_FALLBACK_CODE, message=RERANK_FALLBACK_MESSAGE)
|
async for event in self._rerank_once(intent, taste_summary, history, selection, correction):
|
||||||
for event in selection.fill_from_pool(RERANK_FALLBACK_JUSTIFICATION):
|
|
||||||
yield event
|
yield event
|
||||||
|
|
||||||
async def _rerank_once(
|
async def _rerank_once(
|
||||||
|
|
@ -219,6 +236,11 @@ class RecommendationPipeline:
|
||||||
yield selection.select(item.track_id, item.justification)
|
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:
|
def _log_completion(selection: _TrackSelection, taste: CompressedTasteProfile) -> None:
|
||||||
"""Log how many recommendations were served and how many are new."""
|
"""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)
|
new_track_count = sum(track.id not in taste.known_track_ids for track in selection.selected)
|
||||||
|
|
|
||||||
|
|
@ -52,3 +52,14 @@ def _track(*, title: str, artist: str) -> Track:
|
||||||
album_art_url=None,
|
album_art_url=None,
|
||||||
external_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
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,8 @@ Wat ik deed:
|
||||||
NDJSON events (metadata / track / warning / error / done); een
|
NDJSON events (metadata / track / warning / error / done); een
|
||||||
client-disconnect stopt de stream en de grounding.
|
client-disconnect stopt de stream en de grounding.
|
||||||
- Playlist-endpoint dat schrijft met een vaste naam-prefix, zodat
|
- 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
|
- Request-timing middleware met per-request counters (Spotify calls, cache
|
||||||
hits, LLM tokens) in gestructureerde logs.
|
hits, LLM tokens) in gestructureerde logs.
|
||||||
- Seed session voor gehost draaien: in live mode installeert een refresh
|
- Seed session voor gehost draaien: in live mode installeert een refresh
|
||||||
|
|
@ -128,8 +129,7 @@ Wat ik deed:
|
||||||
|
|
||||||
Waarom:
|
Waarom:
|
||||||
|
|
||||||
- Streamen maakt de wachttijd eerlijk: de eerste kaart telt, niet de
|
- Streamen maakt de wachttijd eerlijk: de eerste kaart telt. Een afgebroken request mag geen werk laten doorlopen.
|
||||||
laatste. Een afgebroken request mag geen werk laten doorlopen.
|
|
||||||
|
|
||||||
### Review-fixes en de account-quota
|
### Review-fixes en de account-quota
|
||||||
|
|
||||||
|
|
@ -149,5 +149,18 @@ Waarom:
|
||||||
|
|
||||||
- De quota is per developer-account en per dag; bulk-werk zoals fixtures
|
- De quota is per developer-account en per dag; bulk-werk zoals fixtures
|
||||||
opnemen moet dus gebudgetteerd, en het cache-ontwerp (naam-naar-id,
|
opnemen moet dus gebudgetteerd, en het cache-ontwerp (naam-naar-id,
|
||||||
smaakprofiel) is geen optimalisatie-garnituur maar
|
smaakprofiel) is noodzakelijk om binnen de quota te blijven.
|
||||||
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue