feat(detect): F1 décomposition noms à trait d'union + F4 filet INSEE opt-in

## 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 14:25:52 +02:00
parent ae50828ce7
commit eaea6b2d7f

View File

@@ -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.""" """Remplace globalement chaque nom extrait dans le texte."""
placeholder = PLACEHOLDERS["NOM"] placeholder = PLACEHOLDERS["NOM"]
_force = force_names or set() _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() safe_names = set()
for n in names: for n in names:
if len(n) < 4 and n not in _force: if len(n) < 4 and n not in _force:
@@ -4766,6 +4785,36 @@ def process_pdf(
residual_count = 0 residual_count = 0
for pat, _label in _residual_pii_patterns: for pat, _label in _residual_pii_patterns:
residual_count += len(pat.findall(final_text)) 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 residual_count > SEUIL_RESCAN_RESIDUEL:
if quarantine_mgr is not None: if quarantine_mgr is not None:
quarantine_mgr.flag( quarantine_mgr.flag(