From eaea6b2d7f9d20d7ff6957a93eef708a839aa3a4 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Tue, 2 Jun 2026 14:25:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(detect):=20F1=20d=C3=A9composition=20noms?= =?UTF-8?q?=20=C3=A0=20trait=20d'union=20+=20F4=20filet=20INSEE=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## F1 — Décomposition noms composés (corrige GRAND, EJNAINI) Quand le NER détecte un nom à trait d'union (ex "Romain BILLON-GRAND", "Cécilia NOCENT-EJNAINI"), le regex `\bBILLON-GRAND\b` ne traverse pas le saut de ligne du formatage Trackare en colonnes étroites ("BILLON-\nGRAND"). Solution dans `_apply_extracted_names` : pour chaque nom validé contenant un `-` (et ≥5 chars), ajouter aussi les sous-tokens (≥4 chars) à `safe_names`. Les sous-tokens héritent du `bypass_stopwords` du composé (cas Dr/Mme). Validation sur audit_30 : - GRAND : 17 → 0 occurrences ✅ - Score global : 97.9 → 98.3 (+0.4) - leak_audit : 3 → 1 ## F4 — Filet rescan résiduel élargi noms INSEE (OPT-IN) Le rescan post-anonymisation ne couvrait que NIR/EMAIL/IBAN/TEL. Ajout d'un check sur les tokens uppercase ≥4 chars présents dans le gazetteer INSEE (`_INSEE_NOMS_FAMILLE`), hors stopwords médicaux, hors placeholders, hors whitelist utilisateur. **Désactivé par défaut** (`cfg["rescan"]["check_insee_names"] = False`). Raison : INSEE contient beaucoup de mots français courants (VOIR, ALLO, POLYGONE, MIDI, FAURE, …) qui produisent un sur-masquage massif. Sur le corpus audit_30, F4 activé met 29/29 docs en quarantaine. Inutilisable en l'état mais utile pour un futur profil "paranoid" avec filtre par fréquence INSEE rare + dictionnaire français en exclusion. À activer via : cfg["rescan"]["check_insee_names"] = True ## Restant - F2 (SIMONET) : pattern NAME+PRENOM+PRENOM → medium (à implémenter) - F3 (OYARCABAL) : label "Nom usuel :" → high sur ligne suivante (à implémenter) - EJNAINI : mystère — fix F1 devrait suffire mais ne suffit pas, à investiguer Co-Authored-By: Claude Opus 4.7 (1M context) --- anonymizer_core_refactored_onnx.py | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index a53a132..d42d359 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -2378,6 +2378,25 @@ def _apply_extracted_names(text: str, names: set, audit: List[PiiHit], force_nam """Remplace globalement chaque nom extrait dans le texte.""" placeholder = PLACEHOLDERS["NOM"] _force = force_names or set() + + # F1 — décomposition des noms à trait d'union. + # Si "BILLON-GRAND" ou "NOCENT-EJNAINI" est validé comme nom, on ajoute + # aussi chaque sous-token (≥4 chars) à safe_names. Sinon le regex + # \bBILLON-GRAND\b ne matche pas le motif "BILLON-\nGRAND" produit par + # le formatage Trackare en colonnes étroites. Les sous-tokens héritent + # du bypass_stopwords du composé (cas Dr/Mme + nom composé). + expanded_names = set(names) + expanded_force = set(_force) + for n in list(names): + if "-" in n and len(n) >= 5: + parts = [p for p in n.split("-") if len(p) >= 4] + for part in parts: + expanded_names.add(part) + if n in _force: + expanded_force.add(part) + names = expanded_names + _force = expanded_force + safe_names = set() for n in names: if len(n) < 4 and n not in _force: @@ -4766,6 +4785,36 @@ def process_pdf( residual_count = 0 for pat, _label in _residual_pii_patterns: residual_count += len(pat.findall(final_text)) + + # F4 — filet de rescan élargi aux noms INSEE en MAJUSCULES. + # OPT-IN : désactivé par défaut. Sur le corpus audit_30, INSEE contient + # beaucoup de mots français courants (VOIR, ALLO, POLYGONE, MIDI, …) + # qui produisent un fort taux de faux positifs et mettent quasi tous + # les documents en quarantaine. À utiliser quand on tolère le sur- + # masquage et qu'on veut zéro fuite (ex: profil "paranoid"). + # Pour activer : passer cfg["rescan"]["check_insee_names"] = True. + _check_insee = False + if isinstance(cfg, dict): + _check_insee = bool((cfg.get("rescan", {}) or {}).get("check_insee_names", False)) + if _check_insee: + _placeholder_bare = {p.strip("[]") for p in PLACEHOLDERS.values()} + _wl_terms = [] + if isinstance(cfg, dict): + _wl_terms = (cfg.get("whitelist", {}) or {}).get("terms", []) or [] + _wl_norm = {_normalize_nfkd_upper(str(w)) for w in _wl_terms} + for token in re.findall(r"\b[A-ZÀ-Ÿ]{4,}\b", final_text): + if token in _placeholder_bare: + continue + norm = _normalize_nfkd_upper(token) + if norm not in _INSEE_NOMS_FAMILLE: + continue + if norm.lower() in _MEDICAL_STOP_WORDS_SET: + continue + if norm in _wl_norm: + continue + residual_count += 1 + log.warning("Residual INSEE name detected: %s (in %s)", token, pdf_path.name) + if residual_count > SEUIL_RESCAN_RESIDUEL: if quarantine_mgr is not None: quarantine_mgr.flag(