feat(anonymizer): add v11.5 P0 layout-aware detectors

Trois détecteurs simples « layout/context-aware » (chantier v11.5 P0),
validés par 2 revues Codex + 10 tests adversariaux Qwen, 0 régression :

- RE_ADRESSE réécrit en grammaire de tokens (_RE_VOIE_TYPE + _RE_VOIE_TOKEN) :
  capture initiales (« J. Loeb »), voies commémoratives à chiffres
  (« 8 Mai 1945 »), apostrophes ' et ’, bornage à la ligne courante,
  arrêt sur point post-mot (anti-débordement clinique).
- _mask_ville_gazetteers : retourne toujours un tuple (texte, liste) même
  sans Aho-Corasick ; masque les communes Saint/St/Sainte/Ste multi-mots à
  espaces (« St Martin de Hinx ») entièrement, sans exiger de contexte géo.
- DATE_NAISSANCE retiré de la propagation globale + DATE_NAISSANCE_GLOBAL
  ajouté aux skip vector/raster : on ne masque plus une date nue sur tout le
  document. La DDN reste masquée en contexte fort, page par page. Les dates
  cliniques identiques à la DDN hors contexte sont préservées.

tests/unit/test_p0_layout_detectors.py : 38 tests dédiés (matrice adresse
générique, anti-FP, communes Saint, propagation DDN, 10 tests adversariaux
Qwen). Suite tests/unit complète : 147 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 10:28:18 +02:00
parent c582c13a08
commit 0e44cd4543
2 changed files with 383 additions and 19 deletions

View File

@@ -890,11 +890,27 @@ RE_DATE = re.compile(
r"\b(\d{1,2})\s+" + _MOIS_FR + r"\s+(\d{4})\b",
re.IGNORECASE,
)
# Adresse contextuelle (v11.5 P0) — ancre forte « numéro + type de voie », puis
# nom de voie décrit par une GRAMMAIRE DE TOKENS généralisée (pas un cas précis) :
# - mot/chiffre : lettres accentuées, chiffres (voies commémoratives « 8 Mai 1945 »,
# « 11 Novembre »), apostrophe droite ' et typographique , traits d'union ;
# - initiale : une seule lettre suivie d'un point (« J. », « A. ») — couvre les voies
# nommées d'après une personne (« avenue de l'interne J. Loeb »).
# Bornage à la LIGNE COURANTE : séparateurs `[ \t]` (jamais `\n`). Un point qui suit un
# mot de plusieurs lettres N'est PAS un token initiale -> on s'arrête (évite d'avaler la
# phrase clinique suivante : « rue des Lilas. Le patient… » s'arrête après « Lilas »).
_RE_VOIE_TYPE = (
r"(?:rue|avenue|av\.?|boulevard|bd\.?|place|chemin|all[ée]e|impasse|route|cours"
r"|passage|square|r[ée]sidence|lotissement|lot\.?|cit[ée]|hameau|quartier|voie"
r"|parvis|esplanade|promenade|côte)"
)
_RE_VOIE_TOKEN = (
r"(?:[A-Za-zÀ-ÿ]\.|[A-Za-zÀ-ÿ0-9']+(?:-[A-Za-zÀ-ÿ0-9']+)*)"
)
RE_ADRESSE = re.compile(
r"\b\d{1,4}[\s,]*(?:bis|ter)?\s*,?\s*"
r"(?:rue|avenue|av\.?|boulevard|bd\.?|place|chemin|all[ée]e|impasse|route|cours|passage|square|r[ée]sidence"
r"|lotissement|lot\.?|cit[ée]|hameau|quartier|voie|parvis|esplanade|promenade|côte)"
r"\s+[A-ZÉÈÀÙÂÊÎÔÛÑa-zéèàùâêîôûñäëïöüçñ\s\-']{2,}",
r"\b\d{1,4}[ \t]*,?[ \t]*(?:bis|ter)?[ \t]*,?[ \t]*"
+ _RE_VOIE_TYPE +
r"[ \t]+" + _RE_VOIE_TOKEN + r"(?:[ \t]+" + _RE_VOIE_TOKEN + r"){0,9}",
re.IGNORECASE,
)
RE_CODE_POSTAL = re.compile(
@@ -3685,7 +3701,9 @@ def _mask_ville_gazetteers(text: str) -> tuple:
if _VILLE_AC is None:
_build_ville_ac()
if _VILLE_AC is None:
return text
# Contrat : toujours retourner un tuple (texte, liste), même si
# Aho-Corasick est indisponible (sinon les appelants/tests cassent).
return text, []
normalized = _normalize_positional(text)
placeholder = PLACEHOLDERS["VILLE"]
@@ -3775,7 +3793,16 @@ def _mask_ville_gazetteers(text: str) -> tuple:
# sauf pour les villes composées avec trait d'union (Saint-Palais,
# Mont-de-Marsan) qui sont très peu ambiguës.
is_compound_hyphen = ("-" in original_span and word_count >= 2)
if not is_compound_hyphen:
# Communes composées préfixées Saint/St/Sainte/Ste écrites avec des
# espaces (ex. « St Martin de Hinx ») : aussi peu ambiguës que les
# formes à tiret -> masquage sans exiger de contexte géographique, et
# masquage de la commune ENTIÈRE (pas de relâchement partiel).
_norm_span = _normalize_positional(original_span)
is_saint_compound = (
word_count >= 2
and re.match(r"(?:st|ste|saint|sainte)[\s\-]", _norm_span) is not None
)
if not (is_compound_hyphen or is_saint_compound):
before_ctx = text[max(0, start_idx - 40):start_idx]
if not _RE_GEO_BEFORE.search(before_ctx):
continue
@@ -4219,7 +4246,7 @@ def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, oc
by_page.setdefault(h.page, []).append(h)
# Kinds à ne pas chercher dans le PDF (dates masquées uniquement dans le texte,
# pas dans le PDF où elles rendent les tableaux illisibles)
_VECTOR_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL"}
_VECTOR_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL", "DATE_NAISSANCE_GLOBAL"}
# Kinds sensibles au substring matching : utiliser _search_whole_word
_VECTOR_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM",
"EDS_HOPITAL", "EDS_VILLE", "ETAB", "ETAB_GLOBAL",
@@ -4378,7 +4405,7 @@ def redact_pdf_raster(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, dp
raise RuntimeError("PyMuPDF non disponible installez pymupdf.")
doc = fitz.open(str(original_pdf))
all_rects: Dict[int, List["fitz.Rect"]] = {}
_RASTER_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL"}
_RASTER_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL", "DATE_NAISSANCE_GLOBAL"}
# Kinds sensibles au substring matching : utiliser _search_whole_word
_RASTER_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM",
"EDS_HOPITAL", "EDS_VILLE", "ETAB", "ETAB_GLOBAL",
@@ -4892,7 +4919,11 @@ def process_pdf(
# 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", "FINESS", "DOSSIER", "NDA", "EPISODE"}
# (v11.5 P0) DATE_NAISSANCE retiré de la propagation globale : on ne masque
# plus une date nue sur tout le document (ni texte, ni audit, ni PDF/raster).
# La DDN reste masquée en contexte fort, page par page (RE_DATE_NAISSANCE +
# multiligne). Cela évite de masquer une date clinique égale à la DDN.
_CRITICAL_PII_TYPES = {"NIR", "IPP", "EMAIL", "force_term", "force_regex", "FINESS", "DOSSIER", "NDA", "EPISODE"}
_global_pii: Dict[str, set] = {}
for h in anon.audit:
@@ -4965,17 +4996,16 @@ def process_pdf(
# [\s/.\-]+ accepte : espace, slash, point, tiret (un ou plusieurs)
date_pattern = rf'{day}[\s/.\-]+{month}[\s/.\-]+{year}'
# Multi-pass replacement pour couvrir tous les cas
# Pass 1 : Avec contexte "Né(e) le" (case-insensitive)
# Propagation globale UNIQUEMENT en contexte fort de naissance.
# (v11.5 P0) On NE propage plus la date nue sur tout le PDF :
# une date cliniquement identique à la DDN mais hors contexte
# (tableau de surveillance, prélèvement, acte) doit être
# préservée. Les contextes forts complémentaires (DDN, date de
# naissance) sont déjà couverts ligne par ligne (RE_DATE_NAISSANCE)
# et en multiligne ; ici on ne couvre que la propagation
# inter-pages du motif « Né(e) le <date> ».
final_text = re.sub(
rf'Né(?:e)?\s+le\s+{date_pattern}',
h.placeholder,
final_text,
flags=re.IGNORECASE
)
# Pass 2 : Sans contexte (date seule)
final_text = re.sub(
rf'\b{date_pattern}\b',
rf'N[ée](?:e)?\s+le\s+{date_pattern}',
h.placeholder,
final_text,
flags=re.IGNORECASE