From cb3b7675bbf1edd00f73c1b16c54053ab97c88da Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Tue, 16 Jun 2026 17:38:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(engines):=20fondation=20'capabilities=20mo?= =?UTF-8?q?teurs'=20testable=20et=20partag=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- engine_capabilities.py | 130 +++++++++++++++++++++++++ tests/unit/test_engine_capabilities.py | 66 +++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 engine_capabilities.py create mode 100644 tests/unit/test_engine_capabilities.py diff --git a/engine_capabilities.py b/engine_capabilities.py new file mode 100644 index 0000000..d98bd35 --- /dev/null +++ b/engine_capabilities.py @@ -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] diff --git a/tests/unit/test_engine_capabilities.py b/tests/unit/test_engine_capabilities.py new file mode 100644 index 0000000..9cd5264 --- /dev/null +++ b/tests/unit/test_engine_capabilities.py @@ -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