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:
2026-03-11 12:16:13 +01:00
parent c9572c383a
commit eb14cd219d
8 changed files with 1957 additions and 9 deletions

View File

@@ -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.)