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"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue