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
|
||||
|
||||
Reference in New Issue
Block a user