feat: anonymisation qualité++ — 15 patterns, subparts tirets, fix entity registry

Bloc A: fix sous-parties dans _mappings, filtre NER anti-tag,
intégration patterns manquants (DESTINATAIRE, PRESCRIPTION_AUTHOR),
whitelist médicaments élargie (+60), villes retirées de whitelist.

Bloc B: CRH dedup chars 200-1000, CP_VILLE vrais codes postaux FR,
DR_NAME capital par mot, BACTERIO header tolère ligne vide.

Bloc C: DR_NAME negative lookahead multi-docteurs même ligne,
entity_registry split tirets (RITZ-QUILLACQ), fix early return
subparts dans _find_matching_entity, PRESCRIPTION_AUTHOR élargi
(Révisé/Traité, variable.), NOTE_AUTHOR élargi (Diététicienne,
Kiné, Ergo), + 8 nouveaux patterns (CONTACT_RELATION, MOD_PAR,
AIDE_NAME, SIGNATURE_LINE, VALIDE_PAR, INTERNE_SIGNATURE,
FOIS_NAME, MALADIE_NAME), adresses inline +ALLEE/IMP,
text_cleaner préserve abréviations médicales.

Validé sur 6 cas (21, 11, 104, 160, 50, 200). 70 tests OK.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-03-03 11:11:47 +01:00
parent f4a23a5f43
commit 99069f150a
7 changed files with 492 additions and 60 deletions

View File

@@ -4,6 +4,7 @@ import pytest
from src.anonymization.entity_registry import EntityRegistry
from src.anonymization.regex_patterns import (
BACTERIO_NOM_HEADER_PATTERN,
CONSULT_ADRESSE_PATTERN,
CRH_FOOTER_IPP_EPISODE,
CRH_FOOTER_PATIENT_PATTERN,
@@ -15,10 +16,14 @@ from src.anonymization.regex_patterns import (
EPISODE_PATTERN,
FOOTER_PATIENT_PATTERN,
IPP_PATTERN,
NOM_NAISSANCE_TRACKARE_PATTERN,
NOTE_AUTHOR_PATTERN,
N_IPP_PATTERN,
PAR_NOM_PATTERN,
PERSONNE_PREVENIR_PATTERN,
PHONE_INTL_PATTERN,
PHONE_PATTERN,
PRENOM_NAISSANCE_PATTERN,
RPPS_PATTERN,
VENUE_PATTERN,
)
@@ -211,18 +216,31 @@ class TestStopWordsAndSubparts:
"""Vérifie que les stop words et sous-parties courtes ne sont pas enregistrés."""
def test_stop_word_not_registered_as_subpart(self):
"""'jean' (stop word) ne doit pas être enregistré en sous-partie."""
"""Les vrais stop words (sans, dans, avec) ne doivent pas être des sous-parties."""
reg = EntityRegistry()
reg.register("Jean Martin", "medecin")
entities = reg.get_all_entities()
assert "jean" not in entities
assert "martin" not in entities # < 5 chars → exclu aussi
reg.register("Sans Martin", "medecin")
assert not reg.is_subpart("sans")
def test_prenoms_are_subparts(self):
"""Les prénoms (jean, paul, marie) sont des PHI et doivent être des sous-parties."""
reg = EntityRegistry()
reg.register("Jean Dupont", "patient")
assert reg.is_subpart("jean")
assert reg.is_subpart("dupont")
def test_short_parts_excluded(self):
"""Les sous-parties < 4 chars sont exclues (trop de faux positifs)."""
reg = EntityRegistry()
reg.register("Ré Dupont", "patient")
assert not reg.is_subpart("")
assert reg.is_subpart("dupont")
def test_long_subpart_registered(self):
"""Les sous-parties >= 5 chars qui ne sont pas des stop words sont enregistrées."""
"""Les sous-parties >= 4 chars qui ne sont pas des stop words sont enregistrées."""
reg = EntityRegistry()
reg.register("Jean Audemar", "medecin")
assert reg.is_subpart("audemar")
assert reg.is_subpart("jean")
def test_sans_not_anonymized_when_dr_sans(self):
"""'sans' ne doit pas être remplacé quand un médecin s'appelle 'Dr Sans'."""
@@ -296,6 +314,60 @@ class TestNewPHIPatterns:
assert m is not None
assert "15 rue des Lilas" in m.group(1)
def test_date_naissance_with_dashes(self):
"""Date de naissance : DD-MM-YYYY (format Trackare)."""
m = DATE_NAISSANCE_PATTERN.search("Date de naissance : 21-01-1948")
assert m is not None
assert m.group(1) == "21-01-1948"
def test_phone_intl_pattern(self):
"""Téléphone international +33(0)XXXXXXXXX."""
m = PHONE_INTL_PATTERN.search("☎ +33(0)156125400")
assert m is not None
def test_phone_intl_with_spaces(self):
m = PHONE_INTL_PATTERN.search("+33 1 56 12 54 00")
assert m is not None
def test_personne_prevenir_pattern(self):
m = PERSONNE_PREVENIR_PATTERN.search("Personne à prévenir EICHE 06 27 56 38")
assert m is not None
assert "EICHE" in m.group(1)
def test_nom_naissance_trackare(self):
m = NOM_NAISSANCE_TRACKARE_PATTERN.search("Nom de naissance : EICHE")
assert m is not None
assert m.group(1).strip() == "EICHE"
def test_nom_utilise_trackare(self):
m = NOM_NAISSANCE_TRACKARE_PATTERN.search("Nom utilisé : DUPONT")
assert m is not None
assert m.group(1).strip() == "DUPONT"
def test_prenom_naissance_pattern(self):
m = PRENOM_NAISSANCE_PATTERN.search("Prénom de naissance : MARIE")
assert m is not None
assert m.group(1) == "MARIE"
def test_prenom_1er_pattern(self):
m = PRENOM_NAISSANCE_PATTERN.search("1er prénom de naissance: MARIE")
assert m is not None
assert m.group(1) == "MARIE"
def test_bacterio_nom_header_pattern(self):
"""En-tête BACTERIO : NOM Prénom avant 'Nom usuel :'."""
text = "Compte renduComplet\nURTIZVEREA Marie\nNom usuel : EICHE URGENCES"
m = BACTERIO_NOM_HEADER_PATTERN.search(text)
assert m is not None
assert m.group(1) == "URTIZVEREA Marie"
def test_bacterio_nom_header_nom_de_naissance(self):
"""En-tête BACTERIO : NOM Prénom avant 'Nom de naissance :'."""
text = "DUPONT Jean-Pierre\nNom de naissance : MARTIN"
m = BACTERIO_NOM_HEADER_PATTERN.search(text)
assert m is not None
assert m.group(1) == "DUPONT Jean-Pierre"
# --- P2-C : Cohérence pseudonymes ---
@@ -316,12 +388,52 @@ class TestEntityMatching:
p2 = reg.register("MARTIN", "medecin")
assert p1 == p2
def test_no_cross_category_match_via_subset(self):
"""Un patient et une adresse ne matchent PAS par sous-ensemble."""
reg = EntityRegistry()
p1 = reg.register("MARTIN Pierre", "patient")
p2 = reg.register("MARTIN Rue", "adresse")
# "MARTIN" est commun mais catégories incompatibles → pas de match
assert p1 != p2
def test_person_categories_compatible(self):
"""Les catégories de personnes (patient/medecin/soignant) sont compatibles."""
reg = EntityRegistry()
p1 = reg.register("DUPONT", "patient")
p2 = reg.register("DUPONT Pierre", "medecin")
# Catégories de personnes compatibles → même pseudo
assert p1 == p2
def test_fix_double_brackets(self):
"""Les doubles crochets sont corrigés."""
"""Les doubles crochets fermants sont corrigés."""
from src.anonymization.anonymizer import Anonymizer
result = Anonymizer._fix_brackets("Mme[PERSONNE_14]]")
assert result == "Mme [PERSONNE_14]"
def test_fix_double_open_brackets(self):
"""Les doubles crochets ouvrants sont corrigés."""
from src.anonymization.anonymizer import Anonymizer
result = Anonymizer._fix_brackets("Dr [[PERSONNE_7]")
assert result == "Dr [PERSONNE_7]"
def test_fix_orphan_digits(self):
"""Les chiffres orphelins entre crochets sont supprimés."""
from src.anonymization.anonymizer import Anonymizer
result = Anonymizer._fix_brackets("[PERSONNE_6]10]")
assert result == "[PERSONNE_6]"
def test_fix_orphan_underscore_digits(self):
"""Les _N] orphelins sont supprimés."""
from src.anonymization.anonymizer import Anonymizer
result = Anonymizer._fix_brackets("[PERSONNE_4]_2]")
assert result == "[PERSONNE_4]"
def test_fix_glued_tags(self):
"""Deux tags collés ][ reçoivent un espace."""
from src.anonymization.anonymizer import Anonymizer
result = Anonymizer._fix_brackets("[MEDECIN_5][MEDECIN_6]")
assert result == "[MEDECIN_5] [MEDECIN_6]"
def test_fix_glued_bracket(self):
"""Un tag collé à un mot reçoit un espace."""
from src.anonymization.anonymizer import Anonymizer