refactor: rebuild orchestration around a track selection

This commit is contained in:
Justin Visser 2026-08-10 13:17:33 +02:00
parent cead39edbc
commit e8d20158e3
2 changed files with 124 additions and 96 deletions

View file

@ -2,9 +2,8 @@
import asyncio import asyncio
import time import time
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator, Iterator
from contextlib import aclosing from contextlib import aclosing
from dataclasses import dataclass
import structlog import structlog
@ -35,14 +34,58 @@ RERANK_FALLBACK_MESSAGE = "Ranking output was invalid, so grounded results are s
RERANK_FALLBACK_JUSTIFICATION = "Selected as a grounded match for your request." RERANK_FALLBACK_JUSTIFICATION = "Selected as a grounded match for your request."
@dataclass class _TrackSelection:
class _RankingState: """Rank tracks from one grounded pool, enforcing bound and uniqueness."""
selected_ids: set[str]
selected_tracks: list[Track] def __init__(self, pool: tuple[Track, ...], limit: int) -> None:
"""Bind the only tracks that may ever be selected."""
self.pool = pool
self.limit = limit
self.selected: list[Track] = []
self._tracks_by_id = {track.id: track for track in pool}
self._selected_ids: set[str] = set()
@property @property
def track_count(self) -> int: def is_full(self) -> bool:
return len(self.selected_tracks) """Return whether the selection reached its limit."""
return len(self.selected) >= self.limit
@property
def remaining_count(self) -> int:
"""Return how many further selections are allowed."""
return self.limit - len(self.selected)
def describe_selected_ids(self) -> str:
"""Render the ids already emitted, for a correction instruction."""
return ", ".join(sorted(self._selected_ids)) or "none"
def select(self, track_id: str, justification: str) -> PipelineTrackEvent:
"""Accept one recommender selection or reject it as invalid output."""
if self.is_full:
raise RecommenderOutputError("Rerank returned too many track ids")
track = self._tracks_by_id.get(track_id)
if track is None:
raise RecommenderOutputError("Rerank selected an out-of-pool track id")
if track_id in self._selected_ids:
raise RecommenderOutputError("Rerank selected a duplicate track id")
return self._emit(track, justification)
def fill_from_pool(self, justification: str) -> Iterator[PipelineTrackEvent]:
"""Complete the selection in pool order after a failed rerank."""
for track in self.pool:
if self.is_full:
return
if track.id not in self._selected_ids:
yield self._emit(track, justification)
def _emit(self, track: Track, justification: str) -> PipelineTrackEvent:
self._selected_ids.add(track.id)
self.selected.append(track)
return PipelineTrackEvent(
rank=len(self.selected),
track=track,
justification=justification,
)
class RecommendationPipeline: class RecommendationPipeline:
@ -86,19 +129,7 @@ class RecommendationPipeline:
candidate_count=len(intent.candidates), candidate_count=len(intent.candidates),
) )
pool = self.last_pools.get(session_id) if intent.is_refinement else None pool = await self._grounded_pool(session_id, catalog, intent, taste)
if pool is None:
result = await self.grounder.ground(
catalog,
intent.candidates,
taste.known_track_ids,
intent.familiarity,
self.settings.rerank_count + self.settings.rerank_pool_buffer,
)
pool = result.tracks
if pool:
self.last_pools[session_id] = pool
if len(pool) < self.settings.grounding_floor: if len(pool) < self.settings.grounding_floor:
yield PipelineErrorEvent( yield PipelineErrorEvent(
code="insufficient_grounding", code="insufficient_grounding",
@ -106,102 +137,95 @@ class RecommendationPipeline:
) )
return return
state = _RankingState(selected_ids=set(), selected_tracks=[]) selection = _TrackSelection(pool, self.settings.rerank_count)
async for event in self._stream_ranking(intent, pool, taste.text, history, state): async for event in self._ranked_events(intent, taste.text, history, selection):
yield event yield event
new_track_count = sum( _log_completion(selection, taste)
track.id not in taste.known_track_ids for track in state.selected_tracks
)
structlog.get_logger().info(
"recommendations_complete",
track_count=state.track_count,
new_track_count=new_track_count,
)
yield PipelineDoneEvent( yield PipelineDoneEvent(
track_count=state.track_count, track_count=len(selection.selected),
total_ms=round((time.monotonic() - started_at) * 1000), total_ms=round((time.monotonic() - started_at) * 1000),
) )
async def _stream_ranking( async def _grounded_pool(
self,
session_id: str,
catalog: MusicCatalog,
intent: Intent,
taste: CompressedTasteProfile,
) -> tuple[Track, ...]:
"""Reuse the session's pool on refinement, otherwise ground anew."""
if intent.is_refinement:
cached_pool = self.last_pools.get(session_id)
if cached_pool:
return cached_pool
result = await self.grounder.ground(
catalog,
intent.candidates,
taste.known_track_ids,
intent.familiarity,
self.settings.rerank_count + self.settings.rerank_pool_buffer,
)
if result.tracks:
self.last_pools[session_id] = result.tracks
return result.tracks
async def _ranked_events(
self, self,
intent: Intent, intent: Intent,
pool: tuple[Track, ...],
taste_summary: str, taste_summary: str,
history: tuple[ConversationTurn, ...], history: tuple[ConversationTurn, ...],
state: _RankingState, selection: _TrackSelection,
) -> AsyncGenerator[PipelineTrackEvent | PipelineWarningEvent]: ) -> AsyncGenerator[PipelineTrackEvent | PipelineWarningEvent]:
"""Stream one rerank, retry once on invalid output, then fall back."""
correction: str | None = None correction: str | None = None
for _attempt_number in range(2): for _ in range(2):
remaining_count = self.settings.rerank_count - state.track_count if selection.is_full:
if remaining_count <= 0:
return return
try: try:
async for event in self._validated_rerank( async for event in self._rerank_once(
intent, intent, taste_summary, history, selection, correction
pool,
taste_summary,
history,
remaining_count,
correction,
state,
): ):
yield event yield event
return return
except RecommenderOutputError as error: except RecommenderOutputError as error:
emitted_ids = ", ".join(sorted(state.selected_ids)) or "none"
correction = ( correction = (
f"Validation failed: {error}. Already emitted track ids: {emitted_ids}." f"Validation failed: {error}."
f" Already emitted track ids: {selection.describe_selected_ids()}."
) )
yield PipelineWarningEvent(code=RERANK_FALLBACK_CODE, message=RERANK_FALLBACK_MESSAGE) yield PipelineWarningEvent(code=RERANK_FALLBACK_CODE, message=RERANK_FALLBACK_MESSAGE)
for track in pool: for event in selection.fill_from_pool(RERANK_FALLBACK_JUSTIFICATION):
if state.track_count >= self.settings.rerank_count: yield event
break
if track.id in state.selected_ids:
continue
state.selected_ids.add(track.id)
state.selected_tracks.append(track)
yield PipelineTrackEvent(
rank=state.track_count,
track=track,
justification=RERANK_FALLBACK_JUSTIFICATION,
)
async def _validated_rerank( async def _rerank_once(
self, self,
intent: Intent, intent: Intent,
pool: tuple[Track, ...],
taste_summary: str, taste_summary: str,
history: tuple[ConversationTurn, ...], history: tuple[ConversationTurn, ...],
selection_count: int, selection: _TrackSelection,
correction: str | None, correction: str | None,
state: _RankingState,
) -> AsyncGenerator[PipelineTrackEvent]: ) -> AsyncGenerator[PipelineTrackEvent]:
tracks_by_id = {track.id: track for track in pool}
stream = self.recommender.stream_rerank( stream = self.recommender.stream_rerank(
intent, intent,
pool, selection.pool,
taste_summary, taste_summary,
history, history,
selection_count, selection.remaining_count,
correction, correction,
) )
async with aclosing(stream) as selections: async with aclosing(stream) as selections:
async for selection in selections: async for item in selections:
if state.track_count >= self.settings.rerank_count: yield selection.select(item.track_id, item.justification)
raise RecommenderOutputError("Rerank returned too many track ids")
track = tracks_by_id.get(selection.track_id)
if track is None: def _log_completion(selection: _TrackSelection, taste: CompressedTasteProfile) -> None:
raise RecommenderOutputError("Rerank selected an out-of-pool track id") """Log how many recommendations were served and how many are new."""
if track.id in state.selected_ids: new_track_count = sum(track.id not in taste.known_track_ids for track in selection.selected)
raise RecommenderOutputError("Rerank selected a duplicate track id") structlog.get_logger().info(
state.selected_ids.add(track.id) "recommendations_complete",
state.selected_tracks.append(track) track_count=len(selection.selected),
yield PipelineTrackEvent( new_track_count=new_track_count,
rank=state.track_count,
track=track,
justification=selection.justification,
) )

View file

@ -29,7 +29,7 @@ Waarom:
Wat ik heb laten vallen of uitgesteld: Wat ik heb laten vallen of uitgesteld:
- Domainmodellen, protocollen, prompts, pipeline-instellingen en het - Domainmodellen, protocols, prompts, pipeline-instellingen en het
frontend-typecontract bewust nog niet neergezet. Die ontstaan in de stap frontend-typecontract bewust nog niet neergezet. Die ontstaan in de stap
waar ze horen. Ik probeer op die manier bewust vroeg drift en dode code te voorkomen. waar ze horen. Ik probeer op die manier bewust vroeg drift en dode code te voorkomen.
- Geen apart beslisdocument. De motivering staat in de README en hier. - Geen apart beslisdocument. De motivering staat in de README en hier.
@ -40,19 +40,19 @@ Wat ik heb laten vallen of uitgesteld:
Wat ik deed: Wat ik deed:
- Login via Spotify met PKCE en cookie-sessies: tokens blijven server-side, de browser krijgt alleen een opaque HttpOnly cookie. - Login via Spotify met PKCE en cookie-sessies: tokens blijven server-side, de browser krijgt alleen een opaque HttpOnly cookie.
- Dunne async client op de Spotify Web API met per-endpoint retrybeleid: - Dunne async client op de Spotify Web API met per-endpoint retry policy:
leesacties herhalen maximaal 1 keer en alleen binnen een grens leesacties herhalen maximaal 1 keer en alleen binnen een grens
(Retry-After), schrijfacties op playlists nooit. (Retry-After), schrijfacties op playlists nooit.
- Token-refresh is single-flight: parallelle requests delen 1 refresh in - Token-refresh is single-flight: parallelle requests delen 1 refresh in
plaats van er allemaal zelf een te starten. plaats van er allemaal zelf een te starten.
- Mapping van Spotify-JSON naar eigen modellen op 1 plek; kapotte items - Mapping van Spotify-JSON naar eigen modellen op 1 plek; kapotte items
vallen weg in plaats van dat ze de app breken. vallen weg in plaats van dat ze de app breken.
- Getypeerde fouten en 12 transport- en routetests op een mock transport. - Getypeerde errors en 12 transport- en routetests op een mock transport.
- Na een eerste review de login-flow uit de routes getrokken naar een eigen - Na een eerste review de login-flow uit de routes getrokken naar een eigen
module (routes zijn nu dunne doorgeefluiken) en het retrybeleid herschreven module (routes zijn nu dunne passthroughs) en de retry policy herschreven
naar een lineaire keten van losse regels in plaats van een loop met flags; naar een lineaire keten van losse regels in plaats van een loop met flags;
foutdetails uit de Spotify-body worden meegenomen in de getypeerde fouten foutdetails uit de Spotify-body worden meegenomen in de getypeerde errors
en quota-uitputting wordt apart herkend en nooit opnieuw geprobeerd. en quota exhaustion (QUOTA_EXCEEDED) wordt apart herkend en nooit opnieuw geprobeerd.
- Daarna het API-contract vastgelegd: request-schema en de gestreamde - Daarna het API-contract vastgelegd: request-schema en de gestreamde
events (metadata / track / warning / error / done), gespiegeld in events (metadata / track / warning / error / done), gespiegeld in
TypeScript. TypeScript.
@ -60,7 +60,7 @@ Wat ik deed:
Waarom: Waarom:
- Schrijfacties blind herhalen kan dubbele playlist-items opleveren; dat - Schrijfacties blind herhalen kan dubbele playlist-items opleveren; dat
risico sluit ik structureel uit in het retrybeleid. risico sluit ik structureel uit in de retry policy.
- Het contract eerst bevriezen maakt parallel werken aan frontend en - Het contract eerst bevriezen maakt parallel werken aan frontend en
pipeline mogelijk zonder elkaar te breken. pipeline mogelijk zonder elkaar te breken.
@ -80,15 +80,19 @@ Wat ik deed:
Spotify-smaakprofiel naar prompttekst plus een set bekende track-ids, Spotify-smaakprofiel naar prompttekst plus een set bekende track-ids,
prompts als data in een eigen module, en alle instelbare waarden prompts als data in een eigen module, en alle instelbare waarden
gesectioneerd in de config met per waarde het waarom. gesectioneerd in de config met per waarde het waarom.
- Twee LLM-aanroepen achter een eigen interface: aanroep 1 interpreteert de - Twee LLM-calls achter een eigen interface: call 1 interpreteert de
vraag (stemming, activiteit, taal, bekendheid) en stelt 30-40 echte vraag (stemming, activiteit, taal, bekendheid) en stelt 30-40 echte
nummers voor als gestructureerde output; aanroep 2 herordent uitsluitend nummers voor als gestructureerde output; call 2 herordent uitsluitend
geverifieerde nummers en streamt per nummer een eerlijke onderbouwing. geverifieerde nummers en streamt per nummer een eerlijke onderbouwing.
Elke output wordt gevalideerd, met hooguit 1 herstelpoging. Elke output wordt gevalideerd, met hooguit 1 herstelpoging.
- Grounding: begrensde parallelle zoekslag met vroege stop, een deadline, - Grounding: begrensde parallelle search fan-out met early stop, een deadline,
een naam-naar-id cache en twee aparte metrieken: niet gevonden versus een naam-naar-id cache en twee aparte metrieken: niet gevonden versus
wel gevonden maar afgekeurd door de controle. Een track-id dat niet in wel gevonden maar afgekeurd door de match-check. Een track-id dat niet in
de geverifieerde pool zit kan nooit bij de gebruiker terechtkomen. de geverifieerde pool zit kan nooit bij de gebruiker terechtkomen.
- Na review de orkestratie herschreven: de stream-functie leest nu als de
pipeline-stappen zelf, en de selectie-logica (alleen pool-ids, geen
duplicaten, begrensd aantal, ranking) zit in 1 kleine klasse die zowel
het normale pad als de fallback bedient.
Waarom: Waarom:
@ -96,13 +100,13 @@ Waarom:
deterministische muren: alles wat hij ziet is echte data, alles wat de deterministische muren: alles wat hij ziet is echte data, alles wat de
gebruiker ziet is geverifieerd op Spotify. Een verzonnen nummer valt gebruiker ziet is geverifieerd op Spotify. Een verzonnen nummer valt
stilletjes af en verschijnt nooit. stilletjes af en verschijnt nooit.
- Zoeken geeft maximaal 10 resultaten per aanroep, dus resolutie is per - Zoeken geeft maximaal 10 resultaten per call, dus resolutie is per
definitie een fan-out; liever een kandidaat laten vallen dan het definitie een fan-out; liever een kandidaat laten vallen dan het
verkeerde nummer aanbevelen. verkeerde nummer aanbevelen.
Wat ik heb laten vallen of uitgesteld: Wat ik heb laten vallen of uitgesteld:
- Verfijnvragen doen geen nieuwe zoekslag: turn 2 herordent de bestaande - Refinements doen geen nieuwe search-ronde: turn 2 herordent de bestaande
geverifieerde pool. Sneller en consistent, maar een verfijning haalt geverifieerde pool. Sneller en consistent, maar een refinement haalt
geen nieuwe nummers op. Dit is een bewuste afweging, mocht er tijd over geen nieuwe nummers op. Dit is een bewuste afweging, mocht er tijd over
zijn is dit 1 van de uitbreidingen die ik op zou kunnen pakken. zijn is dit 1 van de uitbreidingen die ik op zou kunnen pakken.