# GUI V6 bêta — Plan 1b : câblage des 7 toggles « Données à détecter » au moteur

> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans, task-by-task. **CODE SÉCURITÉ — revue Qwen post-implémentation obligatoire** (P1-2).
> **Version 2** — intègre la revue adversariale Qwen (GO-avec-réserves, F-1..F-5) ET la vérification indépendante Claude (Dom : « réfléchis et vérifie les phases critiques »).

**Goal:** Rendre les 7 interrupteurs « Données à détecter » réellement effectifs : décocher une catégorie la laisse en clair en sortie (texte ET PDF) et relâche le filet de sécurité pour cette catégorie — **sans jamais démasquer une catégorie non décochée**.

**Architecture:** Masquage inline éclaté (≥3 passes : détection, `selective_rescan`, propagation globale, VLM, burn PDF ; ≥30 sites ; pas de chokepoint). On porte un `disabled_kinds: set[str]` via `cfg` et on applique : **(T1)** filtre de l'`audit` avant le burn PDF = **porteur de sûreté du livrable PDF**, **default-deny** ; **(T2/T3)** gates texte à toutes les passes de masquage ; **(T4)** garde-fous burn indépendants de l'audit ; **(QUAR)** coordination du rescan résiduel pour éviter la quarantaine systématique. Catégorie d'un kind **dérivée des maps sources** (anti-dérive), pas d'une table figée.

**Décisions/faits vérifiés (Claude) :**
- EDS : `PiiHit(-1, f"EDS_{label}", …)` (core:3282) ; catégorie via `EDS_LABEL_MAP` (eds_pseudo_manager.py:24).
- VLM : `VLM_CATEGORY_MAP` (vlm_manager.py:51) `label→(kind, placeholder)` — source de vérité (Qwen ratait `VLM_CP`).
- `_GLOBAL` : `PiiHit(kind=f"{kind}_GLOBAL")` (core:5286) pour `_CRITICAL_PII_TYPES` (core:5245) — **plusieurs** kinds, pas seulement NIR/ADHERENT.
- Burn : `_VECTOR/_RASTER_SKIP_KINDS` (core:4564/4723) excluent déjà `EDS_SECU/EDS_TEL/EDS_DATE_NAISSANCE` du PDF.
- **Décision CP/ZIP (TRANCHÉE Dom 2026-06-26)** : code postal (`VLM_CP`, `EDS_ZIP`, placeholder `CODE_POSTAL`) **suit le toggle « Adresses »** → catégorie `ADRESSE`. Décocher « Adresses » révèle voie + CP (rendu « 12 rue X 64100 Ville »). **Override explicite de la spec D2/D3** (qui listait CODE_POSTAL non-toggleable). **Périmètre strict = CP uniquement** : `VILLE` reste non-toggleable (toujours masquée), hors de cette décision.

**Référence spec :** `docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md` (chantier D, P1-2, D2/D3 : pas de plancher dur ; `EMAIL/IBAN/IPP/VILLE/FAX` non toggleables = toujours masqués). **NB** : `CODE_POSTAL` retiré de cette liste par décision Dom 2026-06-26 (suit « Adresses »).

---

### Task 1 : `_category_of` DÉRIVÉ des maps sources + filtre audit Tier 1 (F-1)

**Files:** Modify `anonymizer_core_refactored_onnx.py` (helper near placeholders ~l.610 ; `disabled_kinds` kwarg on `process_pdf` ~l.4973 ; inject into `cfg` after ~l.5002 ; audit filter before PDF write ~l.5553). Test `tests/unit/test_core_category_gating.py`.

**Approche (anti-dérive)** : ne PAS hardcoder une table exhaustive (elle dérive). Catégorie d'un kind dérivée ainsi, dans l'ordre :
1. suffixe `_GLOBAL` retiré → re-catégoriser la base (`NIR_GLOBAL`→`NIR`) ;
2. table explicite des kinds regex/inline non dérivables ;
3. kind == un placeholder toggleable lui-même ;
4. `VLM_*` → placeholder via reverse de `VLM_CATEGORY_MAP` ;
5. `EDS_*` → label → placeholder via `EDS_LABEL_MAP` ;
6. sinon `None` (**default-deny → toujours masqué**).

- [ ] **Step 1 — Failing test.** Create `tests/unit/test_core_category_gating.py`. Couvre UN kind de CHAQUE source + default-deny + une **garde anti-dérive** :

```python
import anonymizer_core_refactored_onnx as core


def test_category_of_each_source():
    assert core._category_of("NOM_FORCE") == "NOM"          # explicite/regex
    assert core._category_of("NIR") == "NIR"                # placeholder-self
    assert core._category_of("NIR_GLOBAL") == "NIR"         # suffixe _GLOBAL
    assert core._category_of("ADHERENT_GLOBAL") == "ADHERENT"
    assert core._category_of("VLM_NOM") == "NOM"            # dérivé VLM
    assert core._category_of("VLM_ETAB") == "ETAB"
    assert core._category_of("EDS_SECU") == "NIR"           # dérivé EDS (SECU→NIR)
    assert core._category_of("EDS_HOPITAL") == "ETAB"
    assert core._category_of("VLM_CP") == "ADRESSE"          # CP suit « Adresses » (Dom 2026-06-26)
    assert core._category_of("EDS_ZIP") == "ADRESSE"


def test_category_of_default_deny():
    # Non toggleables → None (restent TOUJOURS masqués). Sécurité.
    # NB : VILLE reste masquée ; seul CODE_POSTAL (VLM_CP/EDS_ZIP) a été basculé vers ADRESSE.
    for k in ("EMAIL", "IBAN", "IPP", "VILLE", "FAX",
              "VLM_VILLE", "EMAIL_GLOBAL", "INCONNU_XYZ"):
        assert core._category_of(k) is None, k


def test_no_toggleable_vlm_or_eds_kind_is_uncategorised():
    # ANTI-DÉRIVE : tout kind VLM/EDS dont le placeholder est une des 7 catégories
    # DOIT être catégorisé (sinon toggle faussé sur ce chemin).
    import vlm_manager, eds_pseudo_manager
    seven = {"NOM", "DATE_NAISSANCE", "ETAB", "ADRESSE", "NIR", "TEL", "ADHERENT"}
    for _label, (kind, placeholder) in vlm_manager.VLM_CATEGORY_MAP.items():
        if core._placeholder_to_category(placeholder) in seven:
            assert core._category_of(kind) is not None, f"VLM {kind} non catégorisé"
    for label, placeholder in eds_pseudo_manager.EDS_LABEL_MAP.items():
        if core._placeholder_to_category(placeholder) in seven:
            assert core._category_of(f"EDS_{label}") is not None, f"EDS_{label} non catégorisé"


def test_filter_audit_drops_only_disabled():
    PiiHit = core.PiiHit
    audit = [PiiHit(1, "NOM", "Dupont", "[NOM]"), PiiHit(1, "NIR", "1850574", "[NIR]"),
             PiiHit(1, "EMAIL", "x@y.fr", "[EMAIL]"), PiiHit(1, "NIR_GLOBAL", "1850574", "[NIR]")]
    kinds = {h.kind for h in core._filter_audit_by_disabled(audit, {"NIR"})}
    assert "NIR" not in kinds and "NIR_GLOBAL" not in kinds  # NIR + propagation retirés
    assert "NOM" in kinds and "EMAIL" in kinds               # autres conservés
```

- [ ] **Step 2 — Run, expect FAIL.**

- [ ] **Step 3 — Implement** (after `PLACEHOLDERS`/`CRITICAL_PII_KEYS` ~l.610). Lire `vlm_manager.VLM_CATEGORY_MAP` et `eds_pseudo_manager.EDS_LABEL_MAP` au moment de l'implémentation pour confirmer les noms ; importer ces deux modules en tête du core (imports déjà présents pour VLM ? sinon import paresseux dans le helper). **Convention admin_rules (vérifiée Qwen — documenter, pas de branche supplémentaire)** : `_apply_admin_identifier_hits` (~l.1376) émet des kinds = **clés de `PLACEHOLDERS`** (ex. "NOM", "NIR", "TEL") → captés par la branche 3 (placeholder-self). Un admin_rule à kind custom hors `PLACEHOLDERS` → `None` → toujours masqué (conservateur, sûr).

```python
# 7 catégories toggleables ↔ type de placeholder. Tout autre placeholder → None (masqué).
_PLACEHOLDER_TO_CATEGORY = {
    "NOM": "NOM", "DATE_NAISSANCE": "DATE_NAISSANCE", "ETAB": "ETAB",
    "ADRESSE": "ADRESSE", "NIR": "NIR", "TEL": "TEL", "ADHERENT": "ADHERENT",
    "CODE_POSTAL": "ADRESSE",  # décision Dom 2026-06-26 : CP suit le toggle « Adresses »
}
# Kinds regex/inline non dérivables d'une map → leur catégorie explicitement.
_EXPLICIT_KIND_CATEGORY = {
    "NOM_FORCE": "NOM", "NOM_EXTRACTED": "NOM", "NOM_INITIAL": "NOM",
    "NER_PER": "NOM", "NER_ORG": "ETAB",
    "ETAB_FINESS": "ETAB", "ETAB_SPACED": "ETAB",
    "ADDR_FINESS": "ADRESSE",
}


def _placeholder_to_category(placeholder):
    return _PLACEHOLDER_TO_CATEGORY.get(str(placeholder).strip("[]").upper())


def _category_of(kind):
    """Catégorie toggleable d'un kind d'audit, ou None (default-deny → masqué)."""
    if not kind:
        return None
    if kind.endswith("_GLOBAL"):
        return _category_of(kind[: -len("_GLOBAL")])
    if kind in _EXPLICIT_KIND_CATEGORY:
        return _EXPLICIT_KIND_CATEGORY[kind]
    if kind in _PLACEHOLDER_TO_CATEGORY:
        return _PLACEHOLDER_TO_CATEGORY[kind]
    if kind.startswith("VLM_"):
        try:
            import vlm_manager
            rev = {k: ph for (k, ph) in vlm_manager.VLM_CATEGORY_MAP.values()}
            return _placeholder_to_category(rev.get(kind))
        except Exception:
            return None
    if kind.startswith("EDS_"):
        try:
            import eds_pseudo_manager
            label = kind[len("EDS_"):]
            ph = eds_pseudo_manager.EDS_LABEL_MAP.get(label, label)
            return _placeholder_to_category(ph)
        except Exception:
            return None
    return None


def _filter_audit_by_disabled(audit, disabled_kinds):
    if not disabled_kinds:
        return audit
    return [h for h in audit if _category_of(h.kind) not in disabled_kinds]
```

Add `disabled_kinds: set = None` kwarg to `process_pdf` (~l.4973). After `cfg = load_dictionaries(config_path)` (~l.5002) : `cfg["disabled_kinds"] = set(disabled_kinds or ())`. Before the PDF write (~l.5553) : `anon.audit = _filter_audit_by_disabled(anon.audit, cfg.get("disabled_kinds") or set())` (adapter `anon.audit` au nom réel de la liste de `PiiHit`).

- [ ] **Step 4 — Run, expect PASS** (incl. anti-dérive).
- [ ] **Step 5 — Non-régression :** `.venv/bin/pytest tests/unit/ -q` (defaults `None` ⇒ 0 changement).
- [ ] **Step 6 — Commit :** `git add anonymizer_core_refactored_onnx.py tests/unit/test_core_category_gating.py && git commit -m "feat(core): _category_of dérivé (anti-dérive) + filtre audit Tier 1 (P1-2/F-1)"`

---

### Task 2 : Quarantaine — coordination NIR/TEL décochés (F-4, le plus subtil)

**Files:** Modify `anonymizer_core_refactored_onnx.py` (`_residual_pii_patterns` ~l.5453-5458). Test extend.

**Faits (F-4)** : 3 pré-quarantaines + 1 piège. (a) `selective_rescan` re-masque NIR/TEL de force (gaté en Task 3) ; (b) propagation globale NIR_GLOBAL (gatée Task 3) ; (c) `_residual_pii_patterns` (seuil=0) → 1 résidu = quarantaine totale. **Piège** : NIR laissé en clair → le pattern TEL résiduel matche ses 10 chiffres centraux → quarantaine injustifiée.

- [ ] **Step 1 — Failing test.** Refactor inline patterns into `_build_residual_patterns(disabled_kinds)` and test : labels include {NIR,EMAIL,IBAN,TEL} when none disabled ; NIR absent when `{"NIR"}` (EMAIL/IBAN restent) ; TEL absent when `{"TEL"}` ; **et** quand NIR disabled, le pattern TEL ne matche PAS un NIR en clair (test : `_build_residual_patterns({"NIR"})` appliqué à « 1 85 05 74 123 456 78 » → 0 match).
- [ ] **Step 2 — Run, expect FAIL.**
- [ ] **Step 3 — Implement.** `_build_residual_patterns(disabled)` : EMAIL+IBAN toujours ; NIR si `"NIR" not in disabled` ; TEL si `"TEL" not in disabled`. **Quand NIR disabled, pré-masquer les spans NIR-like (13-15 chiffres groupés) UNIQUEMENT pour le scan TEL** (PAS EMAIL/IBAN/NIR — sinon on efface la queue numérique d'un IBAN et on affaiblit son backstop toujours-actif). Gate la branche INSEE-names (~l.5470) sous `"NOM" not in disabled`. Brancher `cfg.get("disabled_kinds")`. **Seuil résiduel : rester strict à `SEUIL_RESCAN_RESIDUEL` (0), INCONDITIONNEL.** Un seuil relâché (1 quand des catégories décochées, suggestion initiale Qwen) affaiblirait globalement les backstops toujours-actifs EMAIL/IBAN (un vrai email fuité ne quarantainerait plus). La contamination croisée (donnée d'une catégorie décochée matchant un pattern actif) **n'apparaît qu'après Task 3** (avant, le texte masque encore tout) et y est traitée **span-précisément** (cf. Task 3), seuil restant 0. **[Divergence assumée vs suggestion Qwen seuil adaptatif — corrigée en revue qualité Claude, à confirmer en re-revue Qwen.]**
- [ ] **Step 4 — Run, expect PASS.**
- [ ] **Step 5 — Non-régression :** `.venv/bin/pytest tests/unit/ -q`.
- [ ] **Step 6 — Commit :** `git commit -m "feat(core): coordination quarantaine résiduelle NIR/TEL décochés (P1-2/F-4)"`

---

### Task 3 : Gates texte — TOUTES les passes de masquage (F-2 + F-5)

**Files:** `anonymizer_core_refactored_onnx.py`, sites ci-dessous. Test `tests/unit/test_core_category_gating_behavior.py`.

**Sites à gater (liste consolidée Claude+Qwen — lire chaque site avant édition ; pattern : `disabled = cfg.get("disabled_kinds") or set()`, sauter le sous-bloc de la catégorie désactivée) :**
- Dispatchers : `_mask_line_by_regex` (~1670), `_kv_value_only_mask` (~2110, subs NOM/label 2098-2106), bloc PERSON-majuscules (~1942-2008→NOM).
- KV distincts (F-2) : `_mask_structured_line` (~2042→ADHERENT/NOM), `_mask_critical_in_key` (~2004→TEL/ADRESSE), `_apply_admin_identifier_hits` (~1376→dynamique : OK via default-deny si kind mappable).
- Noms : `_apply_extracted_names` (~2809→early-return si NOM off).
- **NER — INTRA-BOUCLE par placeholder (F-5, point le plus fragile)** : `_mask_with_hf` (~3136) et `_mask_with_eds_pseudo` (~3208) — **NE PAS** sauter la fonction entière (perdrait ETAB/VILLE) ; sauter **par hit** selon `_category_of(kind)`/placeholder.
- Trackare (F-2) : `_apply_trackare_hits_to_text` (~2909→NIR/DOSSIER) — gate NIR ; **`RE_TRACKARE_IAO_MULTILINE_VALUE` (~3102→NOM_FORCE) — gate NOM** (site ajouté re-revue Qwen ; sinon NOM faussé sur docs Trackare IAO).
- selective_rescan (~4159) : DATE_NAISSANCE(4203), ADRESSE(4205-4207), ETAB(4229-4251), ADHERENT(4200-4201), TEL(4191-4193), NIR(4187-4188).
- Phase-0 multiline : DATE_NAISSANCE(~3014), NIR(~3034).
- **Propagation globale step 4e (F-2 #1, F-4)** : boucle `_CRITICAL_PII_TYPES`/`{kind}_GLOBAL` (~5279-5286) — ne pas propager une catégorie désactivée.
- **Résiduel post-gating (coordination Task 2, F-4)** : une fois une catégorie laissée EN CLAIR dans le texte, **pré-masquer ses spans avant le scan résiduel de quarantaine** (depuis les hits filtrés de l'audit Task 1 — les capturer au moment du filtre, ou recomposer) pour éviter une quarantaine injustifiée. **Le seuil résiduel reste 0** (backstops EMAIL/IBAN/NIR/TEL stricts — un vrai leak quarantaine toujours). C'est le pendant texte du premask NIR⇄TEL de Task 2, généralisé aux catégories décochées.
- **VLM (F-2 #2, CRITIQUE scanné)** : `_apply_vlm_on_scanned_pdf` (~4898-4965) — masque dans texte+raster indépendamment ; gate par `_category_of(vlm_kind)`.
- Post-mask cleanups (F-2 #6) : NOM orphan (~5098/5137/5148), TEL fragment (~5118/5128).

- [ ] **Step 1 — Failing behavioral tests.** Create `tests/unit/test_core_category_gating_behavior.py` : pour CHAQUE catégorie, un `pages_text` avec un cas clair de cette catégorie + un cas d'une AUTRE catégorie ; `anonymise_document_regex(pages, [], cfg_disabled)` ⇒ la catégorie décochée est PRÉSENTE en clair, l'autre RESTE masquée. **+ baseline « tout activé = non-régression »** (rien en clair). Vérifier la forme de retour réelle de `anonymise_document_regex` d'abord. Crafter des entrées valides en lisant les vraies regex. (Le chemin VLM se teste avec un faux `vlm_manager` injecté ou un test ciblé sur `_apply_vlm_on_scanned_pdf`.)
- [ ] **Step 2 — Run, expect FAIL.**
- [ ] **Step 3 — Implement** site par site ; re-run le test de la catégorie après chaque.
- [ ] **Step 4 — Run, expect ALL PASS.**
- [ ] **Step 5 — Non-régression + qualité :** `.venv/bin/pytest tests/unit/ -q` + `.venv/bin/python scripts/evaluate_quality.py` (A+ maintenu défauts). Gate `synthetic_regression` vert.
- [ ] **Step 6 — Commit :** `git commit -m "feat(core): gates texte par catégorie sur toutes les passes (P1-2/F-2/F-5)"`

---

### Task 4 : Garde-fous burn indépendants de l'audit + alignement SKIP_KINDS (F-3)

**Files:** `anonymizer_core_refactored_onnx.py` (`_search_pdf_address_lines` calls ~4575/4746 ; `_VECTOR/_RASTER_SKIP_KINDS` ~4564/4723).

**Faits (F-3)** : burn dérive 100% de l'audit POUR les PII détectées (donc T1 suffit), SAUF 3 chemins indépendants — adresse (à gater), images/barcodes (conservateurs, hors scope toggle, **à documenter**). `_SKIP_KINDS` exclut déjà EDS_SECU/EDS_TEL/EDS_DATE_NAISSANCE du PDF (cohérent avec un futur toggle).

- [ ] **Step 1 — Failing test :** ADRESSE désactivé ⇒ `_search_pdf_address_lines` skippé. (Lire comment `disabled_kinds` atteint `redact_pdf_*` — passer le set en param ou via `cfg`.)
- [ ] **Step 2 — Run, expect FAIL.**
- [ ] **Step 3 — Implement :** guard les 2 appels `_search_pdf_address_lines(page)` sous `if "ADRESSE" not in disabled_kinds:`. Documenter en commentaire que images/barcodes restent conservateurs (sur-masquage assumé, jamais de fuite). Vérifier que `_SKIP_KINDS` n'entre pas en conflit avec le toggle DATE_NAISSANCE (sinon ajuster).
- [ ] **Step 4 — Run, expect PASS.**
- [ ] **Step 5 — Non-régression :** `.venv/bin/pytest tests/unit/ -q`.
- [ ] **Step 6 — Commit :** `git commit -m "feat(core): garde-fou adresse burn + doc chemins conservateurs (P1-2/F-3)"`

---

### Task 5 : Câblage GUI — 7 booléens → moteur

(Inchangé vs v1 — voir historique git ; `gui_v6/config_state.py` 7 bool + `disabled_kinds`, `engine_bridge.EngineSettings`/`build_engine_kwargs`, `tab_config.py` les 7 `_mini_toggle` câblés.)

- [ ] Tests `tests/unit/test_gui_v6_category_toggles.py` (défaut tous ON ⇒ `disabled_kinds == frozenset()` ; décocher NIR+ETAB ⇒ `{"NIR","ETAB"}` ; `build_engine_kwargs` propage). Implémenter, self-test, non-régression GUI, commit `feat(gui): câbler les 7 toggles catégories au moteur (P1-2)`.

---

## Self-review (couverture spec + revue Qwen + vérif Claude)
- **F-1** : `_category_of` dérivé (VLM_CATEGORY_MAP + EDS_LABEL_MAP + suffixe _GLOBAL) + **test anti-dérive** → couvre les 15 kinds de Qwen ET ceux qu'il a ratés. Default-deny. **CODE_POSTAL→ADRESSE** (décision Dom 2026-06-26). ✓
- **F-2** : Task 3 liste consolidée incl. propagation globale, VLM, Trackare, structured/critical, cleanups. ✓
- **F-3** : Task 4 adresse gaté + images/barcodes documentés conservateurs + SKIP_KINDS vérifiés. ✓
- **F-4** : Task 2 coordonne résiduel + **exclusion NIR-like du scan TEL UNIQUEMENT** (backstop IBAN préservé) + **seuil résiduel strict 0** (pas de relâchement aveugle des backstops EMAIL/IBAN) + gate selective_rescan/propagation (Task 3). Contamination croisée des catégories décochées traitée span-précisément en Task 3. ✓
- **F-5** : Task 3 impose le gating NER **intra-boucle** (per-hit), jamais per-function. ✓
- **Risque résiduel** : un site oublié ⇒ catégorie reste masquée (test rouge), JAMAIS fuite croisée (default-deny + filtre par catégorie). Livrable PDF garanti par T1 seul.
- **Décision Dom 2026-06-26 (TRANCHÉE)** : CODE_POSTAL (CP/ZIP) → catégorie ADRESSE (suit le toggle « Adresses »). VILLE reste toujours masquée (hors décision).
- **Re-revue Qwen post-implémentation obligatoire** (Tasks 1-4).
