discovery-by-llm/backend/app/pipeline/grounding.py
2026-08-10 13:09:08 +02:00

339 lines
12 KiB
Python

"""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,
)