diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index 290042a..bb0b54a 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -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 ». 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 diff --git a/tests/unit/test_p0_layout_detectors.py b/tests/unit/test_p0_layout_detectors.py new file mode 100644 index 0000000..66c2193 --- /dev/null +++ b/tests/unit/test_p0_layout_detectors.py @@ -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}" + )