9 Commits

Author SHA1 Message Date
4357a58d7d fix(core): exempter les hits forcés (overrides) du filtre catégorie — anti-fuite PDF (P1-2/T1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:38:19 +02:00
5663966938 docs(coord): verdict Qwen GO — re-revue post-impl Tasks 1-4 gating catégories
§FAX: aucun autre type non-toggleable dépend d'une passe gatée.
§Divergence: accord seuil 0 strict + premask scopé (retire suggestion adaptatif).
§Task 3b: report acceptable pour beta (fail-closed).
1 trouble mineur T1: override user-defined nommé avec catégorie toggleable.
2026-06-27 11:27:24 +02:00
bf832e12f0 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>
2026-06-26 11:33:13 +02:00
daec1f53bd feat(core): garde-fou adresse burn + doc chemins conservateurs (P1-2/F-3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:13:42 +02:00
a02bca516d feat(core): gates texte par catégorie sur toutes les passes (P1-2/F-2/F-5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:03:04 +02:00
dd392c4a50 docs(beta): plan 1b — Task 2 seuil résiduel strict 0 + premask scopé TEL (revue qualité) + Task 3 span-aware
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:23:40 +02:00
2a3aab117d feat(core): coordination quarantaine résiduelle NIR/TEL décochés (P1-2/F-4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:21:27 +02:00
b15d0da141 feat(core): _category_of dérivé (anti-dérive) + filtre audit Tier 1 (P1-2/F-1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:04:03 +02:00
c93dc34a70 docs(beta): plan 1b — décision Dom CODE_POSTAL suit le toggle Adresses (catégorie ADRESSE)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:50:07 +02:00
13 changed files with 1558 additions and 185 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
---
from: qwen
to: claude
date: 2026-06-27T10:00:00+02:00
topic: verdict-post-impl-tasks-1-4
status: closed
priority: blocker
references:
- demande: docs/coordination/inbox/for-qwen/2026-06-26_11-32_claude_re-revue-impl-1b-tasks-1-4.md
- commits: b15d0da, 2a3aab1, dd392c4, a02bca5, daec1f5, a6bd364
---
# Verdict Qwen — re-revue post-implémentation Tasks 1-4 (cœur sécurité)
## VERDICT : **GO**
4 commits examinés, 66 tests passed, 0 régression. Aucune fuite croisée, aucun type non-toggleable dépendant d'une passe gatée (sauf CP = toggleable par design). L'implémentation est correcte et sécurisée.
---
## §FAX — aucun autre type non-toggleable dépend d'une passe gatée ✅
J'ai vérifié exhaustivement les 16 types non-toggleables (EMAIL, IBAN, ADELI, DOSSIER, NDA, EPISODE, IPP, FINESS, OGC, RPPS, VILLE, AGE, URL, force_term, force_regex, FAX). **Tous ont des chemins d'audit hit inconditionnels** — ils ne dépendent pas d'une passe gatée.
| Type | Chemins audit hit | Gaté ? | Verdict |
|---|---|---|---|
| EMAIL | `_mask_line_by_regex` (incond.) + `_mask_with_hf/eds` (incond.) | ❌ | ✅ Safe |
| IBAN | `_mask_line_by_regex` (incond.) + `_kv_value_only_mask` (incond.) | ❌ | ✅ Safe |
| FAX | `_mask_fax_unconditional` (incond., fix post-review) | ❌ | ✅ Safe |
| ADELI | `_mask_line_by_regex` (incond.) + `_mask_admin_label` (incond.) | ❌ | ✅ Safe |
| DOSSIER/NDA/EPISODE | `_mask_line_by_regex` (incond.) + `_kv_value_only_mask` (incond.) + Trackare (incond.) | ❌ | ✅ Safe |
| IPP/FINESS/OGC/RPPS | `_mask_line_by_regex` (incond.) + `_mask_admin_label` (incond.) + Trackare (incond.) | ❌ | ✅ Safe |
| VILLE/AGE | `_mask_line_by_regex` (incond.) + gazetteers (incond.) | ❌ | ✅ Safe |
| force_term/force_regex | `anonymise_document_regex` (incond.) | ❌ | ✅ Safe |
**CODE_POSTAL** est le seul "problème" — mais c'est **toggleable par design** (décision Dom : CP suit le toggle ADRESSE). Ses hits sont gatés dans 7 chemins, mais c'est intentional : quand ADRESSE est décoché, CP est aussi décoché. `_category_of("CODE_POSTAL") = "ADRESSE"` → cohérent. ✅
---
## §Divergence seuil — **ACCORD : seuil 0 strict + premask scopé** ✅
Claude a raison de garder seuil 0 inconditionnel. Mon suggestion de seuil adaptatif (0→1) était **incorrecte** car :
1. **Seuil 1 affaiblit EMAIL/IBAN backstops** : un vrai email fuité (1 occurrence) ne quarantainerait plus dès qu'une catégorie est décochée. C'est une régression sécurité.
2. **Premask span-précis** est supérieur : `_residual_premask_text` neutralise les spans NIR-like avant le scan TEL → le pattern TEL ne matche pas les chiffres du NIR. **Span-précis, pas aveugle.**
3. **Fail-closed** : si un fragment ambigu (8 chiffres) matche TEL résiduel → quarantaine injustifiée, mais **aucune fuite**. Acceptable pour beta.
Je retire ma suggestion de seuil adaptatif et **confirme l'approche Claude** (seuil 0 strict + premask scopé NIR→TEL).
---
## §Task 3b — **Report acceptable pour beta** ✅
Le premask généralisé (neutraliser les spans de toutes catégories décochées avant le scan résiduel) est **différé**. Sans lui :
- **Pire cas** : ADHERENT décoché laisse un numéro adhérent en clair qui matche NIR/TEL résiduel → sur-quarantaine
- **Impact** : fail-closed (document retenu, **aucune fuite**)
- **Fréquence** : rare — ADHERENT/NIR/TEL overlap est marginal dans les documents réels
- **Verdict** : acceptable pour beta. Le premask généralisé est un v12 enhancement.
---
## Implémentation review — highlights
### `_category_of(kind)` (Task 1, b15d0da)
- **5 branches dérivation** : suffixe `_GLOBAL` → table explicite → placeholder-self → VLM reverse → EDS map → None (default-deny). Correct. ✅
- **Anti-dérive test** : vérifie que toutes les kinds VLM/EDS sont couverts par la dérivation. ✅
- **CODE_POSTAL = ADRESSE** : reflète la décision Dom. ✅
- **`_EXPLICIT_KIND_CATEGORY`** : table manuelle pour kinds regex/inline. 7 entrées. ⚠️ Fragile pour les futurs kinds (nécessite update manuelle), mais documenté avec warning.
### `_filter_audit_by_disabled` (Task 1)
- **Placement** : avant le bloc Sauvegardes → couvre audit.jsonl + redact_pdf_vector + redact_pdf_raster. ✅
- **disabled_kinds = set de CATÉGORIES** (pas de kinds). Utilise `_category_of(kind)` pour mapper. ✅
- **None → ne retire pas** (default-deny kinds restent toujours masqués). ✅
### `_build_residual_patterns` + `_residual_premask_text` (Task 2, 2a3aab1)
- **EMAIL/IBAN toujours inclus**. ✅
- **NIR/TEL conditionnels** (retirés si catégorie décochée). ✅
- **Premask NIR→TEL** : neutralise spans NIR-like avant le scan TEL quand NIR est décoché. ✅
- **Non-régression** : `_build_residual_patterns(set())` = liste historique byte-for-byte. ✅
### Gates texte (Task 3, a02bca5) — ~20 sites
- **NER/VLM per-hit** (intra-boucle), jamais per-function. ✅ — c'est le point critique que j'avais flagué en F-5 S1.
- **Test anti-fuite croisée** : 7 variants (chaque catégorie décochée, les 6 autres restent masquées). ✅
- **`_mask_fax_unconditional`** : FAX non-toggleable, masqué+audité inconditionnellement. ✅
- **`RE_TRACKARE_IAO_MULTILINE_VALUE`** : NOM_FORCE gated sous NOM. ✅ (correction de ma F-2 A)
- **Post-mask cleanups** : NOM orphan, TEL fragment gated. ✅
- **Propagation globale** : gated par catégorie (step 4e). ✅
- **`_apply_trackare_hits_to_text`** : gated par catégorie. ✅
### Address burn guard (Task 4, daec1f5)
- **`_search_pdf_address_lines`** : gardé par `"ADRESSE" not in disabled`. ✅
- **Images/barcodes** : documentés conservateurs. ✅
- **`_VECTOR/_RASTER_SKIP_KINDS`** : composition soustractive, pas de conflit avec gating. ✅
---
## 1 trouble mineur (T1 — MOYEN)
**Override user-defined nommé avec une catégorie toggleable** : si un utilisateur définit un admin_rule override avec `name="NOM"` (au lieu du défaut `name="override"`), l'override kind="NOM" est toggleable → quand NOM est décoché, `_filter_audit_by_disabled` retire le hit du PDF burn, mais le texte reste masqué par l'override (l'override masque le texte directement dans `anonymise_document_regex`). **Incohérence texte/PDF** : texte masqué `[NOM]`, PDF non masqué (rect retiré de l'audit).
**Impact** : faible — le défaut `name="override"` → kind="override" → `_category_of("override") = None` → default-deny → toujours masqué. Le cas `name="NOM"` est un choix utilisateur explicite.
**Correction** : documenter que les overrides user-defined doivent utiliser `name` non-toggleable (ex: "override" ou "custom_nom"), pas un nom de catégorie toggleable. Ou bien : les overrides sont toujours inconditionnels (non-gatés) dans le texte ET l'audit. **Pas bloquant pour beta.**
---
## Verdict final
| Critère | Résultat |
|---|---|
| Fuite croisée (catégorie cochée démasquée) | ❌ Aucune |
| Type non-toggleable fuyant via passe gatée | ❌ Aucun (sauf CP = toggleable par design) |
| Régression chemin par défaut (tout coché) | ❌ Aucune (66 tests passed, 0 xfail) |
| §Divergence seuil | ✅ Accord Claude (seuil 0 strict) |
| §Task 3b | ✅ Report acceptable (fail-closed) |
**GO.** Exécution Tasks 1-4 validée. Dom peut diffuser.
— Qwen (auditeur/reviewer, boucle adversariale 3/3)

View File

@@ -12,9 +12,9 @@
- 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). Qwen d'accord (RGPD : le CP identifie le lieu de soin/résidence). **Caveat UX à documenter** : décocher « Adresses » donne un rendu type « 12 rue X [CODE_POSTAL] Ville » (voie en clair, CP masqué) — assumé. À confirmer Dom.
- **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/CODE_POSTAL` 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` non toggleables = toujours masqués). **NB** : `CODE_POSTAL` retiré de cette liste par décision Dom 2026-06-26 (suit « Adresses »).
---
@@ -45,11 +45,14 @@ def test_category_of_each_source():
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é.
for k in ("EMAIL", "IBAN", "IPP", "VILLE", "FAX", "VLM_CP", "EDS_ZIP",
# 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
@@ -85,6 +88,7 @@ def test_filter_audit_drops_only_disabled():
_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 = {
@@ -149,7 +153,7 @@ Add `disabled_kinds: set = None` kwarg to `process_pdf` (~l.4973). After `cfg =
- [ ] **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, 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")`. **Seuil adaptatif (suggestion re-revue Qwen)** : `SEUIL_RESCAN_RESIDUEL=0` est trop strict quand des catégories sont décochées (un fragment de 8 chiffres peut matcher le pattern TEL résiduel → quarantaine injustifiée) ; passer le seuil à **1 si `disabled_kinds` non vide**, 0 sinon (préserve la rigueur quand tout est activé).
- [ ] **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)"`
@@ -169,6 +173,7 @@ Add `disabled_kinds: set = None` kwarg to `process_pdf` (~l.4973). After `cfg =
- 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).
@@ -205,11 +210,11 @@ Add `disabled_kinds: set = None` kwarg to `process_pdf` (~l.4973). After `cfg =
---
## 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 (VLM_CP→masqué). Default-deny. ✓
- **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 pattern TEL** + gate selective_rescan/propagation (Task 3). ✓
- **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 ouverte Dom** : CODE_POSTAL (CP/ZIP) hors des 7 toggles (masqué) — confirmer.
- **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).

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,160 @@
#!/usr/bin/env python3
"""Plan 1b — Task 4 (P1-2/F-3) : garde-fou du burn adresse géométrique.
`_search_pdf_address_lines` est un chemin de caviardage INDÉPENDANT de
l'audit : il noircit directement les lignes d'adresse trouvées
géométriquement sur la page (cf. `test_pdf_redaction_directly_masks_finess_address_range`).
Le filtre d'audit de la Task 1 ne le couvre donc PAS.
Ces tests vérifient que ce chemin est gaté sous la catégorie ADRESSE :
- ADRESSE désactivée → `_search_pdf_address_lines` n'est PAS appliqué ;
- ADRESSE activée (ou disabled vide) → il est appelé comme avant.
"""
import anonymizer_core_refactored_onnx as core
from anonymizer_core_refactored_onnx import (
PiiHit,
fitz,
redact_pdf_raster,
redact_pdf_vector,
)
def _make_address_pdf(tmp_path):
source = tmp_path / "addr.pdf"
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), "15 à 35 rue Claude Boucher Bordeaux Cedex")
page.insert_text((72, 108), "Motif d'hospitalisation : contrôle clinique.")
doc.save(source)
doc.close()
return source
# ---------------------------------------------------------------------------
# VECTOR
# ---------------------------------------------------------------------------
def test_vector_address_search_called_when_adresse_enabled(tmp_path, monkeypatch):
if fitz is None:
return
source = _make_address_pdf(tmp_path)
output = tmp_path / "addr.enabled.pdf"
calls = []
real = core._search_pdf_address_lines
def _spy(page):
calls.append(page.number)
return real(page)
monkeypatch.setattr(core, "_search_pdf_address_lines", _spy)
# disabled vide → comportement par défaut (adresse cherchée)
redact_pdf_vector(source, [], output, disabled_kinds=set())
assert calls, "ADRESSE activée : _search_pdf_address_lines doit être appelé"
redacted = fitz.open(output)
text = redacted[0].get_text()
redacted.close()
# L'adresse a bien été caviardée (le burn géométrique s'applique)
assert "rue Claude Boucher" not in text
# La ligne clinique reste lisible
assert "Motif d'hospitalisation" in text
def test_vector_address_search_not_applied_when_adresse_disabled(tmp_path, monkeypatch):
if fitz is None:
return
source = _make_address_pdf(tmp_path)
output = tmp_path / "addr.disabled.pdf"
calls = []
real = core._search_pdf_address_lines
def _spy(page):
calls.append(page.number)
return real(page)
monkeypatch.setattr(core, "_search_pdf_address_lines", _spy)
redact_pdf_vector(source, [], output, disabled_kinds={"ADRESSE"})
# Le burn géométrique d'adresse ne doit PAS être appliqué.
assert not calls, (
"ADRESSE désactivée : _search_pdf_address_lines ne doit pas être appliqué"
)
redacted = fitz.open(output)
text = redacted[0].get_text()
redacted.close()
# L'adresse reste lisible puisque la catégorie est décochée.
assert "rue Claude Boucher" in text
# ---------------------------------------------------------------------------
# RASTER
# ---------------------------------------------------------------------------
def test_raster_address_search_called_when_adresse_enabled(tmp_path, monkeypatch):
if fitz is None:
return
source = _make_address_pdf(tmp_path)
output = tmp_path / "addr.raster.enabled.pdf"
calls = []
real = core._search_pdf_address_lines
def _spy(page):
calls.append(page.number)
return real(page)
monkeypatch.setattr(core, "_search_pdf_address_lines", _spy)
redact_pdf_raster(source, [], output, disabled_kinds=set())
assert calls, "ADRESSE activée (raster) : _search_pdf_address_lines doit être appelé"
def test_raster_address_search_not_applied_when_adresse_disabled(tmp_path, monkeypatch):
if fitz is None:
return
source = _make_address_pdf(tmp_path)
output = tmp_path / "addr.raster.disabled.pdf"
calls = []
real = core._search_pdf_address_lines
def _spy(page):
calls.append(page.number)
return real(page)
monkeypatch.setattr(core, "_search_pdf_address_lines", _spy)
redact_pdf_raster(source, [], output, disabled_kinds={"ADRESSE"})
assert not calls, (
"ADRESSE désactivée (raster) : _search_pdf_address_lines ne doit pas être appliqué"
)
# ---------------------------------------------------------------------------
# Non-régression : signature positionnelle d'origine + défaut byte-for-byte
# ---------------------------------------------------------------------------
def test_vector_default_signature_still_calls_address_search(tmp_path, monkeypatch):
"""Sans disabled_kinds (appel positionnel d'origine), le burn adresse
reste actif — non-régression stricte."""
if fitz is None:
return
source = _make_address_pdf(tmp_path)
output = tmp_path / "addr.default.pdf"
calls = []
real = core._search_pdf_address_lines
def _spy(page):
calls.append(page.number)
return real(page)
monkeypatch.setattr(core, "_search_pdf_address_lines", _spy)
# Appel d'origine : aucun argument disabled.
redact_pdf_vector(source, [PiiHit(0, "OGC", "14", "[OGC]")], output)
assert calls, "Défaut (pas de disabled) : burn adresse doit rester actif"

View File

@@ -0,0 +1,47 @@
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
# Garde de terminaison de la récursion (_GLOBAL strip) : entrées vides.
assert core._category_of(None) is None
assert core._category_of("") is None
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

View File

@@ -0,0 +1,260 @@
"""Plan 1b — Task 3 (P1-2 / F-2 + F-5) : gating TEXTE par catégorie.
Vérifie que, quand une des 7 catégories toggleables est décochée
(``cfg["disabled_kinds"]``), la valeur de cette catégorie ressort EN CLAIR
dans le texte produit, SANS jamais démasquer une autre catégorie encore
activée (pas de fuite croisée) et SANS régression quand rien n'est désactivé.
Entrées RÉELLES fabriquées à partir des vraies regex du moteur (aucun mock).
NIR valide (clé modulo 97) calculé : body 1850578006084 → clé 91.
"""
import re
import anonymizer_core_refactored_onnx as core
# --- Échantillons clairs par catégorie (1 PII de la catégorie cible) ---------
# Chaque échantillon est validé : masqué quand la catégorie est activée.
_SAMPLES = {
"NOM": ("Nom de famille : DUPONT", "DUPONT", "[NOM]"),
"DATE_NAISSANCE": ("Né le 12/03/1950", "12/03/1950", "[DATE_NAISSANCE]"),
"ETAB": ("Etablissement : EHPAD Solemnis", "Solemnis", "[ETABLISSEMENT]"),
"ADRESSE": ("Domicile : 13 rue des Lilas", "rue des Lilas", "[ADRESSE]"),
"NIR": ("NIR 185057800608491", "185057800608491", "[NIR]"),
"TEL": ("Tel : 0612345678", "0612345678", "[TEL]"),
"ADHERENT": ("N° adhérent : ABC123456", "ABC123456", "[ADHERENT]"),
}
# Une catégorie « témoin » différente, toujours activée, dont le placeholder doit
# rester présent (anti-fuite croisée). On choisit NIR comme témoin sauf pour la
# catégorie cible NIR (témoin = TEL).
_WITNESS = {
"NOM": ("NIR 185057800608491", "[NIR]"),
"DATE_NAISSANCE": ("NIR 185057800608491", "[NIR]"),
"ETAB": ("NIR 185057800608491", "[NIR]"),
"ADRESSE": ("NIR 185057800608491", "[NIR]"),
"NIR": ("Tel : 0612345678", "[TEL]"),
"TEL": ("NIR 185057800608491", "[NIR]"),
"ADHERENT": ("NIR 185057800608491", "[NIR]"),
}
_SEVEN = ["NOM", "DATE_NAISSANCE", "ETAB", "ADRESSE", "NIR", "TEL", "ADHERENT"]
def _run(text, disabled):
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = set(disabled)
return core.anonymise_document_regex([text], [], cfg)
import pytest
@pytest.mark.parametrize("cat", _SEVEN)
def test_disabled_category_left_in_clear_witness_masked(cat):
"""La catégorie décochée ressort en clair ; le témoin reste masqué."""
target_line, clear_value, target_ph = _SAMPLES[cat]
witness_line, witness_ph = _WITNESS[cat]
text = target_line + "\n" + witness_line
res = _run(text, {cat})
out = res.text_out
# 1) la valeur de la catégorie décochée doit être EN CLAIR
assert clear_value in out, (
f"{cat} décochée : '{clear_value}' devrait être en clair.\nout={out!r}")
# 2) son placeholder ne doit PAS apparaître
assert target_ph not in out, (
f"{cat} décochée : '{target_ph}' ne devrait pas apparaître.\nout={out!r}")
# 3) le témoin (autre catégorie activée) doit RESTER masqué
assert witness_ph in out, (
f"{cat} décochée : témoin {witness_ph} devrait rester masqué.\nout={out!r}")
@pytest.mark.parametrize("cat", _SEVEN)
def test_enabled_category_still_masked(cat):
"""Avec rien de désactivé, chaque catégorie reste masquée (non-régression)."""
target_line, clear_value, target_ph = _SAMPLES[cat]
res = _run(target_line, set())
assert target_ph in res.text_out, (
f"{cat} activée devrait être masquée.\nout={res.text_out!r}")
def test_one_disabled_all_others_stay_masked():
"""1 catégorie décochée : TOUTES les autres restent masquées (anti-fuite)."""
text = "\n".join(s[0] for s in _SAMPLES.values())
for off in _SEVEN:
res = _run(text, {off})
out = res.text_out
# la catégorie décochée doit être en clair
clear = _SAMPLES[off][1]
assert clear in out, f"{off} décochée devrait être en clair.\nout={out!r}"
# toutes les AUTRES doivent rester masquées
for other in _SEVEN:
if other == off:
continue
ph = _SAMPLES[other][2]
assert ph in out, (
f"{off} décochée NE doit PAS démasquer {other} ({ph}).\nout={out!r}")
def test_baseline_all_enabled_byte_for_byte():
"""disabled vide ⇒ sortie identique à un run sans la clé disabled_kinds."""
text = "\n".join(s[0] for s in _SAMPLES.values())
cfg_a = core.load_dictionaries(None)
cfg_a["disabled_kinds"] = set()
cfg_b = core.load_dictionaries(None) # pas de clé du tout
out_a = core.anonymise_document_regex([text], [], cfg_a).text_out
out_b = core.anonymise_document_regex([text], [], cfg_b).text_out
assert out_a == out_b
# et tout est bien masqué
for _line, _clear, ph in _SAMPLES.values():
assert ph in out_a
# --- selective_rescan : filet de sécurité, doit aussi gater ------------------
@pytest.mark.parametrize("cat,line,clear,ph", [
("TEL", "Joindre au 0612345678", "0612345678", "[TEL]"),
("NIR", "Secu 185057800608491", "185057800608491", "[NIR]"),
("ADRESSE", "13 rue des Lilas ici", "rue des Lilas", "[ADRESSE]"),
("DATE_NAISSANCE", "Né le 12/03/1950", "12/03/1950", "[DATE_NAISSANCE]"),
("ETAB", "Etablissement EHPAD Solemnis", "Solemnis", "[ETABLISSEMENT]"),
("ADHERENT", "N° adhérent : ABC123456", "ABC123456", "[ADHERENT]"),
])
def test_selective_rescan_gates_disabled(cat, line, clear, ph):
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = {cat}
out = core.selective_rescan(line, cfg=cfg)
assert clear in out, f"rescan {cat} décochée : '{clear}' devrait rester clair.\nout={out!r}"
assert ph not in out, f"rescan {cat} décochée : {ph} ne devrait pas apparaître.\nout={out!r}"
def test_selective_rescan_empty_disabled_byte_for_byte():
"""selective_rescan : disabled vide == aucune clé (non-régression)."""
line = ("Joindre au 0612345678, Secu 185057800608491, "
"13 rue des Lilas, Né le 12/03/1950, EHPAD Solemnis")
cfg_none = core.load_dictionaries(None)
cfg_empty = core.load_dictionaries(None)
cfg_empty["disabled_kinds"] = set()
assert core.selective_rescan(line, cfg=cfg_none) == core.selective_rescan(line, cfg=cfg_empty)
def test_selective_rescan_enabled_still_masks():
"""Non-régression rescan : rien désactivé ⇒ masque tout."""
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = set()
line = "Joindre au 0612345678 et Secu 185057800608491"
out = core.selective_rescan(line, cfg=cfg)
assert "[TEL]" in out and "[NIR]" in out
assert "0612345678" not in out and "185057800608491" not in out
# --- NER per-hit (F-5) : _mask_with_hf -------------------------------------
def test_mask_with_hf_per_hit_gating():
"""NOM décoché : l'entité PER ressort en clair, l'ORG (ETAB) reste masquée."""
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = {"NOM"}
text = "Le patient Martin suivi par Hopital Saint-Louis"
ents = [
{"word": "Martin", "entity_group": "PER"},
{"word": "Hopital Saint-Louis", "entity_group": "ORG"},
]
audit = []
out = core._mask_with_hf(text, ents, cfg, audit)
assert "Martin" in out, f"NOM décoché : Martin devrait rester clair.\nout={out!r}"
assert "[NOM]" not in out
assert "[ETABLISSEMENT]" in out, f"ETAB activé devrait être masqué.\nout={out!r}"
def test_mask_with_hf_no_disabled_masks_all():
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = set()
text = "Le patient Martin"
ents = [{"word": "Martin", "entity_group": "PER"}]
out = core._mask_with_hf(text, ents, cfg, [])
assert "[NOM]" in out and "Martin" not in out
# --- NER per-hit (F-5) : _mask_with_eds_pseudo -----------------------------
def test_mask_with_eds_pseudo_per_hit_gating():
"""NOM décoché : entité EDS NOM en clair, HOPITAL (ETAB) reste masquée."""
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = {"NOM"}
text = "Compte rendu Bernardo signe a Belledonne"
ents = [
{"word": "Bernardo", "entity_group": "NOM", "eds_mapped_key": "NOM", "score": 0.99},
{"word": "Belledonne", "entity_group": "HOPITAL", "eds_mapped_key": "ETAB", "score": 0.99},
]
out = core._mask_with_eds_pseudo(text, ents, cfg, [])
assert "Bernardo" in out, f"NOM décoché : Bernardo devrait rester clair.\nout={out!r}"
assert "[NOM]" not in out
assert "[ETABLISSEMENT]" in out, f"ETAB activé devrait être masqué.\nout={out!r}"
# --- VLM per-hit (F-2) : _apply_vlm gating helper --------------------------
def test_vlm_kind_gating_is_per_hit():
"""Le gating VLM s'évalue par hit via _category_of(kind)."""
import vlm_manager
# NOM décoché : VLM_NOM doit être filtré, VLM_ETAB conservé.
nom_kind, _ = vlm_manager.VLM_CATEGORY_MAP["NOM"]
etab_kind, _ = vlm_manager.VLM_CATEGORY_MAP["ETABLISSEMENT"]
assert core._category_of(nom_kind) == "NOM"
assert core._category_of(etab_kind) == "ETAB"
# === Régression AUDIT-LEVEL (revue qualité : fuite PDF FAX avec TEL décoché) ===
# Le burn PDF (vector+raster) dérive UNIQUEMENT de anon.audit. Un type non
# toggleable dont l'unique site de détection tombait dans (ou en aval d')un bloc
# gaté ne produisait plus de hit audit → numéro VISIBLE dans le PDF livré, même
# si le .txt paraissait propre. Ces tests assertent sur anon.audit, pas le texte.
def _audit_kinds(text, disabled):
"""Lance le constructeur d'audit (anonymise_document_regex) et renvoie les hits."""
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = set(disabled)
return core.anonymise_document_regex([text], [], cfg).audit
def _has_hit(audit, kind, placeholder=None):
for h in audit:
if h.kind == kind and (placeholder is None or h.placeholder == placeholder):
return True
return False
@pytest.mark.parametrize("line,fax_value", [
("Fax : 0512345678", "0512345678"),
("Télécopie : 05 12 34 56 78", "05 12 34 56 78"),
("Télécopieur : 0512345678", "0512345678"),
])
def test_fax_audit_hit_survives_tel_disabled(line, fax_value):
"""FAX (non toggleable) DOIT rester dans anon.audit quand TEL est décoché.
C'est le test qui échouait avant le correctif de découplage FAX (fuite PDF)."""
audit = _audit_kinds(line, {"TEL"})
# Un hit FAX doit exister (kind ET placeholder), pour que le burn PDF le masque.
assert _has_hit(audit, "FAX", core.PLACEHOLDERS["FAX"]), (
f"FAX absent de l'audit avec TEL décoché → fuite PDF.\n"
f"line={line!r}\naudit={[(h.kind, h.original) for h in audit]}")
# La valeur ne doit pas survivre déguisée en hit TEL non plus.
assert not _has_hit(audit, "TEL"), "Un fax ne doit pas devenir un hit TEL."
def test_fax_audit_hit_present_when_nothing_disabled():
"""Non-régression : FAX produit bien un hit audit sur le chemin par défaut."""
audit = _audit_kinds("Fax : 0512345678", set())
assert _has_hit(audit, "FAX", core.PLACEHOLDERS["FAX"])
def test_tel_audit_hit_dropped_when_tel_disabled():
"""Cohérence : un vrai TÉLÉPHONE (toggleable) sort bien de l'audit si TEL décoché."""
audit = _audit_kinds("Tel : 0612345678", {"TEL"})
assert not _has_hit(audit, "TEL"), "TEL décoché ⇒ pas de hit TEL (numéro laissé clair)."
@pytest.mark.parametrize("off", ["NOM", "ADRESSE", "NIR", "ADHERENT", "ETAB", "DATE_NAISSANCE"])
def test_fax_audit_survives_any_unrelated_toggle(off):
"""Général : le non toggleable FAX reste dans l'audit quel que soit le toggle décoché."""
audit = _audit_kinds("Fax : 0512345678", {off})
assert _has_hit(audit, "FAX", core.PLACEHOLDERS["FAX"]), (
f"FAX absent de l'audit avec {off} décoché.\n"
f"audit={[(h.kind, h.original) for h in audit]}")

View File

@@ -0,0 +1,76 @@
"""Anti-fuite PDF : un masque FORCÉ (override utilisateur / blacklist force-mask)
ne doit JAMAIS être retiré de l'audit par un toggle de catégorie.
Contexte (Plan 1b, P1-2/T1) :
- `_apply_overrides` masque le TEXTE inline ET ajoute un `PiiHit` dont le `kind`
est contrôlé par l'utilisateur (`name` de l'override). Cet appel est
inconditionnel (pas gaté par `disabled_kinds`).
- `_filter_audit_by_disabled` retire ensuite de l'audit les hits dont la
catégorie est désactivée, AVANT la gravure PDF.
- BUG : si un utilisateur nomme un override avec une catégorie toggleable
(ex. `name="NOM"`) et désactive cette catégorie, le texte reste masqué mais
le hit est retiré de l'audit → la gravure PDF laisse la valeur EN CLAIR.
Correctif attendu : marquer les hits forcés (`forced=True`) et les exempter du
filtre catégorie. Un terme explicitement forcé est TOUJOURS gravé.
"""
import re
import anonymizer_core_refactored_onnx as core
def test_forced_override_hit_survives_category_filter():
"""Un override nommé "NOM" produit un hit FORCÉ qui survit au filtre {"NOM"}."""
cfg = {
"regex_overrides": [
{"pattern": r"\bDupont\b", "placeholder": "[NOM]", "name": "NOM"},
],
}
audit: list = []
line = "Patient Dupont vu ce jour."
masked = core._apply_overrides(line, audit, 0, cfg)
# Le texte est bien masqué (comportement inline inchangé).
assert "Dupont" not in masked
assert "[NOM]" in masked
# Un hit a été produit, de catégorie NOM, et marqué forcé.
assert len(audit) == 1
forced_hit = audit[0]
assert forced_hit.kind == "NOM"
assert core._category_of(forced_hit.kind) == "NOM"
assert getattr(forced_hit, "forced", False) is True
# Cœur du correctif : avec NOM désactivé, le hit FORCÉ reste dans l'audit
# (donc serait gravé dans le PDF) → pas de fuite.
filtered = core._filter_audit_by_disabled(list(audit), {"NOM"})
assert forced_hit in filtered, "le hit forcé a été retiré → fuite PDF"
def test_genuine_nom_hit_still_dropped_by_filter():
"""Le correctif ne sur-exempte pas : un vrai hit NOM (non forcé) est bien retiré."""
genuine = core.PiiHit(0, "NOM", "Martin", "[NOM]")
# Par défaut un PiiHit n'est PAS forcé.
assert getattr(genuine, "forced", False) is False
filtered = core._filter_audit_by_disabled([genuine], {"NOM"})
assert genuine not in filtered, "un hit NOM non forcé doit être retiré quand NOM est désactivé"
def test_forced_blacklist_terms_marked_forced():
"""Les force_mask_terms / force_mask_regex sont aussi marqués forcés."""
cfg = {
"blacklist": {
"force_mask_terms": ["CHUXX"],
"force_mask_regex": [r"SIGLE-\d+"],
},
}
audit: list = []
line = "Etablissement CHUXX, code SIGLE-42."
core._apply_overrides(line, audit, 0, cfg)
assert len(audit) == 2
for h in audit:
assert getattr(h, "forced", False) is True, f"{h.kind} non marqué forcé"

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

View File

@@ -182,6 +182,138 @@ class TestRescanQuarantine:
assert mgr.has_full_quarantine("doc_leak")
# === Tests F4 : patterns résiduels gated par catégorie désactivée ===
class TestResidualPatternsGating:
"""F-4 (P1-2) — `_build_residual_patterns(disabled)` : une catégorie
décochée ne doit pas déclencher la quarantaine résiduelle, ni directement,
ni via le pattern résiduel d'une autre catégorie (piège NIR ⇄ TEL)."""
def _labels(self, patterns):
return {label for _pat, label in patterns}
@staticmethod
def _residual_count(text, disabled):
"""Reproduit EXACTEMENT le calcul du call-site (process_pdf) :
seul le scan TEL voit le texte pré-masqué ; EMAIL/IBAN/NIR voient
le texte ORIGINAL."""
from anonymizer_core_refactored_onnx import (
_build_residual_patterns,
_residual_premask_text,
)
patterns = _build_residual_patterns(disabled)
tel_text = _residual_premask_text(text, disabled)
total = 0
for pat, label in patterns:
scan = tel_text if label == "TEL" else text
total += len(pat.findall(scan))
return total
def test_default_set_includes_all_labels(self) -> None:
"""Aucune catégorie désactivée → NIR, EMAIL, IBAN, TEL tous présents."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
labels = self._labels(_build_residual_patterns(set()))
assert {"NIR", "EMAIL", "IBAN", "TEL"}.issubset(labels)
def test_nir_disabled_drops_nir_keeps_others(self) -> None:
"""NIR décoché → NIR absent, EMAIL/IBAN toujours présents."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
labels = self._labels(_build_residual_patterns({"NIR"}))
assert "NIR" not in labels
assert "EMAIL" in labels
assert "IBAN" in labels
def test_tel_disabled_drops_tel(self) -> None:
"""TEL décoché → TEL absent."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
labels = self._labels(_build_residual_patterns({"TEL"}))
assert "TEL" not in labels
def test_nir_disabled_tel_does_not_match_nir_in_clear(self) -> None:
"""Piège F-4 : NIR décoché laissé en clair → le pré-masquage SCOPÉ-TEL
empêche le pattern TEL de matcher le bloc central de chiffres du NIR.
Le NIR-pattern est retiré du set et EMAIL/IBAN ne matchent pas des
chiffres nus → décompte résiduel global == 0 pour ce NIR en clair."""
from anonymizer_core_refactored_onnx import (
_build_residual_patterns,
_residual_premask_text,
)
nir_en_clair = "1 85 05 74 123 456 78"
disabled = {"NIR"}
# Le pattern TEL appliqué au texte pré-masqué → 0 match.
patterns = _build_residual_patterns(disabled)
tel_pat = next(pat for pat, label in patterns if label == "TEL")
premasked = _residual_premask_text(nir_en_clair, disabled)
assert tel_pat.findall(premasked) == []
# Décompte résiduel global (logique call-site, TEL-scopé) == 0.
total = self._residual_count(nir_en_clair, disabled)
assert total == 0, (
f"NIR décoché ne doit pas déclencher la quarantaine, "
f"or {total} match(s) résiduel(s) sur {nir_en_clair!r}"
)
def test_nir_disabled_clear_iban_still_matches(self) -> None:
"""Fix 1 (régression) : le pré-masquage NIR est SCOPÉ au seul scan TEL.
Un IBAN en clair, avec NIR décoché, DOIT toujours déclencher le filet
IBAN résiduel — le pré-masquage ne doit PAS effacer ses groupes de
chiffres (sinon le backstop IBAN, toujours actif, serait affaibli)."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
iban_clair = "FR76 3000 1007 9412 3456 7890 185"
disabled = {"NIR"}
# Le pattern IBAN (scanné sur le texte ORIGINAL) matche toujours.
patterns = _build_residual_patterns(disabled)
iban_pat = next(pat for pat, label in patterns if label == "IBAN")
assert iban_pat.findall(iban_clair), "le filet IBAN doit rester actif"
# Décompte résiduel global (logique call-site) ≥ 1.
total = self._residual_count(iban_clair, disabled)
assert total >= 1, (
f"IBAN en clair doit déclencher la quarantaine même NIR décoché, "
f"or {total} match(s)"
)
def test_residual_threshold_is_strict_zero_regardless_of_disabled(self) -> None:
"""Fix 2 (régression) : le seuil résiduel reste STRICT (0)
inconditionnellement. Un EMAIL en clair → 1 résidu, et 1 > 0 ⇒
quarantaine, même avec des catégories décochées (pas de relâchement
à 1 qui laisserait passer une fuite EMAIL/IBAN)."""
from anonymizer_core_refactored_onnx import SEUIL_RESCAN_RESIDUEL
assert SEUIL_RESCAN_RESIDUEL == 0
email_clair = "a@b.fr"
# Une catégorie est décochée mais le seuil effectif reste 0.
for disabled in (set(), {"NIR"}, {"NIR", "TEL"}):
total = self._residual_count(email_clair, disabled)
assert total == 1, (disabled, total)
# 1 résidu > seuil strict (0) ⇒ quarantaine déclenchée.
assert total > SEUIL_RESCAN_RESIDUEL
def test_nir_enabled_tel_behavior_unchanged(self) -> None:
"""Non-régression : NIR activé → le pré-masquage est l'identité et
un vrai téléphone est toujours détecté par le pattern TEL."""
from anonymizer_core_refactored_onnx import (
_build_residual_patterns,
_residual_premask_text,
)
tel = "06 12 34 56 78"
patterns = _build_residual_patterns(set())
text = _residual_premask_text(tel, set())
assert text == tel # identité quand rien n'est désactivé
tel_pat = next(pat for pat, label in patterns if label == "TEL")
assert tel_pat.findall(tel), "un vrai téléphone doit rester détecté"
# === Tests A : INDEX.md et errors.log ===========================
class TestQuarantineArtifacts: