From dd1e005c70dc594643603eb5f0b05bc7be162898 Mon Sep 17 00:00:00 2001 From: sepehr Date: Tue, 14 Jul 2026 17:05:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(cache):=20C2=20=E2=80=94=20Redis=20transla?= =?UTF-8?q?tion=20cache=20with=20user/prompt/glossary=20namespacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/translation_cache.py | 427 +++++++++++++++++++++++++ tests/test_translation_cache.py | 531 ++++++++++++++++++++++++++++++++ 2 files changed, 958 insertions(+) create mode 100644 services/translation_cache.py create mode 100644 tests/test_translation_cache.py diff --git a/services/translation_cache.py b/services/translation_cache.py new file mode 100644 index 0000000..32995c7 --- /dev/null +++ b/services/translation_cache.py @@ -0,0 +1,427 @@ +""" +Track C2 — Translation cache with Redis backend. + +This module provides a translation cache that: + - Defaults to in-process LRU (same as the previous behavior) + - Optionally uses Redis when REDIS_CACHE_ENABLED=true + - Namespaces keys by user_id (so free and pro users don't share) + - Includes custom_prompt_hash + glossary_hash in the key (so two + users with different prompts/glossaries don't get cross-contamination) + - Falls back to LRU if Redis is unavailable (transient connection + error, etc.) — a Redis outage MUST NOT break translation jobs + +Public API: + TranslationCache — single instance per process, async-friendly. + get_cache() — singleton accessor. + +Usage: + cache = get_cache() + cached = await cache.get(text, target, source, provider, user_id, custom_prompt_hash, glossary_hash) + if cached is None: + translated = await provider.translate(text, target, source) + await cache.set(text, target, source, provider, user_id, custom_prompt_hash, glossary_hash, translated) +""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +import asyncio +import threading +from collections import OrderedDict +from typing import Optional, Dict, List + +from core.logging import get_logger + +logger = get_logger(__name__) + + +# ============================================================================= +# Key construction +# ============================================================================= + +def make_cache_key( + text: str, + target_language: str, + source_language: str, + provider: str, + user_id: Optional[str] = None, + custom_prompt_hash: Optional[str] = None, + glossary_hash: Optional[str] = None, +) -> str: + """Build a stable cache key. + + The key includes: + - The text being translated + - The language pair + - The provider + - The user (so free/pro users don't collide) + - The custom_prompt hash (so different prompts are isolated) + - The glossary hash (so different glossaries are isolated) + """ + parts = [ + f"provider={provider}", + f"src={source_language or 'auto'}", + f"tgt={target_language}", + f"user={user_id or 'anon'}", + f"prompt={custom_prompt_hash or 'none'}", + f"glossary={glossary_hash or 'none'}", + f"text={text}", + ] + content = "|".join(parts) + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def hash_prompt(prompt: Optional[str]) -> Optional[str]: + """Hash a custom prompt for use in a cache key. Returns None for None/empty.""" + if not prompt: + return None + return hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:16] + + +def hash_glossary_terms(terms: Optional[List[dict]]) -> Optional[str]: + """Hash a glossary's term list for use in a cache key. + + We only hash the source/target pairs (not metadata) so that two + glossaries with the same content but different names still share + cache entries. + """ + if not terms: + return None + # Stable representation: list of (source, target) tuples + normalized = sorted( + (str(t.get("source", "")), str(t.get("target", ""))) + for t in terms + if t.get("source") and t.get("target") + ) + content = json.dumps(normalized, ensure_ascii=False, sort_keys=True) + return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16] + + +# ============================================================================= +# In-process LRU fallback +# ============================================================================= + +class _LRUBackend: + """Thread-safe LRU cache, used as a fallback when Redis is unavailable.""" + + def __init__(self, maxsize: int = 5000): + self._cache: "OrderedDict[str, str]" = OrderedDict() + self._maxsize = maxsize + self._lock = threading.RLock() + self.hits = 0 + self.misses = 0 + + def get(self, key: str) -> Optional[str]: + with self._lock: + if key in self._cache: + self.hits += 1 + self._cache.move_to_end(key) + return self._cache[key] + self.misses += 1 + return None + + def set(self, key: str, value: str, ttl: Optional[int] = None) -> None: + # LRU doesn't honor TTL — it's a process-lifetime cache. + with self._lock: + if key in self._cache: + self._cache.move_to_end(key) + self._cache[key] = value + while len(self._cache) > self._maxsize: + self._cache.popitem(last=False) + + def clear(self) -> None: + with self._lock: + self._cache.clear() + self.hits = 0 + self.misses = 0 + + def stats(self) -> Dict: + with self._lock: + total = self.hits + self.misses + hit_rate = (self.hits / total * 100) if total > 0 else 0 + return { + "backend": "lru", + "size": len(self._cache), + "maxsize": self._maxsize, + "hits": self.hits, + "misses": self.misses, + "hit_rate": f"{hit_rate:.1f}%", + } + + +# ============================================================================= +# Redis backend +# ============================================================================= + +class _RedisBackend: + """Redis-backed cache with TTL and async support. + + Connection is lazy: we don't connect on import. If Redis is + unreachable, every operation falls back to the LRU backend. + """ + + def __init__( + self, + url: str, + namespace: str = "translate_cache", + default_ttl: int = 86400, # 24 hours + lru_fallback: Optional[_LRUBackend] = None, + socket_timeout: float = 2.0, + ): + self._url = url + self._namespace = namespace + self._default_ttl = default_ttl + self._lru_fallback = lru_fallback + self._socket_timeout = socket_timeout + self._client = None + self._lock = threading.RLock() + self.hits = 0 + self.misses = 0 + self.errors = 0 + self._available = None # None = unknown, True/False = probed + + def _get_client(self): + """Lazy client init. Never raises; returns None on failure.""" + if self._client is not None: + return self._client + with self._lock: + if self._client is not None: + return self._client + try: + import redis + client = redis.from_url( + self._url, + socket_timeout=self._socket_timeout, + socket_connect_timeout=self._socket_timeout, + decode_responses=True, + ) + # Probe once + client.ping() + self._client = client + self._available = True + logger.info("redis_cache_connected", url=self._url.split("@")[-1]) + return client + except Exception as e: + self._available = False + self.errors += 1 + logger.warning( + "redis_cache_unavailable", + error=str(e)[:200], + fallback="lru", + ) + return None + + def _key(self, key: str) -> str: + return f"{self._namespace}:{key}" + + def get(self, key: str) -> Optional[str]: + client = self._get_client() + if client is None: + # Fall back to LRU + if self._lru_fallback is not None: + v = self._lru_fallback.get(key) + if v is not None: + self.hits += 1 + else: + self.misses += 1 + return v + return None + try: + v = client.get(self._key(key)) + if v is not None: + self.hits += 1 + # Promote to LRU too (in-process L1 cache) + if self._lru_fallback is not None: + self._lru_fallback.set(key, v) + return v + self.misses += 1 + return None + except Exception as e: + self.errors += 1 + self._available = False + self._client = None # force reconnect on next call + logger.warning("redis_cache_get_error", error=str(e)[:200]) + if self._lru_fallback is not None: + return self._lru_fallback.get(key) + return None + + def set(self, key: str, value: str, ttl: Optional[int] = None) -> None: + client = self._get_client() + if client is not None: + try: + client.set( + self._key(key), + value, + ex=ttl if ttl is not None else self._default_ttl, + ) + # Also write to LRU + if self._lru_fallback is not None: + self._lru_fallback.set(key, value) + return + except Exception as e: + self.errors += 1 + self._available = False + self._client = None + logger.warning("redis_cache_set_error", error=str(e)[:200]) + # Fall through to LRU + if self._lru_fallback is not None: + self._lru_fallback.set(key, value, ttl=ttl) + + def clear(self) -> None: + if self._lru_fallback is not None: + self._lru_fallback.clear() + client = self._get_client() + if client is not None: + try: + # Delete only keys in our namespace + cursor = 0 + pattern = f"{self._namespace}:*" + while True: + cursor, keys = client.scan(cursor=cursor, match=pattern, count=500) + if keys: + client.delete(*keys) + if cursor == 0: + break + except Exception as e: + logger.warning("redis_cache_clear_error", error=str(e)[:200]) + + def stats(self) -> Dict: + out = { + "backend": "redis", + "available": self._available, + "url": self._url.split("@")[-1] if self._url else None, + "namespace": self._namespace, + "default_ttl": self._default_ttl, + "hits": self.hits, + "misses": self.misses, + "errors": self.errors, + } + if self._lru_fallback is not None: + lru_stats = self._lru_fallback.stats() + out["lru_size"] = lru_stats["size"] + return out + + +# ============================================================================= +# TranslationCache — public API +# ============================================================================= + +class TranslationCache: + """High-level translation cache. + + Delegates to a backend (Redis or LRU) based on configuration. + Provides an async-friendly API (sync methods, but no blocking + Redis I/O — Redis client is async-compatible via redis.asyncio + if we want to add that later). + """ + + def __init__( + self, + backend: str = "auto", + redis_url: Optional[str] = None, + redis_namespace: str = "translate_cache", + default_ttl: int = 86400, + lru_maxsize: int = 5000, + ): + # Decide backend + if backend == "auto": + backend = "redis" if ( + os.getenv("REDIS_CACHE_ENABLED", "false").lower() == "true" + and (redis_url or os.getenv("REDIS_URL", "")) + ) else "lru" + + self._lru = _LRUBackend(maxsize=lru_maxsize) + if backend == "redis" and (redis_url or os.getenv("REDIS_URL", "")): + self._backend: object = _RedisBackend( + url=redis_url or os.getenv("REDIS_URL", ""), + namespace=redis_namespace, + default_ttl=default_ttl, + lru_fallback=self._lru, + ) + self._backend_name = "redis" + else: + self._backend = self._lru + self._backend_name = "lru" + + @property + def backend_name(self) -> str: + return self._backend_name + + def get( + self, + text: str, + target_language: str, + source_language: str, + provider: str, + user_id: Optional[str] = None, + custom_prompt_hash: Optional[str] = None, + glossary_hash: Optional[str] = None, + ) -> Optional[str]: + key = make_cache_key( + text, target_language, source_language, provider, + user_id, custom_prompt_hash, glossary_hash, + ) + return self._backend.get(key) + + def set( + self, + text: str, + target_language: str, + source_language: str, + provider: str, + translation: str, + user_id: Optional[str] = None, + custom_prompt_hash: Optional[str] = None, + glossary_hash: Optional[str] = None, + ttl: Optional[int] = None, + ) -> None: + key = make_cache_key( + text, target_language, source_language, provider, + user_id, custom_prompt_hash, glossary_hash, + ) + self._backend.set(key, translation, ttl=ttl) + + def clear(self) -> None: + self._backend.clear() + + def stats(self) -> Dict: + return self._backend.stats() + + +# ============================================================================= +# Singleton accessor +# ============================================================================= + +_cache: Optional[TranslationCache] = None +_cache_lock = threading.RLock() + + +def get_cache() -> TranslationCache: + """Get the process-wide translation cache (lazy init).""" + global _cache + if _cache is not None: + return _cache + with _cache_lock: + if _cache is None: + _cache = TranslationCache( + backend="auto", + redis_url=os.getenv("REDIS_URL", "") or None, + redis_namespace=os.getenv("REDIS_CACHE_NAMESPACE", "translate_cache"), + default_ttl=int(os.getenv("REDIS_CACHE_TTL", "86400")), + lru_maxsize=int(os.getenv("LRU_CACHE_MAXSIZE", "5000")), + ) + logger.info( + "translation_cache_initialized", + backend=_cache.backend_name, + ) + return _cache + + +def reset_cache_for_tests() -> None: + """Reset the singleton — only for tests.""" + global _cache + with _cache_lock: + _cache = None diff --git a/tests/test_translation_cache.py b/tests/test_translation_cache.py new file mode 100644 index 0000000..f3f05b7 --- /dev/null +++ b/tests/test_translation_cache.py @@ -0,0 +1,531 @@ +""" +Tests for Track C2 — Redis translation cache. + +Covers: + - make_cache_key: stable across calls, distinct inputs → distinct keys + - hash_prompt / hash_glossary_terms + - _LRUBackend: get/set, eviction order, stats + - _RedisBackend: connection probe, fallback to LRU on error + - TranslationCache: auto backend selection, user_id namespacing, + prompt/glossary isolation + - get_cache: singleton, reset_cache_for_tests +""" +import os +import sys +import threading +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from services.translation_cache import ( + make_cache_key, + hash_prompt, + hash_glossary_terms, + _LRUBackend, + _RedisBackend, + TranslationCache, + get_cache, + reset_cache_for_tests, +) + + +# ============================================================================ +# make_cache_key +# ============================================================================ + +class TestMakeCacheKey: + def test_stable_across_calls(self): + k1 = make_cache_key("Hello", "fr", "en", "google") + k2 = make_cache_key("Hello", "fr", "en", "google") + assert k1 == k2 + + def test_different_target_different_key(self): + k1 = make_cache_key("Hello", "fr", "en", "google") + k2 = make_cache_key("Hello", "es", "en", "google") + assert k1 != k2 + + def test_different_provider_different_key(self): + k1 = make_cache_key("Hello", "fr", "en", "google") + k2 = make_cache_key("Hello", "fr", "en", "deepl") + assert k1 != k2 + + def test_different_text_different_key(self): + k1 = make_cache_key("Hello", "fr", "en", "google") + k2 = make_cache_key("Goodbye", "fr", "en", "google") + assert k1 != k2 + + def test_user_namespacing(self): + k1 = make_cache_key("Hello", "fr", "en", "google", user_id="alice") + k2 = make_cache_key("Hello", "fr", "en", "google", user_id="bob") + assert k1 != k2, "Different users should not share cache entries" + + def test_same_user_same_key(self): + k1 = make_cache_key("Hello", "fr", "en", "google", user_id="alice") + k2 = make_cache_key("Hello", "fr", "en", "google", user_id="alice") + assert k1 == k2 + + def test_anon_default(self): + k1 = make_cache_key("Hello", "fr", "en", "google") + k2 = make_cache_key("Hello", "fr", "en", "google", user_id=None) + assert k1 == k2, "user_id=None should equal the default anonymous user" + + def test_prompt_isolation(self): + k1 = make_cache_key("Hello", "fr", "en", "google", custom_prompt_hash="abc") + k2 = make_cache_key("Hello", "fr", "en", "google", custom_prompt_hash="xyz") + assert k1 != k2, "Different prompts should not share cache entries" + + def test_glossary_isolation(self): + k1 = make_cache_key("Hello", "fr", "en", "google", glossary_hash="g1") + k2 = make_cache_key("Hello", "fr", "en", "google", glossary_hash="g2") + assert k1 != k2 + + def test_returns_hex_string(self): + k = make_cache_key("Hello", "fr", "en", "google") + assert isinstance(k, str) + assert len(k) == 64 # sha256 hex digest length + assert all(c in "0123456789abcdef" for c in k) + + +# ============================================================================ +# Hash helpers +# ============================================================================ + +class TestHashHelpers: + def test_hash_prompt_none(self): + assert hash_prompt(None) is None + + def test_hash_prompt_empty(self): + assert hash_prompt("") is None + + def test_hash_prompt_stable(self): + h1 = hash_prompt("Translate this") + h2 = hash_prompt("Translate this") + assert h1 == h2 + + def test_hash_prompt_different_inputs(self): + h1 = hash_prompt("A") + h2 = hash_prompt("B") + assert h1 != h2 + + def test_hash_prompt_short(self): + h = hash_prompt("test") + # We truncate to 16 chars in the implementation + assert len(h) == 16 + + def test_hash_glossary_none(self): + assert hash_glossary_terms(None) is None + + def test_hash_glossary_empty(self): + assert hash_glossary_terms([]) is None + + def test_hash_glossary_stable(self): + terms = [{"source": "Hello", "target": "Bonjour"}] + h1 = hash_glossary_terms(terms) + h2 = hash_glossary_terms(terms) + assert h1 == h2 + + def test_hash_glossary_order_invariant(self): + """The order of terms should not change the hash (sorted internally).""" + a = [ + {"source": "Hello", "target": "Bonjour"}, + {"source": "World", "target": "Monde"}, + ] + b = [ + {"source": "World", "target": "Monde"}, + {"source": "Hello", "target": "Bonjour"}, + ] + assert hash_glossary_terms(a) == hash_glossary_terms(b) + + def test_hash_glossary_metadata_invariant(self): + """The name/id of the glossary should not affect the hash.""" + a = [{"source": "X", "target": "Y", "name": "g1", "id": "g1"}] + b = [{"source": "X", "target": "Y", "name": "g2", "id": "g2"}] + assert hash_glossary_terms(a) == hash_glossary_terms(b) + + +# ============================================================================ +# LRU backend +# ============================================================================ + +class TestLRUBackend: + def test_get_missing_returns_none(self): + c = _LRUBackend() + assert c.get("nope") is None + assert c.misses == 1 + + def test_set_and_get(self): + c = _LRUBackend() + c.set("k", "v") + assert c.get("k") == "v" + assert c.hits == 1 + + def test_overwrite(self): + c = _LRUBackend() + c.set("k", "v1") + c.set("k", "v2") + assert c.get("k") == "v2" + assert len(c._cache) == 1 + + def test_lru_eviction(self): + c = _LRUBackend(maxsize=2) + c.set("a", "1") + c.set("b", "2") + c.set("c", "3") # Should evict "a" (least recently used) + assert c.get("a") is None + assert c.get("b") == "2" + assert c.get("c") == "3" + + def test_get_promotes_to_mru(self): + c = _LRUBackend(maxsize=2) + c.set("a", "1") + c.set("b", "2") + c.get("a") # Promote "a" + c.set("c", "3") # Should evict "b" (now LRU) + assert c.get("a") == "1" + assert c.get("b") is None + assert c.get("c") == "3" + + def test_clear(self): + c = _LRUBackend() + c.set("a", "1") + c.set("b", "2") + c.clear() + assert c.get("a") is None + # After clear, both counters are reset + assert c.hits == 0 + # The get after clear is a miss (1) + assert c.misses == 1 + + def test_stats(self): + c = _LRUBackend(maxsize=10) + c.set("a", "1") + c.get("a") # hit + c.get("b") # miss + s = c.stats() + assert s["backend"] == "lru" + assert s["hits"] == 1 + assert s["misses"] == 1 + assert s["size"] == 1 + assert s["maxsize"] == 10 + + def test_thread_safety(self): + c = _LRUBackend(maxsize=100) + errors = [] + + def worker(i): + try: + for j in range(100): + c.set(f"k_{i}_{j}", f"v_{i}_{j}") + c.get(f"k_{i}_{j}") + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + assert errors == [] + + +# ============================================================================ +# Redis backend — fallback behavior +# ============================================================================ + +class TestRedisBackendFallback: + """When Redis is unavailable, the backend should fall back to LRU.""" + + def test_redis_unavailable_falls_back_to_lru(self): + lru = _LRUBackend() + backend = _RedisBackend( + url="redis://invalid-host:9999/0", + lru_fallback=lru, + ) + # First call probes the connection, fails, and falls back + assert backend.get("test_key") is None # miss in LRU + backend.set("test_key", "hello") + # Second get should hit the LRU + assert backend.get("test_key") == "hello" + # Errors should have been counted + assert backend.errors >= 1 + # The backend should not have a client + assert backend._client is None + + def test_redis_set_falls_back_to_lru(self): + lru = _LRUBackend() + backend = _RedisBackend( + url="redis://invalid-host:9999/0", + lru_fallback=lru, + ) + backend.set("k", "v") + # The LRU should have it + assert lru.get("k") == "v" + + def test_stats_includes_lru(self): + lru = _LRUBackend() + backend = _RedisBackend( + url="redis://invalid-host:9999/0", + lru_fallback=lru, + ) + backend.set("k", "v") + stats = backend.stats() + assert stats["backend"] == "redis" + assert stats["available"] is False + assert stats["errors"] >= 1 + assert "lru_size" in stats + + +# ============================================================================ +# Redis backend — happy path with a mock +# ============================================================================ + +class TestRedisBackendWithMock: + """When Redis is reachable, the backend should use it.""" + + def _build_backend_with_mock_client(self, client): + """Build a RedisBackend with a pre-set mock client.""" + lru = _LRUBackend() + backend = _RedisBackend( + url="redis://fake:6379/0", + lru_fallback=lru, + ) + backend._client = client + backend._available = True + return backend, lru + + def test_get_uses_redis(self): + client = MagicMock() + client.get.return_value = "cached_value" + backend, lru = self._build_backend_with_mock_client(client) + + result = backend.get("k") + assert result == "cached_value" + client.get.assert_called_once() + # Should also have been written to LRU + assert lru.get("k") == "cached_value" + + def test_get_miss_redis(self): + client = MagicMock() + client.get.return_value = None + backend, lru = self._build_backend_with_mock_client(client) + + result = backend.get("k") + assert result is None + assert backend.misses == 1 + + def test_set_uses_redis(self): + client = MagicMock() + backend, lru = self._build_backend_with_mock_client(client) + + backend.set("k", "v", ttl=60) + # Should have called Redis SET + client.set.assert_called_once() + args = client.set.call_args[0] + assert args[1] == "v" # value + # The TTL is passed as a keyword arg (ex=ttl) + assert client.set.call_args[1]["ex"] == 60 + + def test_set_uses_default_ttl(self): + client = MagicMock() + backend, lru = self._build_backend_with_mock_client(client) + + backend.set("k", "v") + # Default TTL = 86400 (24h) is passed as ex=... + assert client.set.call_args[1]["ex"] == 86400 + + def test_redis_error_during_get_resets_client(self): + client = MagicMock() + client.get.side_effect = RuntimeError("redis down") + backend, lru = self._build_backend_with_mock_client(client) + + result = backend.get("k") + # Falls back to LRU + assert result is None + # Client should be reset so we re-probe next time + assert backend._client is None + assert backend._available is False + assert backend.errors == 1 + + def test_redis_error_during_set_resets_client(self): + client = MagicMock() + client.set.side_effect = RuntimeError("redis down") + backend, lru = self._build_backend_with_mock_client(client) + + backend.set("k", "v") + # Should have fallen back to LRU + assert lru.get("k") == "v" + assert backend._client is None + assert backend.errors == 1 + + def test_namespace_prefix(self): + client = MagicMock() + client.get.return_value = None + backend, lru = self._build_backend_with_mock_client(client) + + backend.get("mykey") + called_key = client.get.call_args[0][0] + assert called_key.startswith("translate_cache:") + assert called_key.endswith(":mykey") + + def test_custom_namespace(self): + client = MagicMock() + client.get.return_value = None + lru = _LRUBackend() + backend = _RedisBackend( + url="redis://fake:6379/0", + namespace="custom_ns", + lru_fallback=lru, + ) + backend._client = client + backend._available = True + + backend.get("mykey") + called_key = client.get.call_args[0][0] + assert called_key.startswith("custom_ns:") + + +# ============================================================================ +# TranslationCache — high-level API +# ============================================================================ + +class TestTranslationCache: + def test_default_backend_is_lru(self, monkeypatch): + """No REDIS_CACHE_ENABLED → LRU backend.""" + monkeypatch.delenv("REDIS_CACHE_ENABLED", raising=False) + monkeypatch.delenv("REDIS_URL", raising=False) + cache = TranslationCache(backend="auto") + assert cache.backend_name == "lru" + + def test_explicit_lru_backend(self, monkeypatch): + monkeypatch.setenv("REDIS_CACHE_ENABLED", "true") + cache = TranslationCache(backend="lru") + assert cache.backend_name == "lru" + + def test_get_and_set(self): + cache = TranslationCache(backend="lru") + cache.set("Hello", "fr", "en", "google", "Bonjour") + assert cache.get("Hello", "fr", "en", "google") == "Bonjour" + + def test_get_miss(self): + cache = TranslationCache(backend="lru") + assert cache.get("Nope", "fr", "en", "google") is None + + def test_user_isolation(self): + cache = TranslationCache(backend="lru") + cache.set("Hello", "fr", "en", "google", "Bonjour", user_id="alice") + cache.set("Hello", "fr", "en", "google", "Hola", user_id="bob") + assert cache.get("Hello", "fr", "en", "google", user_id="alice") == "Bonjour" + assert cache.get("Hello", "fr", "en", "google", user_id="bob") == "Hola" + + def test_prompt_isolation(self): + cache = TranslationCache(backend="lru") + cache.set("Hello", "fr", "en", "google", "V1", + custom_prompt_hash="p1") + cache.set("Hello", "fr", "en", "google", "V2", + custom_prompt_hash="p2") + assert cache.get("Hello", "fr", "en", "google", custom_prompt_hash="p1") == "V1" + assert cache.get("Hello", "fr", "en", "google", custom_prompt_hash="p2") == "V2" + + def test_glossary_isolation(self): + cache = TranslationCache(backend="lru") + cache.set("Hello", "fr", "en", "google", "V1", + glossary_hash="g1") + cache.set("Hello", "fr", "en", "google", "V2", + glossary_hash="g2") + assert cache.get("Hello", "fr", "en", "google", glossary_hash="g1") == "V1" + assert cache.get("Hello", "fr", "en", "google", glossary_hash="g2") == "V2" + + def test_clear(self): + cache = TranslationCache(backend="lru") + cache.set("k", "fr", "en", "google", "v") + cache.clear() + assert cache.get("k", "fr", "en", "google") is None + + def test_stats_returns_dict(self): + cache = TranslationCache(backend="lru") + cache.set("k", "fr", "en", "google", "v") + cache.get("k", "fr", "en", "google") + s = cache.stats() + assert isinstance(s, dict) + assert s["backend"] == "lru" + + +# ============================================================================ +# Singleton accessor +# ============================================================================ + +class TestGetCache: + def test_returns_singleton(self, monkeypatch): + reset_cache_for_tests() + c1 = get_cache() + c2 = get_cache() + assert c1 is c2 + + def test_reset_for_tests(self, monkeypatch): + reset_cache_for_tests() + c1 = get_cache() + reset_cache_for_tests() + c2 = get_cache() + assert c1 is not c2 + + def test_singleton_thread_safe(self, monkeypatch): + reset_cache_for_tests() + caches = [] + errors = [] + + def worker(): + try: + caches.append(get_cache()) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=worker) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + assert errors == [] + assert all(c is caches[0] for c in caches) + + +# ============================================================================ +# End-to-end: integrate with hash_prompt / hash_glossary +# ============================================================================ + +class TestE2EHashIntegration: + def test_real_prompt_and_glossary(self): + """A realistic scenario: same text, different prompt/glossary + should give different cache keys and thus different entries.""" + cache = TranslationCache(backend="lru") + + prompt_a = "Translate this as a formal document." + prompt_b = "Translate this as a casual chat message." + glossary_a = [{"source": "API", "target": "API"}] + glossary_b = [{"source": "API", "target": "interface"}] + + cache.set( + "Hello", "fr", "en", "google", + "Bonjour (formel)", + custom_prompt_hash=hash_prompt(prompt_a), + glossary_hash=hash_glossary_terms(glossary_a), + ) + cache.set( + "Hello", "fr", "en", "google", + "Salut (décontracté)", + custom_prompt_hash=hash_prompt(prompt_b), + glossary_hash=hash_glossary_terms(glossary_b), + ) + + # Reading with the same prompt/glossary should give the right entry + assert cache.get( + "Hello", "fr", "en", "google", + custom_prompt_hash=hash_prompt(prompt_a), + glossary_hash=hash_glossary_terms(glossary_a), + ) == "Bonjour (formel)" + assert cache.get( + "Hello", "fr", "en", "google", + custom_prompt_hash=hash_prompt(prompt_b), + glossary_hash=hash_glossary_terms(glossary_b), + ) == "Salut (décontracté)"