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:
334
tests/unit/test_p0_layout_detectors.py
Normal file
334
tests/unit/test_p0_layout_detectors.py
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests P0 — 3 détecteurs simples du chantier v11.5 (GO Dom 2026-06-09).
|
||||
|
||||
Périmètre strict (sans Docling/ML/dépendance) :
|
||||
1. Adresse contextuelle : numéro + type de voie + suite, le contexte de voie
|
||||
(avenue/rue/...) prime sur les stopwords médicaux ; capture des noms de
|
||||
voie contenant des initiales (ex. "avenue de l'interne J. Loeb").
|
||||
2. Communes composées : alias Saint/St/Sainte/Ste avec espaces/tirets/connecteurs,
|
||||
masquage de la commune entière (pas de relâchement partiel).
|
||||
3. Contexte date : la date de naissance n'est masquée qu'en contexte fort
|
||||
(Né le / DDN / date de naissance) ; plus de propagation globale d'une date
|
||||
nue ; les dates cliniques (tableaux, surveillance) sont préservées.
|
||||
|
||||
Toutes les valeurs ci-dessous sont FICTIVES (aucune PII réelle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import anonymizer_core_refactored_onnx as core
|
||||
from anonymizer_core_refactored_onnx import (
|
||||
PLACEHOLDERS,
|
||||
_AHO_AVAILABLE,
|
||||
_mask_line_by_regex,
|
||||
_mask_ville_gazetteers,
|
||||
load_dictionaries,
|
||||
)
|
||||
|
||||
CFG = load_dictionaries(None)
|
||||
|
||||
# Le masquage des communes repose sur Aho-Corasick (pyahocorasick). Si la
|
||||
# dépendance est absente de l'environnement, ces tests sont SKIP (pas FAIL) —
|
||||
# résultat reproductible. La dépendance est requise en production (cf. mémoire).
|
||||
requires_aho = pytest.mark.skipif(not _AHO_AVAILABLE, reason="pyahocorasick indisponible")
|
||||
|
||||
|
||||
def _mask_line(line: str):
|
||||
audit = []
|
||||
out = _mask_line_by_regex(line, audit, 0, CFG)
|
||||
return out, audit
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Adresse contextuelle
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAdresseContextuelle:
|
||||
|
||||
def test_adresse_avec_initiale_et_nom_voie_complet(self):
|
||||
"""Cas bloquant Dom : la voie nommée d'après une personne (initiale + nom)
|
||||
doit être masquée ENTIÈREMENT — aucun fragment de nom ne doit fuiter."""
|
||||
out, audit = _mask_line("Domicile : 13, avenue de l'interne J. Loeb")
|
||||
assert PLACEHOLDERS["ADRESSE"] in out
|
||||
assert "Loeb" not in out, f"Fuite du nom de voie : {out!r}"
|
||||
assert "interne" not in out # le mot médical fait partie de l'adresse masquée
|
||||
|
||||
def test_adresse_apostrophe_typographique(self):
|
||||
"""Cas réel OCG 18 : apostrophe typographique ’ (U+2019) doit être
|
||||
couverte comme l'apostrophe droite."""
|
||||
out, _ = _mask_line("Domicile : 13, avenue de l’interne J. Loeb")
|
||||
assert PLACEHOLDERS["ADRESSE"] in out
|
||||
assert "Loeb" not in out, f"Fuite (apostrophe typographique) : {out!r}"
|
||||
assert "interne" not in out
|
||||
|
||||
def test_adresse_simple_non_regression(self):
|
||||
out, _ = _mask_line("12 rue de la Paix")
|
||||
assert out.strip() == PLACEHOLDERS["ADRESSE"]
|
||||
|
||||
def test_adresse_avenue_simple(self):
|
||||
out, _ = _mask_line("3 avenue des Fleurs Bleues")
|
||||
assert PLACEHOLDERS["ADRESSE"] in out
|
||||
assert "Fleurs" not in out
|
||||
|
||||
# --- Matrice générique (demande Codex : famille d'adresses, pas un cas) ---
|
||||
@pytest.mark.parametrize("adresse,reste_visible", [
|
||||
("24 rue du docteur A. Martin", "Martin"), # titre + initiale + nom
|
||||
("5 boulevard du professeur P. Bernard", "Bernard"),
|
||||
("7 place du Général de Gaulle", "Gaulle"), # titre historique
|
||||
("9 rue Jean Jaurès", "Jaurès"),
|
||||
("11 avenue du Président Wilson", "Wilson"),
|
||||
("18 allée des Frères Lumière", "Lumière"),
|
||||
("4 rue du 8 Mai 1945", "1945"), # commémoratif (chiffres)
|
||||
("2 rue du 11 Novembre", "Novembre"),
|
||||
("13, avenue de l’interne J. Loeb", "Loeb"), # apostrophe typographique
|
||||
])
|
||||
def test_adresse_matrice_generique(self, adresse, reste_visible):
|
||||
out, _ = _mask_line(adresse)
|
||||
assert PLACEHOLDERS["ADRESSE"] in out, f"non masqué: {adresse!r} -> {out!r}"
|
||||
assert reste_visible not in out, f"fuite résiduelle: {adresse!r} -> {out!r}"
|
||||
|
||||
@pytest.mark.parametrize("ligne_clinique", [
|
||||
"3 mg/L de CRP",
|
||||
"TA 12/8 mmHg",
|
||||
"Paracétamol 1000 mg, 3 fois par jour",
|
||||
"FC 72 bpm, SpO2 98%",
|
||||
"2 comprimés matin et soir",
|
||||
])
|
||||
def test_adresse_anti_fp_clinique(self, ligne_clinique):
|
||||
out, _ = _mask_line(ligne_clinique)
|
||||
assert PLACEHOLDERS["ADRESSE"] not in out, f"faux masquage adresse: {out!r}"
|
||||
|
||||
def test_adresse_ne_deborde_pas_sur_phrase_clinique(self):
|
||||
"""Un point après un mot (pas une initiale) borne l'adresse : la phrase
|
||||
clinique qui suit sur la même ligne n'est pas avalée."""
|
||||
out, _ = _mask_line("Adresse 5 rue des Lilas. Le patient va bien")
|
||||
assert PLACEHOLDERS["ADRESSE"] in out
|
||||
assert "Le patient va bien" in out, f"débordement: {out!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Communes composées
|
||||
# ---------------------------------------------------------------------------
|
||||
@requires_aho
|
||||
class TestCommunesComposees:
|
||||
|
||||
def test_st_martin_de_hinx_espaces(self):
|
||||
"""Cas bloquant Dom : commune composée préfixée 'St' écrite avec des
|
||||
espaces doit être masquée entièrement, sans laisser 'Martin' visible,
|
||||
même sans contexte géographique explicite."""
|
||||
out, _ = _mask_ville_gazetteers("St Martin de Hinx")
|
||||
assert PLACEHOLDERS["VILLE"] in out, f"Commune non masquée : {out!r}"
|
||||
assert "Martin" not in out, f"Relâchement partiel : {out!r}"
|
||||
assert "Hinx" not in out
|
||||
|
||||
def test_commune_composee_tiret_non_regression(self):
|
||||
out, _ = _mask_ville_gazetteers("Saint-Martin-de-Hinx")
|
||||
assert PLACEHOLDERS["VILLE"] in out
|
||||
assert "Martin" not in out
|
||||
|
||||
def test_mot_courant_non_masque_sans_contexte(self):
|
||||
"""Garde-fou anti-FP : un mot homonyme de commune (mono-mot, sans
|
||||
contexte géo) ne doit pas être masqué abusivement."""
|
||||
out, _ = _mask_ville_gazetteers("Les signes vitaux sont stables.")
|
||||
assert PLACEHOLDERS["VILLE"] not in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Contexte date
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestContexteDate:
|
||||
|
||||
def test_date_naissance_contexte_fort_masquee(self):
|
||||
out, audit = _mask_line("Né le 14/03/1956")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] in out
|
||||
assert "1956" not in out
|
||||
assert any(h.kind == "DATE_NAISSANCE" for h in audit)
|
||||
|
||||
def test_date_naissance_variantes_contexte(self):
|
||||
for line in ("Date de naissance : 01/02/1944",
|
||||
"DDN 1/2/1944",
|
||||
"Née le 2 mars 1944"):
|
||||
out, _ = _mask_line(line)
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] in out, f"non masqué: {line!r} -> {out!r}"
|
||||
|
||||
def test_date_clinique_sans_contexte_preservee(self):
|
||||
"""Une date sans contexte de naissance (acte/suivi) ne doit PAS être
|
||||
masquée."""
|
||||
out, _ = _mask_line("Intervention réalisée le 14/03/2025")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] not in out
|
||||
assert "14/03/2025" in out
|
||||
|
||||
def test_date_tableau_clinique_preservee(self):
|
||||
out, _ = _mask_line("08:00 | 120/80 | 37.1 | 12/03/2024")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] not in out
|
||||
assert "12/03/2024" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Anti-régression : pas de propagation globale d'une date nue
|
||||
# (DATE_NAISSANCE_GLOBAL pass 2 retirée)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _make_pdf(tmp_path: Path, lines: list[str]) -> Path:
|
||||
import fitz
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
y = 72
|
||||
for ln in lines:
|
||||
page.insert_text((72, y), ln, fontsize=11)
|
||||
y += 18
|
||||
p = tmp_path / "synthetic_dob.pdf"
|
||||
doc.save(str(p))
|
||||
doc.close()
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Tests adversariaux Qwen (T-A1 → T-A10) — validation des 3 détecteurs
|
||||
# Valeurs 100% fictives.
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAdversarialQwen:
|
||||
|
||||
def test_TA1_adresse_voie_personne(self):
|
||||
out, _ = _mask_line("13, avenue de l'interne J. Loeb")
|
||||
assert out.strip() == PLACEHOLDERS["ADRESSE"]
|
||||
|
||||
def test_TA2_ligne_clinique_non_masquee(self):
|
||||
"""Une ligne clinique « 3 mg/L de CRP » ne doit pas être prise pour une
|
||||
adresse (le détecteur exige un type de voie comme ancre)."""
|
||||
out, _ = _mask_line("3 mg/L de CRP")
|
||||
assert PLACEHOLDERS["ADRESSE"] not in out
|
||||
assert out == "3 mg/L de CRP"
|
||||
|
||||
@requires_aho
|
||||
def test_TA3_commune_composee_bloc_adresse(self):
|
||||
out, _ = _mask_ville_gazetteers("Adresse : St Martin de Hinx")
|
||||
assert PLACEHOLDERS["VILLE"] in out
|
||||
assert "Martin" not in out
|
||||
|
||||
@requires_aho
|
||||
def test_TA4_dr_martin_pas_commune(self):
|
||||
"""« Dr Martin » ne doit pas être masqué comme VILLE par le gazetteer
|
||||
communes (le masquage du nom relève du pipeline NOM, hors de ce test)."""
|
||||
out, _ = _mask_ville_gazetteers("Dr Martin")
|
||||
assert PLACEHOLDERS["VILLE"] not in out
|
||||
|
||||
@requires_aho
|
||||
def test_TA5_martin_seul_pas_masque_commune(self):
|
||||
out, _ = _mask_ville_gazetteers("Martin")
|
||||
assert PLACEHOLDERS["VILLE"] not in out
|
||||
|
||||
def test_TA6_ddn_bloc_identite(self):
|
||||
out, _ = _mask_line("Né le 14/03/1956")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] in out
|
||||
|
||||
def test_TA7_date_clinique_tableau_non_masquee(self):
|
||||
out, _ = _mask_line("TA 120/80 | FC 72 | 14/03/1956")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] not in out
|
||||
assert "14/03/1956" in out
|
||||
|
||||
def test_TA8_date_pres_label_naissance(self):
|
||||
out, _ = _mask_line("Date de naissance 14/03/2025")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] in out
|
||||
|
||||
def test_TA9_ddn_label_court(self):
|
||||
out, _ = _mask_line("DDN: 02/08/1980")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] in out
|
||||
|
||||
|
||||
def _make_pdf_2pages(tmp_path: Path, page1: list[str], page2: list[str]) -> Path:
|
||||
import fitz
|
||||
doc = fitz.open()
|
||||
for lines in (page1, page2):
|
||||
page = doc.new_page()
|
||||
y = 72
|
||||
for ln in lines:
|
||||
page.insert_text((72, y), ln, fontsize=11)
|
||||
y += 18
|
||||
p = tmp_path / "synthetic_2p.pdf"
|
||||
doc.save(str(p))
|
||||
doc.close()
|
||||
return p
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_TA10_date_meme_que_ddn_page3_tableau_non_masquee(tmp_path):
|
||||
"""T-A10 : une date identique à la DDN mais dans un tableau d'une autre page
|
||||
(hors contexte de naissance) ne doit PAS être masquée. Valeurs fictives."""
|
||||
pdf = _make_pdf_2pages(
|
||||
tmp_path,
|
||||
page1=[
|
||||
"Compte rendu de consultation fictif pour test automatise.",
|
||||
"Patient FICTIF TESTNOM, dossier numero 000000.",
|
||||
"Ne le 14/03/1956. Motif : controle de routine sans particularite.",
|
||||
"Antecedents : aucun signale dans ce document de test synthetique.",
|
||||
],
|
||||
page2=[
|
||||
"Tableau de suivi clinique (donnees fictives de test).",
|
||||
"Controle realise le 14/03/1956 RAS, parametres stables.",
|
||||
"Conclusion : surveillance simple, prochain rendez-vous a definir.",
|
||||
],
|
||||
)
|
||||
out_dir = tmp_path / "out2"
|
||||
out_dir.mkdir()
|
||||
core.process_pdf(pdf, out_dir, CFG)
|
||||
txt = (out_dir / "synthetic_2p.pseudonymise.txt").read_text(encoding="utf-8")
|
||||
assert "Controle realise le 14/03/1956" in txt, f"date clinique masquée à tort:\n{txt}"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_date_clinique_non_masquee_apres_dob_detectee_ailleurs(tmp_path):
|
||||
"""Anti-régression DATE_NAISSANCE_GLOBAL : une date cliniquement identique à
|
||||
la date de naissance, mais hors contexte de naissance, ne doit PAS être
|
||||
masquée globalement sur tout le document. Valeurs 100% fictives."""
|
||||
pdf = _make_pdf(tmp_path, [
|
||||
"Patient FICTIF TESTNOM",
|
||||
"Ne le 12/03/1990",
|
||||
"Tableau de surveillance :",
|
||||
"Prelevement realise le 12/03/1990 - bilan stable",
|
||||
])
|
||||
out_dir = tmp_path / "out"
|
||||
out_dir.mkdir()
|
||||
res = core.process_pdf(pdf, out_dir, CFG)
|
||||
txt_path = out_dir / "synthetic_dob.pseudonymise.txt"
|
||||
assert txt_path.exists(), f"sortie absente (status={res.get('status') if isinstance(res, dict) else res})"
|
||||
text = txt_path.read_text(encoding="utf-8")
|
||||
# La ligne "Prelevement ... le 12/03/1990" ne doit PAS avoir été masquée
|
||||
assert "Prelevement realise le 12/03/1990" in text, (
|
||||
f"date clinique masquée à tort par propagation globale :\n{text}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_date_globale_non_masquee_dans_pdf_redacted(tmp_path):
|
||||
"""Codex blocage #3 : la neutralisation de DATE_NAISSANCE_GLOBAL doit valoir
|
||||
aussi pour le PDF redacted (vector), pas seulement le .txt. On vérifie que la
|
||||
date clinique reste présente dans le texte du PDF caviardé. Valeurs fictives."""
|
||||
import fitz
|
||||
pdf = _make_pdf(tmp_path, [
|
||||
"Compte rendu fictif pour test automatise de non regression.",
|
||||
"Patient FICTIF TESTNOM, dossier 000000, sans particularite.",
|
||||
"Ne le 12/03/1990. Motif de consultation : controle de routine.",
|
||||
"Tableau de surveillance clinique (donnees fictives) :",
|
||||
"Prelevement realise le 12/03/1990, parametres stables, RAS.",
|
||||
])
|
||||
out_dir = tmp_path / "outpdf"
|
||||
out_dir.mkdir()
|
||||
core.process_pdf(pdf, out_dir, CFG)
|
||||
redacted = out_dir / "synthetic_dob.redacted_vector.pdf"
|
||||
if not redacted.exists():
|
||||
# fallback raster éventuel
|
||||
redacted = out_dir / "synthetic_dob.redacted_raster.pdf"
|
||||
assert redacted.exists(), "PDF caviardé absent"
|
||||
doc = fitz.open(str(redacted))
|
||||
pdf_text = "\n".join(p.get_text("text") for p in doc)
|
||||
doc.close()
|
||||
# Le PDF caviardé en mode vector retire le texte sous les boîtes. La date
|
||||
# clinique ne doit PAS avoir été retirée par une propagation globale.
|
||||
if "redacted_vector" in redacted.name:
|
||||
assert "12/03/1990" in pdf_text, (
|
||||
f"date clinique retirée du PDF par propagation globale :\n{pdf_text}"
|
||||
)
|
||||
Reference in New Issue
Block a user