cache.py 3.66 KB
"""Shared translation cache utilities."""

from __future__ import annotations

import hashlib
import logging
from typing import Mapping, Optional

try:
    import redis
except ImportError:  # pragma: no cover - runtime fallback for minimal envs
    redis = None  # type: ignore[assignment]

from config.loader import get_app_config

logger = logging.getLogger(__name__)


class TranslationCache:
    """Redis-backed cache shared by all translation capabilities."""

    def __init__(self, config: Mapping[str, object]) -> None:
        self.ttl_seconds = int(config["ttl_seconds"])
        self.sliding_expiration = bool(config["sliding_expiration"])
        self.redis_client = self._init_redis_client()

    @property
    def available(self) -> bool:
        return self.redis_client is not None

    def build_key(self, *, model: str, target_lang: str, source_text: str) -> str:
        normalized_model = str(model or "").strip().lower()
        normalized_target_lang = str(target_lang or "").strip().lower()
        text = str(source_text or "")
        text_prefix = text[:4]
        digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
        return f"trans:{normalized_model}:{normalized_target_lang}:{text_prefix}{digest}"

    def get(
        self,
        *,
        model: str,
        target_lang: str,
        source_text: str
    ) -> Optional[str]:
        if self.redis_client is None:
            return None
        key = self.build_key(model=model, target_lang=target_lang, source_text=source_text)
        try:
            value = self.redis_client.get(key)
            logger.info(
                "Translation cache %s | text_len=%s key=%s",
                "hit" if value is not None else "miss",
                len(str(source_text or "")),
                key,
            )
            if value and self.sliding_expiration:
                self.redis_client.expire(key, self.ttl_seconds)
            return value
        except Exception as exc:
            logger.warning("Redis get translation cache failed: %s", exc)
            return None

    def set(self, *, model: str, target_lang: str, source_text: str, translated_text: str) -> None:
        if self.redis_client is None:
            return
        key = self.build_key(model=model, target_lang=target_lang, source_text=source_text)
        try:
            self.redis_client.setex(key, self.ttl_seconds, translated_text)
            logger.info(
                "Translation cache write | text_len=%s result_len=%s ttl_seconds=%s key=%s",
                len(str(source_text or "")),
                len(str(translated_text or "")),
                self.ttl_seconds,
                key,
            )
        except Exception as exc:
            logger.warning("Redis set translation cache failed: %s", exc)

    @staticmethod
    def _init_redis_client() -> Optional[redis.Redis]:
        if redis is None:
            logger.warning("redis package is not installed; translation cache disabled")
            return None
        redis_config = get_app_config().infrastructure.redis
        try:
            client = redis.Redis(
                host=redis_config.host,
                port=redis_config.port,
                password=redis_config.password,
                decode_responses=True,
                socket_timeout=redis_config.socket_timeout,
                socket_connect_timeout=redis_config.socket_connect_timeout,
                retry_on_timeout=redis_config.retry_on_timeout,
                health_check_interval=10,
            )
            client.ping()
            return client
        except Exception as exc:
            logger.warning("Failed to initialize translation redis cache: %s", exc)
            return None