fix: harden request handling, pipeline containment, and startup boundaries

This commit is contained in:
Justin Visser 2026-08-10 21:50:48 +02:00
parent 2cc33a721c
commit 3555256a02
18 changed files with 656 additions and 107 deletions

View file

@ -7,6 +7,7 @@ from contextlib import aclosing
import structlog
from app.adapters.spotify.errors import SpotifyError
from app.config import Settings
from app.domain.models import (
CompressedTasteProfile,
@ -27,7 +28,12 @@ from app.pipeline.event import (
PipelineWarningEvent,
)
from app.pipeline.grounding import Grounder
from app.ports.protocols import MusicCatalog, Recommender, RecommenderOutputError
from app.ports.protocols import (
CatalogQuotaExhaustedError,
MusicCatalog,
Recommender,
RecommenderOutputError,
)
RERANK_FALLBACK_CODE = "rerank_fallback"
RERANK_FALLBACK_MESSAGE = "Ranking output was invalid, so grounded results are shown instead."
@ -115,21 +121,43 @@ class RecommendationPipeline:
) -> AsyncGenerator[PipelineEvent]:
"""Yield ordered events for one recommendation request."""
started_at = time.monotonic()
taste = await self.taste_cache.get(session_id, catalog)
intent = await self.recommender.create_intent(
query,
history,
previous_recommendations,
taste.text,
self.settings.candidate_count,
)
deadline_at = started_at + self.settings.request_deadline_seconds
try:
taste = await self.taste_cache.get(session_id, catalog)
intent = await self.recommender.create_intent(
query,
history,
previous_recommendations,
taste.text,
self.settings.candidate_count,
)
except RecommenderOutputError:
yield PipelineErrorEvent(
code="intent_failed",
message="Recommendation intent could not be generated from the model response.",
)
return
except CatalogQuotaExhaustedError:
yield PipelineErrorEvent(
code="quota_exhausted",
message=(
"Spotify request quota was exhausted before recommendations could be prepared."
),
)
return
except SpotifyError:
yield PipelineErrorEvent(
code="spotify_unavailable",
message="Spotify was unavailable while preparing recommendations.",
)
return
yield PipelineMetadataEvent(
request_id=request_id,
intent_summary=intent.intent_summary,
candidate_count=len(intent.candidates),
)
pool = await self._grounded_pool(session_id, catalog, intent, taste)
pool = await self._grounded_pool(session_id, catalog, intent, taste, deadline_at)
if not pool:
yield PipelineErrorEvent(
code="no_grounded_results",
@ -160,6 +188,7 @@ class RecommendationPipeline:
catalog: MusicCatalog,
intent: Intent,
taste: CompressedTasteProfile,
deadline_at: float,
) -> tuple[Track, ...]:
"""Reuse the session's pool on refinement, otherwise ground anew."""
if intent.is_refinement:
@ -172,6 +201,7 @@ class RecommendationPipeline:
taste.known_track_ids,
intent.familiarity,
self.settings.rerank_count + self.settings.rerank_pool_buffer,
deadline_at,
)
if result.tracks:
self.last_pools[session_id] = result.tracks
@ -290,19 +320,31 @@ class _TasteProfileCache:
return profile
async def _fetch(self, catalog: MusicCatalog) -> CompressedTasteProfile:
short_artists, long_artists, short_tracks, long_tracks, saved_tracks = await asyncio.gather(
catalog.fetch_top_artists("short_term", self.settings.top_items_limit),
catalog.fetch_top_artists("long_term", self.settings.top_items_limit),
catalog.fetch_top_tracks("short_term", self.settings.top_items_limit),
catalog.fetch_top_tracks("long_term", self.settings.top_items_limit),
catalog.fetch_saved_tracks(self.settings.saved_tracks_limit),
)
try:
async with asyncio.TaskGroup() as task_group:
short_artists_task = task_group.create_task(
catalog.fetch_top_artists("short_term", self.settings.top_items_limit)
)
long_artists_task = task_group.create_task(
catalog.fetch_top_artists("long_term", self.settings.top_items_limit)
)
short_tracks_task = task_group.create_task(
catalog.fetch_top_tracks("short_term", self.settings.top_items_limit)
)
long_tracks_task = task_group.create_task(
catalog.fetch_top_tracks("long_term", self.settings.top_items_limit)
)
saved_tracks_task = task_group.create_task(
catalog.fetch_saved_tracks(self.settings.saved_tracks_limit)
)
except ExceptionGroup as errors:
raise errors.exceptions[0] from None
return compress_taste_profile(
TasteProfile(
short_term_artists=tuple(short_artists),
long_term_artists=tuple(long_artists),
short_term_tracks=tuple(short_tracks),
long_term_tracks=tuple(long_tracks),
saved_tracks=tuple(saved_tracks),
short_term_artists=tuple(short_artists_task.result()),
long_term_artists=tuple(long_artists_task.result()),
short_term_tracks=tuple(short_tracks_task.result()),
long_term_tracks=tuple(long_tracks_task.result()),
saved_tracks=tuple(saved_tracks_task.result()),
)
)