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:
@@ -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 <date> ».
|
||||
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
|
||||
|
||||
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