docs(beta): plan 1b v2 — intègre revue Qwen + vérif Claude (gating cœur P1-2)
Revue adversariale Qwen = GO-avec-réserves (F-1 critique : 15 kinds manquants VLM/EDS/ _GLOBAL ; F-2 24+ sites ; F-3 burn ; F-4 quarantaine NIR/TEL ; F-5 NER intra-boucle). Vérif indépendante Claude : table Qwen elle-même incomplète (VLM_CP raté). Décision : _category_of DÉRIVÉ de VLM_CATEGORY_MAP + EDS_LABEL_MAP + suffixe _GLOBAL + test anti-dérive, au lieu d'une table figée. Sites consolidés, coordination quarantaine, gating NER intra-boucle, garde-fous burn. Sauvegarde avant implémentation (consigne Dom). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,247 +1,215 @@
|
|||||||
# GUI V6 bêta — Plan 1b : câblage des 7 toggles « Données à détecter » au moteur
|
# GUI V6 bêta — Plan 1b : câblage des 7 toggles « Données à détecter » au moteur
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans, task-by-task. Steps use checkbox (`- [ ]`). **CODE SÉCURITÉ — revue Qwen obligatoire** (décision spec P1-2).
|
> **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.
|
**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, ~50 sites, pas de chokepoint). On porte un `disabled_kinds: set[str]` via `cfg` (déjà threadé partout) et on applique un **filtre 3-tiers** : (T1) filtrer l'`audit` avant le burn PDF = porteur de sûreté pour le livrable PDF, **default-deny** ; (T2) gater le texte aux fonctions dispatcher + `selective_rescan` ; (T3) gater les blocs phase-0 multiline. Plus la relaxation du rescan résiduel (NIR/TEL) et un garde-fou adresse. Filet de validation = **tests comportementaux end-to-end par catégorie**.
|
**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.
|
||||||
|
|
||||||
**Tech Stack:** Python, pytest. Fichier cœur `anonymizer_core_refactored_onnx.py` (5731 l.) + `gui_v6/`.
|
**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** : code postal (`VLM_CP`, `EDS_ZIP`, placeholder `CODE_POSTAL`) = **PAS** dans les 7 toggles → **toujours masqué** (conservateur ; « Adresses » révèle la voie, pas le CP). À confirmer Dom.
|
||||||
|
|
||||||
**Référence spec :** `docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md` (chantier D, P1-2, décisions D2/D3 : pas de plancher dur ; `EMAIL/IBAN/IPP/VILLE/FAX` non toggleables = toujours masqués).
|
**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/CODE_POSTAL` non toggleables = toujours masqués).
|
||||||
|
|
||||||
**Mapping catégorie → kinds d'audit** (`_CATEGORY_OF`, default-deny : tout kind absent reste masqué) :
|
|
||||||
- NOM ← NOM, NOM_FORCE, NOM_GLOBAL, NOM_EXTRACTED, NOM_INITIAL, NER_PER, EDS_NOM, EDS_PRENOM
|
|
||||||
- DATE_NAISSANCE ← DATE_NAISSANCE, DATE_NAISSANCE_GLOBAL
|
|
||||||
- ETAB ← ETAB, ETAB_FINESS, ETAB_SPACED, ETAB_GLOBAL, NER_ORG, EDS_HOPITAL
|
|
||||||
- ADRESSE ← ADRESSE, ADDR_FINESS, EDS_ADRESSE *(VILLE/NER_LOC restent toujours masqués — hors des 7 toggles)*
|
|
||||||
- NIR ← NIR
|
|
||||||
- TEL ← TEL *(FAX reste toujours masqué)*
|
|
||||||
- ADHERENT ← ADHERENT
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 1 : Infrastructure — `disabled_kinds` + `_CATEGORY_OF` + filtre audit (Tier 1)
|
### Task 1 : `_category_of` DÉRIVÉ des maps sources + filtre audit Tier 1 (F-1)
|
||||||
|
|
||||||
**Files:** Modify `anonymizer_core_refactored_onnx.py` (add `_CATEGORY_OF`/`_category_of` near placeholders ~l.610 ; add `disabled_kinds` kwarg to `process_pdf` ~l.4973 ; inject into `cfg` after ~l.5002 ; add the audit filter before the PDF write ~l.5553). Test `tests/unit/test_core_category_gating.py`.
|
**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`.
|
||||||
|
|
||||||
- [ ] **Step 1 — Failing test (audit filter + default-deny).** Create `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
|
```python
|
||||||
import anonymizer_core_refactored_onnx as core
|
import anonymizer_core_refactored_onnx as core
|
||||||
|
|
||||||
|
|
||||||
def test_category_of_maps_known_kinds():
|
def test_category_of_each_source():
|
||||||
assert core._category_of("NOM_FORCE") == "NOM"
|
assert core._category_of("NOM_FORCE") == "NOM" # explicite/regex
|
||||||
assert core._category_of("NER_PER") == "NOM"
|
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("EDS_HOPITAL") == "ETAB"
|
||||||
assert core._category_of("ADDR_FINESS") == "ADRESSE"
|
|
||||||
assert core._category_of("NIR") == "NIR"
|
|
||||||
assert core._category_of("TEL") == "TEL"
|
|
||||||
assert core._category_of("ADHERENT") == "ADHERENT"
|
|
||||||
|
|
||||||
|
|
||||||
def test_category_of_default_deny_for_unknown():
|
def test_category_of_default_deny():
|
||||||
# Un kind non mappé NE doit JAMAIS être filtrable (reste masqué). Sécurité.
|
# Non toggleables → None (restent TOUJOURS masqués). Sécurité.
|
||||||
assert core._category_of("EMAIL") is None
|
for k in ("EMAIL", "IBAN", "IPP", "VILLE", "FAX", "VLM_CP", "EDS_ZIP",
|
||||||
assert core._category_of("IBAN") is None
|
"VLM_VILLE", "EMAIL_GLOBAL", "INCONNU_XYZ"):
|
||||||
assert core._category_of("VILLE") is None
|
assert core._category_of(k) is None, k
|
||||||
assert core._category_of("FAX") is None
|
|
||||||
assert core._category_of("INCONNU_XYZ") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_filter_audit_drops_only_disabled_categories():
|
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
|
PiiHit = core.PiiHit
|
||||||
audit = [
|
audit = [PiiHit(1, "NOM", "Dupont", "[NOM]"), PiiHit(1, "NIR", "1850574", "[NIR]"),
|
||||||
PiiHit(1, "NOM", "Dupont", "[NOM]"),
|
PiiHit(1, "EMAIL", "x@y.fr", "[EMAIL]"), PiiHit(1, "NIR_GLOBAL", "1850574", "[NIR]")]
|
||||||
PiiHit(1, "NIR", "1850574...", "[NIR]"),
|
kinds = {h.kind for h in core._filter_audit_by_disabled(audit, {"NIR"})}
|
||||||
PiiHit(1, "EMAIL", "x@y.fr", "[EMAIL]"),
|
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
|
||||||
kept = core._filter_audit_by_disabled(audit, {"NIR"})
|
|
||||||
kinds = {h.kind for h in kept}
|
|
||||||
assert "NIR" not in kinds # NIR décoché → retiré
|
|
||||||
assert "NOM" in kinds # non décoché → conservé
|
|
||||||
assert "EMAIL" in kinds # non toggleable → toujours conservé
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2 — Run, expect FAIL** (`_category_of`/`_filter_audit_by_disabled` absent): `.venv/bin/pytest tests/unit/test_core_category_gating.py -v`.
|
- [ ] **Step 2 — Run, expect FAIL.**
|
||||||
|
|
||||||
- [ ] **Step 3 — Implement.** In `anonymizer_core_refactored_onnx.py`, after the `PLACEHOLDERS`/`CRITICAL_PII_KEYS` block (~l.610), add:
|
- [ ] **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).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# --- Gating par catégorie (toggles GUI « Données à détecter ») -------------
|
# 7 catégories toggleables ↔ type de placeholder. Tout autre placeholder → None (masqué).
|
||||||
# Mappe chaque kind d'audit vers l'une des 7 catégories toggleables. Tout kind
|
_PLACEHOLDER_TO_CATEGORY = {
|
||||||
# ABSENT de cette table est NON filtrable (default-deny → reste masqué). Les
|
"NOM": "NOM", "DATE_NAISSANCE": "DATE_NAISSANCE", "ETAB": "ETAB",
|
||||||
# catégories non toggleables (EMAIL/IBAN/IPP/VILLE/FAX/…) ne figurent pas ici.
|
"ADRESSE": "ADRESSE", "NIR": "NIR", "TEL": "TEL", "ADHERENT": "ADHERENT",
|
||||||
_CATEGORY_OF: dict[str, str] = {
|
}
|
||||||
"NOM": "NOM", "NOM_FORCE": "NOM", "NOM_GLOBAL": "NOM",
|
# Kinds regex/inline non dérivables d'une map → leur catégorie explicitement.
|
||||||
"NOM_EXTRACTED": "NOM", "NOM_INITIAL": "NOM",
|
_EXPLICIT_KIND_CATEGORY = {
|
||||||
"NER_PER": "NOM", "EDS_NOM": "NOM", "EDS_PRENOM": "NOM",
|
"NOM_FORCE": "NOM", "NOM_EXTRACTED": "NOM", "NOM_INITIAL": "NOM",
|
||||||
"DATE_NAISSANCE": "DATE_NAISSANCE", "DATE_NAISSANCE_GLOBAL": "DATE_NAISSANCE",
|
"NER_PER": "NOM", "NER_ORG": "ETAB",
|
||||||
"ETAB": "ETAB", "ETAB_FINESS": "ETAB", "ETAB_SPACED": "ETAB",
|
"ETAB_FINESS": "ETAB", "ETAB_SPACED": "ETAB",
|
||||||
"ETAB_GLOBAL": "ETAB", "NER_ORG": "ETAB", "EDS_HOPITAL": "ETAB",
|
"ADDR_FINESS": "ADRESSE",
|
||||||
"ADRESSE": "ADRESSE", "ADDR_FINESS": "ADRESSE", "EDS_ADRESSE": "ADRESSE",
|
|
||||||
"NIR": "NIR",
|
|
||||||
"TEL": "TEL",
|
|
||||||
"ADHERENT": "ADHERENT",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _category_of(kind: str) -> str | None:
|
def _placeholder_to_category(placeholder):
|
||||||
"""Catégorie toggleable d'un kind d'audit, ou None si non toggleable."""
|
return _PLACEHOLDER_TO_CATEGORY.get(str(placeholder).strip("[]").upper())
|
||||||
return _CATEGORY_OF.get(kind)
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_audit_by_disabled(audit: list, disabled_kinds: set) -> list:
|
def _category_of(kind):
|
||||||
"""Retire de l'audit les hits dont la catégorie est désactivée (default-deny)."""
|
"""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:
|
if not disabled_kinds:
|
||||||
return audit
|
return audit
|
||||||
return [h for h in audit if _category_of(h.kind) not in disabled_kinds]
|
return [h for h in audit if _category_of(h.kind) not in disabled_kinds]
|
||||||
```
|
```
|
||||||
|
|
||||||
Add the kwarg to `process_pdf` (signature ~l.4973-4987): append `disabled_kinds: set = None,`. After `cfg = load_dictionaries(config_path)` (~l.5002), add:
|
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`).
|
||||||
```python
|
|
||||||
cfg["disabled_kinds"] = set(disabled_kinds or ())
|
|
||||||
```
|
|
||||||
Before the PDF-writing block (~l.5553, right before `if make_vector_redaction:`), add:
|
|
||||||
```python
|
|
||||||
# Tier 1 : retirer du livrable PDF les catégories désactivées par l'utilisateur.
|
|
||||||
anon.audit = _filter_audit_by_disabled(anon.audit, cfg.get("disabled_kinds") or set())
|
|
||||||
```
|
|
||||||
(Adapt `anon.audit` to the actual audit variable name at that point — read the surrounding code; it is the list of `PiiHit` passed to `redact_pdf_vector`/`redact_pdf_raster`.)
|
|
||||||
|
|
||||||
- [ ] **Step 4 — Run, expect PASS:** `.venv/bin/pytest tests/unit/test_core_category_gating.py -v`.
|
- [ ] **Step 4 — Run, expect PASS** (incl. anti-dérive).
|
||||||
- [ ] **Step 5 — Non-régression:** `.venv/bin/pytest tests/unit/ -q` (expect prior count, 0 regression — defaults `disabled_kinds=None` ⇒ no behavior change).
|
- [ ] **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): infra gating par catégorie + filtre audit Tier 1 (P1-2)"`
|
- [ ] **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 : Relaxation du rescan résiduel (NIR/TEL) — couplage sécurité D3
|
### 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 + INSEE-names branch ~l.5470-5490). Test `tests/unit/test_core_category_gating.py` (extend).
|
**Files:** Modify `anonymizer_core_refactored_onnx.py` (`_residual_pii_patterns` ~l.5453-5458). Test extend.
|
||||||
|
|
||||||
- [ ] **Step 1 — Failing test.** Add to `tests/unit/test_core_category_gating.py` a test that the residual-pattern builder skips NIR/TEL when disabled. First read the code around l.5449-5519 to expose the pattern-building as a testable helper `_build_residual_patterns(disabled_kinds)` (refactor the inline list into this helper). Test:
|
**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.
|
||||||
|
|
||||||
```python
|
|
||||||
def test_residual_patterns_skip_disabled_nir_tel():
|
|
||||||
labels_all = {lbl for _pat, lbl in core._build_residual_patterns(set())}
|
|
||||||
assert {"NIR", "EMAIL", "IBAN", "TEL"} <= labels_all
|
|
||||||
labels_no_nir = {lbl for _pat, lbl in core._build_residual_patterns({"NIR"})}
|
|
||||||
assert "NIR" not in labels_no_nir
|
|
||||||
assert "EMAIL" in labels_no_nir and "IBAN" in labels_no_nir # non toggleables restent
|
|
||||||
labels_no_tel = {lbl for _pat, lbl in core._build_residual_patterns({"TEL"})}
|
|
||||||
assert "TEL" not in labels_no_tel
|
|
||||||
```
|
|
||||||
|
|
||||||
|
- [ ] **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 2 — Run, expect FAIL.**
|
||||||
- [ ] **Step 3 — Implement.** Refactor the inline `_residual_pii_patterns` (~l.5453-5458) into a module function `_build_residual_patterns(disabled_kinds: set) -> list[tuple]` that always includes EMAIL+IBAN, includes NIR only if `"NIR" not in disabled_kinds`, includes TEL only if `"TEL" not in disabled_kinds`. Call it in the residual check with `cfg.get("disabled_kinds") or set()`. Gate the opt-in INSEE-names branch (~l.5470) additionally under `"NOM" not in disabled`.
|
- [ ] **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, le pattern TEL doit exclure les spans de type NIR** (13-15 chiffres groupés) — soit en pré-masquant les spans NIR-like, soit en bornant le pattern TEL. Gate la branche INSEE-names (~l.5470) sous `"NOM" not in disabled`. Brancher `cfg.get("disabled_kinds")`.
|
||||||
- [ ] **Step 4 — Run, expect PASS.**
|
- [ ] **Step 4 — Run, expect PASS.**
|
||||||
- [ ] **Step 5 — Non-régression:** `.venv/bin/pytest tests/unit/ -q`.
|
- [ ] **Step 5 — Non-régression :** `.venv/bin/pytest tests/unit/ -q`.
|
||||||
- [ ] **Step 6 — Commit:** `git commit -m "feat(core): relâcher le rescan résiduel pour NIR/TEL décochés (P1-2/D3)"`
|
- [ ] **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 (Tier 2 + Tier 3) — passes de détection + selective_rescan
|
### Task 3 : Gates texte — TOUTES les passes de masquage (F-2 + F-5)
|
||||||
|
|
||||||
**Files:** Modify `anonymizer_core_refactored_onnx.py` at the dispatcher sites listed below. Test `tests/unit/test_core_category_gating_behavior.py` (behavioral, end-to-end on `anonymise_document_regex`).
|
**Files:** `anonymizer_core_refactored_onnx.py`, sites ci-dessous. Test `tests/unit/test_core_category_gating_behavior.py`.
|
||||||
|
|
||||||
**Sites à gater** (lire chaque site avant édition ; pattern : récupérer `disabled = cfg.get("disabled_kinds") or set()` en tête de fonction, puis sauter le sous-bloc `.sub`/`PiiHit` de la catégorie si désactivée) :
|
**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) :**
|
||||||
`_mask_line_by_regex` (~1670), `_kv_value_only_mask` (~2110, incl. subs NOM/label 2098-2106), bloc PERSON-majuscules (~1942-2008 → NOM), `_apply_extracted_names` (~2809 → early-return `text` inchangé si NOM désactivé), `_mask_with_hf` (~3136 → par placeholder NOM/ETAB/ADRESSE), `_mask_with_eds_pseudo` (~3208 → idem via EDS_LABEL_MAP), `selective_rescan` (~4159 → DATE_NAISSANCE 4203, ADRESSE 4205-4207, ETAB 4229-4251, ADHERENT 4200-4201, TEL 4191-4193, NIR 4187-4188), blocs phase-0 multiline DATE_NAISSANCE (~3014) / NIR (~3034).
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- **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`. For each of the 7 categories, build a minimal `pages_text` containing a clear instance of that category + one instance of a DIFFERENT category, run `anonymise_document_regex(pages_text, [], cfg)` with the category disabled, and assert: the disabled category's value is PRESENT (en clair) in the output, AND the other category is still masked. Example (NIR + TEL) — adapt others by reading the real regexes for realistic inputs:
|
- [ ] **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`.)
|
||||||
|
|
||||||
```python
|
|
||||||
import anonymizer_core_refactored_onnx as core
|
|
||||||
|
|
||||||
|
|
||||||
def _cfg(disabled):
|
|
||||||
cfg = core.load_dictionaries(None)
|
|
||||||
cfg["disabled_kinds"] = set(disabled)
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabling_nir_leaves_nir_clear_but_masks_tel():
|
|
||||||
pages = ["NIR : 1 85 05 74 123 456 78\nTél : 05 59 12 34 56"]
|
|
||||||
out, _audit = core.anonymise_document_regex(pages, [], _cfg({"NIR"}))[:2]
|
|
||||||
text = "\n".join(out) if isinstance(out, list) else str(out)
|
|
||||||
assert "1 85 05 74 123 456 78" in text # NIR décoché → en clair
|
|
||||||
assert "05 59 12 34 56" not in text # TEL non décoché → masqué
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_enabled_is_unchanged_baseline():
|
|
||||||
pages = ["NIR : 1 85 05 74 123 456 78"]
|
|
||||||
out, _audit = core.anonymise_document_regex(pages, [], _cfg(set()))[:2]
|
|
||||||
text = "\n".join(out) if isinstance(out, list) else str(out)
|
|
||||||
assert "1 85 05 74 123 456 78" not in text # tout activé → masqué (non-régression)
|
|
||||||
```
|
|
||||||
|
|
||||||
(Write one analogous test per category: NOM, DATE_NAISSANCE, ETAB, ADRESSE, ADHERENT — using inputs that the real regexes detect. Read the regex definitions to craft valid inputs. Verify the exact return shape of `anonymise_document_regex` first.)
|
|
||||||
|
|
||||||
- [ ] **Step 2 — Run, expect FAIL** (categories still masked because text-gates absent).
|
|
||||||
- [ ] **Step 3 — Implement** the gates at each site above. Apply the same `if "CAT" in disabled: <skip this sub>` pattern. Work site by site; after each, re-run the behavioral test for that category.
|
|
||||||
- [ ] **Step 4 — Run, expect ALL PASS** (7 category tests + baseline).
|
|
||||||
- [ ] **Step 5 — Non-régression + gate qualité:** `.venv/bin/pytest tests/unit/ -q` and `.venv/bin/python scripts/evaluate_quality.py` (score must stay A+ with defaults; the synthetic regression gate must pass).
|
|
||||||
- [ ] **Step 6 — Commit:** `git commit -m "feat(core): gates texte par catégorie (Tier 2/3) + selective_rescan (P1-2)"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4 : Garde-fou adresse dans le burn PDF (`_search_pdf_address_lines`)
|
|
||||||
|
|
||||||
**Files:** Modify `anonymizer_core_refactored_onnx.py` (~l.4572 vector, ~l.4744 raster — `_search_pdf_address_lines` is called independently of audit). Test: extend behavioral test (or a focused unit test on the redact function with ADRESSE disabled).
|
|
||||||
|
|
||||||
- [ ] **Step 1 — Failing test:** assert that when ADRESSE is disabled, the independent address-line search is skipped (so addresses aren't burned). Read `redact_pdf_vector`/`redact_pdf_raster` to find how `disabled_kinds` reaches them (pass `cfg["disabled_kinds"]` or the set as a param; the functions already receive `cfg` or can).
|
|
||||||
- [ ] **Step 2 — Run, expect FAIL.**
|
- [ ] **Step 2 — Run, expect FAIL.**
|
||||||
- [ ] **Step 3 — Implement:** guard both `_search_pdf_address_lines(page)` calls with `if "ADRESSE" not in disabled_kinds:`.
|
- [ ] **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 4 — Run, expect PASS.**
|
||||||
- [ ] **Step 5 — Non-régression:** `.venv/bin/pytest tests/unit/ -q`.
|
- [ ] **Step 5 — Non-régression :** `.venv/bin/pytest tests/unit/ -q`.
|
||||||
- [ ] **Step 6 — Commit:** `git commit -m "feat(core): garde-fou adresse burn PDF si catégorie décochée (P1-2)"`
|
- [ ] **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
|
### Task 5 : Câblage GUI — 7 booléens → moteur
|
||||||
|
|
||||||
**Files:** Modify `gui_v6/config_state.py` (7 bool fields + map to `disabled_kinds`), `gui_v6/engine_bridge.py` (`EngineSettings` + `build_engine_kwargs`), `gui_v6/tabs/tab_config.py` (les 7 `_mini_toggle` ~l.351-357 → `variable`+`command` sur `ConfigState`). Tests `tests/unit/test_gui_v6_category_toggles.py`.
|
(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.)
|
||||||
|
|
||||||
- [ ] **Step 1 — Failing test.** Create `tests/unit/test_gui_v6_category_toggles.py`:
|
- [ ] 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)`.
|
||||||
|
|
||||||
```python
|
|
||||||
from gui_v6.config_state import ConfigState
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_all_categories_enabled_means_no_disabled_kinds():
|
|
||||||
es = ConfigState().to_engine_settings()
|
|
||||||
assert es.disabled_kinds == frozenset()
|
|
||||||
|
|
||||||
|
|
||||||
def test_unchecking_nir_and_etab_propagates_as_disabled_kinds():
|
|
||||||
cs = ConfigState()
|
|
||||||
cs.mask_nir = False
|
|
||||||
cs.mask_etab = False
|
|
||||||
es = cs.to_engine_settings()
|
|
||||||
assert es.disabled_kinds == frozenset({"NIR", "ETAB"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_engine_kwargs_passes_disabled_kinds():
|
|
||||||
from gui_v6.engine_bridge import EngineSettings, build_engine_kwargs
|
|
||||||
es = EngineSettings(disabled_kinds=frozenset({"TEL"}))
|
|
||||||
kwargs = build_engine_kwargs(es)
|
|
||||||
assert kwargs["disabled_kinds"] == frozenset({"TEL"})
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2 — Run, expect FAIL.**
|
|
||||||
- [ ] **Step 3 — Implement.**
|
|
||||||
- `gui_v6/config_state.py`: add 7 bool fields (default True): `mask_noms, mask_ddn, mask_etab, mask_adresse, mask_nir, mask_tel, mask_adherent`. In `to_engine_settings`, build `disabled_kinds = frozenset(cat for field, cat in [(self.mask_noms,"NOM"),(self.mask_ddn,"DATE_NAISSANCE"),(self.mask_etab,"ETAB"),(self.mask_adresse,"ADRESSE"),(self.mask_nir,"NIR"),(self.mask_tel,"TEL"),(self.mask_adherent,"ADHERENT")] if not field)` and pass it to `EngineSettings`.
|
|
||||||
- `gui_v6/engine_bridge.py`: add `disabled_kinds: frozenset = frozenset()` to `EngineSettings`; in `build_engine_kwargs`, add `kwargs["disabled_kinds"] = settings.disabled_kinds`.
|
|
||||||
- `gui_v6/tabs/tab_config.py`: wire each of the 7 `_mini_toggle` to a `ctk.BooleanVar` bound to the matching `ConfigState` field with a `command` that writes it back. (Read the current `_mini_toggle` signature; follow the existing pattern used by other wired toggles in this tab.)
|
|
||||||
- [ ] **Step 4 — Run, expect PASS** + `.venv/bin/python Pseudonymisation_Gui_V6.py --self-test`.
|
|
||||||
- [ ] **Step 5 — Non-régression GUI:** `.venv/bin/pytest tests/unit/ -k gui_v6 -q`.
|
|
||||||
- [ ] **Step 6 — Commit:** `git commit -m "feat(gui): câbler les 7 toggles catégories au moteur (P1-2)"`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Self-review (couverture spec P1-2 + map)
|
## Self-review (couverture spec + revue Qwen + vérif Claude)
|
||||||
- T1 audit filter (Task 1) · rescan relax NIR/TEL (Task 2) · text gates incl. selective_rescan + NER paths + phase-0 (Task 3) · address burn guard (Task 4) · GUI wiring (Task 5). ✓
|
- **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 (VLM_CP→masqué). Default-deny. ✓
|
||||||
- Default-deny vérifié (Task 1 test `EMAIL/IBAN/VILLE/FAX → None`). EMAIL/IBAN/IPP/VILLE/FAX toujours masqués. ✓
|
- **F-2** : Task 3 liste consolidée incl. propagation globale, VLM, Trackare, structured/critical, cleanups. ✓
|
||||||
- Baseline « tout activé = non-régression » testée (Task 3) + `evaluate_quality` A+ gate. ✓
|
- **F-3** : Task 4 adresse gaté + images/barcodes documentés conservateurs + SKIP_KINDS vérifiés. ✓
|
||||||
- **Risque** : un site texte oublié ⇒ la catégorie reste masquée dans le texte (test rouge le détecte) mais JAMAIS de fuite croisée (default-deny). Le livrable PDF est garanti par T1 (audit filter) seul.
|
- **F-4** : Task 2 coordonne résiduel + **exclusion NIR-like du pattern TEL** + gate selective_rescan/propagation (Task 3). ✓
|
||||||
- **Revue Qwen obligatoire** sur Tasks 1-4 (cœur sécurité) avant exécution/après implémentation.
|
- **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 ouverte Dom** : CODE_POSTAL (CP/ZIP) hors des 7 toggles (masqué) — confirmer.
|
||||||
|
- **Re-revue Qwen post-implémentation obligatoire** (Tasks 1-4).
|
||||||
|
|||||||
Reference in New Issue
Block a user