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:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user