fix(gui): fail-close si CamemBERT-bio indisponible (P0-1, anti-fuite PII)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,14 @@ class ManagerState(str, Enum):
|
|||||||
UNAVAILABLE = "unavailable"
|
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
|
@dataclass
|
||||||
class EngineSettings:
|
class EngineSettings:
|
||||||
"""Réglages d'appel moteur exposés par l'onglet Configuration."""
|
"""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
|
``engine`` est injectable pour les tests ; par défaut, import paresseux de
|
||||||
``process_document`` (aucun chargement du moteur à l'import de ce module).
|
``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)
|
managers = managers if managers is not None else NerManagers(settings)
|
||||||
|
|
||||||
def process_fn(doc_path: Path, out_dir: Path) -> dict:
|
def process_fn(doc_path: Path, out_dir: Path) -> dict:
|
||||||
if settings.use_local_ner:
|
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)
|
kwargs = build_engine_kwargs(settings, managers)
|
||||||
run = engine
|
run = engine
|
||||||
if run is None:
|
if run is None:
|
||||||
|
|||||||
63
tests/unit/test_gui_v6_engine_failclose.py
Normal file
63
tests/unit/test_gui_v6_engine_failclose.py
Normal file
@@ -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"}
|
||||||
Reference in New Issue
Block a user