"""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]