346 lines
12 KiB
Python
346 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
|
|
)
|
|
structlog.get_logger().info(
|
|
"candidate_unresolved",
|
|
title=candidate.title,
|
|
artist=candidate.artist,
|
|
status=status,
|
|
result_count=len(field_results) + len(bare_results),
|
|
)
|
|
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,
|
|
)
|