#!/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}" )