feat: add key-free demo mode replaying recorded fixtures
This commit is contained in:
parent
0664cc2d27
commit
401a0ddea7
21 changed files with 1040 additions and 31 deletions
128
backend/app/adapters/demo/cassette.py
Normal file
128
backend/app/adapters/demo/cassette.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Decode recorded Spotify and Anthropic responses for demo replay."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
from app.adapters.demo.scenario import FIXTURE_ROOT
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecordedResponse:
|
||||
"""One decoded response retained from a recorded HTTP interaction."""
|
||||
|
||||
method: str
|
||||
url: str
|
||||
status: int
|
||||
response_body: bytes
|
||||
response_chunks: tuple[bytes, ...]
|
||||
|
||||
def json_body(self) -> object:
|
||||
"""Parse the decoded response body as JSON."""
|
||||
return json.loads(self.response_body)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemoCassette:
|
||||
"""The recorded service responses needed by one demo scenario."""
|
||||
|
||||
key: str
|
||||
spotify_responses: tuple[RecordedResponse, ...]
|
||||
intent_response_bodies: tuple[bytes, ...]
|
||||
rerank_response_chunks: tuple[bytes, ...]
|
||||
|
||||
@property
|
||||
def intent_response_body(self) -> bytes:
|
||||
"""Return the final intent response recorded for this scenario."""
|
||||
return self.intent_response_bodies[-1]
|
||||
|
||||
@property
|
||||
def spotify_search_responses(self) -> tuple[RecordedResponse, ...]:
|
||||
"""Return recorded Spotify search responses in capture order."""
|
||||
return tuple(response for response in self.spotify_responses if "/search?" in response.url)
|
||||
|
||||
@property
|
||||
def spotify_taste_responses(self) -> tuple[RecordedResponse, ...]:
|
||||
"""Return recorded Spotify taste responses in capture order."""
|
||||
return tuple(
|
||||
response
|
||||
for response in self.spotify_responses
|
||||
if any(
|
||||
endpoint in response.url
|
||||
for endpoint in ("/me/top/artists", "/me/top/tracks", "/me/tracks?")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def load_cassette(scenario_key: str) -> DemoCassette:
|
||||
"""Load and decode both service cassettes for one scenario."""
|
||||
scenario_root = FIXTURE_ROOT / scenario_key
|
||||
spotify = _load_responses(scenario_root / "spotify.json")
|
||||
anthropic = _load_responses(scenario_root / "anthropic.json")
|
||||
intent_responses = tuple(
|
||||
response for response in anthropic if _is_anthropic_message(response.response_body)
|
||||
)
|
||||
rerank_responses = tuple(
|
||||
response for response in anthropic if not _is_anthropic_message(response.response_body)
|
||||
)
|
||||
if not spotify or not intent_responses or not rerank_responses:
|
||||
raise ValueError(f"Scenario cassette is incomplete: {scenario_key}")
|
||||
return DemoCassette(
|
||||
key=scenario_key,
|
||||
spotify_responses=spotify,
|
||||
intent_response_bodies=tuple(response.response_body for response in intent_responses),
|
||||
rerank_response_chunks=rerank_responses[-1].response_chunks,
|
||||
)
|
||||
|
||||
|
||||
def _load_responses(path: Path) -> tuple[RecordedResponse, ...]:
|
||||
payload = json.loads(path.read_text(encoding="ascii"))
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError(f"Cassette must contain a response list: {path}")
|
||||
return tuple(_decode_response(entry) for entry in payload)
|
||||
|
||||
|
||||
def _decode_response(payload: object) -> RecordedResponse:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Cassette response must be a mapping")
|
||||
try:
|
||||
chunks_value = payload["response_chunks_base64"]
|
||||
if not isinstance(chunks_value, list):
|
||||
raise TypeError
|
||||
chunks = tuple(_decode_base64(value) for value in chunks_value)
|
||||
return RecordedResponse(
|
||||
method=_string(payload["method"]),
|
||||
url=_string(payload["url"]),
|
||||
status=_integer(payload["status"]),
|
||||
response_body=_decode_base64(payload["response_body_base64"]),
|
||||
response_chunks=chunks,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise ValueError("Cassette response is invalid") from error
|
||||
|
||||
|
||||
def _decode_base64(value: object) -> bytes:
|
||||
return base64.b64decode(_string(value), validate=True)
|
||||
|
||||
|
||||
def _string(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object) -> int:
|
||||
if not isinstance(value, int):
|
||||
raise TypeError
|
||||
return value
|
||||
|
||||
|
||||
def _is_anthropic_message(body: bytes) -> bool:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return False
|
||||
return isinstance(payload, dict) and payload.get("type") == "message"
|
||||
89
backend/app/adapters/demo/catalog.py
Normal file
89
backend/app/adapters/demo/catalog.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Spotify catalog adapter backed by one recorded demo cassette."""
|
||||
|
||||
from collections import defaultdict
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from app.adapters.demo.cassette import DemoCassette, RecordedResponse
|
||||
from app.adapters.demo.scenario import normalize_text
|
||||
from app.adapters.spotify.mapping import (
|
||||
parse_saved_track_page,
|
||||
parse_search_tracks,
|
||||
parse_top_artists,
|
||||
parse_track_page,
|
||||
)
|
||||
from app.domain.models import Track
|
||||
from app.ports.protocols import TimeRange
|
||||
|
||||
|
||||
class DemoCatalog:
|
||||
"""Replay recorded Spotify search and taste responses without HTTP."""
|
||||
|
||||
def __init__(self, cassette: DemoCassette) -> None:
|
||||
"""Index one scenario cassette for deterministic request replay."""
|
||||
self.cassette = cassette
|
||||
self._search_pages = _index_search_pages(cassette.spotify_search_responses)
|
||||
self._search_cursors: dict[str, int] = defaultdict(int)
|
||||
|
||||
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
|
||||
"""Return the next recorded search page for a normalized query."""
|
||||
normalized_query = normalize_text(query)
|
||||
pages = self._search_pages.get(normalized_query, ())
|
||||
cursor = self._search_cursors[normalized_query]
|
||||
if cursor >= len(pages):
|
||||
return []
|
||||
self._search_cursors[normalized_query] += 1
|
||||
return parse_search_tracks(pages[cursor].json_body())[:limit]
|
||||
|
||||
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
|
||||
"""Return the recorded synthetic top artists for a time range."""
|
||||
response = self._taste_response("/me/top/artists", time_range)
|
||||
return parse_top_artists(response.json_body())[:limit]
|
||||
|
||||
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
|
||||
"""Return the recorded synthetic top tracks for a time range."""
|
||||
response = self._taste_response("/me/top/tracks", time_range)
|
||||
return parse_track_page(response.json_body())[:limit]
|
||||
|
||||
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
|
||||
"""Return recorded synthetic saved-track pages in offset order."""
|
||||
responses = sorted(
|
||||
(
|
||||
response
|
||||
for response in self.cassette.spotify_taste_responses
|
||||
if urlparse(response.url).path.endswith("/me/tracks")
|
||||
),
|
||||
key=_saved_track_offset,
|
||||
)
|
||||
tracks: list[Track] = []
|
||||
for response in responses:
|
||||
tracks.extend(parse_saved_track_page(response.json_body()))
|
||||
if len(tracks) >= limit:
|
||||
break
|
||||
return tracks[:limit]
|
||||
|
||||
def _taste_response(self, endpoint: str, time_range: TimeRange) -> RecordedResponse:
|
||||
for response in self.cassette.spotify_taste_responses:
|
||||
parsed_url = urlparse(response.url)
|
||||
query = parse_qs(parsed_url.query)
|
||||
if parsed_url.path.endswith(endpoint) and query.get("time_range") == [time_range]:
|
||||
return response
|
||||
raise ValueError(f"Cassette lacks {endpoint} for {time_range}")
|
||||
|
||||
|
||||
def _index_search_pages(
|
||||
responses: tuple[RecordedResponse, ...],
|
||||
) -> dict[str, tuple[RecordedResponse, ...]]:
|
||||
pages: dict[str, list[RecordedResponse]] = defaultdict(list)
|
||||
for response in responses:
|
||||
query = parse_qs(urlparse(response.url).query).get("q")
|
||||
if query:
|
||||
pages[normalize_text(query[0])].append(response)
|
||||
return {key: tuple(value) for key, value in pages.items()}
|
||||
|
||||
|
||||
def _saved_track_offset(response: RecordedResponse) -> int:
|
||||
value = parse_qs(urlparse(response.url).query).get("offset", ["0"])[0]
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return 0
|
||||
78
backend/app/adapters/demo/pipeline.py
Normal file
78
backend/app/adapters/demo/pipeline.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Demo-only pipeline decorator for honest fuzzy replay disclosure."""
|
||||
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import aclosing
|
||||
|
||||
from app.adapters.demo.catalog import DemoCatalog
|
||||
from app.adapters.demo.recommender import DemoRecommender, parse_recorded_intent
|
||||
from app.adapters.demo.scenario import select_replay_scenario
|
||||
from app.config import Settings
|
||||
from app.domain.models import ConversationTurn, PreviousRecommendation, Track
|
||||
from app.pipeline.event import PipelineEvent, PipelineMetadataEvent, PipelineWarningEvent
|
||||
from app.pipeline.grounding import Grounder
|
||||
from app.pipeline.orchestrator import RecommendationPipeline
|
||||
from app.ports.protocols import MusicCatalog
|
||||
|
||||
DEMO_REPLAY_CODE = "demo_replay"
|
||||
|
||||
|
||||
class DemoReplayPipeline:
|
||||
"""Decorate the real pipeline with a fuzzy-replay warning event."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Create one cache-preserving pipeline with a replay recommender."""
|
||||
self.settings = settings
|
||||
self.pipeline = RecommendationPipeline(DemoRecommender(settings), settings)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
session_id: str,
|
||||
request_id: str,
|
||||
catalog: MusicCatalog,
|
||||
query: str,
|
||||
history: tuple[ConversationTurn, ...],
|
||||
previous_recommendations: tuple[PreviousRecommendation, ...],
|
||||
) -> AsyncGenerator[PipelineEvent]:
|
||||
"""Stream the selected fixture and disclose non-exact selection."""
|
||||
match = select_replay_scenario(query, bool(previous_recommendations))
|
||||
seeded_pool = await self._prepare_refinement_pool(catalog)
|
||||
event_stream = self.pipeline.stream(
|
||||
session_id,
|
||||
request_id,
|
||||
catalog,
|
||||
query,
|
||||
history,
|
||||
previous_recommendations,
|
||||
seeded_pool=seeded_pool,
|
||||
)
|
||||
async with aclosing(event_stream) as events:
|
||||
async for event in events:
|
||||
yield event
|
||||
if isinstance(event, PipelineMetadataEvent) and not match.is_exact:
|
||||
yield PipelineWarningEvent(
|
||||
code=DEMO_REPLAY_CODE,
|
||||
message=f'Demo replay is showing the recorded "{match.chip}" scenario.',
|
||||
)
|
||||
|
||||
async def _prepare_refinement_pool(
|
||||
self,
|
||||
catalog: MusicCatalog,
|
||||
) -> tuple[Track, ...] | None:
|
||||
if not isinstance(catalog, DemoCatalog):
|
||||
return None
|
||||
intent_bodies = catalog.cassette.intent_response_bodies
|
||||
if len(intent_bodies) < 2:
|
||||
return None
|
||||
# Refinement cassettes record the parent intent first and refinement intent last.
|
||||
parent_intent = parse_recorded_intent(intent_bodies[0])
|
||||
result = await Grounder(self.settings).ground(
|
||||
catalog,
|
||||
parent_intent.candidates,
|
||||
# The recorded parent pool is listener-neutral, so replay has no known-track exclusions.
|
||||
frozenset(),
|
||||
parent_intent.familiarity,
|
||||
self.settings.rerank_count + self.settings.rerank_pool_buffer,
|
||||
time.monotonic() + self.settings.request_deadline_seconds,
|
||||
)
|
||||
return result.tracks
|
||||
28
backend/app/adapters/demo/playlist.py
Normal file
28
backend/app/adapters/demo/playlist.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Playlist writer that makes demo saves explicit and network-free."""
|
||||
|
||||
import hashlib
|
||||
|
||||
import structlog
|
||||
|
||||
from app.domain.models import CreatedPlaylist
|
||||
|
||||
|
||||
class DemoPlaylistWriter:
|
||||
"""Simulate playlist writes with stable Spotify-shaped URLs."""
|
||||
|
||||
async def create_playlist(self, name: str, description: str) -> CreatedPlaylist:
|
||||
"""Return a deterministic fake playlist without an external write."""
|
||||
digest = hashlib.sha256(f"{name}\n{description}".encode()).hexdigest()[:12]
|
||||
playlist_id = f"demo-{digest}"
|
||||
return CreatedPlaylist(
|
||||
id=playlist_id,
|
||||
url=f"https://open.spotify.com/playlist/{playlist_id}",
|
||||
)
|
||||
|
||||
async def add_tracks_to_playlist(self, playlist_id: str, track_uris: list[str]) -> None:
|
||||
"""Log the simulated track addition without writing to Spotify."""
|
||||
structlog.get_logger().info(
|
||||
"demo_playlist_simulated",
|
||||
playlist_id=playlist_id,
|
||||
track_count=len(track_uris),
|
||||
)
|
||||
128
backend/app/adapters/demo/recommender.py
Normal file
128
backend/app/adapters/demo/recommender.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Anthropic recommender adapter backed by recorded response chunks."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextvars import ContextVar
|
||||
|
||||
from app.adapters.anthropic.llm import (
|
||||
IntentOutput,
|
||||
RecommendationObjectParser,
|
||||
RerankOutput,
|
||||
to_intent,
|
||||
)
|
||||
from app.adapters.demo.cassette import DemoCassette, load_cassette
|
||||
from app.adapters.demo.scenario import select_replay_scenario
|
||||
from app.config import Settings
|
||||
from app.domain.models import (
|
||||
ConversationTurn,
|
||||
Intent,
|
||||
PreviousRecommendation,
|
||||
RerankSelection,
|
||||
Track,
|
||||
)
|
||||
from app.ports.protocols import RecommenderOutputError
|
||||
|
||||
|
||||
class DemoRecommender:
|
||||
"""Replay recorded intent and rerank output through live validation."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Bind replay pacing and task-local scenario state."""
|
||||
self.settings = settings
|
||||
self._cassette: ContextVar[DemoCassette | None] = ContextVar(
|
||||
"demo_recommender_cassette",
|
||||
default=None,
|
||||
)
|
||||
|
||||
async def create_intent(
|
||||
self,
|
||||
query: str,
|
||||
history: tuple[ConversationTurn, ...],
|
||||
previous_recommendations: tuple[PreviousRecommendation, ...],
|
||||
taste_summary: str,
|
||||
candidate_count: int,
|
||||
) -> Intent:
|
||||
"""Parse the selected scenario's recorded structured intent."""
|
||||
match = select_replay_scenario(query, bool(previous_recommendations))
|
||||
cassette = load_cassette(match.key)
|
||||
self._cassette.set(cassette)
|
||||
return parse_recorded_intent(cassette.intent_response_body)
|
||||
|
||||
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]:
|
||||
"""Replay recorded SSE chunks through the live incremental parser."""
|
||||
cassette = self._cassette.get()
|
||||
if cassette is None:
|
||||
raise RecommenderOutputError("Demo rerank has no selected scenario")
|
||||
parser = RecommendationObjectParser()
|
||||
async for text_delta in _text_deltas(
|
||||
cassette.rerank_response_chunks,
|
||||
self.settings.demo_chunk_delay_seconds,
|
||||
):
|
||||
for selection in parser.feed(text_delta):
|
||||
yield RerankSelection(selection.track_id, selection.justification)
|
||||
try:
|
||||
validated = RerankOutput.model_validate_json(parser.complete_text)
|
||||
except ValueError as error:
|
||||
raise RecommenderOutputError("Recorded rerank response is invalid") from error
|
||||
if len(validated.recommendations) > selection_count:
|
||||
raise RecommenderOutputError("Recorded rerank returned too many selections")
|
||||
|
||||
|
||||
def parse_recorded_intent(response_body: bytes) -> Intent:
|
||||
"""Parse an Anthropic message body through the live intent output model."""
|
||||
try:
|
||||
response = json.loads(response_body)
|
||||
content = response["content"]
|
||||
text = content[0]["text"]
|
||||
if not isinstance(text, str):
|
||||
raise TypeError
|
||||
return to_intent(IntentOutput.model_validate_json(text))
|
||||
except (IndexError, KeyError, TypeError, ValueError) as error:
|
||||
raise RecommenderOutputError("Recorded intent response is invalid") from error
|
||||
|
||||
|
||||
async def _text_deltas(
|
||||
chunks: tuple[bytes, ...],
|
||||
delay_seconds: float,
|
||||
) -> AsyncGenerator[str]:
|
||||
buffer = b""
|
||||
for chunk_index, chunk in enumerate(chunks):
|
||||
if chunk_index and delay_seconds > 0:
|
||||
await asyncio.sleep(delay_seconds)
|
||||
buffer += chunk
|
||||
buffer = buffer.replace(b"\r\n", b"\n")
|
||||
while b"\n\n" in buffer:
|
||||
event, buffer = buffer.split(b"\n\n", 1)
|
||||
text_delta = _event_text_delta(event)
|
||||
if text_delta is not None:
|
||||
yield text_delta
|
||||
if buffer:
|
||||
text_delta = _event_text_delta(buffer)
|
||||
if text_delta is not None:
|
||||
yield text_delta
|
||||
|
||||
|
||||
def _event_text_delta(event: bytes) -> str | None:
|
||||
data_lines = [line[5:].strip() for line in event.splitlines() if line.startswith(b"data:")]
|
||||
if not data_lines:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(b"\n".join(data_lines))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or payload.get("type") != "content_block_delta":
|
||||
return None
|
||||
delta = payload.get("delta")
|
||||
if not isinstance(delta, dict) or delta.get("type") != "text_delta":
|
||||
return None
|
||||
text = delta.get("text")
|
||||
return text if isinstance(text, str) else None
|
||||
151
backend/app/adapters/demo/scenario.py
Normal file
151
backend/app/adapters/demo/scenario.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Select replayable demo scenarios from the shared scenario catalog."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
_REPOSITORY_ROOT = Path(__file__).parents[4]
|
||||
SCENARIO_PATH = _REPOSITORY_ROOT / "eval" / "scenarios.yaml"
|
||||
FIXTURE_ROOT = _REPOSITORY_ROOT / "eval" / "fixtures"
|
||||
_WORD_PATTERN = re.compile(r"[a-z0-9]+")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scenario:
|
||||
"""One shared suggestion and its optional parent scenario."""
|
||||
|
||||
key: str
|
||||
chip: str
|
||||
query: str
|
||||
after: str | None = None
|
||||
|
||||
@property
|
||||
def is_refinement(self) -> bool:
|
||||
"""Return whether this scenario refines an earlier result."""
|
||||
return self.after is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScenarioMatch:
|
||||
"""Describe which recorded scenario will answer a demo request."""
|
||||
|
||||
key: str
|
||||
is_exact: bool
|
||||
scenario_query: str
|
||||
chip: str
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_scenarios() -> tuple[Scenario, ...]:
|
||||
"""Load and validate the shared scenario catalog once."""
|
||||
payload = yaml.safe_load(SCENARIO_PATH.read_text(encoding="ascii"))
|
||||
if not isinstance(payload, dict) or payload.get("version") != 1:
|
||||
raise ValueError("Scenario catalog must have version 1")
|
||||
entries = payload.get("scenarios")
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise ValueError("Scenario catalog must contain scenarios")
|
||||
scenarios = tuple(_parse_scenario(entry) for entry in entries)
|
||||
keys = {scenario.key for scenario in scenarios}
|
||||
if len(keys) != len(scenarios):
|
||||
raise ValueError("Scenario keys must be unique")
|
||||
if any(scenario.after not in keys for scenario in scenarios if scenario.after is not None):
|
||||
raise ValueError("Scenario catalog contains an unknown parent")
|
||||
return scenarios
|
||||
|
||||
|
||||
def select_scenario(query: str) -> ScenarioMatch:
|
||||
"""Select an exact scenario or the nearest available recorded fixture."""
|
||||
scenarios = _replayable_scenarios()
|
||||
normalized_query = normalize_text(query)
|
||||
for scenario in scenarios:
|
||||
if normalized_query in {normalize_text(scenario.query), normalize_text(scenario.chip)}:
|
||||
return _to_match(scenario, is_exact=True)
|
||||
|
||||
selected = max(
|
||||
scenarios,
|
||||
key=lambda scenario: max(
|
||||
SequenceMatcher(None, normalized_query, normalize_text(scenario.query)).ratio(),
|
||||
SequenceMatcher(None, normalized_query, normalize_text(scenario.chip)).ratio(),
|
||||
),
|
||||
)
|
||||
return _to_match(selected, is_exact=False)
|
||||
|
||||
|
||||
def select_replay_scenario(
|
||||
query: str,
|
||||
has_previous_recommendations: bool,
|
||||
) -> ScenarioMatch:
|
||||
"""Map a request with prior results to its recorded refinement when available."""
|
||||
selected = select_scenario(query)
|
||||
scenarios = _replayable_scenarios()
|
||||
selected_scenario = next(scenario for scenario in scenarios if scenario.key == selected.key)
|
||||
if selected_scenario.is_refinement or not has_previous_recommendations:
|
||||
return selected
|
||||
refinement = next(
|
||||
(scenario for scenario in scenarios if scenario.after == selected_scenario.key),
|
||||
None,
|
||||
)
|
||||
return _to_match(refinement, is_exact=False) if refinement is not None else selected
|
||||
|
||||
|
||||
def suggestion_items() -> list[dict[str, str]]:
|
||||
"""Return chip and query pairs for non-refinement scenarios."""
|
||||
return [
|
||||
{"chip": scenario.chip, "query": scenario.query}
|
||||
for scenario in load_scenarios()
|
||||
if not scenario.is_refinement
|
||||
]
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
"""Normalize text for exact and similarity matching."""
|
||||
decomposed = unicodedata.normalize("NFKD", value.casefold())
|
||||
ascii_text = decomposed.encode("ascii", errors="ignore").decode("ascii")
|
||||
return " ".join(_WORD_PATTERN.findall(ascii_text))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _replayable_scenarios() -> tuple[Scenario, ...]:
|
||||
fixture_keys = {path.name for path in FIXTURE_ROOT.iterdir() if path.is_dir()}
|
||||
scenarios = tuple(scenario for scenario in load_scenarios() if scenario.key in fixture_keys)
|
||||
if not scenarios:
|
||||
raise ValueError("Demo mode requires at least one scenario fixture")
|
||||
return scenarios
|
||||
|
||||
|
||||
def _parse_scenario(payload: object) -> Scenario:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Each scenario must be a mapping")
|
||||
required = {"key", "chip", "query", "facets"}
|
||||
if not required <= set(payload):
|
||||
raise ValueError("Scenario is missing a required field")
|
||||
after = payload.get("after")
|
||||
if after is not None and not isinstance(after, str):
|
||||
raise ValueError("Scenario parent must be a key")
|
||||
return Scenario(
|
||||
key=_required_string(payload, "key"),
|
||||
chip=_required_string(payload, "chip"),
|
||||
query=_required_string(payload, "query"),
|
||||
after=after,
|
||||
)
|
||||
|
||||
|
||||
def _required_string(payload: dict[object, object], key: str) -> str:
|
||||
value = payload.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"Scenario {key} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _to_match(scenario: Scenario, is_exact: bool) -> ScenarioMatch:
|
||||
return ScenarioMatch(
|
||||
key=scenario.key,
|
||||
is_exact=is_exact,
|
||||
scenario_query=scenario.query,
|
||||
chip=scenario.chip,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue