"""Response cache. Not a performance nicety. Stack Exchange allows 300 calls/day/IP without a key and GitHub 60/hour, and those budgets are shared by every user of the assistant at once. Without caching, two people comparing package versions can spend the day's quota before lunch and every later answer degrades to "I couldn't check" - or worse, to the model guessing. Fails open: if valkey is unreachable the tools still work, they are just uncached. A dead cache should never take the assistant's tools with it. """ from __future__ import annotations import hashlib import logging import redis.asyncio as redis from .settings import settings log = logging.getLogger(__name__) _pool: redis.Redis | None = None # Chosen per-source from how fast the underlying thing actually changes. TTL = { "search": 900, # 15m - fresh enough for "latest", cheap to redo "page": 3600, # 1h - article text rarely changes within a session "wikipedia": 21600, # 6h "stackexchange": 21600, # 6h - 300/day keyless makes this the tightest budget "rfc": 604800, # 7d - a published RFC is immutable "man": 604800, # 7d "cve": 10800, # 3h - CVSS scores do get revised "package": 3600, # 1h - the whole point is catching a new release "rdap": 86400, # 24h "dns": 300, # 5m - short on purpose; people debug live changes "tls": 1800, # 30m "papers": 86400, # 24h "datasheet": 604800, # 7d } def _key(namespace: str, *parts: str) -> str: digest = hashlib.sha256("\x1f".join(parts).encode()).hexdigest()[:32] return f"owuitools:{namespace}:{digest}" async def get_redis() -> redis.Redis | None: global _pool if _pool is None: try: _pool = redis.from_url(settings.valkey_url, decode_responses=True, socket_timeout=2) await _pool.ping() except Exception as exc: log.warning("cache unavailable, continuing without it: %s", exc) _pool = None return _pool async def close_redis() -> None: global _pool if _pool is not None: await _pool.aclose() _pool = None async def cached(namespace: str, *parts: str) -> str | None: client = await get_redis() if client is None: return None try: return await client.get(_key(namespace, *parts)) except Exception: return None async def store(namespace: str, value: str, *parts: str) -> None: client = await get_redis() if client is None: return try: await client.setex(_key(namespace, *parts), TTL.get(namespace, 3600), value) except Exception as exc: log.debug("cache write failed for %s: %s", namespace, exc)