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:
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
121
tests/unit/test_gui_v6_category_toggles.py
Normal file
121
tests/unit/test_gui_v6_category_toggles.py
Normal 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"})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user