feat: qualité anonymisation — sur-anonymisation, fuites PHI, nettoyage bruit

P0-A: stop words français + seuil subparts 5 chars + sweep conditionnel
P0-B: 6 nouveaux patterns PHI (DDN, Par, N Ipp, Adresse, DEMANDE, venue)
P2-C: cohérence pseudonymes (_find_matching_entity) + fix crochets
P1-B: text_cleaner.py — sidebar OCR, footers, dédup vitales, collapse blanks
P1-A: dédup CRH par SequenceMatcher (seuil 85%)
Tests: 34 nouveaux tests (996 pass, 0 fail)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-25 14:00:07 +01:00
parent 63354e75bc
commit f4a23a5f43
7 changed files with 499 additions and 8 deletions

View File

@@ -31,6 +31,9 @@ MEDICAL_TERMS_WHITELIST = {
"angiocholite", "cholécystite", "cholecystite",
"morphine", "paracétamol", "paracetamol", "cétirizine", "cetirizine",
"tramadol", "contramal", "acupan", "nefopam",
"mestinon", "augmentin", "doliprane", "spasfon", "lasilix",
"lovenox", "kardegic", "inexium", "mopral", "gaviscon",
"loxen", "perfalgan", "profenid", "voltarene", "toplexil",
"service", "médecin", "medecin", "docteur", "chirurgie",
"gastro", "entérologie", "enterologie", "oncologie",
"hépato", "hepato", "digestif", "digestive",
@@ -156,6 +159,23 @@ class Anonymizer:
text, n = self._replace_footer(text)
count += n
# Documents spécialisés (BACTERIO, CONSULTATION, ANAPATH)
text, n = self._replace_pattern(text, patterns.DDN_PATTERN, "date_naissance")
count += n
text, n = self._replace_pattern(text, patterns.PAR_NOM_PATTERN, "soignant")
count += n
text, n = self._replace_pattern(text, patterns.DEMANDE_NUM_PATTERN, "identifiant")
count += n
text, n = self._replace_pattern(text, patterns.VENUE_PATTERN, "episode")
count += n
text, n = self._replace_pattern(text, patterns.N_IPP_PATTERN, "ipp")
count += n
text, n = self._replace_pattern(
text, patterns.CONSULT_ADRESSE_PATTERN, "adresse",
skip_establishment_check=True,
)
count += n
self.report.regex_replacements = count
return text
@@ -204,9 +224,15 @@ class Anonymizer:
# --- Phase 3 : Balayage final ---
def _phase3_sweep(self, text: str) -> str:
"""Balayage brute-force des entités connues restantes."""
"""Balayage brute-force des entités connues restantes.
Les sous-parties (fragments de noms composés) ne sont remplacées
que si elles apparaissent en contexte de nom propre (capitalisées)
pour éviter la sur-anonymisation de mots courants.
"""
count = 0
all_entities = self.registry.get_all_entities()
subparts = self.registry.get_subparts()
for original, replacement in sorted(
all_entities.items(), key=lambda x: len(x[0]), reverse=True
@@ -219,12 +245,36 @@ class Anonymizer:
# Recherche insensible à la casse, avec frontières de mots
escaped = re.escape(original)
pattern = re.compile(r"\b" + escaped + r"\b", re.IGNORECASE)
matches = pattern.findall(text)
if matches:
matches = list(pattern.finditer(text))
if not matches:
continue
# Les sous-parties ne sont remplacées que si capitalisées
# (contexte de nom propre vs. mot courant)
if original in subparts:
for m in reversed(matches):
matched_text = m.group(0)
if matched_text[0].isupper():
text = text[:m.start()] + replacement + text[m.end():]
count += 1
else:
text = pattern.sub(replacement, text)
count += len(matches)
self.report.sweep_replacements = count
# Post-traitement : corriger les crochets malformés
text = self._fix_brackets(text)
return text
@staticmethod
def _fix_brackets(text: str) -> str:
"""Corrige les crochets malformés après anonymisation."""
# Doubles crochets fermants
text = re.sub(r"\]\]", "]", text)
# Ajouter un espace avant un tag collé à un mot
text = re.sub(r"([A-Za-zéèêëàâäùûüôöîïç])\[", r"\1 [", text)
return text
# --- Helpers ---

View File

@@ -5,6 +5,20 @@ from __future__ import annotations
import re
from collections import defaultdict
# Mots français trop courants pour être des sous-parties fiables
FRENCH_STOP_WORDS = {
# Articles, déterminants, prépositions
"les", "des", "une", "uns", "aux", "ces", "ses", "mes",
"tes", "nos", "vos", "mon", "ton", "son", "sur", "par",
"pour", "dans", "avec", "sans", "sous", "vers", "chez",
"entre", "mais", "donc", "car", "pas", "plus", "bien",
"peu", "très", "trop", "tout", "tous", "rien", "fait",
"été", "sont", "ont", "qui", "que", "dont", "peut",
"cette", "être", "avoir", "faire", "dire", "aussi",
# Prénoms courts très courants (trop de faux positifs)
"jean", "paul", "marc", "anne", "marie",
}
class EntityRegistry:
"""Maintient un mapping cohérent entre entités réelles et pseudonymes."""
@@ -13,6 +27,7 @@ class EntityRegistry:
self._counters: dict[str, int] = defaultdict(int)
self._mappings: dict[str, str] = {}
self._category_map: dict[str, str] = {}
self._subparts: set[str] = set()
self._whitelist: set[str] = whitelist or set()
def register(self, entity: str, category: str) -> str:
@@ -27,6 +42,12 @@ class EntityRegistry:
if key in self._mappings:
return self._mappings[key]
# Vérifier si une entité existante est un sur-ensemble ou sous-ensemble
existing = self._find_matching_entity(key)
if existing is not None:
self._mappings[key] = existing
return existing
self._counters[category] += 1
count = self._counters[category]
@@ -34,17 +55,22 @@ class EntityRegistry:
self._mappings[key] = pseudo
self._category_map[key] = category
# Enregistrer aussi les sous-parties du nom (sauf termes médicaux)
# Enregistrer les sous-parties du nom (seuil >= 5 chars, pas de stop words)
parts = key.split()
if len(parts) > 1:
for part in parts:
if len(part) >= 3 and part not in self._whitelist:
part_key = part
if part_key not in self._mappings:
self._mappings[part_key] = f"[{category.upper()}]"
if (len(part) >= 5
and part not in self._whitelist
and part not in FRENCH_STOP_WORDS
and part not in self._mappings):
self._subparts.add(part)
return pseudo
def is_subpart(self, key: str) -> bool:
"""Vérifie si un token est une sous-partie (pas une entité complète)."""
return self._normalize(key) in self._subparts
def get_replacement(self, entity: str) -> str | None:
"""Retourne le pseudonyme d'une entité connue, ou None."""
key = self._normalize(entity)
@@ -58,6 +84,26 @@ class EntityRegistry:
"""Retourne toutes les entités originales (noms avant normalisation)."""
return list(self._mappings.keys())
def get_subparts(self) -> set[str]:
"""Retourne l'ensemble des sous-parties enregistrées."""
return set(self._subparts)
def _find_matching_entity(self, key: str) -> str | None:
"""Cherche une entité existante qui est un sur- ou sous-ensemble de key.
Retourne le pseudo de l'entité existante si trouvée, None sinon.
"""
key_parts = set(key.split())
for existing_key, pseudo in self._mappings.items():
existing_parts = set(existing_key.split())
# key est contenu dans une entité existante
if key_parts and key_parts.issubset(existing_parts):
return pseudo
# Une entité existante est contenue dans key
if existing_parts and existing_parts.issubset(key_parts) and len(existing_parts) > 0:
return pseudo
return None
def _normalize(self, text: str) -> str:
"""Normalise un nom pour lookup : minuscules, espaces simplifiés."""
text = text.strip()

View File

@@ -167,6 +167,38 @@ LIEU_NAISSANCE_PATTERN = regex.compile(
r"Lieu de naissance\s*:\s*(.+?)(?:\n|$)"
)
# --- Documents spécialisés (BACTERIO, CONSULTATION_ANESTH, ANAPATH) ---
# "DDN : 21/01/1948" ou "DDN : 21-01-1948"
DDN_PATTERN = regex.compile(
r"DDN\s*:\s*(\d{2}[/\-]\d{2}[/\-]\d{4})"
)
# "Par : GENDRE Juliette" (prescripteur/préleveur)
PAR_NOM_PATTERN = regex.compile(
r"Par\s*:\s*([A-ZÉÈÊËÀÂ][A-Za-zéèêëàâäùûüôöîïç\.\-]+(?:\s+[A-Za-zéèêëàâäùûüôöîïç\.\-]+)+)"
)
# "DEMANDE N° 2300126709"
DEMANDE_NUM_PATTERN = regex.compile(
r"DEMANDE\s*N°?\s*(\d{8,12})"
)
# "N° venue : 23111304"
VENUE_PATTERN = regex.compile(
r"\s*venue\s*:\s*(\d{6,10})"
)
# "N Ipp : 19029841"
N_IPP_PATTERN = regex.compile(
r"N\s+Ipp\s*:\s*(\d{6,10})"
)
# "Adresse : 15 rue des Lilas 64100 BAYONNE" (consultation anesthésie)
CONSULT_ADRESSE_PATTERN = regex.compile(
r"Adresse\s*:\s*(.+?)(?:\n|$)"
)
# Auteurs de prescription dans Trackare
PRESCRIPTION_AUTHOR_PATTERN = regex.compile(
r"(?:Presc\.\s*de\s*Sortie|Normal|Signé|Arrêté|Réalisé)\s+([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][a-zéèêëàâäùûüôöîïç]+(?:\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-Za-zéèêëàâäùûüôöîïç\-]+)+)"