143 lines
5.4 KiB
Python
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]
|