feat: orchestrate grounded recommendations

This commit is contained in:
Justin Visser 2026-08-10 13:09:08 +02:00
parent 751391e6a2
commit cead39edbc
8 changed files with 1098 additions and 7 deletions

View file

@ -0,0 +1,62 @@
"""Application-owned events emitted by the recommendation pipeline."""
from dataclasses import dataclass, field
from typing import Literal
from app.domain.models import Track
@dataclass(frozen=True)
class PipelineMetadataEvent:
"""Describe the interpreted request before track results."""
request_id: str
intent_summary: str
candidate_count: int
type: Literal["metadata"] = field(default="metadata", init=False)
@dataclass(frozen=True)
class PipelineTrackEvent:
"""Carry one ranked, grounded recommendation."""
rank: int
track: Track
justification: str
type: Literal["track"] = field(default="track", init=False)
@dataclass(frozen=True)
class PipelineWarningEvent:
"""Report a non-terminal degradation."""
code: str
message: str
type: Literal["warning"] = field(default="warning", init=False)
@dataclass(frozen=True)
class PipelineErrorEvent:
"""Report a terminal recommendation failure."""
code: str
message: str
type: Literal["error"] = field(default="error", init=False)
@dataclass(frozen=True)
class PipelineDoneEvent:
"""Report final track count and elapsed time."""
track_count: int
total_ms: int
type: Literal["done"] = field(default="done", init=False)
type PipelineEvent = (
PipelineMetadataEvent
| PipelineTrackEvent
| PipelineWarningEvent
| PipelineErrorEvent
| PipelineDoneEvent
)

View file

@ -0,0 +1,339 @@
"""Resolve proposed tracks with bounded concurrency and conservative matching."""
import asyncio
import time
from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
import structlog
from app.config import Settings
from app.domain.matching import candidate_key, judge_candidate_match, track_key
from app.domain.models import Familiarity, Track, TrackCandidate
from app.observability.timing import increment_cache_hits
from app.ports.protocols import CatalogQuotaExhaustedError, MusicCatalog
class ResolutionStatus(StrEnum):
"""Terminal outcome of one candidate resolution attempt."""
RESOLVED = "resolved"
MISS = "miss"
MISMATCH = "mismatch"
QUOTA = "quota"
@dataclass(frozen=True)
class GroundingMetrics:
"""Separate resolver outcomes for operational visibility."""
attempted_count: int
miss_count: int
mismatch_guard_count: int
cache_hit_count: int
did_reach_deadline: bool
did_exhaust_quota: bool
@property
def miss_rate(self) -> float:
"""Return the share of attempted candidates with no search result."""
return self.miss_count / self.attempted_count if self.attempted_count else 0.0
@property
def mismatch_guard_rate(self) -> float:
"""Return the share rejected by client-side identity checks."""
return self.mismatch_guard_count / self.attempted_count if self.attempted_count else 0.0
@dataclass(frozen=True)
class GroundingResult:
"""Resolved pool and its operational metrics."""
tracks: tuple[Track, ...]
metrics: GroundingMetrics
@dataclass(frozen=True)
class _ResolutionAttempt:
index: int
status: ResolutionStatus
track: Track | None = None
is_cache_hit: bool = False
class ResolutionCache:
"""Bound successful name resolutions by age and least-recent use."""
def __init__(
self,
ttl_seconds: float,
max_entries: int,
clock: Callable[[], float] = time.monotonic,
) -> None:
"""Create an empty successful-resolution cache."""
self.ttl_seconds = ttl_seconds
self.max_entries = max_entries
self.clock = clock
self._entries: OrderedDict[str, tuple[float, Track]] = OrderedDict()
def get(self, key: str) -> Track | None:
"""Return a fresh cached track and refresh its recency."""
entry = self._entries.get(key)
if entry is None:
return None
created_at, track = entry
if self.clock() - created_at >= self.ttl_seconds:
del self._entries[key]
return None
self._entries.move_to_end(key)
increment_cache_hits()
return track
def put(self, key: str, track: Track) -> None:
"""Cache a successful unambiguous resolution."""
if self.max_entries <= 0 or self.ttl_seconds <= 0:
return
self._entries[key] = (self.clock(), track)
self._entries.move_to_end(key)
while len(self._entries) > self.max_entries:
self._entries.popitem(last=False)
class Grounder:
"""Build a safe Spotify pool with early stop and bounded fan-out."""
def __init__(self, settings: Settings, cache: ResolutionCache | None = None) -> None:
"""Bind resolver settings and a process-local resolution cache."""
self.settings = settings
self.cache = cache or ResolutionCache(
settings.resolution_cache_ttl_seconds,
settings.resolution_cache_max_entries,
)
async def ground(
self,
catalog: MusicCatalog,
candidates: tuple[TrackCandidate, ...],
known_track_ids: frozenset[str],
familiarity: Familiarity,
pool_target: int,
) -> GroundingResult:
"""Resolve candidates until the target, deadline, or quota boundary."""
accepted: dict[int, Track] = {}
seen_ids: set[str] = set()
seen_keys: set[str] = set()
metrics = _MutableMetrics()
pending: dict[asyncio.Task[_ResolutionAttempt], int] = {}
next_index = 0
deadline_at = time.monotonic() + self.settings.request_deadline_seconds
semaphore = asyncio.Semaphore(self.settings.grounding_concurrency)
try:
while next_index < len(candidates) or pending:
if len(accepted) >= pool_target:
break
next_index = self._launch_tasks(
catalog,
candidates,
pending,
next_index,
semaphore,
)
if not pending:
break
remaining_seconds = deadline_at - time.monotonic()
if remaining_seconds <= 0:
metrics.did_reach_deadline = True
break
done, _ = await asyncio.wait(
pending,
timeout=remaining_seconds,
return_when=asyncio.FIRST_COMPLETED,
)
if not done:
metrics.did_reach_deadline = True
break
self._collect_done(
done,
pending,
accepted,
seen_ids,
seen_keys,
known_track_ids,
familiarity,
pool_target,
metrics,
)
if metrics.did_exhaust_quota:
break
finally:
await _cancel_tasks(tuple(pending))
tracks = tuple(track for _, track in sorted(accepted.items()))
if familiarity is Familiarity.FAMILIAR:
tracks = tuple(sorted(tracks, key=lambda track: track.id not in known_track_ids))
result = GroundingResult(tracks=tracks, metrics=metrics.freeze())
_log_grounding(result)
return result
def _launch_tasks(
self,
catalog: MusicCatalog,
candidates: tuple[TrackCandidate, ...],
pending: dict[asyncio.Task[_ResolutionAttempt], int],
next_index: int,
semaphore: asyncio.Semaphore,
) -> int:
while next_index < len(candidates) and len(pending) < self.settings.grounding_concurrency:
task = asyncio.create_task(
self._resolve(catalog, next_index, candidates[next_index], semaphore)
)
pending[task] = next_index
next_index += 1
return next_index
def _collect_done(
self,
done: set[asyncio.Task[_ResolutionAttempt]],
pending: dict[asyncio.Task[_ResolutionAttempt], int],
accepted: dict[int, Track],
seen_ids: set[str],
seen_keys: set[str],
known_track_ids: frozenset[str],
familiarity: Familiarity,
pool_target: int,
metrics: "_MutableMetrics",
) -> None:
for task in sorted(done, key=pending.__getitem__):
del pending[task]
attempt = task.result()
metrics.record(attempt)
if attempt.status is ResolutionStatus.QUOTA:
continue
track = attempt.track
if track is None or len(accepted) >= pool_target:
continue
if familiarity is Familiarity.NEW and track.id in known_track_ids:
continue
normalized_key = track_key(track)
if track.id in seen_ids or normalized_key in seen_keys:
continue
seen_ids.add(track.id)
seen_keys.add(normalized_key)
accepted[attempt.index] = track
async def _resolve(
self,
catalog: MusicCatalog,
index: int,
candidate: TrackCandidate,
semaphore: asyncio.Semaphore,
) -> _ResolutionAttempt:
async with semaphore:
return await self._resolve_with_slot(catalog, index, candidate)
async def _resolve_with_slot(
self,
catalog: MusicCatalog,
index: int,
candidate: TrackCandidate,
) -> _ResolutionAttempt:
key = candidate_key(candidate)
cached_track = self.cache.get(key)
if cached_track is not None:
return _ResolutionAttempt(
index=index,
status=ResolutionStatus.RESOLVED,
track=cached_track,
is_cache_hit=True,
)
try:
field_results = await catalog.search_tracks(_field_query(candidate))
matched_track = _best_match(
candidate, field_results, self.settings.title_similarity_threshold
)
if matched_track is None:
bare_results = await catalog.search_tracks(f"{candidate.title} {candidate.artist}")
matched_track = _best_match(
candidate,
bare_results,
self.settings.title_similarity_threshold,
)
else:
bare_results = []
except CatalogQuotaExhaustedError:
return _ResolutionAttempt(index=index, status=ResolutionStatus.QUOTA)
if matched_track is not None:
self.cache.put(key, matched_track)
return _ResolutionAttempt(
index=index, status=ResolutionStatus.RESOLVED, track=matched_track
)
status = (
ResolutionStatus.MISMATCH if field_results or bare_results else ResolutionStatus.MISS
)
return _ResolutionAttempt(index=index, status=status)
@dataclass
class _MutableMetrics:
attempted_count: int = 0
miss_count: int = 0
mismatch_guard_count: int = 0
cache_hit_count: int = 0
did_reach_deadline: bool = False
did_exhaust_quota: bool = False
def record(self, attempt: _ResolutionAttempt) -> None:
self.attempted_count += 1
self.miss_count += attempt.status is ResolutionStatus.MISS
self.mismatch_guard_count += attempt.status is ResolutionStatus.MISMATCH
self.cache_hit_count += attempt.is_cache_hit
self.did_exhaust_quota = self.did_exhaust_quota or attempt.status is ResolutionStatus.QUOTA
def freeze(self) -> GroundingMetrics:
return GroundingMetrics(
attempted_count=self.attempted_count,
miss_count=self.miss_count,
mismatch_guard_count=self.mismatch_guard_count,
cache_hit_count=self.cache_hit_count,
did_reach_deadline=self.did_reach_deadline,
did_exhaust_quota=self.did_exhaust_quota,
)
def _best_match(candidate: TrackCandidate, tracks: list[Track], threshold: float) -> Track | None:
verdicts = ((judge_candidate_match(candidate, track, threshold), track) for track in tracks)
accepted = [
(verdict.title_similarity, track) for verdict, track in verdicts if verdict.is_match
]
return max(accepted, key=lambda item: item[0])[1] if accepted else None
def _field_query(candidate: TrackCandidate) -> str:
title = candidate.title.replace('"', " ")
artist = candidate.artist.replace('"', " ")
return f'track:"{title}" artist:"{artist}"'
async def _cancel_tasks(tasks: tuple[asyncio.Task[_ResolutionAttempt], ...]) -> None:
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
def _log_grounding(result: GroundingResult) -> None:
metrics = result.metrics
structlog.get_logger().info(
"grounding_complete",
track_count=len(result.tracks),
attempted_count=metrics.attempted_count,
miss_rate=metrics.miss_rate,
mismatch_guard_rate=metrics.mismatch_guard_rate,
cache_hits=metrics.cache_hit_count,
deadline_reached=metrics.did_reach_deadline,
quota_exhausted=metrics.did_exhaust_quota,
)

View file

@ -0,0 +1,255 @@
"""Compose taste, intent, grounding, and reranking into streamed events."""
import asyncio
import time
from collections.abc import AsyncGenerator
from contextlib import aclosing
from dataclasses import dataclass
import structlog
from app.config import Settings
from app.domain.models import (
CompressedTasteProfile,
ConversationTurn,
Intent,
PreviousRecommendation,
TasteProfile,
Track,
)
from app.domain.profile import compress_taste_profile
from app.observability.timing import increment_cache_hits
from app.pipeline.event import (
PipelineDoneEvent,
PipelineErrorEvent,
PipelineEvent,
PipelineMetadataEvent,
PipelineTrackEvent,
PipelineWarningEvent,
)
from app.pipeline.grounding import Grounder
from app.ports.protocols import MusicCatalog, Recommender, RecommenderOutputError
RERANK_FALLBACK_CODE = "rerank_fallback"
RERANK_FALLBACK_MESSAGE = "Ranking output was invalid, so grounded results are shown instead."
RERANK_FALLBACK_JUSTIFICATION = "Selected as a grounded match for your request."
@dataclass
class _RankingState:
selected_ids: set[str]
selected_tracks: list[Track]
@property
def track_count(self) -> int:
return len(self.selected_tracks)
class RecommendationPipeline:
"""Orchestrate the code-defined recommendation stages."""
def __init__(
self,
recommender: Recommender,
settings: Settings,
grounder: Grounder | None = None,
) -> None:
"""Create process-local caches around the provided service ports."""
self.recommender = recommender
self.settings = settings
self.grounder = grounder or Grounder(settings)
self.taste_cache = _TasteProfileCache(settings)
self.last_pools: dict[str, tuple[Track, ...]] = {}
async def stream(
self,
session_id: str,
request_id: str,
catalog: MusicCatalog,
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
) -> 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,
)
yield PipelineMetadataEvent(
request_id=request_id,
intent_summary=intent.intent_summary,
candidate_count=len(intent.candidates),
)
pool = self.last_pools.get(session_id) if intent.is_refinement else None
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:
yield PipelineErrorEvent(
code="insufficient_grounding",
message="Not enough requested tracks could be verified safely.",
)
return
state = _RankingState(selected_ids=set(), selected_tracks=[])
async for event in self._stream_ranking(intent, pool, taste.text, history, state):
yield event
new_track_count = sum(
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(
track_count=state.track_count,
total_ms=round((time.monotonic() - started_at) * 1000),
)
async def _stream_ranking(
self,
intent: Intent,
pool: tuple[Track, ...],
taste_summary: str,
history: tuple[ConversationTurn, ...],
state: _RankingState,
) -> AsyncGenerator[PipelineTrackEvent | PipelineWarningEvent]:
correction: str | None = None
for _attempt_number in range(2):
remaining_count = self.settings.rerank_count - state.track_count
if remaining_count <= 0:
return
try:
async for event in self._validated_rerank(
intent,
pool,
taste_summary,
history,
remaining_count,
correction,
state,
):
yield event
return
except RecommenderOutputError as error:
emitted_ids = ", ".join(sorted(state.selected_ids)) or "none"
correction = (
f"Validation failed: {error}. Already emitted track ids: {emitted_ids}."
)
yield PipelineWarningEvent(code=RERANK_FALLBACK_CODE, message=RERANK_FALLBACK_MESSAGE)
for track in pool:
if state.track_count >= self.settings.rerank_count:
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(
self,
intent: Intent,
pool: tuple[Track, ...],
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection_count: int,
correction: str | None,
state: _RankingState,
) -> AsyncGenerator[PipelineTrackEvent]:
tracks_by_id = {track.id: track for track in pool}
stream = self.recommender.stream_rerank(
intent,
pool,
taste_summary,
history,
selection_count,
correction,
)
async with aclosing(stream) as selections:
async for selection in selections:
if state.track_count >= self.settings.rerank_count:
raise RecommenderOutputError("Rerank returned too many track ids")
track = tracks_by_id.get(selection.track_id)
if track is None:
raise RecommenderOutputError("Rerank selected an out-of-pool track id")
if track.id in state.selected_ids:
raise RecommenderOutputError("Rerank selected a duplicate track id")
state.selected_ids.add(track.id)
state.selected_tracks.append(track)
yield PipelineTrackEvent(
rank=state.track_count,
track=track,
justification=selection.justification,
)
class _TasteProfileCache:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._entries: dict[str, tuple[float, CompressedTasteProfile]] = {}
self._locks: dict[str, asyncio.Lock] = {}
async def get(self, session_id: str, catalog: MusicCatalog) -> CompressedTasteProfile:
cached = self._fresh_entry(session_id)
if cached is not None:
increment_cache_hits()
return cached
lock = self._locks.setdefault(session_id, asyncio.Lock())
async with lock:
cached = self._fresh_entry(session_id)
if cached is not None:
increment_cache_hits()
return cached
compressed = await self._fetch(catalog)
self._entries[session_id] = (time.monotonic(), compressed)
return compressed
def _fresh_entry(self, session_id: str) -> CompressedTasteProfile | None:
entry = self._entries.get(session_id)
if entry is None:
return None
created_at, profile = entry
if time.monotonic() - created_at >= self.settings.taste_profile_ttl_seconds:
del self._entries[session_id]
return None
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),
)
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),
)
)