feat(gui): câbler les 7 toggles catégories au moteur (P1-2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:26:47 +02:00
parent daec1f53bd
commit bf832e12f0
5 changed files with 228 additions and 14 deletions

View File

@@ -9,10 +9,36 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, List, Optional
from typing import Callable, FrozenSet, List, Optional
from gui_v6.engine_bridge import EngineSettings
# Mapping centralisé champ ConfigState → CATÉGORIE moteur (Plan 1b / P1-2).
#
# Les 7 catégories doivent matcher EXACTEMENT le set accepté par
# ``anonymizer_core_refactored_onnx.process_pdf(disabled_kinds=...)`` :
# {"NOM", "DATE_NAISSANCE", "ETAB", "ADRESSE", "NIR", "TEL", "ADHERENT"}.
#
# Sémantique des booléens ``detect_*`` : True = « détecter cette catégorie »
# (= masquer, comportement par défaut). False = laisser en clair → la catégorie
# entre dans ``disabled_kinds``. Note : CODE_POSTAL suit le toggle ADRESSE côté
# moteur (décision Dom 2026-06-26), aucun toggle dédié n'est exposé.
#
# L'ordre suit les 7 lignes de ``tab_config._DETECTION_OPTIONS`` :
# Noms/prénoms · Dates de naissance · Établissements · Adresses/CP ·
# N° sécurité sociale · Téléphones/e-mails · N° adhérent mutuelle.
CATEGORY_FIELDS = {
"detect_nom": "NOM",
"detect_date_naissance": "DATE_NAISSANCE",
"detect_etab": "ETAB",
"detect_adresse": "ADRESSE",
"detect_nir": "NIR",
"detect_tel": "TEL",
"detect_adherent": "ADHERENT",
}
# Catégories canoniques (ordre = ordre des toggles UI).
DETECTION_CATEGORIES = tuple(CATEGORY_FIELDS.values())
@dataclass
class ConfigState:
@@ -33,6 +59,26 @@ class ConfigState:
mask_margin_y: int = 1
mask_rounded_corners: bool = False
# 7 toggles « Données à détecter » — tous ON par défaut (zéro changement).
detect_nom: bool = True
detect_date_naissance: bool = True
detect_etab: bool = True
detect_adresse: bool = True
detect_nir: bool = True
detect_tel: bool = True
detect_adherent: bool = True
def disabled_kinds(self) -> FrozenSet[str]:
"""Set des CATÉGORIES décochées (laissées en clair).
Défaut (tous les toggles ON) ⇒ ``frozenset()`` (no-op moteur).
"""
return frozenset(
category
for field_name, category in CATEGORY_FIELDS.items()
if not getattr(self, field_name)
)
def to_engine_settings(self, config_path: Optional[Path] = None) -> EngineSettings:
return EngineSettings(
make_vector_redaction=False,
@@ -43,6 +89,7 @@ class ConfigState:
enable_gliner=self.enable_gliner,
ogc_label=self.ogc_label,
profile=self.profile,
disabled_kinds=self.disabled_kinds(),
)

View File

@@ -19,10 +19,10 @@ Aucune logique de détection ici : on orchestre uniquement.
from __future__ import annotations
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from typing import Any, Callable, Dict, FrozenSet, Optional
from engine_capabilities import capabilities_map
@@ -61,6 +61,9 @@ class EngineSettings:
enable_gliner: bool = False
ogc_label: Optional[str] = None
profile: Optional[str] = None
# Plan 1b (P1-2) — set des CATÉGORIES laissées en clair (toggles décochés).
# Vide par défaut ⇒ aucun changement de comportement (tout est masqué).
disabled_kinds: FrozenSet[str] = field(default_factory=frozenset)
def _default_factories() -> dict[str, ManagerFactory]:
@@ -206,6 +209,8 @@ def build_engine_kwargs(
"also_make_raster_burn": settings.also_make_raster_burn,
"config_path": settings.config_path,
"ogc_label": settings.ogc_label,
# Plan 1b (P1-2) — catégories décochées laissées en clair (set vide = no-op).
"disabled_kinds": frozenset(settings.disabled_kinds or ()),
}
if managers is not None and settings.use_local_ner:
kwargs.update(managers.as_kwargs())

View File

@@ -26,14 +26,17 @@ _SUBTABS = [
("shr", "🔄 Partage"),
]
# Chaque ligne = (libellé, aide, champ ConfigState). Le champ relie le toggle
# à la catégorie moteur (cf. gui_v6.config_state.CATEGORY_FIELDS). ON = détecter
# (masquer) ; OFF = laisser en clair (entre dans disabled_kinds).
_DETECTION_OPTIONS = [
("Noms et prénoms", "Annuaire + IA"),
("Dates de naissance", "Contexte naissance"),
("Établissements", "FINESS + contexte"),
("Adresses / CP", "Voie, ville, code"),
("N° sécurité sociale", "NIR"),
("Téléphones / e-mails", "Contact"),
("N° adhérent mutuelle", "Identifiant local"),
("Noms et prénoms", "Annuaire + IA", "detect_nom"),
("Dates de naissance", "Contexte naissance", "detect_date_naissance"),
("Établissements", "FINESS + contexte", "detect_etab"),
("Adresses / CP", "Voie, ville, code", "detect_adresse"),
("N° sécurité sociale", "NIR", "detect_nir"),
("Téléphones / e-mails", "Contact", "detect_tel"),
("N° adhérent mutuelle", "Identifiant local", "detect_adherent"),
]
_MASK_COLORS = [
@@ -353,8 +356,20 @@ class ConfigTab(ctk.CTkFrame):
help_text=_HELP_DONNEES_DETECTER, help_title="Données à détecter",
)
det.pack(fill="both", expand=True)
for label, hint in _DETECTION_OPTIONS:
self._mini_toggle(det, label, hint, value=True).pack(fill="x", padx=12, pady=1)
# Les 7 toggles « Données à détecter » sont câblés sur les booléens
# detect_* de ConfigState (lecture initiale + écriture au changement).
# ON = détecter/masquer ; OFF = laisser en clair (→ disabled_kinds).
self._detect_toggles: dict[str, object] = {}
for label, hint, field_name in _DETECTION_OPTIONS:
toggle = self._mini_toggle(
det,
label,
hint,
value=bool(getattr(self._state, field_name)),
command=lambda f=field_name: self._on_detect_toggle(f),
)
toggle.pack(fill="x", padx=12, pady=1)
self._detect_toggles[field_name] = toggle
ner = ui_kit.Card(
cols[1], p, title="🧠 Moteurs et masques",
@@ -865,6 +880,16 @@ class ConfigTab(ctk.CTkFrame):
def _on_profile(self, value: str) -> None:
self._state.profile = value
def _on_detect_toggle(self, field_name: str) -> None:
"""Recopie l'état d'un toggle « Données à détecter » dans ConfigState.
ON = détecter (masquer) ; OFF = laisser en clair. ``disabled_kinds()``
de ConfigState dérive ensuite le set des catégories désactivées.
"""
toggle = self._detect_toggles.get(field_name)
if toggle is not None:
setattr(self._state, field_name, bool(toggle.get()))
def _on_ner(self) -> None:
self._state.use_local_ner = self._tog_ner.get()

View File

@@ -0,0 +1,121 @@
"""Tests du câblage des 7 toggles « Données à détecter » → moteur (Plan 1b / P1-2).
Sémantique UI : un toggle ON = « détecter cette catégorie » (= masquer).
Un toggle OFF = la catégorie est laissée en clair → elle entre dans
``disabled_kinds`` (set des CATÉGORIES désactivées passé au moteur).
Aucun widget, aucun display : on teste l'état (ConfigState) et le pont
(build_engine_kwargs / make_process_fn) en pur Python.
"""
from __future__ import annotations
from pathlib import Path
from gui_v6.config_state import (
CATEGORY_FIELDS,
DETECTION_CATEGORIES,
ConfigState,
)
from gui_v6.engine_bridge import (
EngineSettings,
NerManagers,
build_engine_kwargs,
make_process_fn,
)
# -- catégories canoniques -------------------------------------------------
def test_seven_categories_match_engine_set():
# Les 7 catégories exposées doivent matcher EXACTEMENT le set moteur.
assert set(DETECTION_CATEGORIES) == {
"NOM",
"DATE_NAISSANCE",
"ETAB",
"ADRESSE",
"NIR",
"TEL",
"ADHERENT",
}
# Un champ booléen par catégorie.
assert set(CATEGORY_FIELDS.values()) == set(DETECTION_CATEGORIES)
# -- disabled_kinds dérivé -------------------------------------------------
def test_disabled_kinds_empty_by_default():
# Défaut : tous les toggles ON ⇒ aucun désactivé (zéro changement vs aujourd'hui).
state = ConfigState()
assert state.disabled_kinds() == frozenset()
def test_disabled_kinds_unchecking_nir_and_etab():
# Décocher « N° sécurité sociale » (NIR) et « Établissements » (ETAB).
state = ConfigState(detect_nir=False, detect_etab=False)
assert state.disabled_kinds() == frozenset({"NIR", "ETAB"})
def test_disabled_kinds_all_off():
state = ConfigState(
detect_nom=False,
detect_date_naissance=False,
detect_etab=False,
detect_adresse=False,
detect_nir=False,
detect_tel=False,
detect_adherent=False,
)
assert state.disabled_kinds() == frozenset(DETECTION_CATEGORIES)
# -- propagation vers EngineSettings --------------------------------------
def test_to_engine_settings_propagates_disabled_kinds():
state = ConfigState(detect_nir=False, detect_tel=False)
settings = state.to_engine_settings()
assert settings.disabled_kinds == frozenset({"NIR", "TEL"})
def test_to_engine_settings_default_empty():
settings = ConfigState().to_engine_settings()
assert settings.disabled_kinds == frozenset()
# -- propagation dans les kwargs moteur -----------------------------------
def test_build_engine_kwargs_includes_disabled_kinds():
settings = EngineSettings(disabled_kinds=frozenset({"NIR", "ETAB"}))
kwargs = build_engine_kwargs(settings, managers=None)
assert kwargs["disabled_kinds"] == frozenset({"NIR", "ETAB"})
def test_build_engine_kwargs_default_empty_disabled_kinds():
# Défaut (set vide) = no-op : la clé est présente mais vide.
kwargs = build_engine_kwargs(EngineSettings(), managers=None)
assert kwargs["disabled_kinds"] == frozenset()
def test_process_fn_threads_disabled_kinds_to_engine(tmp_path):
settings = EngineSettings(
use_local_ner=False, disabled_kinds=frozenset({"ADRESSE"})
)
managers = NerManagers(settings)
captured = {}
def fake_engine(doc_path, out_dir, **kwargs):
captured["kwargs"] = kwargs
return {"status": "ok"}
fn = make_process_fn(settings, managers=managers, engine=fake_engine)
fn(tmp_path / "doc.pdf", tmp_path / "out")
assert captured["kwargs"]["disabled_kinds"] == frozenset({"ADRESSE"})
# -- bout-en-bout : ConfigState → settings → kwargs -----------------------
def test_end_to_end_state_to_kwargs(tmp_path):
state = ConfigState(detect_adherent=False)
settings = state.to_engine_settings(config_path=Path("/tmp/c.yml"))
kwargs = build_engine_kwargs(settings, managers=None)
assert kwargs["disabled_kinds"] == frozenset({"ADHERENT"})

View File

@@ -55,10 +55,26 @@ def test_config_interaction_contract_prebuilds_panels_and_mask_editor():
]
def test_detection_options_fields_match_category_fields():
"""Garde-fou anti-dérive : les champs déclarés dans _DETECTION_OPTIONS doivent
rester alignés (mêmes champs ET même ordre) sur CATEGORY_FIELDS, sinon un
toggle pointerait vers un attribut ConfigState inexistant (AttributeError au
lancement de la GUI au lieu d'un échec de test)."""
from gui_v6.config_state import CATEGORY_FIELDS, ConfigState
fields = [field for _l, _h, field in _DETECTION_OPTIONS]
assert fields == list(CATEGORY_FIELDS) # mêmes champs ET même ordre (ordre UI = ordre catégories)
for f in fields: # chacun est bien un booléen réel de ConfigState
assert isinstance(getattr(ConfigState(), f), bool)
def test_detection_rows_are_readable_in_light_theme():
"""Retour Dom : les sous-labels de la colonne détection doivent rester lisibles."""
assert ("Noms et prénoms", "Annuaire + IA") in _DETECTION_OPTIONS
assert ("Noms et prénoms", "Bases de données + IA") not in _DETECTION_OPTIONS
# Chaque ligne est désormais (libellé, aide, champ ConfigState) ; on ne
# vérifie ici que le couple (libellé, aide) reste lisible.
label_hint = [(label, hint) for label, hint, _field in _DETECTION_OPTIONS]
assert ("Noms et prénoms", "Annuaire + IA") in label_hint
assert ("Noms et prénoms", "Bases de données + IA") not in label_hint
assert MINI_TOGGLE_HEIGHT >= 44
assert MINI_TOGGLE_LABEL_FONT_SIZE >= 12
assert MINI_TOGGLE_HINT_FONT_SIZE >= 11