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", r"\b(\d{1,2})\s+" + _MOIS_FR + r"\s+(\d{4})\b",
re.IGNORECASE, 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( RE_ADRESSE = re.compile(
r"\b\d{1,4}[\s,]*(?:bis|ter)?\s*,?\s*" r"\b\d{1,4}[ \t]*,?[ \t]*(?:bis|ter)?[ \t]*,?[ \t]*"
r"(?:rue|avenue|av\.?|boulevard|bd\.?|place|chemin|all[ée]e|impasse|route|cours|passage|square|r[ée]sidence" + _RE_VOIE_TYPE +
r"|lotissement|lot\.?|cit[ée]|hameau|quartier|voie|parvis|esplanade|promenade|côte)" r"[ \t]+" + _RE_VOIE_TOKEN + r"(?:[ \t]+" + _RE_VOIE_TOKEN + r"){0,9}",
r"\s+[A-ZÉÈÀÙÂÊÎÔÛÑa-zéèàùâêîôûñäëïöüçñ\s\-']{2,}",
re.IGNORECASE, re.IGNORECASE,
) )
RE_CODE_POSTAL = re.compile( RE_CODE_POSTAL = re.compile(
@@ -3685,7 +3701,9 @@ def _mask_ville_gazetteers(text: str) -> tuple:
if _VILLE_AC is None: if _VILLE_AC is None:
_build_ville_ac() _build_ville_ac()
if _VILLE_AC is None: 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) normalized = _normalize_positional(text)
placeholder = PLACEHOLDERS["VILLE"] 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, # sauf pour les villes composées avec trait d'union (Saint-Palais,
# Mont-de-Marsan) qui sont très peu ambiguës. # Mont-de-Marsan) qui sont très peu ambiguës.
is_compound_hyphen = ("-" in original_span and word_count >= 2) 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] before_ctx = text[max(0, start_idx - 40):start_idx]
if not _RE_GEO_BEFORE.search(before_ctx): if not _RE_GEO_BEFORE.search(before_ctx):
continue 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) by_page.setdefault(h.page, []).append(h)
# Kinds à ne pas chercher dans le PDF (dates masquées uniquement dans le texte, # Kinds à ne pas chercher dans le PDF (dates masquées uniquement dans le texte,
# pas dans le PDF où elles rendent les tableaux illisibles) # 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 # Kinds sensibles au substring matching : utiliser _search_whole_word
_VECTOR_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM", _VECTOR_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM",
"EDS_HOPITAL", "EDS_VILLE", "ETAB", "ETAB_GLOBAL", "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.") raise RuntimeError("PyMuPDF non disponible installez pymupdf.")
doc = fitz.open(str(original_pdf)) doc = fitz.open(str(original_pdf))
all_rects: Dict[int, List["fitz.Rect"]] = {} 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 # Kinds sensibles au substring matching : utiliser _search_whole_word
_RASTER_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM", _RASTER_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM",
"EDS_HOPITAL", "EDS_VILLE", "ETAB", "ETAB_GLOBAL", "EDS_HOPITAL", "EDS_VILLE", "ETAB", "ETAB_GLOBAL",
@@ -4892,7 +4919,11 @@ def process_pdf(
# 4b) Propagation globale SÉLECTIVE : uniquement pour les PII critiques # 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 # 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) # 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] = {} _global_pii: Dict[str, set] = {}
for h in anon.audit: for h in anon.audit:
@@ -4965,17 +4996,16 @@ def process_pdf(
# [\s/.\-]+ accepte : espace, slash, point, tiret (un ou plusieurs) # [\s/.\-]+ accepte : espace, slash, point, tiret (un ou plusieurs)
date_pattern = rf'{day}[\s/.\-]+{month}[\s/.\-]+{year}' date_pattern = rf'{day}[\s/.\-]+{month}[\s/.\-]+{year}'
# Multi-pass replacement pour couvrir tous les cas # Propagation globale UNIQUEMENT en contexte fort de naissance.
# Pass 1 : Avec contexte "Né(e) le" (case-insensitive) # (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( final_text = re.sub(
rf'Né(?:e)?\s+le\s+{date_pattern}', rf'N[ée](?: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',
h.placeholder, h.placeholder,
final_text, final_text,
flags=re.IGNORECASE flags=re.IGNORECASE

View 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 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}"
)