Files
anonymisation/engine_capabilities.py

143 lines
5.4 KiB
Python

"""Couche « capabilities moteurs » — vérité testable sur les moteurs réellement
disponibles dans l'environnement courant (dev ou build frozen).
Un moteur n'est *disponible* que si ses dépendances chargent réellement (et, pour
CamemBERT-bio, si son modèle ONNX embarqué est présent). Cette couche est
consommée par :
- la GUI V6 (afficher / désactiver les moteurs optionnels honnêtement) ;
- le CLI (`--engines`, diagnostic des moteurs réellement embarqués).
Objectif produit : ne jamais afficher / promettre un moteur que le build
n'embarque pas. Dans les builds frozen Windows, `edsnlp`/`spacy`/`gliner` ne sont
pas embarqués → leur spec d'import est introuvable → moteur marqué indisponible.
Les sondes (probes) sont **légères** (`importlib.util.find_spec`, pas d'import
lourd d'edsnlp/spacy au démarrage de la GUI) et **injectables** pour les tests
(aucun modèle, aucun réseau).
"""
from __future__ import annotations
import importlib.util
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, List, Optional
# Probe = () -> (available: bool, reason: str)
Probe = Callable[[], "tuple[bool, str]"]
@dataclass(frozen=True)
class EngineCapability:
"""État d'un moteur : disponible ou non, requis ou optionnel, raison courte."""
key: str # "camembert" | "eds" | "gliner"
label: str # libellé présentable (GUI / CLI)
available: bool
required: bool # CamemBERT-bio = moteur standard requis
reason: str # explication courte (FR), surtout si indisponible
# -- Métadonnées des moteurs (libellé + caractère requis) --------------------
_ENGINES = [
("camembert", "CamemBERT-bio (standard)", True),
("eds", "EDS-Pseudo (optionnel)", False),
("gliner", "GLiNER (optionnel)", False),
]
# -- Sondes par défaut (environnement réel) ----------------------------------
def _has_module(name: str) -> bool:
"""Vrai si le module est importable sans l'importer réellement.
`find_spec` interroge les importateurs (y compris ceux de PyInstaller en
frozen) sans exécuter le module → léger et sûr au démarrage.
"""
try:
return importlib.util.find_spec(name) is not None
except Exception:
return False
def _app_dir() -> Path:
"""Racine des ressources (modèles) : `_MEIPASS` en frozen, sinon ce dossier."""
if getattr(sys, "frozen", False):
return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
return Path(__file__).resolve().parent
def _camembert_model_path() -> Path:
return _app_dir() / "models" / "camembert-bio-deid" / "onnx" / "model.onnx"
def _eds_model_path() -> Path:
return _app_dir() / "models" / "eds-pseudo-public"
def _gliner_model_path() -> Path:
return _app_dir() / "models" / "gliner_multi_pii-v1"
def _probe_camembert() -> "tuple[bool, str]":
if not _has_module("onnxruntime"):
return False, "onnxruntime non embarqué dans cette version"
if not _camembert_model_path().exists():
return False, "modèle CamemBERT-bio ONNX absent du build"
return True, "modèle ONNX embarqué (moteur standard)"
def _probe_eds() -> "tuple[bool, str]":
missing = [m for m in ("edsnlp", "spacy") if not _has_module(m)]
if missing:
return False, "non embarqué dans cette version (manque : " + ", ".join(missing) + ")"
if not _eds_model_path().is_dir():
return False, "dépendances disponibles, modèle AP-HP eds-pseudo-public non embarqué"
return True, "edsnlp + spacy + modèle AP-HP embarqués"
def _probe_gliner() -> "tuple[bool, str]":
if not _has_module("gliner"):
return False, "non embarqué dans cette version (manque : gliner)"
if not _gliner_model_path().is_dir():
return False, "dépendance disponible, modèle GLiNER non embarqué"
return True, "gliner + modèle local embarqués"
def _default_probes() -> Dict[str, Probe]:
return {"camembert": _probe_camembert, "eds": _probe_eds, "gliner": _probe_gliner}
# -- API publique ------------------------------------------------------------
def capabilities_map(probes: Optional[Dict[str, Probe]] = None) -> Dict[str, EngineCapability]:
"""Retourne {key: EngineCapability} pour chaque moteur connu.
`probes` (injectable) mappe chaque clé moteur vers une sonde
`() -> (available, reason)`. Par défaut : sondes réelles de l'environnement.
Une sonde qui lève est traitée comme « indisponible » (fail-closed).
"""
probes = probes if probes is not None else _default_probes()
caps: Dict[str, EngineCapability] = {}
for key, label, required in _ENGINES:
probe = probes.get(key)
if probe is None:
available, reason = False, "aucune sonde fournie pour ce moteur"
else:
try:
available, reason = probe()
except Exception as exc: # noqa: BLE001 — fail-closed
available, reason = False, f"sonde en échec : {exc}"
caps[key] = EngineCapability(
key=key, label=label, available=bool(available), required=required, reason=str(reason)
)
return caps
def available_engines(probes: Optional[Dict[str, Probe]] = None) -> List[EngineCapability]:
"""Liste des moteurs réellement disponibles (ordre stable des moteurs connus)."""
caps = capabilities_map(probes)
return [caps[key] for key, _, _ in _ENGINES if caps[key].available]