diff --git a/.env.template b/.env.template index 48fe88a..f388ff5 100644 --- a/.env.template +++ b/.env.template @@ -28,3 +28,15 @@ ELASTIC_PASSWORD= PAGE_SIZE=1000 GCS_BUCKET= GCS_PREFIX= + +# Chat persistence (optional, default: in-memory) +# Set to true to persist chat history across server restarts (uses SQLite) +ENABLE_PERSISTENCE=false +# CHAT_STORAGE_BACKEND=sqlite # or "memory" (default) +# CHAT_DB_PATH= # custom SQLite file path + +# Response cache (optional, default: disabled) +# Set to true to cache responses for identical queries +ENABLE_CACHE=false +# CACHE_MAX_SIZE=128 # max cached entries +# CACHE_TTL_SECONDS=3600 # cache TTL in seconds diff --git a/.gitignore b/.gitignore index 3ba759d..74b98e8 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,8 @@ cython_debug/ #Embedded data files data_processing/embeddings.jsonl + +# Chat history database (generated when ENABLE_PERSISTENCE=true) +backend/chat_history.db +backend/chat_history.db-wal +backend/chat_history.db-shm diff --git a/backend/agents.py b/backend/agents.py index 80743e4..bb71491 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -471,9 +471,14 @@ async def generate_final_response(state: AgentState) -> AgentState: class NeuroscienceAssistant: - def __init__(self): - self.chat_history: Dict[str, List[str]] = {} - self.session_memory: Dict[str, Dict[str, Any]] = {} + def __init__(self, storage=None, cache=None): + from chat_storage import create_storage + from response_cache import create_cache + + self.storage: "ChatStorage" = storage or create_storage() + self.cache: "ResponseCache" = cache or create_cache() + # Large result lists stay in-memory (too bulky for SQLite). + self._results_buffer: Dict[str, List[dict]] = {} self.graph = self._build_graph() def _build_graph(self): @@ -490,19 +495,31 @@ def _build_graph(self): return workflow.compile() def reset_session(self, session_id: str): - self.chat_history.pop(session_id, None) - self.session_memory.pop(session_id, None) - + self.storage.clear(session_id) + self.storage.clear_session_memory(session_id) + self._results_buffer.pop(session_id, None) + + def _append_and_trim(self, session_id: str, user_msg: str, assistant_msg: str): + """Append user+assistant turns and keep history at most 20 entries.""" + self.storage.append(session_id, user_msg) + self.storage.append(session_id, assistant_msg) + history = self.storage.get_history(session_id) + if len(history) > 20: + self.storage.set_history(session_id, history[-20:]) async def handle_chat(self, session_id: str, query: str, reset: bool = False) -> str: try: if reset: self.reset_session(session_id) - if session_id not in self.chat_history: - self.chat_history[session_id] = [] + + history = self.storage.get_history(session_id) + + # Merge persisted memory with in-memory results buffer + mem = self.storage.get_session_memory(session_id) + if session_id in self._results_buffer: + mem["all_results"] = self._results_buffer[session_id] more_count = _is_more_query(query) - mem = self.session_memory.get(session_id, {}) if more_count is not None or (query.strip().lower() in {"more", "next", "continue", "more please", "show more", "keep going"}): all_results = mem.get("all_results", []) if not all_results: @@ -516,7 +533,7 @@ async def handle_chat(self, session_id: str, query: str, reset: bool = False) -> intents = mem.get("intents", [QueryIntent.DATA_DISCOVERY.value]) effective_query = mem.get("effective_query", "") prev_text = mem.get("last_text", "") - + text = await call_gemini_for_final_synthesis( effective_query, batch, intents, start_number=start + 1, previous_text=prev_text ) @@ -525,16 +542,22 @@ async def handle_chat(self, session_id: str, query: str, reset: bool = False) -> "page_size": page_size, "last_text": f"{prev_text}\n\n{text}"[-12000:], }) - self.session_memory[session_id] = mem - self.chat_history[session_id].extend([f"User: {query}", f"Assistant: {text}"]) - if len(self.chat_history[session_id]) > 20: - self.chat_history[session_id] = self.chat_history[session_id][-20:] + self.storage.set_session_memory(session_id, mem) + self._append_and_trim(session_id, f"User: {query}", f"Assistant: {text}") return text + # Check cache for exact-match hit + cached = self.cache.get(query, session_id=session_id) + if cached is not None: + response_text, _ = cached + print(f"[cache] HIT: {query[:50]}…") + self._append_and_trim(session_id, f"User: {query}", f"Assistant: {response_text}") + return response_text + initial_state: AgentState = { "session_id": session_id, "query": query, - "history": self.chat_history[session_id][-10:], + "history": history[-10:], "keywords": [], "effective_query": "", "intents": [], @@ -549,19 +572,23 @@ async def handle_chat(self, session_id: str, query: str, reset: bool = False) -> final_state = await self.graph.ainvoke(initial_state) response_text = final_state.get("final_response", "I encountered an unexpected empty response.") - self.session_memory[session_id] = { - "all_results": final_state.get("all_results", []), + # Persist lightweight session memory + self.storage.set_session_memory(session_id, { "page": 1, "page_size": 15, "effective_query": final_state.get("effective_query", initial_state["query"]), "keywords": final_state.get("keywords", []), "intents": final_state.get("intents", [QueryIntent.DATA_DISCOVERY.value]), "last_text": response_text, - } + }) + # Keep bulky results in memory only + self._results_buffer[session_id] = final_state.get("all_results", []) + + self._append_and_trim(session_id, f"User: {query}", f"Assistant: {response_text}") + + # Store in cache for future identical queries + self.cache.put(query, response_text, session_id=session_id) - self.chat_history[session_id].extend([f"User: {query}", f"Assistant: {response_text}"]) - if len(self.chat_history[session_id]) > 20: - self.chat_history[session_id] = self.chat_history[session_id][-20:] return response_text except Exception as e: print(f"Error in handle_chat: {e}") diff --git a/backend/chat_storage.py b/backend/chat_storage.py new file mode 100644 index 0000000..55a1e8c --- /dev/null +++ b/backend/chat_storage.py @@ -0,0 +1,261 @@ +""" +Modular chat history storage layer for the Knowledge Space Agent. + +Provides a simple abstract interface with two implementations: + - InMemoryChatStorage : dict-backed, matches original behaviour (default) + - SQLiteChatStorage : file-backed via stdlib sqlite3, zero extra deps + +Default behaviour is unchanged. Persistence is opt-in via the +ENABLE_PERSISTENCE env var. + + from chat_storage import create_storage + storage = create_storage() # reads env vars +""" + +import os +import json +import sqlite3 +import threading +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + + +# --------------------------------------------------------------------------- +# Abstract interface +# --------------------------------------------------------------------------- + +class ChatStorage(ABC): + """Minimal interface that any chat-history backend must satisfy.""" + + @abstractmethod + def get_history(self, session_id: str) -> List[str]: + ... + + @abstractmethod + def append(self, session_id: str, entry: str) -> None: + ... + + @abstractmethod + def set_history(self, session_id: str, history: List[str]) -> None: + ... + + @abstractmethod + def clear(self, session_id: str) -> None: + ... + + @abstractmethod + def get_session_memory(self, session_id: str) -> Dict[str, Any]: + ... + + @abstractmethod + def set_session_memory(self, session_id: str, memory: Dict[str, Any]) -> None: + ... + + @abstractmethod + def clear_session_memory(self, session_id: str) -> None: + ... + + @abstractmethod + def list_sessions(self) -> List[str]: + ... + + +# --------------------------------------------------------------------------- +# In-memory (default — identical to original behaviour) +# --------------------------------------------------------------------------- + +class InMemoryChatStorage(ChatStorage): + + def __init__(self) -> None: + self._history: Dict[str, List[str]] = {} + self._memory: Dict[str, Dict[str, Any]] = {} + + def get_history(self, session_id: str) -> List[str]: + return list(self._history.get(session_id, [])) + + def append(self, session_id: str, entry: str) -> None: + self._history.setdefault(session_id, []).append(entry) + + def set_history(self, session_id: str, history: List[str]) -> None: + self._history[session_id] = list(history) + + def clear(self, session_id: str) -> None: + self._history.pop(session_id, None) + + def get_session_memory(self, session_id: str) -> Dict[str, Any]: + return dict(self._memory.get(session_id, {})) + + def set_session_memory(self, session_id: str, memory: Dict[str, Any]) -> None: + self._memory[session_id] = dict(memory) + + def clear_session_memory(self, session_id: str) -> None: + self._memory.pop(session_id, None) + + def list_sessions(self) -> List[str]: + return list(set(list(self._history) + list(self._memory))) + + +# --------------------------------------------------------------------------- +# SQLite (opt-in persistence) +# --------------------------------------------------------------------------- + +_DEFAULT_DB_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "chat_history.db" +) + + +class SQLiteChatStorage(ChatStorage): + """ + SQLite-backed persistent chat storage. + + Uses stdlib ``sqlite3`` — zero extra dependencies. Thread-safe via a + lock with ``check_same_thread=False``. + """ + + def __init__(self, db_path: Optional[str] = None) -> None: + self._db_path = db_path or os.getenv("CHAT_DB_PATH", _DEFAULT_DB_PATH) + self._lock = threading.Lock() + self._conn = sqlite3.connect( + self._db_path, timeout=5, check_same_thread=False + ) + self._conn.execute("PRAGMA journal_mode=WAL") + self._init_db() + + def _init_db(self) -> None: + with self._lock: + self._conn.execute( + """CREATE TABLE IF NOT EXISTS chat_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + entry TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )""" + ) + self._conn.execute( + """CREATE TABLE IF NOT EXISTS session_memory ( + session_id TEXT PRIMARY KEY, + data TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )""" + ) + self._conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_session " + "ON chat_history(session_id)" + ) + self._conn.commit() + + def close(self) -> None: + """Close the underlying database connection.""" + with self._lock: + self._conn.close() + + # -- interface ----------------------------------------------------------- + + def get_history(self, session_id: str) -> List[str]: + with self._lock: + rows = self._conn.execute( + "SELECT entry FROM chat_history WHERE session_id=? ORDER BY id", + (session_id,), + ).fetchall() + return [r[0] for r in rows] + + def append(self, session_id: str, entry: str) -> None: + now = datetime.now(timezone.utc).isoformat() + with self._lock: + self._conn.execute( + "INSERT INTO chat_history(session_id, entry, created_at) VALUES(?,?,?)", + (session_id, entry, now), + ) + self._conn.commit() + + def set_history(self, session_id: str, history: List[str]) -> None: + now = datetime.now(timezone.utc).isoformat() + with self._lock: + self._conn.execute("DELETE FROM chat_history WHERE session_id=?", (session_id,)) + self._conn.executemany( + "INSERT INTO chat_history(session_id, entry, created_at) VALUES(?,?,?)", + [(session_id, e, now) for e in history], + ) + self._conn.commit() + + def clear(self, session_id: str) -> None: + with self._lock: + self._conn.execute("DELETE FROM chat_history WHERE session_id=?", (session_id,)) + self._conn.commit() + + def get_session_memory(self, session_id: str) -> Dict[str, Any]: + with self._lock: + row = self._conn.execute( + "SELECT data FROM session_memory WHERE session_id=?", (session_id,) + ).fetchone() + if row: + try: + return json.loads(row[0]) + except (json.JSONDecodeError, TypeError): + return {} + return {} + + def set_session_memory(self, session_id: str, memory: Dict[str, Any]) -> None: + now = datetime.now(timezone.utc).isoformat() + # Exclude bulky all_results from DB; store a count instead. + serializable = {k: v for k, v in memory.items() if k != "all_results"} + if "all_results" in memory: + serializable["all_results_count"] = len(memory["all_results"]) + data = json.dumps(serializable, default=str) + with self._lock: + self._conn.execute( + """INSERT INTO session_memory(session_id, data, updated_at) + VALUES(?,?,?) + ON CONFLICT(session_id) + DO UPDATE SET data=excluded.data, updated_at=excluded.updated_at""", + (session_id, data, now), + ) + self._conn.commit() + + def clear_session_memory(self, session_id: str) -> None: + with self._lock: + self._conn.execute("DELETE FROM session_memory WHERE session_id=?", (session_id,)) + self._conn.commit() + + def list_sessions(self) -> List[str]: + with self._lock: + rows = self._conn.execute( + """SELECT DISTINCT session_id FROM ( + SELECT session_id FROM chat_history + UNION + SELECT session_id FROM session_memory + )""" + ).fetchall() + return [r[0] for r in rows] + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + +def create_storage(backend: Optional[str] = None, **kwargs: Any) -> ChatStorage: + """ + Create a ChatStorage from configuration. + + Reads ``CHAT_STORAGE_BACKEND`` or ``ENABLE_PERSISTENCE`` from env. + Defaults to in-memory (original behaviour). Falls back to in-memory + gracefully if SQLite initialisation fails. + """ + if backend is None: + backend = os.getenv("CHAT_STORAGE_BACKEND", "").strip().lower() + if not backend: + flag = os.getenv("ENABLE_PERSISTENCE", "").strip().lower() + backend = "sqlite" if flag in {"1", "true", "yes"} else "memory" + + if backend == "sqlite": + try: + storage = SQLiteChatStorage(**kwargs) + print("[chat_storage] Using SQLite persistent storage") + return storage + except Exception as exc: + print(f"[chat_storage] SQLite init failed ({exc}), falling back to in-memory") + return InMemoryChatStorage() + + print("[chat_storage] Using in-memory storage (default)") + return InMemoryChatStorage() diff --git a/backend/main.py b/backend/main.py index 9021161..85152ef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -147,6 +147,32 @@ async def reset_session(payload: Dict[str, str]): return {"status": "ok", "session_id": sid, "message": "Session cleared"} +@app.get("/api/sessions", tags=["Chat"]) +async def list_sessions(): + """List all known session IDs with message counts.""" + sessions = assistant.storage.list_sessions() + return { + "sessions": [ + {"session_id": sid, "message_count": len(assistant.storage.get_history(sid))} + for sid in sessions + ], + "total": len(sessions), + } + + +@app.get("/api/cache/stats", tags=["General"]) +async def cache_stats(): + """Return response-cache statistics.""" + return assistant.cache.stats() + + +@app.post("/api/cache/clear", tags=["General"]) +async def cache_clear(): + """Flush the response cache.""" + assistant.cache.clear() + return {"status": "ok", "message": "Cache cleared"} + + # Entry point if __name__ == "__main__": env = os.getenv("ENVIRONMENT", "production").lower() diff --git a/backend/response_cache.py b/backend/response_cache.py new file mode 100644 index 0000000..9195565 --- /dev/null +++ b/backend/response_cache.py @@ -0,0 +1,130 @@ +""" +Lightweight in-memory response cache for the Knowledge Space Agent. + +Reduces redundant LLM calls by caching responses for identical queries. +Opt-in via ``ENABLE_CACHE=true``. Zero extra dependencies. + + from response_cache import create_cache + cache = create_cache() # reads env vars + hit = cache.get("what is EEG?") + cache.put("what is EEG?", "EEG is …") +""" + +import os +import time +import hashlib +import threading +from collections import OrderedDict +from typing import Any, Dict, Optional, Tuple + + +class ResponseCache: + """Exact-match response cache with TTL expiry and LRU eviction.""" + + def __init__( + self, + max_size: int = 128, + ttl_seconds: int = 3600, + enabled: bool = True, + ) -> None: + self._enabled = enabled + self._max_size = max_size + self._ttl = ttl_seconds + self._lock = threading.Lock() + # Ordered by insertion/access time (LRU order). + # key -> (response, metadata, monotonic_timestamp) + self._store: OrderedDict[str, Tuple[str, Dict[str, Any], float]] = OrderedDict() + + # -- helpers ------------------------------------------------------------- + + @staticmethod + def _normalise(query: str) -> str: + return " ".join(query.lower().split()) + + @staticmethod + def _key(session_id: str, query: str) -> str: + raw = f"{session_id}:{query}" + return hashlib.sha256(raw.encode()).hexdigest() + + def _evict_expired(self) -> None: + now = time.monotonic() + expired = [k for k, (_, _, ts) in self._store.items() if now - ts > self._ttl] + for k in expired: + del self._store[k] + + def _evict_oldest(self) -> None: + while len(self._store) >= self._max_size: + self._store.popitem(last=False) # O(1) — pop oldest + + # -- public API ---------------------------------------------------------- + + def get(self, query: str, session_id: str = "") -> Optional[Tuple[str, Dict[str, Any]]]: + if not self._enabled: + return None + key = self._key(session_id, self._normalise(query)) + with self._lock: + self._evict_expired() + entry = self._store.get(key) + if entry is None: + return None + resp, meta, _ = entry + # Move to end (most-recently-used) and refresh timestamp + self._store.move_to_end(key) + self._store[key] = (resp, meta, time.monotonic()) + return (resp, meta) + + def put(self, query: str, response: str, session_id: str = "", metadata: Optional[Dict[str, Any]] = None) -> None: + if not self._enabled: + return + key = self._key(session_id, self._normalise(query)) + with self._lock: + self._evict_expired() + self._evict_oldest() + self._store[key] = (response, metadata or {}, time.monotonic()) + + def invalidate(self, query: str, session_id: str = "") -> None: + if not self._enabled: + return + key = self._key(session_id, self._normalise(query)) + with self._lock: + self._store.pop(key, None) + + def clear(self) -> None: + with self._lock: + self._store.clear() + + @property + def size(self) -> int: + with self._lock: + return len(self._store) + + @property + def enabled(self) -> bool: + return self._enabled + + def stats(self) -> Dict[str, Any]: + with self._lock: + return { + "enabled": self._enabled, + "size": len(self._store), + "max_size": self._max_size, + "ttl_seconds": self._ttl, + } + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + +def create_cache(**kwargs: Any) -> ResponseCache: + """Create a ResponseCache from env configuration.""" + enabled = os.getenv("ENABLE_CACHE", "").strip().lower() in {"1", "true", "yes"} + max_size = int(os.getenv("CACHE_MAX_SIZE", "128")) + ttl = int(os.getenv("CACHE_TTL_SECONDS", "3600")) + + if enabled: + print(f"[response_cache] Enabled (max_size={max_size}, ttl={ttl}s)") + else: + print("[response_cache] Disabled (default)") + + return ResponseCache(max_size=max_size, ttl_seconds=ttl, enabled=enabled, **kwargs) diff --git a/tests/test_chat_storage.py b/tests/test_chat_storage.py new file mode 100644 index 0000000..a03b01d --- /dev/null +++ b/tests/test_chat_storage.py @@ -0,0 +1,119 @@ +"""Tests for chat_storage module.""" + +import os +import sys +import tempfile +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +from chat_storage import InMemoryChatStorage, SQLiteChatStorage, create_storage + + +class TestInMemoryChatStorage: + def setup_method(self): + self.s = InMemoryChatStorage() + + def test_empty_history(self): + assert self.s.get_history("s1") == [] + + def test_append_and_get(self): + self.s.append("s1", "User: hi") + self.s.append("s1", "Assistant: hello") + assert self.s.get_history("s1") == ["User: hi", "Assistant: hello"] + + def test_sessions_isolated(self): + self.s.append("a", "User: x") + self.s.append("b", "User: y") + assert self.s.get_history("a") == ["User: x"] + assert self.s.get_history("b") == ["User: y"] + + def test_set_history_replaces(self): + self.s.append("s1", "old") + self.s.set_history("s1", ["new"]) + assert self.s.get_history("s1") == ["new"] + + def test_clear(self): + self.s.append("s1", "x") + self.s.clear("s1") + assert self.s.get_history("s1") == [] + + def test_session_memory_round_trip(self): + self.s.set_session_memory("s1", {"page": 2}) + assert self.s.get_session_memory("s1")["page"] == 2 + + def test_clear_session_memory(self): + self.s.set_session_memory("s1", {"a": 1}) + self.s.clear_session_memory("s1") + assert self.s.get_session_memory("s1") == {} + + def test_list_sessions(self): + self.s.append("s1", "x") + self.s.set_session_memory("s2", {}) + assert set(self.s.list_sessions()) == {"s1", "s2"} + + +class TestSQLiteChatStorage: + def setup_method(self): + self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.tmp.close() + self.s = SQLiteChatStorage(db_path=self.tmp.name) + + def teardown_method(self): + self.s.close() + try: + os.unlink(self.tmp.name) + except OSError: + pass + + def test_append_and_get(self): + self.s.append("s1", "User: hi") + self.s.append("s1", "Assistant: hello") + assert self.s.get_history("s1") == ["User: hi", "Assistant: hello"] + + def test_persistence_across_instances(self): + self.s.append("s1", "User: persisted") + self.s.set_session_memory("s1", {"k": "v"}) + fresh = SQLiteChatStorage(db_path=self.tmp.name) + assert fresh.get_history("s1") == ["User: persisted"] + assert fresh.get_session_memory("s1")["k"] == "v" + fresh.close() + + def test_set_history_replaces(self): + self.s.append("s1", "old") + self.s.set_history("s1", ["new"]) + assert self.s.get_history("s1") == ["new"] + + def test_clear(self): + self.s.append("s1", "x") + self.s.clear("s1") + assert self.s.get_history("s1") == [] + + def test_session_memory_upsert(self): + self.s.set_session_memory("s1", {"page": 1}) + self.s.set_session_memory("s1", {"page": 2}) + assert self.s.get_session_memory("s1")["page"] == 2 + + def test_list_sessions(self): + self.s.append("a", "x") + self.s.set_session_memory("b", {}) + assert set(self.s.list_sessions()) == {"a", "b"} + + +class TestFactory: + def test_default_is_memory(self, monkeypatch): + monkeypatch.delenv("ENABLE_PERSISTENCE", raising=False) + monkeypatch.delenv("CHAT_STORAGE_BACKEND", raising=False) + assert isinstance(create_storage(), InMemoryChatStorage) + + def test_persistence_gives_sqlite(self, monkeypatch, tmp_path): + monkeypatch.setenv("ENABLE_PERSISTENCE", "true") + monkeypatch.delenv("CHAT_STORAGE_BACKEND", raising=False) + monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "t.db")) + assert isinstance(create_storage(), SQLiteChatStorage) + + def test_graceful_fallback_on_bad_path(self, monkeypatch): + """If SQLite can't create the DB, factory falls back to InMemory.""" + monkeypatch.setenv("CHAT_DB_PATH", "/nonexistent/dir/impossible.db") + storage = create_storage(backend="sqlite") + assert isinstance(storage, InMemoryChatStorage) diff --git a/tests/test_response_cache.py b/tests/test_response_cache.py new file mode 100644 index 0000000..1e03658 --- /dev/null +++ b/tests/test_response_cache.py @@ -0,0 +1,85 @@ +"""Tests for response_cache module.""" + +import os +import sys +import time +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +from response_cache import ResponseCache, create_cache + + +class TestResponseCache: + def test_disabled_returns_none(self): + c = ResponseCache(enabled=False) + c.put("q", "r") + assert c.get("q") is None + + def test_put_and_get(self): + c = ResponseCache(enabled=True) + c.put("What is EEG?", "EEG is…") + resp, _ = c.get("What is EEG?") + assert resp == "EEG is…" + + def test_case_insensitive(self): + c = ResponseCache(enabled=True) + c.put(" What IS EEG? ", "r") + assert c.get("what is eeg?") is not None + + def test_miss(self): + c = ResponseCache(enabled=True) + assert c.get("unknown") is None + + def test_session_scoped(self): + """Same query in different sessions should be cached separately.""" + c = ResponseCache(enabled=True) + c.put("q", "resp-A", session_id="sessA") + c.put("q", "resp-B", session_id="sessB") + rA, _ = c.get("q", session_id="sessA") + rB, _ = c.get("q", session_id="sessB") + assert rA == "resp-A" + assert rB == "resp-B" + + def test_max_size_eviction(self): + c = ResponseCache(enabled=True, max_size=2) + c.put("q1", "r1") + c.put("q2", "r2") + c.put("q3", "r3") + assert c.size <= 2 + + def test_ttl_expiry(self): + c = ResponseCache(enabled=True, ttl_seconds=0) + c.put("q", "r") + time.sleep(0.05) # Windows timer resolution is ~15ms + assert c.get("q") is None + + def test_invalidate(self): + c = ResponseCache(enabled=True) + c.put("q", "r") + c.invalidate("q") + assert c.get("q") is None + + def test_clear(self): + c = ResponseCache(enabled=True) + c.put("a", "1") + c.put("b", "2") + c.clear() + assert c.size == 0 + + def test_stats(self): + c = ResponseCache(enabled=True, max_size=64, ttl_seconds=300) + c.put("q", "r") + s = c.stats() + assert s["enabled"] is True + assert s["size"] == 1 + + +class TestFactory: + def test_default_disabled(self, monkeypatch): + monkeypatch.delenv("ENABLE_CACHE", raising=False) + assert create_cache().enabled is False + + def test_enabled(self, monkeypatch): + monkeypatch.setenv("ENABLE_CACHE", "true") + assert create_cache().enabled is True