refactor: rebuild orchestration around a track selection
This commit is contained in:
parent
cead39edbc
commit
e8d20158e3
2 changed files with 124 additions and 96 deletions
|
|
@ -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,103 +137,96 @@ 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _TasteProfileCache:
|
class _TasteProfileCache:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue