"""pipeline/cache.py — LRU answer cache. Keyed on MD5(author_id + book_id + normalized query). Stores raw LLM answers only — upsell formatting is re-applied per session on hit. Max 256 slots. Evicts LRU on overflow. NOT cached: purchase_intent, complaint, greeting (personal / time-sensitive). """ from __future__ import annotations import hashlib from collections import OrderedDict from dataclasses import dataclass, field @dataclass class CachedAnswer: """Raw generation output — upsell/link state is NOT cached.""" raw_response: str faithfulness_score: float = 1.0 hallucination_detected: bool = False prompt_tokens: int = 0 completion_tokens: int = 0 top_book_ids: list[str] = field(default_factory=list) intent: str = "question" intent_confidence: float = 0.7 _CACHE_MAX = 256 _answer_cache: OrderedDict[str, CachedAnswer] = OrderedDict() def cache_key(author_id: str, book_id: str | None, query: str) -> str: """Generate a stable MD5 cache key.""" raw = f"{author_id}:{book_id or ''}:{query.lower().strip()}" return hashlib.md5(raw.encode()).hexdigest() def cache_get(key: str) -> CachedAnswer | None: """Return cached raw answer or None. Promotes the key to most-recently-used.""" if key in _answer_cache: _answer_cache.move_to_end(key) return _answer_cache[key] return None def cache_set(key: str, answer: CachedAnswer) -> None: """Store a raw answer. Evicts least-recently-used entry when over capacity.""" _answer_cache[key] = answer _answer_cache.move_to_end(key) if len(_answer_cache) > _CACHE_MAX: _answer_cache.popitem(last=False) def invalidate_book_cache(author_id: str, book_id: str) -> int: # noqa: ARG001 """Remove ALL cached answers for this author when a book changes. Called by the ingest pipeline on re-upload to prevent stale answers. Returns: Number of cache entries removed. """ count = len(_answer_cache) _answer_cache.clear() return count