feat: orchestrate grounded recommendations
This commit is contained in:
parent
751391e6a2
commit
cead39edbc
8 changed files with 1098 additions and 7 deletions
153
backend/tests/test_grounding.py
Normal file
153
backend/tests/test_grounding.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Deterministic tests for bounded Spotify grounding."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from app.config import Settings
|
||||
from app.domain.models import Familiarity, Track, TrackCandidate
|
||||
from app.pipeline.grounding import Grounder
|
||||
from app.ports.protocols import TimeRange
|
||||
|
||||
SearchHandler = Callable[[str], Awaitable[list[Track]]]
|
||||
|
||||
|
||||
class FakeCatalog:
|
||||
"""Expose a programmable search surface for grounding tests."""
|
||||
|
||||
def __init__(self, search_handler: SearchHandler) -> None:
|
||||
"""Store the search behavior and call trace."""
|
||||
self.search_handler = search_handler
|
||||
self.search_queries: list[str] = []
|
||||
|
||||
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
|
||||
"""Record and delegate one fake search."""
|
||||
self.search_queries.append(query)
|
||||
return await self.search_handler(query)
|
||||
|
||||
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
|
||||
"""Return no top artists."""
|
||||
return []
|
||||
|
||||
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
|
||||
"""Return no top tracks."""
|
||||
return []
|
||||
|
||||
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
|
||||
"""Return no saved tracks."""
|
||||
return []
|
||||
|
||||
|
||||
def test_early_stop_honors_pool_target() -> None:
|
||||
async def run() -> None:
|
||||
async def search(query: str) -> list[Track]:
|
||||
title = query.split('track:"', 1)[1].split('"', 1)[0]
|
||||
return [_track(title, title, "Artist")]
|
||||
|
||||
catalog = FakeCatalog(search)
|
||||
grounder = Grounder(_settings(grounding_concurrency=2))
|
||||
candidates = tuple(TrackCandidate(f"track-{index}", "Artist") for index in range(6))
|
||||
|
||||
result = await grounder.ground(catalog, candidates, frozenset(), Familiarity.MIX, 2)
|
||||
|
||||
assert len(result.tracks) == 2
|
||||
assert len(catalog.search_queries) == 2
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_miss_and_mismatch_are_counted_separately() -> None:
|
||||
async def run() -> None:
|
||||
async def search(query: str) -> list[Track]:
|
||||
if "Missing" in query:
|
||||
return []
|
||||
return [_track("wrong", "Different Song", "Different Artist")]
|
||||
|
||||
catalog = FakeCatalog(search)
|
||||
candidates = (
|
||||
TrackCandidate("Missing", "Artist"),
|
||||
TrackCandidate("Rejected", "Artist"),
|
||||
)
|
||||
|
||||
result = await Grounder(_settings()).ground(
|
||||
catalog,
|
||||
candidates,
|
||||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
2,
|
||||
)
|
||||
|
||||
assert result.metrics.miss_count == 1
|
||||
assert result.metrics.mismatch_guard_count == 1
|
||||
assert result.metrics.attempted_count == 2
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_resolution_cache_hit_skips_catalog() -> None:
|
||||
async def run() -> None:
|
||||
async def search(query: str) -> list[Track]:
|
||||
return [_track("cached", "Cached Song", "Artist")]
|
||||
|
||||
catalog = FakeCatalog(search)
|
||||
grounder = Grounder(_settings())
|
||||
candidates = (TrackCandidate("Cached Song", "Artist"),)
|
||||
|
||||
await grounder.ground(catalog, candidates, frozenset(), Familiarity.MIX, 1)
|
||||
first_call_count = len(catalog.search_queries)
|
||||
second = await grounder.ground(catalog, candidates, frozenset(), Familiarity.MIX, 1)
|
||||
|
||||
assert len(catalog.search_queries) == first_call_count
|
||||
assert second.metrics.cache_hit_count == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_deadline_returns_resolved_partial_pool() -> None:
|
||||
async def run() -> None:
|
||||
never_finishes = asyncio.Event()
|
||||
|
||||
async def search(query: str) -> list[Track]:
|
||||
if "Slow Song" in query:
|
||||
await never_finishes.wait()
|
||||
return [_track("fast", "Fast Song", "Artist")]
|
||||
|
||||
catalog = FakeCatalog(search)
|
||||
settings = _settings(grounding_concurrency=2, request_deadline_seconds=0.02)
|
||||
candidates = (
|
||||
TrackCandidate("Fast Song", "Artist"),
|
||||
TrackCandidate("Slow Song", "Artist"),
|
||||
)
|
||||
|
||||
result = await Grounder(settings).ground(
|
||||
catalog,
|
||||
candidates,
|
||||
frozenset(),
|
||||
Familiarity.MIX,
|
||||
2,
|
||||
)
|
||||
|
||||
assert [track.id for track in result.tracks] == ["fast"]
|
||||
assert result.metrics.did_reach_deadline
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def _settings(**overrides: object) -> Settings:
|
||||
values: dict[str, object] = {
|
||||
"grounding_concurrency": 1,
|
||||
"request_deadline_seconds": 1.0,
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings.model_validate(values)
|
||||
|
||||
|
||||
def _track(track_id: str, title: str, artist: str) -> Track:
|
||||
return Track(
|
||||
id=track_id,
|
||||
uri=f"spotify:track:{track_id}",
|
||||
title=title,
|
||||
artists=(artist,),
|
||||
album_name="Album",
|
||||
album_art_url=None,
|
||||
external_url=None,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue