Files
anonymisation/tests/unit/test_p0_layout_detectors.py
Domi31tls 87f5e48d66 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>
2026-06-10 10:28:18 +02:00

335 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 linterne 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 linterne 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}"
)