From bf832e12f0d3e817c0a52828a72adcece5924a34 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Fri, 26 Jun 2026 11:26:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20c=C3=A2bler=20les=207=20toggles=20?= =?UTF-8?q?cat=C3=A9gories=20au=20moteur=20(P1-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- gui_v6/config_state.py | 49 ++++++- gui_v6/engine_bridge.py | 9 +- gui_v6/tabs/tab_config.py | 43 +++++-- tests/unit/test_gui_v6_category_toggles.py | 121 ++++++++++++++++++ .../test_gui_v6_config_mockup_sections.py | 20 ++- 5 files changed, 228 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_gui_v6_category_toggles.py diff --git a/gui_v6/config_state.py b/gui_v6/config_state.py index d5a05e7..97216b4 100644 --- a/gui_v6/config_state.py +++ b/gui_v6/config_state.py @@ -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(), ) diff --git a/gui_v6/engine_bridge.py b/gui_v6/engine_bridge.py index 10723d6..e89520d 100644 --- a/gui_v6/engine_bridge.py +++ b/gui_v6/engine_bridge.py @@ -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()) diff --git a/gui_v6/tabs/tab_config.py b/gui_v6/tabs/tab_config.py index d6697ed..3c13607 100644 --- a/gui_v6/tabs/tab_config.py +++ b/gui_v6/tabs/tab_config.py @@ -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() diff --git a/tests/unit/test_gui_v6_category_toggles.py b/tests/unit/test_gui_v6_category_toggles.py new file mode 100644 index 0000000..0e10908 --- /dev/null +++ b/tests/unit/test_gui_v6_category_toggles.py @@ -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"}) diff --git a/tests/unit/test_gui_v6_config_mockup_sections.py b/tests/unit/test_gui_v6_config_mockup_sections.py index 8a972c1..e6a5c31 100644 --- a/tests/unit/test_gui_v6_config_mockup_sections.py +++ b/tests/unit/test_gui_v6_config_mockup_sections.py @@ -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