fix: Propagation globale sélective pour corriger fuites dates CRO

Problème:
- 36 CRO avec fuites dates de naissance (Né(e) le DD/MM/YYYY)
- Dates détectées page 0 mais pas propagées pages suivantes
- Désactivation propagation globale avait éliminé 951 FP mais créé fuites

Solution:
- Propagation SÉLECTIVE: uniquement PII critiques (DATE_NAISSANCE, NIR, IPP, EMAIL, force_term)
- PII non-critiques (TEL, ADRESSE, etc.) NON propagés (évite 951 FP)
- Remplacement amélioré: gère variations format dates (/, ., -, espaces)
- Gère contexte 'Né(e) le' avec case-insensitive

Impact attendu:
- Rappel: 100% (plus de fuites)
- Précision: 85-87% (légère baisse vs 88.27%, mais acceptable)
- FP réintroduits: ~10-20 (vs 951 avant)

Fichiers:
- anonymizer_core_refactored_onnx.py: propagation sélective + remplacement amélioré
- tools/test_date_propagation.py: script test sur CRO
- LEAK_FIX.md: documentation complète de la correction
This commit is contained in:
2026-03-02 11:59:32 +01:00
parent 6806aee587
commit f188116bc1
4 changed files with 554 additions and 9 deletions

View File

@@ -2032,19 +2032,32 @@ def process_pdf(
# for token in _global_name_tokens:
# anon.audit.append(PiiHit(page=-1, kind="NOM_GLOBAL", original=token, placeholder=PLACEHOLDERS["NOM"]))
# 4b) TEL, EMAIL, ADRESSE, CODE_POSTAL : propager les valeurs uniques sur toutes les pages
# 4b) Propagation globale SÉLECTIVE : uniquement pour les PII critiques
# Les PII critiques (DATE_NAISSANCE, NIR, IPP, EMAIL) sont propagés sur toutes les pages
# pour éviter les fuites sur les documents multi-pages (ex: CRO)
_CRITICAL_PII_TYPES = {"DATE_NAISSANCE", "NIR", "IPP", "EMAIL", "force_term", "force_regex"}
_global_pii: Dict[str, set] = {}
for h in anon.audit:
# Collecter TOUS les types pour analyse, mais ne propager que les critiques
if h.kind in {"TEL", "EMAIL", "ADRESSE", "CODE_POSTAL", "EPISODE", "RPPS", "VILLE", "ETAB",
"VLM_SERVICE", "VLM_ETAB", "DATE_NAISSANCE",
"VLM_SERVICE", "VLM_ETAB", "DATE_NAISSANCE", "NIR", "IPP",
"force_term", "force_regex"}:
_global_pii.setdefault(h.kind, set()).add(h.original.strip())
# DÉSACTIVÉ: Tous les types *_GLOBAL génèrent 951 FP avec 0 TP (100% faux positifs)
# La propagation globale est trop agressive et ne détecte aucun vrai positif
# for kind, values in _global_pii.items():
# placeholder = PLACEHOLDERS.get(kind, PLACEHOLDERS["MASK"])
# for val in values:
# anon.audit.append(PiiHit(page=-1, kind=f"{kind}_GLOBAL", original=val, placeholder=placeholder))
# Propager UNIQUEMENT les PII critiques (évite les 951 FP des autres types)
for kind, values in _global_pii.items():
if kind not in _CRITICAL_PII_TYPES:
continue # Skip non-critical PII (TEL, ADRESSE, etc.)
placeholder = PLACEHOLDERS.get(kind, PLACEHOLDERS["MASK"])
for val in values:
if not val or len(val) < 3: # Skip valeurs trop courtes
continue
anon.audit.append(PiiHit(page=-1, kind=f"{kind}_GLOBAL", original=val, placeholder=placeholder))
log.info("Propagation globale sélective : %d types critiques propagés",
sum(1 for k in _global_pii.keys() if k in _CRITICAL_PII_TYPES))
# 4e) Appliquer les tokens globaux sur le texte pseudonymisé
_GLOBAL_SKIP_KINDS = {"EDS_DATE_GLOBAL"}
@@ -2061,12 +2074,35 @@ def process_pdf(
# Garde trackare : NOM_GLOBAL très court (<=3) risque de masquer des codes diagnostics
if anon.is_trackare and h.kind == "NOM_GLOBAL" and len(token) <= 3:
continue
try:
# Traitement spécial pour DATE_NAISSANCE_GLOBAL : gérer les variations de format
if h.kind == "DATE_NAISSANCE_GLOBAL":
# Extraire la date pure (DD/MM/YYYY ou DD/MM/YY)
date_match = re.search(r'\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4}', token)
if date_match:
date_str = date_match.group(0)
# Normaliser les séparateurs pour le pattern
date_pattern = re.escape(date_str).replace(r'\/', r'[\s/.\-]').replace(r'\.', r'[\s/.\-]').replace(r'\-', r'[\s/.\-]')
# Remplacer avec ou sans contexte "Né(e) le"
final_text = re.sub(
rf'(?:Né(?:e)?\s+le\s+)?{date_pattern}',
h.placeholder,
final_text,
flags=re.IGNORECASE
)
continue
# Traitement standard pour les autres types
pat = re.escape(token)
# Noms composés : tolérer les sauts de ligne/espaces autour du tiret
if "-" in token:
pat = pat.replace(r"\-", r"\-\s*")
final_text = re.sub(rf"\b{pat}\b", h.placeholder, final_text)
# Dates : tolérer variations de séparateurs
if "/" in token or "." in token:
pat = pat.replace(r"\.", r"[\s/.\-]").replace(r"\/", r"[\s/.\-]")
final_text = re.sub(rf"\b{pat}\b", h.placeholder, final_text, flags=re.IGNORECASE)
except re.error:
final_text = final_text.replace(token, h.placeholder)