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>
131 lines
4.9 KiB
Python
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]
|