"""Response cache — SQLite inside the container, ephemeral, TTL per source (ADR-0001 §8). ToolUniverse's own result cache is switched off (``src.tu_client``) so this is the only one, and it keeps the one fact a cache usually hides: ``retrieved_at`` is when the SOURCE answered, and a hit returns that original time, never the time the cache served it. Only responses that passed their schema check are stored, so a drifted response is never replayed. The file lives in the temp dir by default (``KNOWLEDGE_CACHE_PATH`` overrides; ``:memory:`` for tests). Nothing here is persistent on a Space, by design: a rebuild empties it. """ from __future__ import annotations import hashlib import json import os import sqlite3 import tempfile import threading import time from collections.abc import Callable from pathlib import Path from typing import Any from src import config DEFAULT_PATH = str(Path(tempfile.gettempdir()) / "pdac-knowledge-cache.sqlite3") def cache_key(tool: str, args: dict[str, Any]) -> str: canonical = json.dumps({"tool": tool, "args": args}, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() class ResponseCache: def __init__(self, path: str | None = None, *, clock: Callable[[], float] = time.time): self.path = path or os.environ.get("KNOWLEDGE_CACHE_PATH") or DEFAULT_PATH self._clock = clock self._lock = threading.Lock() self._db = sqlite3.connect(self.path, check_same_thread=False) self._db.execute( "CREATE TABLE IF NOT EXISTS responses (" " key TEXT PRIMARY KEY, source TEXT NOT NULL, tool TEXT NOT NULL," " data TEXT NOT NULL, retrieved_at REAL NOT NULL)" ) self._db.commit() def get(self, source: str, tool: str, args: dict[str, Any]) -> tuple[Any, float] | None: """``(data, retrieved_at)`` for a live entry, else None. Expired entries are dropped.""" key = cache_key(tool, args) ttl = config.source(source)["ttl_seconds"] with self._lock: row = self._db.execute( "SELECT data, retrieved_at FROM responses WHERE key = ?", (key,) ).fetchone() if row is None: return None data, retrieved_at = row if self._clock() - retrieved_at >= ttl: self._db.execute("DELETE FROM responses WHERE key = ?", (key,)) self._db.commit() return None return json.loads(data), retrieved_at def put( self, source: str, tool: str, args: dict[str, Any], data: Any, retrieved_at: float ) -> None: key = cache_key(tool, args) with self._lock: self._db.execute( "INSERT OR REPLACE INTO responses (key, source, tool, data, retrieved_at)" " VALUES (?, ?, ?, ?, ?)", (key, source, tool, json.dumps(data), retrieved_at), ) self._db.commit() def clear(self) -> None: with self._lock: self._db.execute("DELETE FROM responses") self._db.commit()