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

View file

@ -0,0 +1,245 @@
"""End-to-end pipeline tests using deterministic service fakes."""
import asyncio
from collections.abc import AsyncGenerator
from app.config import Settings
from app.domain.models import (
ConversationTurn,
Familiarity,
Intent,
PreviousRecommendation,
RerankSelection,
Track,
TrackCandidate,
)
from app.pipeline.event import PipelineEvent, PipelineTrackEvent
from app.pipeline.orchestrator import RecommendationPipeline
from app.ports.protocols import RecommenderOutputError, TimeRange
class FakeCatalog:
"""Return exact tracks and a configurable known-track sample."""
def __init__(self, tracks: tuple[Track, ...], known_tracks: tuple[Track, ...] = ()) -> None:
"""Index tracks by title and expose taste-call counters."""
self.tracks_by_title = {track.title: track for track in tracks}
self.known_tracks = known_tracks
self.search_call_count = 0
self.taste_call_count = 0
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Resolve an exact fielded title and miss bare fallbacks."""
self.search_call_count += 1
if 'track:"' not in query:
return []
title = query.split('track:"', 1)[1].split('"', 1)[0]
track = self.tracks_by_title.get(title)
return [track] if track is not None else []
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
"""Return one stable taste artist."""
self.taste_call_count += 1
return ["Taste Artist"]
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
"""Return no top tracks."""
self.taste_call_count += 1
return []
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
"""Return the configured known tracks."""
self.taste_call_count += 1
return list(self.known_tracks)
class FakeRecommender:
"""Return fixed intents and either selections or structured failures."""
def __init__(
self,
intents: list[Intent],
selection_ids: tuple[str, ...] = (),
failure_count: int = 0,
) -> None:
"""Store deterministic outputs for successive calls."""
self.intents = intents
self.selection_ids = selection_ids
self.failure_count = failure_count
self.rerank_call_count = 0
async def create_intent(
self,
query: str,
history: tuple[ConversationTurn, ...],
previous_recommendations: tuple[PreviousRecommendation, ...],
taste_summary: str,
candidate_count: int,
) -> Intent:
"""Return the next fixed intent."""
return self.intents.pop(0)
async def stream_rerank(
self,
intent: Intent,
grounded_tracks: tuple[Track, ...],
taste_summary: str,
history: tuple[ConversationTurn, ...],
selection_count: int,
correction: str | None = None,
) -> AsyncGenerator[RerankSelection]:
"""Stream configured ids or fail before yielding."""
self.rerank_call_count += 1
if self.failure_count:
self.failure_count -= 1
raise RecommenderOutputError("invalid test output")
selected_ids = self.selection_ids or tuple(track.id for track in grounded_tracks)
for track_id in selected_ids[:selection_count]:
yield RerankSelection(track_id, f"Reason for {track_id}")
def test_event_order_and_rerank_ids_stay_inside_grounded_pool() -> None:
async def run() -> None:
first = _track("first", "First Song")
second = _track("second", "Second Song")
catalog = FakeCatalog((first, second))
recommender = FakeRecommender([_intent(first, second)], ("second", "first"))
events = await _run_pipeline(catalog, recommender)
assert [event.type for event in events] == ["metadata", "track", "track", "done"]
track_events = [event for event in events if isinstance(event, PipelineTrackEvent)]
assert [event.track.id for event in track_events] == ["second", "first"]
assert {event.track.id for event in track_events} <= {"first", "second"}
asyncio.run(run())
def test_rerank_fallback_warns_then_streams_grounded_order() -> None:
async def run() -> None:
first = _track("first", "First Song")
second = _track("second", "Second Song")
catalog = FakeCatalog((first, second))
recommender = FakeRecommender([_intent(first, second)], failure_count=2)
events = await _run_pipeline(catalog, recommender)
assert [event.type for event in events] == [
"metadata",
"warning",
"track",
"track",
"done",
]
assert recommender.rerank_call_count == 2
track_events = [event for event in events if isinstance(event, PipelineTrackEvent)]
assert [event.track.id for event in track_events] == ["first", "second"]
asyncio.run(run())
def test_new_familiarity_excludes_known_track_ids() -> None:
async def run() -> None:
known = _track("known", "Known Song")
new = _track("new", "New Song")
catalog = FakeCatalog((known, new), known_tracks=(known,))
intent = _intent(known, new, familiarity=Familiarity.NEW)
recommender = FakeRecommender([intent])
events = await _run_pipeline(catalog, recommender, rerank_count=1)
track_events = [event for event in events if isinstance(event, PipelineTrackEvent)]
assert [event.track.id for event in track_events] == ["new"]
asyncio.run(run())
def test_refinement_reuses_last_grounded_pool_and_cached_taste() -> None:
async def run() -> None:
track = _track("first", "First Song")
initial = _intent(track)
refinement = _intent(track, is_refinement=True)
catalog = FakeCatalog((track,))
recommender = FakeRecommender([initial, refinement])
pipeline = _pipeline(recommender, rerank_count=1)
await _collect(pipeline, catalog, "first request")
initial_search_calls = catalog.search_call_count
initial_taste_calls = catalog.taste_call_count
await _collect(pipeline, catalog, "refine it")
assert catalog.search_call_count == initial_search_calls
assert catalog.taste_call_count == initial_taste_calls
asyncio.run(run())
async def _run_pipeline(
catalog: FakeCatalog,
recommender: FakeRecommender,
rerank_count: int = 2,
) -> list[PipelineEvent]:
return await _collect(_pipeline(recommender, rerank_count=rerank_count), catalog, "query")
def _pipeline(recommender: FakeRecommender, rerank_count: int) -> RecommendationPipeline:
return RecommendationPipeline(
recommender,
Settings(
rerank_count=rerank_count,
rerank_pool_buffer=0,
grounding_floor=1,
grounding_concurrency=2,
request_deadline_seconds=1.0,
),
)
async def _collect(
pipeline: RecommendationPipeline,
catalog: FakeCatalog,
query: str,
) -> list[PipelineEvent]:
return [
event
async for event in pipeline.stream(
"session",
"request",
catalog,
query,
(),
(),
)
]
def _intent(
*tracks: Track,
familiarity: Familiarity = Familiarity.MIX,
is_refinement: bool = False,
) -> Intent:
return Intent(
mood=("focused",),
activity=None,
era=(),
languages=(),
genres=("electronic",),
familiarity=familiarity,
is_refinement=is_refinement,
intent_summary="Focused electronic discovery.",
candidates=tuple(
TrackCandidate(title=track.title, artist=track.artists[0]) for track in tracks
),
)
def _track(track_id: str, title: 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,
)