feat(phase3): CamemBERT v3 + détection villes + initiales + texte espacé + docs réglementaires
Intégration du modèle CamemBERT-bio-deid v3 (F1=0.96, Recall=0.97, 1112 docs)
et corrections qualité issues de l'audit approfondi sur 29 fichiers.
Détection des villes en texte libre :
- Automate Aho-Corasick sur 33K communes INSEE + 11.6K villes FINESS
- Stratégie contextuelle : exige un contexte géographique (à, de, vers,
habite, urgences de, etc.) sauf pour les villes composées (Saint-Palais)
- Blacklist de ~80 communes homonymes de mots courants (charge, signes, plan...)
- Normalisation SAINT↔ST pour les variantes orthographiques
- De 18 fuites de villes à 2 cas résiduels atypiques
Masquage des initiales de prénom :
- Post-traitement regex : "Dr T. [NOM]" → "Dr [NOM] [NOM]"
- Références initiales : "Ref : JF/VA" → "Ref : [NOM]/[NOM]"
Détection texte espacé d'en-tête :
- "C E N T R E H O S P I T A L I E R" → [ETABLISSEMENT]
Autres corrections :
- Fix regex RE_EXTRACT_MME_MR (Mr?.? → Mr.?, \s+ → [ \t]+, * → {0,4})
- Stop words médicaux : lever, coucher, services hospitaliers (viscérale, etc.)
- CamemBERT NER manager : version tracking, propriété version, log F1/Recall
- Script finetune : export ONNX automatique + mise à jour VERSION.json
- Évaluateur qualité : exclusion stop words médicaux des alertes INSEE
Documentation :
- Spécifications techniques CamemBERT-bio-deid v3
- Conformité RGPD + AI Act (caviardage PDF raster)
- AIPD (Analyse d'Impact Protection des Données)
Score qualité : 97.0/100 (Grade A), Leak score 100/100
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -156,6 +156,48 @@ _FINESS_ETAB_NAMES: set = set() # noms d'établissements (lowercase)
|
||||
_FINESS_TELEPHONES: set = set() # téléphones 10 chiffres
|
||||
_FINESS_VILLES: set = set() # villes FINESS (uppercase)
|
||||
_FINESS_AC = None # Automate Aho-Corasick pour noms distinctifs
|
||||
_VILLE_AC = None # Automate Aho-Corasick pour villes (INSEE + FINESS)
|
||||
|
||||
# Communes trop ambiguës (homonymes de mots courants, trop courts, etc.)
|
||||
_VILLE_BLACKLIST = {
|
||||
# Directions / mots géographiques génériques
|
||||
"SAINT", "NORD", "SUD", "EST", "OUEST",
|
||||
"CENTRE", "SERVICE", "BOURG",
|
||||
# Communes homonymes de mots courants français
|
||||
"ORANGE", "TOURS", "NICE", "SENS", "VITRE",
|
||||
"ROMANS", "MENTON", "SALON", "VIENNE",
|
||||
"BREST", # trop court et ambigu
|
||||
"HYERES", # proche de termes médicaux
|
||||
"AGEN", "AUCH", "ALBI",
|
||||
"BLOIS", "LAON", "LENS",
|
||||
"GIEN", "GRAY",
|
||||
"AIRE", "LURE", "SETE", "DOLE",
|
||||
"VIRE", "LUNEL", "MURET", "MORET",
|
||||
"COEUR", "FOIX", "GIVET",
|
||||
"EVIAN", "MAURE", "MENDE",
|
||||
"JOUE", "MEAUX", "REDON",
|
||||
"CREIL", "CERGY",
|
||||
# Communes de 4-5 lettres homonymes de mots très courants
|
||||
"VERS", "MONT", "MARS", "PORT", "PONT", "FORT",
|
||||
"BOIS", "ISLE", "LACS", "MURS", "OUST", "PREY",
|
||||
"VAUX", "VERT", "FAUX", "REZE",
|
||||
"BILLE", "PLACE", "VILLE", "COURS", "GRAND",
|
||||
"ROUGE", "RICHE", "NUITS", "SORE", "SARE",
|
||||
"TRANS", "RANS", "MARSA",
|
||||
# Mots courants français (6+ lettres) aussi communes
|
||||
"CHARGE", "SIGNES", "BARRES", "FOSSES", "GARDES",
|
||||
"MARCHE", "LIGNES", "MOULIN", "PIERRE", "CHAISE",
|
||||
"SOURCE", "VALLEE", "MAISON", "BEAUNE", "CORPS",
|
||||
"PUITS", "CROIX", "LIGNE", "QUATRE", "PRISON",
|
||||
# Prénoms très courants (aussi communes)
|
||||
"MARIE", "PIERRE", "JEAN", "PAUL", "ANNE",
|
||||
# Expressions composées ambiguës (aussi communes INSEE)
|
||||
"LONG", "RECY", "PLAN", "MARCHE", "SALLE",
|
||||
"CONTRE", "MERE", "ONDRES", "VEBRE",
|
||||
# Mots structurels / médicaux
|
||||
"PARIS", # omniprésent, source de faux positifs
|
||||
"FRANCE", "EUROPE",
|
||||
}
|
||||
|
||||
try:
|
||||
import ahocorasick as _ahocorasick
|
||||
@@ -537,6 +579,8 @@ _MEDICAL_STOP_WORDS_SET = {
|
||||
"digestif", "digestive", "digestives", "nutritive",
|
||||
# Abréviations soins trackare détectées comme NOM (batch 20 OGC)
|
||||
"soins", "lit", "jeun", "lever", "pose", "surv", "ggt", "vvp",
|
||||
# Verbes d'instructions soins (aussi des patronymes INSEE → FP)
|
||||
"coucher", "manger", "marcher", "sortir",
|
||||
"verif", "crop", "evs", "maco", "pan", "cet", "trou", "nit", "nfs",
|
||||
# Mots narratifs CRH capturés par fusion sidebar 2-colonnes
|
||||
"evolution", "évolution", "explorations", "fermeture", "allergie", "allergies",
|
||||
@@ -671,6 +715,11 @@ _MEDICAL_STOP_WORDS_SET = {
|
||||
"probnp", "pro-bnp", "nt-probnp",
|
||||
"bpco", "colle", "gsc", "masse",
|
||||
"selle", "selles",
|
||||
# Noms de services hospitaliers (aussi patronymes INSEE → FP récurrents)
|
||||
"viscerale", "viscérale", "vasculaire", "vasculaires",
|
||||
"conventionnelle", "conventionnel",
|
||||
"polyvalente", "polyvalent",
|
||||
"infectieuse", "infectieuses",
|
||||
}
|
||||
# Enrichissement automatique avec les ~4000 noms de médicaments d'edsnlp
|
||||
_MEDICAL_STOP_WORDS_SET.update(_load_edsnlp_drug_names())
|
||||
@@ -741,9 +790,9 @@ RE_EXTRACT_REDIGE = re.compile(
|
||||
# Token nom composé : JEAN-PIERRE, CAZELLES-BOUDIER, etc.
|
||||
_UC_COMPOUND = r"[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]{2,}(?:-[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]{2,})*"
|
||||
RE_EXTRACT_MME_MR = re.compile(
|
||||
r"(?:MME|Mme|Madame|Monsieur|Mr?\.?)\s+"
|
||||
r"(?:MME|Mme|Madame|Monsieur|Mr\.?)\s+"
|
||||
r"(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\.\s*(?:-?\s*[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\.\s*)?)?"
|
||||
rf"((?:{_UC_COMPOUND})(?:\s+(?:{_UC_COMPOUND}))*)",
|
||||
rf"((?:{_UC_NAME_TOKEN})(?:[ \t]+(?:{_UC_NAME_TOKEN})){{0,4}})",
|
||||
)
|
||||
_INITIAL_OPT = r"(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\.\s*(?:-?\s*[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\.\s*)?)?"
|
||||
RE_EXTRACT_DR_DEST = re.compile(
|
||||
@@ -772,6 +821,11 @@ RE_EXTRACT_OPERATEUR = re.compile(
|
||||
+ _INITIAL_OPT +
|
||||
rf"((?:{_UC_COMPOUND})(?:[ \t]+(?:{_UC_COMPOUND})){{0,2}})",
|
||||
)
|
||||
# En-tête "Courrier Epi - NOM, PRENOM" (lettres de sortie)
|
||||
RE_EXTRACT_COURRIER = re.compile(
|
||||
r"Courrier\s+(?:Epi|Ep[ée]ph[ée]m[eé]ride|Hospit)\s*[\-–]\s*"
|
||||
rf"((?:{_UC_NAME_TOKEN})(?:\s*,\s*(?:{_UC_NAME_TOKEN}))*)",
|
||||
)
|
||||
# Téléphone avec extension slash : 05.59.44.38.32/34
|
||||
RE_TEL_SLASH = re.compile(
|
||||
r"(?<!\d)(?:\+33\s?|0)\d(?:[\s.\-]?\d){8}(?:/\d{1,4})(?!\d)"
|
||||
@@ -1265,6 +1319,35 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
|
||||
# mais on log le fait qu'un match gazetteer a eu lieu)
|
||||
audit.append(PiiHit(page_idx, "ETAB_FINESS", "gazetteer", PLACEHOLDERS["ETAB"]))
|
||||
|
||||
# Texte espacé d'en-tête : "C E N T R E H O S P I T A L I E R D E ..."
|
||||
# Les lettres majuscules séparées par des espaces échappent à toute détection normale
|
||||
_RE_SPACED_TEXT = re.compile(
|
||||
r'(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\s){4,}[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]'
|
||||
)
|
||||
_SPACED_ETAB_KEYWORDS = {
|
||||
"HOSPITALIER", "HOSPITALIERE", "HOSPITALIERES", "HOSPITALIERS",
|
||||
"CLINIQUE", "HOPITAL", "HÔPITAL", "POLYCLINIQUE",
|
||||
"CENTRE", "ETABLISSEMENT", "MAISON", "RESIDENCE",
|
||||
"EHPAD", "SSR", "USLD", "CHU", "CHRU",
|
||||
}
|
||||
for m_spaced in _RE_SPACED_TEXT.finditer(line):
|
||||
spaced_span = m_spaced.group(0)
|
||||
# Collapse les espaces : "C E N T R E" → "CENTRE"
|
||||
collapsed = spaced_span.replace(" ", "")
|
||||
# Vérifier si le texte collapsé contient un mot clé d'établissement
|
||||
collapsed_upper = collapsed.upper()
|
||||
if any(kw in collapsed_upper for kw in _SPACED_ETAB_KEYWORDS):
|
||||
audit.append(PiiHit(page_idx, "ETAB_SPACED", spaced_span, PLACEHOLDERS["ETAB"]))
|
||||
line = line.replace(spaced_span, PLACEHOLDERS["ETAB"], 1)
|
||||
|
||||
# Villes par gazetteer Aho-Corasick (INSEE + FINESS)
|
||||
if _VILLE_AC is None:
|
||||
_build_ville_ac()
|
||||
if _VILLE_AC is not None:
|
||||
line, ville_originals = _mask_ville_gazetteers(line)
|
||||
for vo in ville_originals:
|
||||
audit.append(PiiHit(page_idx, "VILLE_GAZ", vo, PLACEHOLDERS["VILLE"]))
|
||||
|
||||
# Services hospitaliers (service de Cardiologie, unité de soins palliatifs, etc.)
|
||||
def _repl_service(m: re.Match) -> str:
|
||||
full_match = m.group(0)
|
||||
@@ -1765,6 +1848,13 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s
|
||||
# Opérateur / Anesthésiste / Chirurgien + nom(s)
|
||||
for m in RE_EXTRACT_OPERATEUR.finditer(full_text):
|
||||
_add_tokens_force_first(m.group(1))
|
||||
# En-tête "Courrier Epi - NOM, PRENOM" (lettres de sortie)
|
||||
for m in RE_EXTRACT_COURRIER.finditer(full_text):
|
||||
# Format "NOM, PRENOM" : chaque partie est un token de nom
|
||||
for part in m.group(1).split(","):
|
||||
part = part.strip()
|
||||
if part:
|
||||
_add_tokens_force_all(part)
|
||||
|
||||
# Extraction des noms dans les listes virgulées après Dr/Docteur
|
||||
# ex: "le Dr DUVAL, MACHELART, CHARLANNE, LAZARO, il a été proposé"
|
||||
@@ -1785,12 +1875,16 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s
|
||||
# Pour les noms composés avec tiret (ex: "LACLAU-LACROUTS"),
|
||||
# ajouter aussi les parties individuelles pour capturer les occurrences standalone.
|
||||
# _apply_extracted_names traite le composé en premier (plus long) puis les parties.
|
||||
# Les parties sont forcées (bypass stop words) car le composé lui-même est un nom
|
||||
# confirmé — ex: "BILLON-GRAND" → "GRAND" doit être masqué même si "grand" est
|
||||
# un mot courant, car c'est un composant d'un nom de personne détecté.
|
||||
compound_names = {n for n in names if "-" in n}
|
||||
for compound in compound_names:
|
||||
for part in compound.split("-"):
|
||||
part = part.strip()
|
||||
if len(part) >= 3 and part.lower() not in _MEDICAL_STOP_WORDS_SET:
|
||||
if len(part) >= 3:
|
||||
names.add(part)
|
||||
force_names.add(part)
|
||||
|
||||
return names, force_names
|
||||
|
||||
@@ -1817,9 +1911,17 @@ def _apply_extracted_names(text: str, names: set, audit: List[PiiHit], force_nam
|
||||
# Ne pas remplacer si le token fait partie d'un mot composé (tiret + lettre)
|
||||
# Ex: "NOCENT-EJNAINI" → ne pas remplacer NOCENT seul
|
||||
# Mais "LACLAU-" (tiret de troncature) → remplacer
|
||||
# Gère aussi le cas cross-line : "BILLON-\nGRAND" (nom intact)
|
||||
# mais pas "[NOM]-\nGRAND" (déjà partiellement masqué → on remplace)
|
||||
if m.start() > 0 and text[m.start() - 1] == "-":
|
||||
if m.start() >= 2 and text[m.start() - 2].isalpha():
|
||||
continue
|
||||
# Cross-line: "\n" juste avant, tiret avant le "\n", lettre avant le tiret
|
||||
if m.start() > 1 and text[m.start() - 1] == "\n" and text[m.start() - 2] == "-":
|
||||
pre_pos = m.start() - 3
|
||||
if pre_pos >= 0 and text[pre_pos].isalpha():
|
||||
continue # Composé intact (BILLON-\nGRAND) → skip
|
||||
# Si le tiret est après un placeholder ([NOM]-\nGRAND) → on remplace
|
||||
if m.end() < len(text) and text[m.end()] == "-":
|
||||
if m.end() + 1 < len(text) and text[m.end() + 1].isalpha():
|
||||
continue
|
||||
@@ -2280,6 +2382,197 @@ def _mask_finess_establishments(text: str) -> str:
|
||||
return "".join(result)
|
||||
|
||||
|
||||
# ----------------- Ville Aho-Corasick gazetteer matching -----------------
|
||||
|
||||
def _build_ville_ac():
|
||||
"""Construit l'automate Aho-Corasick pour les villes (INSEE + FINESS).
|
||||
|
||||
Appelé en lazy au premier besoin.
|
||||
Les noms sont normalisés sans accents (position-preserving matching via _normalize_positional).
|
||||
NOTE : on ne filtre PAS par _MEDICAL_STOP_WORDS_SET car ces villes y ont été ajoutées
|
||||
pour empêcher leur détection comme NOMS DE PERSONNES, pas pour empêcher leur détection
|
||||
comme villes. Le filtrage anti-faux-positifs se fait via _VILLE_BLACKLIST et le seuil
|
||||
de longueur minimale.
|
||||
"""
|
||||
global _VILLE_AC
|
||||
if not _AHO_AVAILABLE:
|
||||
return
|
||||
|
||||
# Combiner les deux sources de villes
|
||||
all_villes: set = set()
|
||||
if _INSEE_COMMUNES:
|
||||
all_villes.update(_INSEE_COMMUNES)
|
||||
if _FINESS_VILLES:
|
||||
all_villes.update(v.upper() for v in _FINESS_VILLES)
|
||||
|
||||
if not all_villes:
|
||||
log.warning("Aucune ville disponible pour l'automate Aho-Corasick VILLE")
|
||||
return
|
||||
|
||||
try:
|
||||
ac = _ahocorasick.Automaton()
|
||||
count = 0
|
||||
added_normalized: set = set() # éviter les doublons après normalisation
|
||||
for ville in all_villes:
|
||||
ville = ville.strip()
|
||||
if not ville:
|
||||
continue
|
||||
# Blacklist de communes ambiguës
|
||||
if ville.upper() in _VILLE_BLACKLIST:
|
||||
continue
|
||||
# Les noms composés dans les gazetteers utilisent des espaces ("MONT DE MARSAN")
|
||||
# mais dans les textes ils apparaissent souvent avec des tirets ("Mont-de-Marsan").
|
||||
# On ajoute les deux variantes dans l'automate.
|
||||
words = ville.split()
|
||||
# Filtre longueur minimale (mono-mot < 4 chars → trop ambigu)
|
||||
# Exception : quelques villes de 3 lettres notables
|
||||
_VILLE_3CHAR_ALLOW = {"DAX", "PAU", "GAP", "APT", "GEX", "LUZ"}
|
||||
if len(words) == 1 and len(ville) < 4 and ville.upper() not in _VILLE_3CHAR_ALLOW:
|
||||
continue
|
||||
# Normaliser sans accents, en lowercase (pour matching positionnel)
|
||||
normalized_ville = _normalize_positional(ville)
|
||||
if normalized_ville not in added_normalized:
|
||||
ac.add_word(normalized_ville, (normalized_ville, ville))
|
||||
added_normalized.add(normalized_ville)
|
||||
count += 1
|
||||
|
||||
def _add_variant(variant_norm: str) -> None:
|
||||
nonlocal count
|
||||
if variant_norm and variant_norm not in added_normalized:
|
||||
ac.add_word(variant_norm, (variant_norm, ville))
|
||||
added_normalized.add(variant_norm)
|
||||
count += 1
|
||||
|
||||
# Variante avec tirets pour les noms composés (ex: "mont de marsan" → "mont-de-marsan")
|
||||
if len(words) >= 2:
|
||||
_add_variant(_normalize_positional("-".join(words)))
|
||||
# Variante SAINT ↔ ST (gazetteers INSEE utilisent "ST", textes "Saint")
|
||||
for prefix_src, prefix_dst in [("ST ", "SAINT "), ("ST ", "SAINT-"),
|
||||
("SAINT ", "ST "), ("SAINT ", "ST-"),
|
||||
("STE ", "SAINTE "), ("STE ", "SAINTE-"),
|
||||
("SAINTE ", "STE "), ("SAINTE ", "STE-")]:
|
||||
if ville.startswith(prefix_src):
|
||||
alt = prefix_dst + ville[len(prefix_src):]
|
||||
_add_variant(_normalize_positional(alt))
|
||||
_add_variant(_normalize_positional("-".join(alt.split())))
|
||||
ac.make_automaton()
|
||||
_VILLE_AC = ac
|
||||
log.info(f"Gazetteer VILLE Aho-Corasick: {count} patterns chargés "
|
||||
f"(INSEE: {len(_INSEE_COMMUNES)}, FINESS: {len(_FINESS_VILLES)})")
|
||||
except Exception as e:
|
||||
log.warning(f"Erreur construction VILLE Aho-Corasick: {e}")
|
||||
|
||||
|
||||
def _mask_ville_gazetteers(text: str) -> tuple:
|
||||
"""Masque les villes détectées par Aho-Corasick dans le texte narratif.
|
||||
|
||||
Stratégie contextuelle : pour éviter les faux positifs massifs (CHARGE, SIGNES,
|
||||
TALON — communes homonymes de mots courants), on ne masque une ville que si :
|
||||
- C'est une ville composée (Saint-Palais), OU
|
||||
- C'est une ville très longue (>= 8 lettres : Bordeaux, Toulouse), OU
|
||||
- Elle apparaît dans un contexte géographique explicite (à, de, vers, habite, etc.)
|
||||
|
||||
Returns: (texte_masqué, liste_des_valeurs_originales_masquées)
|
||||
"""
|
||||
global _VILLE_AC
|
||||
if _VILLE_AC is None:
|
||||
_build_ville_ac()
|
||||
if _VILLE_AC is None:
|
||||
return text
|
||||
|
||||
normalized = _normalize_positional(text)
|
||||
placeholder = PLACEHOLDERS["VILLE"]
|
||||
|
||||
# Contextes géographiques avant une ville
|
||||
# NOTE : "de" seul est trop ambigu ("prise de selles", "nombre de jumeaux")
|
||||
# On exige "de" uniquement après un verbe/nom géographique ou une préposition composée
|
||||
_RE_GEO_BEFORE = re.compile(
|
||||
r"(?:"
|
||||
# Préposition "à" (très spécifique géographiquement)
|
||||
r"[àÀ]\s+|"
|
||||
# "de" seulement dans un contexte géographique (vient de, originaire de, etc.)
|
||||
r"(?:vient|venant|arrivant|provenant|originaire|issu(?:e)?)\s+(?:de\s+|d['']\s*)|"
|
||||
# "urgences de", "hôpital de", "clinique de"
|
||||
r"(?:urgences?|h[oô]pital|clinique|CHU?|CH\b)\s+(?:de\s+|d['']\s*)|"
|
||||
# Verbes de localisation directement suivis de la ville
|
||||
r"(?:habit|résid|viv|domicilié(?:e)?|transféré(?:e)?|"
|
||||
r"adressé(?:e)?|hospitalisé(?:e)?|opéré(?:e)?|"
|
||||
r"Fait)\s+(?:à\s+|de\s+|d['']\s*)?|"
|
||||
# "vers" (prép. géo directe) — NOTE: "sur" exclu car trop ambigu ("sur le plan")
|
||||
r"vers\s+|"
|
||||
# Après code postal ou parenthèse ouvrante (adresse)
|
||||
r"\[CODE_POSTAL\]\s*|"
|
||||
r"\(\s*|"
|
||||
# Contextes médicaux spécifiques d'adressage
|
||||
r"(?:urg(?:ences?)?\s+)"
|
||||
r")\s*$",
|
||||
re.I,
|
||||
)
|
||||
|
||||
# Collecter les matches Aho-Corasick
|
||||
matches = []
|
||||
for end_idx, (norm_name, orig_name) in _VILLE_AC.iter(normalized):
|
||||
start_idx = end_idx - len(norm_name) + 1
|
||||
# Vérifier frontières de mots (pas au milieu d'un mot)
|
||||
if start_idx > 0 and normalized[start_idx - 1].isalnum():
|
||||
continue
|
||||
if end_idx + 1 < len(normalized) and normalized[end_idx + 1].isalnum():
|
||||
continue
|
||||
# Vérifier que ce n'est pas déjà dans un placeholder [...]
|
||||
ctx_before = text[max(0, start_idx - 1):start_idx]
|
||||
ctx_after = text[end_idx + 1:min(len(text), end_idx + 2)]
|
||||
if "[" in ctx_before or "]" in ctx_after:
|
||||
continue
|
||||
# Vérifier proximité placeholder (pas juste après [ETABLISSEMENT] de ...)
|
||||
wide_before = text[max(0, start_idx - 25):start_idx]
|
||||
if re.search(r"\[(VILLE|ADRESSE|ETABLISSEMENT)\]\s*(?:de\s+|du\s+|d['']\s*|à\s+)?$", wide_before):
|
||||
continue
|
||||
# Récupérer le texte original à cette position
|
||||
original_span = text[start_idx:end_idx + 1]
|
||||
word_count = len(orig_name.split())
|
||||
word_len = len(orig_name.strip())
|
||||
# Stratégie contextuelle pour éviter les FP :
|
||||
# TOUJOURS exiger un contexte géographique (à, de, vers, habite, etc.)
|
||||
# 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:
|
||||
before_ctx = text[max(0, start_idx - 40):start_idx]
|
||||
if not _RE_GEO_BEFORE.search(before_ctx):
|
||||
continue
|
||||
matches.append((start_idx, end_idx + 1, original_span))
|
||||
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
# Dédupliquer : préférer le match le plus long en cas de chevauchement
|
||||
# Trier par longueur décroissante, puis sélectionner gloutonement les non-chevauchants
|
||||
matches.sort(key=lambda x: -(x[1] - x[0]))
|
||||
deduped = []
|
||||
for start, end, orig in matches:
|
||||
# Vérifier que cet intervalle ne chevauche pas un intervalle déjà retenu
|
||||
if any(s < end and start < e for s, e, _ in deduped):
|
||||
continue
|
||||
deduped.append((start, end, orig))
|
||||
# Re-trier par position pour la reconstruction
|
||||
deduped.sort(key=lambda x: x[0])
|
||||
|
||||
# Reconstruire le texte avec les remplacements
|
||||
result = []
|
||||
masked_originals = []
|
||||
last_pos = 0
|
||||
for start, end, orig in deduped:
|
||||
if start > len(text) or end > len(text):
|
||||
continue
|
||||
result.append(text[last_pos:start])
|
||||
result.append(placeholder)
|
||||
masked_originals.append(orig)
|
||||
last_pos = end
|
||||
result.append(text[last_pos:])
|
||||
|
||||
return "".join(result), masked_originals
|
||||
|
||||
|
||||
# ----------------- Selective safety rescan -----------------
|
||||
|
||||
def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
|
||||
@@ -2329,6 +2622,21 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
|
||||
# Établissements (gazetteer Aho-Corasick FINESS — 116K noms distinctifs)
|
||||
if _FINESS_AC is not None:
|
||||
protected = _mask_finess_establishments(protected)
|
||||
# Texte espacé d'en-tête : "C E N T R E H O S P I T A L I E R" → [ETABLISSEMENT]
|
||||
_re_spaced = re.compile(r'(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\s){4,}[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]')
|
||||
_spaced_kw = {"HOSPITALIER", "HOSPITALIERE", "HOSPITALIERES", "HOSPITALIERS",
|
||||
"CLINIQUE", "HOPITAL", "HÔPITAL", "POLYCLINIQUE",
|
||||
"CENTRE", "ETABLISSEMENT", "MAISON", "RESIDENCE",
|
||||
"EHPAD", "SSR", "USLD", "CHU", "CHRU"}
|
||||
for m_sp in _re_spaced.finditer(protected):
|
||||
collapsed = m_sp.group(0).replace(" ", "").upper()
|
||||
if any(kw in collapsed for kw in _spaced_kw):
|
||||
protected = protected.replace(m_sp.group(0), PLACEHOLDERS["ETAB"], 1)
|
||||
# Villes (gazetteer Aho-Corasick — INSEE + FINESS)
|
||||
if _VILLE_AC is None:
|
||||
_build_ville_ac()
|
||||
if _VILLE_AC is not None:
|
||||
protected, _ = _mask_ville_gazetteers(protected)
|
||||
# Services hospitaliers
|
||||
protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected)
|
||||
# Lieu de naissance / Ville de résidence (accepte tout : villes, codes INSEE, minuscules)
|
||||
@@ -2355,6 +2663,15 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
|
||||
return raw
|
||||
return raw.replace(span, PLACEHOLDERS["NOM"])
|
||||
protected = RE_PERSON_CONTEXT.sub(_rescan_person, protected)
|
||||
# Initiales identifiantes devant [NOM] : "Dr T. [NOM]" → "Dr [NOM] [NOM]"
|
||||
_re_init_nom = re.compile(r'\b([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ])\.[\s\-]*(\[NOM\])')
|
||||
protected = _re_init_nom.sub(r'[NOM] \2', protected)
|
||||
# Références initiales : "Ref : JF/VA" → "Ref : [NOM]/[NOM]"
|
||||
_re_ref_init = re.compile(r'(?:Ref\s*:\s*|Réf\s*:\s*)([A-Z]{1,3})\s*/\s*([A-Z]{1,3})\b')
|
||||
protected = _re_ref_init.sub(
|
||||
lambda m: m.group(0)[:m.group(0).index(m.group(1))] + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"],
|
||||
protected,
|
||||
)
|
||||
res = list(protected)
|
||||
for start, end, payload in kept:
|
||||
res[start:end] = list(payload)
|
||||
@@ -2772,6 +3089,26 @@ def process_pdf(
|
||||
return m.group(0)
|
||||
final_text = _re_tel_partial.sub(_clean_tel_partial, final_text)
|
||||
|
||||
# 3c) Initiales identifiantes devant [NOM] : "Dr T. [NOM]" → "Dr [NOM] [NOM]"
|
||||
_RE_INITIAL_BEFORE_NOM = re.compile(
|
||||
r'\b([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ])\.[\s\-]*(\[NOM\])'
|
||||
)
|
||||
def _clean_initial_before_nom(m):
|
||||
anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(1) + ".", PLACEHOLDERS["NOM"]))
|
||||
return PLACEHOLDERS["NOM"] + " " + m.group(2)
|
||||
final_text = _RE_INITIAL_BEFORE_NOM.sub(_clean_initial_before_nom, final_text)
|
||||
|
||||
# 3d) Références initiales : "Ref : JF/VA", "Réf : AD/EP" → "Ref : [NOM]/[NOM]"
|
||||
_RE_REF_INITIALS = re.compile(
|
||||
r'(?:Ref\s*:\s*|Réf\s*:\s*)([A-Z]{1,3})\s*/\s*([A-Z]{1,3})\b'
|
||||
)
|
||||
def _clean_ref_initials(m):
|
||||
anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(1), PLACEHOLDERS["NOM"]))
|
||||
anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(2), PLACEHOLDERS["NOM"]))
|
||||
prefix = m.group(0)[:m.group(0).index(m.group(1))]
|
||||
return prefix + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"]
|
||||
final_text = _RE_REF_INITIALS.sub(_clean_ref_initials, final_text)
|
||||
|
||||
# 4) Consolidation : propager les PII détectés sur toutes les pages (page=-1)
|
||||
# pour que la redaction PDF les cherche partout (sidebar répété, etc.)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user