| import hashlib |
| import json |
| import logging |
|
|
| logger = logging.getLogger(__name__) |
|
|
| _redis_client = None |
|
|
|
|
| def init_redis(url: str, token: str): |
| """Create and store the Upstash Redis client. Call once at startup.""" |
| global _redis_client |
| try: |
| from upstash_redis import Redis |
|
|
| _redis_client = Redis(url=url, token=token) |
| logger.info("Upstash Redis client initialised.") |
| except Exception as e: |
| logger.warning("Redis init failed — cache disabled: %s", e) |
| _redis_client = None |
|
|
|
|
| def close_redis() -> None: |
| """No-op for upstash-redis (HTTP client, no persistent connections).""" |
| global _redis_client |
| _redis_client = None |
|
|
|
|
| def make_cache_key(question: str, corpus_version: str, card_mentions: list[str] | None = None, prompt_version: str = "v1") -> str: |
| """Derive a deterministic cache key. |
| |
| Key = SHA-256({"q": normalize(question), "cv": corpus_version, "pv": prompt_version, "m": sorted(card_mentions)}) |
| """ |
| normalized = question.strip().lower() |
| mentions = sorted(card_mentions) if card_mentions else [] |
| payload = json.dumps( |
| {"q": normalized, "cv": corpus_version, "pv": prompt_version, "m": mentions}, |
| ensure_ascii=False, |
| sort_keys=True, |
| ) |
| return hashlib.sha256(payload.encode()).hexdigest() |
|
|
|
|
| def get_cached(key: str) -> str | None: |
| """Return the cached JSON string for *key*, or None on miss/error. |
| |
| Synchronous: the Upstash client is an HTTP client with no async API, so an |
| ``async def`` here never yielded to the event loop — it only masked a |
| blocking call. The pipeline runs in FastAPI's threadpool (sync handler), so |
| a plain blocking call is correct and honest about what happens. |
| """ |
| if _redis_client is None: |
| return None |
| try: |
| value = _redis_client.get(key) |
| return value if isinstance(value, str) else None |
| except Exception as e: |
| logger.warning("Redis GET failed (cache miss): %s", e) |
| return None |
|
|
|
|
| def set_cached(key: str, value: str, ttl: int) -> None: |
| """Store *value* under *key* with *ttl* seconds. No-op on error. |
| |
| Synchronous — see get_cached for why the previous ``async`` was decorative. |
| """ |
| if _redis_client is None: |
| return |
| try: |
| _redis_client.set(key, value, ex=ttl) |
| except Exception as e: |
| logger.warning("Redis SET failed (caching skipped): %s", e) |
|
|