Files
anonymisation/engine_capabilities.py
Domi31tls cb3b7675bb feat(engines): fondation 'capabilities moteurs' testable et partagée
Utilitaire neutre (ni CLI ni GUI) qui dit la vérité sur les moteurs réellement
disponibles dans le build COURANT (la sonde reflète l'exécutable qui tourne, sans
présumer d'un autre build). Consommé séparément par chaque axe produit.

- `EngineCapability(key, label, available, required, reason)`.
- Sondes légères `importlib.util.find_spec` (pas d'import lourd au démarrage) +
  présence du modèle ONNX pour CamemBERT (gère _MEIPASS en frozen).
- camembert=requis ; eds (edsnlp+spacy) / gliner=optionnels. Sondes injectables,
  fail-closed. `capabilities_map()` / `available_engines()`.

6 tests (sondes injectables dispo/indispo, required, reasons, sondes réelles).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:38:56 +02:00

131 lines
4.9 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 _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) + ")"
return True, "edsnlp + spacy disponibles"
def _probe_gliner() -> "tuple[bool, str]":
if not _has_module("gliner"):
return False, "non embarqué dans cette version (manque : gliner)"
return True, "gliner disponible"
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]