"""Cache persistant thread-safe pour les résultats Ollama.""" from __future__ import annotations import json import logging import threading from pathlib import Path logger = logging.getLogger(__name__) class OllamaCache: """Cache JSON persistant pour éviter les appels Ollama redondants. Clé = (texte_diagnostic_normalisé, type). Le modèle Ollama est stocké PAR ENTRÉE : si le modèle change pour un rôle, seules les entrées de cet ancien modèle sont invalides. Migration automatique depuis l'ancien format (model global) au chargement. """ def __init__(self, cache_path: Path, model: str | None = None): self._path = cache_path self._default_model = model self._lock = threading.Lock() self._data: dict[str, dict] = {} self._dirty = False self._load() def _load(self) -> None: """Charge le cache depuis le disque, avec migration automatique.""" if not self._path.exists(): logger.info("Cache Ollama : nouveau cache (%s)", self._path) return try: raw = json.loads(self._path.read_text(encoding="utf-8")) except (json.JSONDecodeError, KeyError) as e: logger.warning("Cache Ollama : fichier corrompu (%s), réinitialisé", e) self._data = {} return entries = raw.get("entries", {}) # Détection ancien format : {"model": "...", "entries": {k: result_dict_sans_model}} global_model = raw.get("model") if global_model and entries: first_val = next(iter(entries.values()), None) if isinstance(first_val, dict) and "model" not in first_val: # Migration : ancien format → nouveau (modèle par entrée) logger.info( "Cache Ollama : migration ancien format (model=%s) → modèle par entrée", global_model, ) migrated: dict[str, dict] = {} for k, v in entries.items(): if isinstance(v, dict): migrated[k] = {"model": global_model, "result": v} self._data = migrated self._dirty = True logger.info("Cache Ollama : %d entrées migrées", len(migrated)) return self._data = entries logger.info("Cache Ollama : %d entrées chargées", len(self._data)) @staticmethod def _make_key(texte: str, diag_type: str) -> str: """Construit une clé normalisée.""" return f"{diag_type}::{texte.strip().lower()}" def get(self, texte: str, diag_type: str, model: str | None = None) -> dict | None: """Récupère un résultat caché, ou None si absent ou modèle différent.""" key = self._make_key(texte, diag_type) use_model = model or self._default_model with self._lock: entry = self._data.get(key) if entry is None: return None if use_model and entry.get("model") != use_model: return None return entry.get("result") def put(self, texte: str, diag_type: str, result: dict, model: str | None = None) -> None: """Stocke un résultat dans le cache avec le modèle utilisé.""" key = self._make_key(texte, diag_type) use_model = model or self._default_model with self._lock: self._data[key] = {"model": use_model, "result": result} self._dirty = True def save(self) -> None: """Persiste le cache sur disque si modifié.""" with self._lock: if not self._dirty: return self._path.parent.mkdir(parents=True, exist_ok=True) payload = {"entries": self._data} self._path.write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", ) self._dirty = False logger.info("Cache Ollama : %d entrées sauvegardées", len(self._data)) def __len__(self) -> int: with self._lock: return len(self._data)