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

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