diff --git a/gui_v6/engine_bridge.py b/gui_v6/engine_bridge.py index f2d4226..10723d6 100644 --- a/gui_v6/engine_bridge.py +++ b/gui_v6/engine_bridge.py @@ -41,6 +41,14 @@ class ManagerState(str, Enum): UNAVAILABLE = "unavailable" +class EngineUnavailableError(RuntimeError): + """Levée quand un moteur de détection OBLIGATOIRE n'a pas pu être chargé. + + Garantit le fail-close : on refuse de produire une sortie plutôt que de + livrer un document potentiellement non anonymisé (aligné sur le code 3 du CLI). + """ + + @dataclass class EngineSettings: """Réglages d'appel moteur exposés par l'onglet Configuration.""" @@ -216,12 +224,21 @@ def make_process_fn( ``engine`` est injectable pour les tests ; par défaut, import paresseux de ``process_document`` (aucun chargement du moteur à l'import de ce module). + + Raises: + EngineUnavailableError: si le NER obligatoire (CamemBERT-bio) est + indisponible alors que ``use_local_ner`` est actif (fail-close). """ managers = managers if managers is not None else NerManagers(settings) def process_fn(doc_path: Path, out_dir: Path) -> dict: if settings.use_local_ner: - managers.ensure_loaded() + state = managers.ensure_loaded() + if state == ManagerState.UNAVAILABLE: + raise EngineUnavailableError( + "Modèle de détection obligatoire (CamemBERT-bio) indisponible — " + "traitement refusé pour éviter une anonymisation incomplète." + ) kwargs = build_engine_kwargs(settings, managers) run = engine if run is None: diff --git a/tests/unit/test_gui_v6_engine_failclose.py b/tests/unit/test_gui_v6_engine_failclose.py new file mode 100644 index 0000000..ad51bb5 --- /dev/null +++ b/tests/unit/test_gui_v6_engine_failclose.py @@ -0,0 +1,63 @@ +"""Tests fail-close P0-1 : le moteur ne doit JAMAIS être invoqué si CamemBERT-bio +est indisponible et que use_local_ner=True. + +Garantie de sécurité : EngineUnavailableError est levée AVANT l'appel du moteur, +ce qui empêche la production d'un document potentiellement non anonymisé. +""" + +from pathlib import Path + +import pytest + +from gui_v6.engine_bridge import ( + EngineSettings, + EngineUnavailableError, + NerManagers, + make_process_fn, +) + + +def _managers_with_broken_camembert(settings): + def boom(): + raise RuntimeError("model.onnx absent") + + return NerManagers( + settings, + factories={"camembert": boom, "eds": boom, "gliner": boom}, + caps_provider=lambda: {}, + ) + + +def test_process_fn_raises_when_mandatory_ner_unavailable(): + settings = EngineSettings(use_local_ner=True) + managers = _managers_with_broken_camembert(settings) + called = {"engine": False} + + def fake_engine(*a, **k): + called["engine"] = True + return {"pdf": "x"} + + fn = make_process_fn(settings, managers=managers, engine=fake_engine) + with pytest.raises(EngineUnavailableError): + fn(Path("doc.pdf"), Path("/tmp/out")) + assert called["engine"] is False + + +def test_process_fn_runs_when_ner_ok(): + settings = EngineSettings(use_local_ner=True) + managers = NerManagers( + settings, + factories={"camembert": lambda: object(), "eds": lambda: None, "gliner": lambda: None}, + caps_provider=lambda: {}, + ) + fn = make_process_fn(settings, managers=managers, engine=lambda *a, **k: {"pdf": "ok"}) + assert fn(Path("d.pdf"), Path("/tmp/out")) == {"pdf": "ok"} + + +def test_process_fn_skips_ner_guard_when_local_ner_disabled(): + # use_local_ner=False : on ne charge pas le NER et on NE bloque PAS, + # même si les factories échoueraient (symétrique du garde-fou fail-close). + settings = EngineSettings(use_local_ner=False) + managers = _managers_with_broken_camembert(settings) + fn = make_process_fn(settings, managers=managers, engine=lambda *a, **k: {"pdf": "ok"}) + assert fn(Path("d.pdf"), Path("/tmp/out")) == {"pdf": "ok"}