"""Pont GUI V6 → moteur d'anonymisation (G3-A). Construit les kwargs d'appel du moteur (``process_document``) au plus proche de la V5 / du CLI de production, et charge les managers NER **paresseusement** : - aucun manager n'est importé ni instancié à l'import de ce module ; - le chargement réel n'a lieu qu'au premier traitement (``ensure_loaded``) ; - les factories sont injectables pour les tests (aucun modèle, aucun réseau). Mapping moteur (identique au CLI validé `scripts/anonymize_cli.py`) : - ``camembert_manager`` ← CamembertNerManager (NER local principal) - ``ner_manager`` ← EdsPseudoManager (optionnel) - ``gliner_manager`` ← GlinerManager (optionnel) - ``use_hf`` ← True si au moins un manager NER est chargé Aucune logique de détection ici : on orchestre uniquement. """ from __future__ import annotations import logging from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Any, Callable, Dict, FrozenSet, Optional from engine_capabilities import capabilities_map log = logging.getLogger(__name__) ProcessFn = Callable[[Path, Path], dict] ManagerFactory = Callable[[], Any] # Fournisseur de capabilities : () -> {key: objet exposant `.available`/`.reason`}. CapsProvider = Callable[[], Dict[str, Any]] class ManagerState(str, Enum): NOT_LOADED = "not_loaded" LOADING = "loading" READY = "ready" UNAVAILABLE = "unavailable" class EngineUnavailableError(RuntimeError): """Levée quand un moteur de détection OBLIGATOIRE n'a pas pu être chargé. Garantit le fail-close : on refuse de produire une sortie plutôt que de livrer un document potentiellement non anonymisé (aligné sur le code 3 du CLI). """ @dataclass class EngineSettings: """Réglages d'appel moteur exposés par l'onglet Configuration.""" make_vector_redaction: bool = False also_make_raster_burn: bool = True config_path: Optional[Path] = None use_local_ner: bool = True enable_eds: bool = False enable_gliner: bool = False ogc_label: Optional[str] = None profile: Optional[str] = None # Plan 1b (P1-2) — set des CATÉGORIES laissées en clair (toggles décochés). # Vide par défaut ⇒ aucun changement de comportement (tout est masqué). disabled_kinds: FrozenSet[str] = field(default_factory=frozenset) def _default_factories() -> dict[str, ManagerFactory]: """Factories par défaut : import paresseux, instanciation + load réels. Définies dans une fonction pour qu'aucun manager ne soit importé à l'import de ce module. """ def camembert() -> Any: from camembert_ner_manager import CamembertNerManager manager = CamembertNerManager() manager.load() return manager def eds() -> Any: from eds_pseudo_manager import EdsPseudoManager manager = EdsPseudoManager() manager.load() return manager def gliner() -> Any: from gliner_manager import GlinerManager manager = GlinerManager() manager.load() return manager return {"camembert": camembert, "eds": eds, "gliner": gliner} class NerManagers: """Conteneur de managers NER à chargement paresseux.""" def __init__( self, settings: EngineSettings, factories: Optional[dict[str, ManagerFactory]] = None, caps_provider: Optional[CapsProvider] = None, ) -> None: self._settings = settings self._factories = factories if factories is not None else _default_factories() # Sonde « moteurs réellement embarqués » (injectable pour les tests). self._caps_provider = caps_provider if caps_provider is not None else capabilities_map self._camembert: Any = None self._eds: Any = None self._gliner: Any = None self._state = ManagerState.NOT_LOADED @property def state(self) -> ManagerState: return self._state @property def use_hf(self) -> bool: return bool(self._camembert or self._eds or self._gliner) def as_kwargs(self) -> dict: return { "ner_manager": self._eds, "gliner_manager": self._gliner, "camembert_manager": self._camembert, } def _apply_capability_guard(self) -> None: """Désactive un moteur optionnel demandé mais non embarqué (fail-safe). Empêche qu'un profil YAML forçant ``enable_eds``/``enable_gliner`` ne déclenche un chargement voué à l'échec silencieux : si la sonde ``engine_capabilities`` indique le moteur indisponible, on log un warning et on force la désactivation dans les réglages runtime. Best-effort : toute erreur de sonde laisse les réglages inchangés (les ``try/except`` de chargement protègent déjà contre un crash). La sonde reste légère (``find_spec``) — aucun import lourd ici. """ requested = [] if self._settings.enable_eds: requested.append(("eds", "EDS-Pseudo")) if self._settings.enable_gliner: requested.append(("gliner", "GLiNER")) if not requested: return try: caps = self._caps_provider() except Exception: # noqa: BLE001 — best-effort, ne jamais bloquer le load return for key, label in requested: cap = caps.get(key) if hasattr(caps, "get") else None if cap is not None and not getattr(cap, "available", False): log.warning( "%s demandé par la configuration mais non embarqué dans " "cette version — désactivation forcée (%s)", label, getattr(cap, "reason", ""), ) if key == "eds": self._settings.enable_eds = False else: self._settings.enable_gliner = False def ensure_loaded(self) -> ManagerState: """Charge les managers requis si nécessaire. Idempotent, sans crash.""" if not self._settings.use_local_ner: self._state = ManagerState.NOT_LOADED return self._state if self._state == ManagerState.READY: return self._state self._state = ManagerState.LOADING # Garde-fou : ne jamais tenter de charger un moteur optionnel non embarqué. self._apply_capability_guard() try: # CamemBERT-bio est le NER local principal (obligatoire si NER actif). self._camembert = self._factories["camembert"]() except Exception: self._state = ManagerState.UNAVAILABLE return self._state if self._settings.enable_eds: try: self._eds = self._factories["eds"]() except Exception: self._eds = None # optionnel : absence tolérée if self._settings.enable_gliner: try: self._gliner = self._factories["gliner"]() except Exception: self._gliner = None # optionnel : absence tolérée self._state = ManagerState.READY return self._state def build_engine_kwargs( settings: EngineSettings, managers: Optional[NerManagers] = None ) -> dict: """Construit le dict de kwargs passé au moteur.""" kwargs: dict = { "make_vector_redaction": settings.make_vector_redaction, "also_make_raster_burn": settings.also_make_raster_burn, "config_path": settings.config_path, "ogc_label": settings.ogc_label, # Plan 1b (P1-2) — catégories décochées laissées en clair (set vide = no-op). "disabled_kinds": frozenset(settings.disabled_kinds or ()), } if managers is not None and settings.use_local_ner: kwargs.update(managers.as_kwargs()) kwargs["use_hf"] = managers.use_hf else: kwargs["use_hf"] = False return kwargs def make_process_fn( settings: EngineSettings, managers: Optional[NerManagers] = None, engine: Optional[Callable[..., dict]] = None, ) -> ProcessFn: """Retourne un ``process_fn(doc, out_dir)`` câblé au moteur. ``engine`` est injectable pour les tests ; par défaut, import paresseux de ``process_document`` (aucun chargement du moteur à l'import de ce module). Raises: EngineUnavailableError: si le NER obligatoire (CamemBERT-bio) est indisponible alors que ``use_local_ner`` est actif (fail-close). """ managers = managers if managers is not None else NerManagers(settings) def process_fn(doc_path: Path, out_dir: Path) -> dict: if settings.use_local_ner: state = managers.ensure_loaded() if state == ManagerState.UNAVAILABLE: raise EngineUnavailableError( "Modèle de détection obligatoire (CamemBERT-bio) indisponible — " "traitement refusé pour éviter une anonymisation incomplète." ) kwargs = build_engine_kwargs(settings, managers) run = engine if run is None: from anonymizer_core_refactored_onnx import process_document run = process_document return run(doc_path, out_dir, **kwargs) return process_fn