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:
@@ -34,6 +34,24 @@ MEDICAL_TERMS_WHITELIST = {
|
||||
"mestinon", "augmentin", "doliprane", "spasfon", "lasilix",
|
||||
"lovenox", "kardegic", "inexium", "mopral", "gaviscon",
|
||||
"loxen", "perfalgan", "profenid", "voltarene", "toplexil",
|
||||
# Médicaments courants qui ressemblent à des noms propres
|
||||
"ondansetron", "lipruzet", "liptruzet", "telmisartan",
|
||||
"phloroglucinol", "oxynormoro", "oxycodone", "oxynorm",
|
||||
"enoxaparine", "pantoprazole", "omeprazole", "esomeprazole",
|
||||
"amoxicilline", "metronidazole", "ceftriaxone", "ciprofloxacine",
|
||||
"ketoprofene", "ibuprofene", "naproxene", "diclofenac",
|
||||
"atorvastatine", "rosuvastatine", "simvastatine",
|
||||
"metformine", "insuline", "levofloxacine",
|
||||
"furosemide", "ramipril", "amlodipine", "bisoprolol",
|
||||
"alprazolam", "bromazepam", "zopiclone", "zolpidem",
|
||||
"hydroxyzine", "cetirizine", "desloratadine",
|
||||
"dexamethasone", "prednisolone", "prednisone",
|
||||
"heparine", "rivaroxaban", "apixaban", "dabigatran",
|
||||
# Termes Trackare / systèmes hospitaliers
|
||||
"regime", "repas", "autonome", "complete", "directe",
|
||||
"toilette", "refection", "desinfection", "environnement",
|
||||
"sommeil", "elimination", "surv", "contention", "isolement",
|
||||
"buccale", "cutanee", "sous-cutanee", "injectable",
|
||||
"service", "médecin", "medecin", "docteur", "chirurgie",
|
||||
"gastro", "entérologie", "enterologie", "oncologie",
|
||||
"hépato", "hepato", "digestif", "digestive",
|
||||
@@ -44,8 +62,7 @@ MEDICAL_TERMS_WHITELIST = {
|
||||
"secrétariat", "infirmier", "infirmière",
|
||||
"unité", "hospitalisation", "urgences",
|
||||
"coordonnateur", "fédération", "federation",
|
||||
"navarre", "institut", "cancérologie",
|
||||
"bordeaux", "strasbourg", "reims", "limoges", "clermont", "ferrand",
|
||||
"institut", "cancérologie",
|
||||
"palais",
|
||||
}
|
||||
|
||||
@@ -124,6 +141,9 @@ class Anonymizer:
|
||||
text, n = self._replace_phone(text)
|
||||
count += n
|
||||
|
||||
text, n = self._replace_pattern(text, patterns.PHONE_INTL_PATTERN, "telephone")
|
||||
count += n
|
||||
|
||||
text, n = self._replace_pattern(
|
||||
text, patterns.EMAIL_PATTERN, "email",
|
||||
skip_establishment_check=True,
|
||||
@@ -151,6 +171,20 @@ class Anonymizer:
|
||||
)
|
||||
count += n
|
||||
|
||||
# Trackare / BACTERIO : zones structurées patient
|
||||
# IMPORTANT : doit s'exécuter AVANT _replace_structured_names() car
|
||||
# les patterns génériques (DR_NAME, PATIENT_NAME) consommeraient le
|
||||
# préfixe "Nom" des lignes "Nom de naissance : EICHE", rendant
|
||||
# NOM_NAISSANCE_TRACKARE_PATTERN inopérant.
|
||||
text, n = self._replace_pattern(text, patterns.BACTERIO_NOM_HEADER_PATTERN, "patient")
|
||||
count += n
|
||||
text, n = self._replace_pattern(text, patterns.PERSONNE_PREVENIR_PATTERN, "contact")
|
||||
count += n
|
||||
text, n = self._replace_pattern(text, patterns.NOM_NAISSANCE_TRACKARE_PATTERN, "patient")
|
||||
count += n
|
||||
text, n = self._replace_pattern(text, patterns.PRENOM_NAISSANCE_PATTERN, "patient")
|
||||
count += n
|
||||
|
||||
# Noms structurés
|
||||
text, n = self._replace_structured_names(text)
|
||||
count += n
|
||||
@@ -200,8 +234,28 @@ class Anonymizer:
|
||||
if self._is_establishment(word):
|
||||
continue
|
||||
|
||||
# Vérifier si déjà anonymisé (contient des crochets)
|
||||
if "[" in word and "]" in word:
|
||||
# Vérifier si déjà anonymisé ou fragment de tag existant
|
||||
# Couvre : "[TAG]" complet, "[TAG" fragment, "TAG]" fragment,
|
||||
# mots internes aux tags (MEDECIN, PATIENT, SOIGNANT, etc.)
|
||||
if "[" in word or "]" in word:
|
||||
continue
|
||||
if word.upper().rstrip("_0123456789") in {
|
||||
"PATIENT", "MEDECIN", "SOIGNANT", "CONTACT", "PERSONNE",
|
||||
"IPP", "EPISODE", "TEL", "EMAIL", "ADRESSE", "DATE_NAISS",
|
||||
"LIEU_NAISS", "FINESS", "CODE_BARRE", "NIR", "RPPS",
|
||||
"IDENTIFIANT",
|
||||
}:
|
||||
continue
|
||||
|
||||
# Vérifier si l'entité se trouve à l'intérieur d'un tag déjà posé
|
||||
# (ex: le NER détecte "Martin" dans "[MEDECIN_3] Martin [ADRESSE_1]"
|
||||
# alors que "Martin" est entre deux tags et fait partie du texte)
|
||||
ctx_start = max(0, ent["start"] - 20)
|
||||
ctx_end = min(len(text), ent["end"] + 20)
|
||||
context = text[ctx_start:ctx_end]
|
||||
# Si on est à l'intérieur d'un tag (entre [ et ] sans interruption)
|
||||
before = text[ctx_start:ent["start"]]
|
||||
if "[" in before and "]" not in before.split("[")[-1]:
|
||||
continue
|
||||
|
||||
pseudo = self.registry.get_replacement(word)
|
||||
@@ -270,9 +324,18 @@ class Anonymizer:
|
||||
|
||||
@staticmethod
|
||||
def _fix_brackets(text: str) -> str:
|
||||
"""Corrige les crochets malformés après anonymisation."""
|
||||
# Doubles crochets fermants
|
||||
"""Corrige les crochets malformés après anonymisation.
|
||||
|
||||
Traite : [[ ouvert, ]] fermé, ]N] orphelin, tags collés ][, mot[TAG].
|
||||
"""
|
||||
# Doubles crochets ouvrants : [[TAG → [TAG
|
||||
text = re.sub(r"\[\[", "[", text)
|
||||
# Chiffres/underscore orphelins entre ] et ] : [TAG]_2] ou [TAG]10] → [TAG]
|
||||
text = re.sub(r"(\[[A-Z_]+\d*\])[_\d]+\]", r"\1", text)
|
||||
# Doubles crochets fermants résiduels : ]] → ]
|
||||
text = re.sub(r"\]\]", "]", text)
|
||||
# Tags collés dos-à-dos : ][ → ] [
|
||||
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
|
||||
@@ -443,11 +506,11 @@ class Anonymizer:
|
||||
return text, count
|
||||
|
||||
def _replace_inline_addresses(self, text: str) -> tuple[str, int]:
|
||||
"""Capture les adresses inline (MAISON xxx, QUARTIER xxx, LOTISSEMENT xxx)."""
|
||||
"""Capture les adresses inline (MAISON xxx, QUARTIER xxx, IMP xxx, etc.)."""
|
||||
count = 0
|
||||
# Pattern : MAISON/QUARTIER/LOTISSEMENT suivi de mots (noms propres de lieux)
|
||||
# Pattern : MAISON/QUARTIER/LOTISSEMENT/IMP/IMPASSE/ALLEE suivi de mots
|
||||
inline_addr = re.compile(
|
||||
r"((?:MAISON|QUARTIER|LOTISSEMENT|RESIDENCE|HAMEAU)\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\s]+?)(?=\n|$|Dr|\d{5}|Chef|médical|coordonnateur)",
|
||||
r"((?:MAISON|QUARTIER|LOTISSEMENT|RESIDENCE|HAMEAU|IMP|IMPASSE|ALLEE|ALLÉE)\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\s]+?)(?=\n|$|Dr|\d{5}|Chef|médical|coordonnateur)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for m in reversed(list(inline_addr.finditer(text))):
|
||||
@@ -540,7 +603,101 @@ class Anonymizer:
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
self.report.regex_replacements += count
|
||||
# Destinataires de courrier (Madame/DR suivi de nom)
|
||||
for m in reversed(list(patterns.DESTINATAIRE_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
# Contacts familiaux (Concubine/Conjoint/Époux NOM Prénom)
|
||||
for m in reversed(list(patterns.CONTACT_RELATION_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "contact")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
# Auteurs de prescriptions Trackare (Signé/Arrêté/Réalisé NOM Prénom)
|
||||
for m in reversed(list(patterns.PRESCRIPTION_AUTHOR_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
# Trackare : noms avec timestamp (HH:MM NOM ou NOM HH:MM)
|
||||
for m in reversed(list(patterns.TRACKARE_TIMESTAMP_NAME_PATTERN.finditer(text))):
|
||||
name = m.group(2).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(2)] + pseudo + text[m.end(2):]
|
||||
count += 1
|
||||
|
||||
for m in reversed(list(patterns.TRACKARE_NAME_TIMESTAMP_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
# Métadonnées document : "mod. le DD/MM/YY HH:MM par NOM Prénom, statut"
|
||||
for m in reversed(list(patterns.MOD_PAR_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
# Aides opératoires : "Aide(s) Prénom NOM"
|
||||
for m in reversed(list(patterns.AIDE_NAME_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
# Signature fin de document : "Prénom NOM" avant footer patient
|
||||
for m in reversed(list(patterns.SIGNATURE_LINE_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
# "validé électroniquement par NOM Prénom le DD/MM/YYYY"
|
||||
for m in reversed(list(patterns.VALIDE_PAR_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
# "NOM Prénom (interne)" — signature d'interne
|
||||
for m in reversed(list(patterns.INTERNE_SIGNATURE_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
# Trackare : "fois NOM" — nom après administration
|
||||
for m in reversed(list(patterns.FOIS_NAME_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
# Trackare : "maladie NOM HH:MM"
|
||||
for m in reversed(list(patterns.MALADIE_NAME_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "soignant")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
|
||||
return text, count
|
||||
|
||||
def _replace_footer(self, text: str) -> tuple[str, int]:
|
||||
@@ -548,9 +705,10 @@ class Anonymizer:
|
||||
count = 0
|
||||
for m in reversed(list(patterns.FOOTER_PATIENT_PATTERN.finditer(text))):
|
||||
name = m.group(1).strip()
|
||||
pseudo = self.registry.register(name, "patient")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
if len(name) >= 3 and not self._is_whitelisted(name):
|
||||
pseudo = self.registry.register(name, "patient")
|
||||
text = text[:m.start(1)] + pseudo + text[m.end(1):]
|
||||
count += 1
|
||||
return text, count
|
||||
|
||||
def _register_address(self, addr: str) -> None:
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
from collections import defaultdict
|
||||
|
||||
# Mots français trop courants pour être des sous-parties fiables
|
||||
# NB : PAS de prénoms ici — les prénoms sont des PHI et doivent être anonymisés
|
||||
FRENCH_STOP_WORDS = {
|
||||
# Articles, déterminants, prépositions
|
||||
"les", "des", "une", "uns", "aux", "ces", "ses", "mes",
|
||||
@@ -15,8 +16,6 @@ FRENCH_STOP_WORDS = {
|
||||
"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",
|
||||
}
|
||||
|
||||
|
||||
@@ -43,27 +42,35 @@ class EntityRegistry:
|
||||
return self._mappings[key]
|
||||
|
||||
# Vérifier si une entité existante est un sur-ensemble ou sous-ensemble
|
||||
existing = self._find_matching_entity(key)
|
||||
existing = self._find_matching_entity(key, category)
|
||||
if existing is not None:
|
||||
self._mappings[key] = existing
|
||||
return existing
|
||||
pseudo = existing
|
||||
else:
|
||||
self._counters[category] += 1
|
||||
count = self._counters[category]
|
||||
pseudo = self._generate_pseudo(category, count)
|
||||
self._mappings[key] = pseudo
|
||||
self._category_map[key] = category
|
||||
|
||||
self._counters[category] += 1
|
||||
count = self._counters[category]
|
||||
|
||||
pseudo = self._generate_pseudo(category, count)
|
||||
self._mappings[key] = pseudo
|
||||
self._category_map[key] = category
|
||||
|
||||
# Enregistrer les sous-parties du nom (seuil >= 5 chars, pas de stop words)
|
||||
parts = key.split()
|
||||
# Enregistrer les sous-parties du nom (seuil >= 4 chars, pas de stop words)
|
||||
# Les sous-parties sont ajoutées à _mappings (avec le pseudo du parent)
|
||||
# ET à _subparts (pour que le sweep les traite conditionnellement :
|
||||
# remplacement uniquement si capitalisé, pour éviter la sur-anonymisation).
|
||||
# On split sur espaces ET tirets pour capturer les noms composés (ex: LASSERRE-QUILLACQ).
|
||||
# NB: cette étape s'exécute AUSSI quand _find_matching_entity matche,
|
||||
# pour capturer les fragments du nom élargi (ex: "QUILLACQ" dans "RITZ-QUILLACQ").
|
||||
import re as _re
|
||||
parts = _re.split(r"[\s\-]+", key)
|
||||
if len(parts) > 1:
|
||||
for part in parts:
|
||||
if (len(part) >= 5
|
||||
if (len(part) >= 4
|
||||
and part not in self._whitelist
|
||||
and part not in FRENCH_STOP_WORDS
|
||||
and part not in self._mappings):
|
||||
self._subparts.add(part)
|
||||
self._mappings[part] = pseudo
|
||||
self._category_map[part] = category
|
||||
|
||||
return pseudo
|
||||
|
||||
@@ -88,19 +95,38 @@ class EntityRegistry:
|
||||
"""Retourne l'ensemble des sous-parties enregistrées."""
|
||||
return set(self._subparts)
|
||||
|
||||
def _find_matching_entity(self, key: str) -> str | None:
|
||||
def _find_matching_entity(self, key: str, category: str) -> str | None:
|
||||
"""Cherche une entité existante qui est un sur- ou sous-ensemble de key.
|
||||
|
||||
Ne matche que si la catégorie est compatible (même catégorie ou
|
||||
catégories de personnes compatibles entre elles).
|
||||
|
||||
Retourne le pseudo de l'entité existante si trouvée, None sinon.
|
||||
"""
|
||||
# Catégories de personnes compatibles entre elles
|
||||
person_categories = {"patient", "medecin", "soignant", "contact", "personne"}
|
||||
|
||||
key_parts = set(key.split())
|
||||
if not key_parts:
|
||||
return None
|
||||
|
||||
for existing_key, pseudo in self._mappings.items():
|
||||
# Vérifier la compatibilité de catégorie
|
||||
existing_cat = self._category_map.get(existing_key)
|
||||
if existing_cat is not None:
|
||||
if existing_cat != category:
|
||||
# Autoriser le match entre catégories de personnes
|
||||
if not (existing_cat in person_categories and category in person_categories):
|
||||
continue
|
||||
|
||||
existing_parts = set(existing_key.split())
|
||||
if not existing_parts:
|
||||
continue
|
||||
# key est contenu dans une entité existante
|
||||
if key_parts and key_parts.issubset(existing_parts):
|
||||
if 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:
|
||||
if existing_parts.issubset(key_parts):
|
||||
return pseudo
|
||||
return None
|
||||
|
||||
|
||||
@@ -86,10 +86,25 @@ def _split_text(text: str, max_chars: int = 500) -> list[str]:
|
||||
|
||||
|
||||
def _deduplicate(entities: list[dict]) -> list[dict]:
|
||||
"""Déduplique les entités par mot (garde le score le plus élevé)."""
|
||||
seen: dict[str, dict] = {}
|
||||
"""Déduplique les entités par position (supprime les chevauchements).
|
||||
|
||||
Garde toutes les occurrences d'un même mot à des positions différentes,
|
||||
mais supprime les entités qui se chevauchent à la même position
|
||||
(garde celle avec le meilleur score).
|
||||
"""
|
||||
if not entities:
|
||||
return []
|
||||
|
||||
# Trier par position de début
|
||||
entities.sort(key=lambda e: e["start"])
|
||||
|
||||
result: list[dict] = []
|
||||
for ent in entities:
|
||||
key = ent["word"].lower()
|
||||
if key not in seen or ent["score"] > seen[key]["score"]:
|
||||
seen[key] = ent
|
||||
return list(seen.values())
|
||||
if result and ent["start"] < result[-1]["end"]:
|
||||
# Chevauchement : garder celle avec le meilleur score
|
||||
if ent["score"] > result[-1]["score"]:
|
||||
result[-1] = ent
|
||||
else:
|
||||
result.append(ent)
|
||||
|
||||
return result
|
||||
|
||||
@@ -42,6 +42,11 @@ PHONE_PATTERN = regex.compile(
|
||||
r"\b(0[1-9])[\s.\-]?(\d{2})[\s.\-]?(\d{2})[\s.\-]?(\d{2})[\s.\-]?(\d{2})\b"
|
||||
)
|
||||
|
||||
# Téléphones internationaux FR : +33(0)XXXXXXXXX ou +33 X XX XX XX XX
|
||||
PHONE_INTL_PATTERN = regex.compile(
|
||||
r"\+33\s*\(?\s*0?\s*\)?\s*\d[\s.\-]?\d{2}[\s.\-]?\d{2}[\s.\-]?\d{2}[\s.\-]?\d{2}"
|
||||
)
|
||||
|
||||
# Emails (y compris @ch-cotebasque.fr qui contiennent des initiales de soignants)
|
||||
EMAIL_PATTERN = regex.compile(
|
||||
r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b"
|
||||
@@ -54,9 +59,9 @@ FAX_PATTERN = regex.compile(
|
||||
|
||||
# --- Adresses ---
|
||||
|
||||
# Code postal + ville (uniquement les ALL_CAPS après 5 digits)
|
||||
# Code postal + ville (vrais CP français : 01-95, 971-976, 984-989)
|
||||
CP_VILLE_PATTERN = regex.compile(
|
||||
r"\b(\d{5})\s+([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ\s\-]{2,})\b"
|
||||
r"\b((?:0[1-9]|[1-8]\d|9[0-5]|97[1-6]|98[4-9])\d{3})\s+([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ\s\-]{2,})\b"
|
||||
)
|
||||
|
||||
# Lignes d'adresse avec mots-clés (y compris noms propres basques/locaux)
|
||||
@@ -73,8 +78,9 @@ ADDRESS_BLOCK_PATTERN = regex.compile(
|
||||
# --- Dates de naissance ---
|
||||
|
||||
# Toutes les variantes : "né(e) le", "née le", "né le", "Né(e) le", "Date de naissance:"
|
||||
# Accepte les séparateurs / et - (DD/MM/YYYY ou DD-MM-YYYY)
|
||||
DATE_NAISSANCE_PATTERN = regex.compile(
|
||||
r"(?:[Nn][ée]+(?:\(e\))?\s+le\s+|Date de naissance\s*[:=]?\s*)(\d{2}/\d{2}/\d{4})"
|
||||
r"(?:[Nn][ée]+(?:\(e\))?\s+le\s+|Date de naissance\s*[:=]?\s*)(\d{2}[/\-]\d{2}[/\-]\d{4})"
|
||||
)
|
||||
|
||||
# --- Noms structurés ---
|
||||
@@ -101,8 +107,13 @@ CIVILITE_NAME_PATTERN = regex.compile(
|
||||
)
|
||||
|
||||
# "DR." / "Dr" / "Docteur" suivi du nom du médecin
|
||||
# Chaque mot doit commencer par une majuscule (nom propre).
|
||||
# Le séparateur de mots est espace/tab/dash/apostrophe (PAS newline)
|
||||
# pour éviter de capturer "FARBOS\nDr" comme un seul nom.
|
||||
# Negative lookahead empêche de capturer "Dr" comme partie du nom
|
||||
# (cas multi-docteurs sur même ligne : "Dr LEYSSENE David Dr BENARD Yohan").
|
||||
DR_NAME_PATTERN = regex.compile(
|
||||
r"(?:DR\.?|Dr\.?|Docteur)\s+([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\.\-]+(?:\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\.\-]+){0,2})"
|
||||
r"(?:DR\.?|Dr\.?|Docteur)\s+([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\.\-']+(?:[ \t\-'](?!DR\.?\s|Dr\.?\s|Docteur\s)[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-Za-zéèêëàâäùûüôöîïç\.\-']+){0,2})"
|
||||
)
|
||||
|
||||
# "Rédigé par" en pied de page CRH
|
||||
@@ -117,7 +128,7 @@ DESTINATAIRE_PATTERN = regex.compile(
|
||||
|
||||
# Noms d'auteurs dans Trackare : "Note d'évolution Prénom NOM DD/MM/YYYY"
|
||||
NOTE_AUTHOR_DATE_PATTERN = regex.compile(
|
||||
r"(?:Note d'évolution|Note IDE|Histoire de la maladie|Conclusion Obs\.?\s*médicales?)\s+"
|
||||
r"(?:Note d'évolution|Note IDE|Note Diététicienne|Note (?:Kiné|Ergo|Psy|AS|Ortho)|Histoire de la maladie|Conclusion Obs\.?\s*médicales?)\s+"
|
||||
r"(?:DR\.?\s+)?"
|
||||
r"([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\.\-]+(?:\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\.\-]+)+)"
|
||||
r"\s+\d{2}/\d{2}/\d{4}",
|
||||
@@ -126,7 +137,7 @@ NOTE_AUTHOR_DATE_PATTERN = regex.compile(
|
||||
# Noms d'auteurs Trackare sans date immédiate : "Note IDE Prénom NOM texte..."
|
||||
# Le nom est toujours un Prénom (Capitalized) suivi d'un NOM (ALL CAPS)
|
||||
NOTE_AUTHOR_PATTERN = regex.compile(
|
||||
r"(?:Note d'évolution|Note IDE|Histoire de la maladie|Conclusion Obs\.?\s*médicales?)\s+"
|
||||
r"(?:Note d'évolution|Note IDE|Note Diététicienne|Note (?:Kiné|Ergo|Psy|AS|Ortho)|Histoire de la maladie|Conclusion Obs\.?\s*médicales?)\s+"
|
||||
r"(?:DR\.?\s+)?"
|
||||
r"([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][a-zéèêëàâäùûüôöîïç]+\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ\-]{2,})"
|
||||
)
|
||||
@@ -199,9 +210,99 @@ CONSULT_ADRESSE_PATTERN = regex.compile(
|
||||
r"Adresse\s*:\s*(.+?)(?:\n|$)"
|
||||
)
|
||||
|
||||
# BACTERIO : nom patient en en-tête (NOM Prénom sur ligne avant "Nom usuel :")
|
||||
# Tolère une ligne vide entre le nom et "Nom usuel" (artefact OCR)
|
||||
BACTERIO_NOM_HEADER_PATTERN = regex.compile(
|
||||
r"([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ]{2,}\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\-]+(?:\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\-]+)*)\s*\n(?:\s*\n)?\s*Nom\s+(?:usuel|de\s+naissance|utilisé)",
|
||||
)
|
||||
|
||||
# --- Trackare : zones structurées patient ---
|
||||
|
||||
# "Personne à prévenir NOM PRENOM 06 XX XX XX XX"
|
||||
PERSONNE_PREVENIR_PATTERN = regex.compile(
|
||||
r"Personne\s+[àa]\s+pr[ée]venir\s+([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-Za-zéèêëàâäùûüôöîïç\s\-]+?)(?:\s+\d{2}[\s.]|\s*$|\s*\n)",
|
||||
)
|
||||
|
||||
# Contacts : "Concubine/Conjoint/Époux/Épouse NOM Prénom"
|
||||
CONTACT_RELATION_PATTERN = regex.compile(
|
||||
r"(?:Concubin[e]?|Conjoint[e]?|[ÉE]poux|[ÉE]pouse|Compagnon|Compagne|Fils|Fille|Père|Mère|Frère|Sœur|Soeur)\s+"
|
||||
r"([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ]{2,}(?:\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\-]+)*)",
|
||||
)
|
||||
|
||||
# "Nom de naissance : DUPONT" ou "Nom utilisé : DUPONT"
|
||||
NOM_NAISSANCE_TRACKARE_PATTERN = regex.compile(
|
||||
r"(?:Nom\s+(?:de\s+naissance|utilisé|usuel))\s*:?\s*([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\s\-]+?)(?:\s*$|\s*\n)",
|
||||
regex.MULTILINE,
|
||||
)
|
||||
|
||||
# "Prénom de naissance : MARIE" ou "Prénom utilisé : MARIE" ou "1er prénom de naissance: MARIE"
|
||||
PRENOM_NAISSANCE_PATTERN = regex.compile(
|
||||
r"(?:(?:1er\s+)?[Pp]rénom\s+(?:de\s+naissance|utilisé))\s*:?\s*([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-Za-zéèêëàâäùûüôöîïç\-]+)",
|
||||
)
|
||||
|
||||
# Auteurs de prescription dans Trackare
|
||||
# Matche "Normal VERGEZ", "Signé DUPONT", "Réalisé POMMIES Héloise",
|
||||
# "Révisé/Traité ETCHECHOURY", "variable. ETCHECHOURY"
|
||||
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éèêëàâäùûüôöîïç\-]+)+)"
|
||||
r"(?:Presc\.\s*de\s*Sortie|Normal|Signé|Arrêté|Réalisé|Révisé/Traité|Révisé|Traité|variable\.)\s+(?:—\s+)?"
|
||||
r"([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ]{2,}(?:[\-][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-Za-zéèêëàâäùûüôöîïç]*)?(?:\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\-]+)?)"
|
||||
r"(?=\s*\n|\s+\d{2}[/:]|\s+\[|\s+le\b|\s+DR\.|\s*$)",
|
||||
)
|
||||
|
||||
# Trackare : noms de soignants avec timestamp (HH:MM NOM ou NOM HH:MM)
|
||||
TRACKARE_TIMESTAMP_NAME_PATTERN = regex.compile(
|
||||
r"\b(\d{2}:\d{2})\s+([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ]{3,}(?:\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\-]+)?)\s*\n",
|
||||
)
|
||||
|
||||
TRACKARE_NAME_TIMESTAMP_PATTERN = regex.compile(
|
||||
r"\n([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ]{3,}(?:\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\-]+)?)\s+(\d{2}:\d{2})\s",
|
||||
)
|
||||
|
||||
# --- Métadonnées document : "mod. le DD/MM/YY HH:MM par NOM Prénom" ---
|
||||
MOD_PAR_PATTERN = regex.compile(
|
||||
r"(?:mod\.\s+le\s+\d{2}/\d{2}/\d{2,4}\s+\d{2}:\d{2}\s+par|par)\s+"
|
||||
r"([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ]{2,}(?:[\-][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-Za-zéèêëàâäùûüôöîïç]*)?(?:\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\-]+)?)"
|
||||
r"\s*,\s*statut",
|
||||
)
|
||||
|
||||
# --- CRO / documents : "Aide(s) Prénom NOM" ---
|
||||
AIDE_NAME_PATTERN = regex.compile(
|
||||
r"Aide\(?s?\)?\s+"
|
||||
r"([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç]+\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ\-]+)"
|
||||
r"(?:\s*,|\s*$|\s*\n)",
|
||||
)
|
||||
|
||||
# --- Signature fin de document : "Prénom NOM" seul sur une ligne ---
|
||||
# Matche une ligne isolée avec Prénom NOM (au moins 1 prénom + 1 nom en majuscules)
|
||||
# Précédé par du contenu médical (pas un titre de section)
|
||||
SIGNATURE_LINE_PATTERN = regex.compile(
|
||||
r"\n([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç]+\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ\-]{2,})\s*\n"
|
||||
r"(?=Patient\(e\)|$|\Z)",
|
||||
)
|
||||
|
||||
# --- "validé électroniquement par NOM Prénom le DD/MM/YYYY" ---
|
||||
VALIDE_PAR_PATTERN = regex.compile(
|
||||
r"validé\s+(?:électroniquement\s+)?par\s+"
|
||||
r"([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ]{2,}(?:\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\-]+)+)"
|
||||
r"\s+le\s+\d{2}/\d{2}/\d{4}",
|
||||
)
|
||||
|
||||
# --- "NOM Prénom (interne)" — signature d'interne fin de compte-rendu ---
|
||||
INTERNE_SIGNATURE_PATTERN = regex.compile(
|
||||
r"\n([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ]{2,}\s+[A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇa-zéèêëàâäùûüôöîïç\-]+)"
|
||||
r"\s+\(interne\)",
|
||||
)
|
||||
|
||||
# --- Trackare : "fois NOM" — nom après administration médicament ---
|
||||
FOIS_NAME_PATTERN = regex.compile(
|
||||
r"fois\s+([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ]{3,}(?:[\-][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-Za-zéèêëàâäùûüôöîïç]*)?)"
|
||||
r"(?=\s*\n|\s+\d|\s*$)",
|
||||
)
|
||||
|
||||
# --- Trackare : "maladie NOM HH:MM" — nom dans contexte prescription ---
|
||||
MALADIE_NAME_PATTERN = regex.compile(
|
||||
r"maladie\s+([A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ]{3,}(?:[\-][A-ZÉÈÊËÀÂÄÙÛÜÔÖÎÏÇ][A-Za-zéèêëàâäùûüôöîïç]*)?)"
|
||||
r"\s+\d{2}:\d{2}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user