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>
This commit is contained in:
2026-06-16 17:38:56 +02:00
parent 764cf00581
commit cb3b7675bb
2 changed files with 196 additions and 0 deletions

130
engine_capabilities.py Normal file
View File

@@ -0,0 +1,130 @@
"""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]

View File

@@ -0,0 +1,66 @@
"""Couche 'capabilities moteurs' : vérité testable sur les moteurs disponibles.
Un moteur n'est *disponible* que si ses dépendances (et son modèle, pour
CamemBERT) chargent réellement dans l'environnement courant. Cette couche est
consommée par la GUI (afficher/désactiver) et le CLI (`--engines`) pour que
l'application ne promette jamais un moteur qu'elle n'embarque pas.
Sondes injectables → aucun modèle, aucun réseau dans les tests.
"""
from __future__ import annotations
import engine_capabilities as ec
def _probes(camembert=True, eds=False, gliner=False):
return {
"camembert": lambda: (camembert, "ok" if camembert else "modèle absent"),
"eds": lambda: (eds, "ok" if eds else "edsnlp non embarqué"),
"gliner": lambda: (gliner, "ok" if gliner else "gliner non embarqué"),
}
def test_capabilities_map_reads_injected_probes():
caps = ec.capabilities_map(probes=_probes(camembert=True, eds=False, gliner=False))
assert set(caps) == {"camembert", "eds", "gliner"}
assert caps["camembert"].available is True
assert caps["eds"].available is False
assert caps["gliner"].available is False
def test_camembert_required_others_optional():
caps = ec.capabilities_map(probes=_probes())
assert caps["camembert"].required is True
assert caps["eds"].required is False
assert caps["gliner"].required is False
def test_reason_surfaced_when_unavailable():
caps = ec.capabilities_map(probes=_probes(eds=False))
assert "edsnlp" in caps["eds"].reason # explication présentable à l'utilisateur
# un moteur disponible expose aussi une raison non vide
assert caps["camembert"].reason
def test_available_engines_filters_unavailable():
avail = ec.available_engines(probes=_probes(camembert=True, eds=True, gliner=False))
keys = {c.key for c in avail}
assert keys == {"camembert", "eds"}
def test_labels_are_human_readable():
caps = ec.capabilities_map(probes=_probes())
assert "CamemBERT" in caps["camembert"].label
assert "EDS" in caps["eds"].label
assert "GLiNER" in caps["gliner"].label
def test_default_probes_run_without_crash_and_are_consistent():
"""Les sondes par défaut (find_spec + fichier modèle) ne crashent pas et
renvoient un booléen + une raison non vide pour chaque moteur."""
caps = ec.capabilities_map() # sondes réelles de l'environnement
assert set(caps) == {"camembert", "eds", "gliner"}
for cap in caps.values():
assert isinstance(cap.available, bool)
assert isinstance(cap.reason, str) and cap.reason