Fix fuites soignants + lieux de naissance : 8/8 noms masqués, 0 lieu en clair

Corrections noms soignants (167 fuites → 0) :
- 5 patterns extraction Trackare : Note d'évolution, Signé, Signé—médicament,
  Flacon/Ampoule, timestamp HH:MM (ETCHEBARNE, ALVARADO)
- Fix tiret de troncature : "LACLAU-" masqué, "NOCENT-EJNAINI" préservé
- Décomposition noms composés : "LACLAU-LACROUTS" → LACLAU + LACROUTS individuels
- +22 stop words (FP trackare, timestamp, médicaments)

Corrections lieux de naissance (49 fuites → 0) :
- Regex élargie : accepte minuscules, codes INSEE, tout format
- Rescan sécurité : lieu de naissance + ville de résidence

Audit batch 130 fichiers : 0 fuite soignant, 0 lieu en clair, 0 régression PII.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 17:10:18 +01:00
parent cede2d64d6
commit 4e6fd97e84

View File

@@ -436,6 +436,14 @@ _MEDICAL_STOP_WORDS_SET = {
"orthopédie", "orthopedie", "traumatologie", "orthopédie", "orthopedie", "traumatologie",
"palliatifs", "palliative", "palliatif", "palliatifs", "palliative", "palliatif",
"addictologie", "alcoologie", "tabacologie", "addictologie", "alcoologie", "tabacologie",
# FP soignants trackare (mots courants capturés par patterns Note d'évolution / Signé / Flacon)
"discussion", "echelle", "échelle", "scope", "tdm", "bouteille",
"evendol", "relais", "repas", "poursuite", "indication",
# FP pattern timestamp (termes ALL-CAPS capturés par "HH:MM NOM")
"eliminatin", "elimination", "élimination", "preremplie", "pré-remplie",
"thermie", "alim", "alimentation", "admin",
# Médicaments/tests labo capturés par patterns soignants
"biprofenid", "bi-profenid", "phosphatase", "phosphatases",
# Termes structurels trackare # Termes structurels trackare
"transmissions", "transmission", "releve", "relevé", "transmissions", "transmission", "releve", "relevé",
"objectif", "objectifs", "evaluation", "évaluation", "objectif", "objectifs", "evaluation", "évaluation",
@@ -533,7 +541,7 @@ RE_EXTRACT_NOM_PRENOM = re.compile(
) )
RE_EXTRACT_LIEU_NAISSANCE = re.compile( RE_EXTRACT_LIEU_NAISSANCE = re.compile(
r"Lieu\s+de\s+naissance\s*:\s*" r"Lieu\s+de\s+naissance\s*:\s*"
r"([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ][A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇa-zéèàùâêîôûäëïöüç\-\' ]+?)(?:\s*$)", r"([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇa-zéèàùâêîôûäëïöüç][A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇa-zéèàùâêîôûäëïöüç\-\' ]+?)(?:\s*$)",
re.MULTILINE, re.MULTILINE,
) )
RE_EXTRACT_VILLE_RESIDENCE = re.compile( RE_EXTRACT_VILLE_RESIDENCE = re.compile(
@@ -946,7 +954,7 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
line = RE_SERVICE.sub(_repl_service, line) line = RE_SERVICE.sub(_repl_service, line)
# Champs structurés : Lieu de naissance, Ville de résidence (masquage direct, sans filtre stop words) # Champs structurés : Lieu de naissance, Ville de résidence (masquage direct, sans filtre stop words)
_re_lieu = re.compile(r"(Lieu\s+de\s+naissance\s*:\s*)([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ][A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇa-zéèàùâêîôûäëïöüç\-\' ]+)") _re_lieu = re.compile(r"(Lieu\s+de\s+naissance\s*:\s*)(\S.+)")
def _repl_lieu(m: re.Match) -> str: def _repl_lieu(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "VILLE", m.group(2).strip(), PLACEHOLDERS["VILLE"])) audit.append(PiiHit(page_idx, "VILLE", m.group(2).strip(), PLACEHOLDERS["VILLE"]))
return m.group(1) + PLACEHOLDERS["VILLE"] return m.group(1) + PLACEHOLDERS["VILLE"]
@@ -1071,11 +1079,14 @@ def _extract_trackare_identity(full_text: str) -> Tuple[set, List[PiiHit]]:
for m in re.finditer(r"Pr[ée]nom\s+(?:de\s+naissance|utilis[ée])\s*:\s*([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ][A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇa-zéèàùâêîôûäëïöüç\s\-']+?)(?:\s*$)", full_text, re.MULTILINE): for m in re.finditer(r"Pr[ée]nom\s+(?:de\s+naissance|utilis[ée])\s*:\s*([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ][A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇa-zéèàùâêîôûäëïöüç\s\-']+?)(?:\s*$)", full_text, re.MULTILINE):
_add_name(m.group(1).strip()) _add_name(m.group(1).strip())
# Lieu de naissance: BAYONNE → masquer comme VILLE # Lieu de naissance: BAYONNE, biarritz, 64102, 99999 → masquer comme VILLE
for m in re.finditer(r"Lieu\s+de\s+naissance\s*:\s*([A-ZÉÈÀÙÂÊÎÔÛ][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôû\s\-']+?)(?:\s*$)", full_text, re.MULTILINE): for m in re.finditer(r"Lieu\s+de\s+naissance\s*:\s*(\S[^\n]*?)(?:\s*$)", full_text, re.MULTILINE):
val = m.group(1).strip() val = m.group(1).strip()
hits.append(PiiHit(-1, "VILLE", val, PLACEHOLDERS["VILLE"])) if val:
names.add(val) hits.append(PiiHit(-1, "VILLE", val, PLACEHOLDERS["VILLE"]))
# Ajouter au set names seulement si alphabétique (pas les codes INSEE numériques)
if re.match(r"[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇa-zéèàùâêîôûäëïöüç]", val):
names.add(val)
# Ville de résidence: TARNOS → masquer comme VILLE # Ville de résidence: TARNOS → masquer comme VILLE
for m in re.finditer(r"Ville\s+de\s+r[ée]sidence\s*:\s*([A-ZÉÈÀÙÂÊÎÔÛ][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôû\s\-']+?)(?:\s*$)", full_text, re.MULTILINE): for m in re.finditer(r"Ville\s+de\s+r[ée]sidence\s*:\s*([A-ZÉÈÀÙÂÊÎÔÛ][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôû\s\-']+?)(?:\s*$)", full_text, re.MULTILINE):
@@ -1170,6 +1181,70 @@ def _extract_trackare_identity(full_text: str) -> Tuple[set, List[PiiHit]]:
_add_name(prenom) _add_name(prenom)
_add_name(nom) _add_name(nom)
# --- Noms soignants sur la même ligne que "Note d'évolution" (ex: "Note d'évolution LACLAU-") ---
for m in re.finditer(
r"Note\s+(?:IDE|AS|d'[ée]volution|m[ée]dicale|kin[ée])\s+"
r"(?:DR\.?\s+)?"
r"([A-ZÉÈÀÙÂÊÎÔÛ][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûäëïöüç\-]+)"
r"(?:\s+([A-ZÉÈÀÙÂÊÎÔÛ][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûäëïöüç\-]+))?",
full_text
):
for g in (m.group(1), m.group(2)):
if g:
tok = g.rstrip('-')
if len(tok) >= 3 and tok.lower() not in _MEDICAL_STOP_WORDS_SET:
_add_name(tok)
# --- "Signé" suivi directement d'un nom de soignant (ex: "Signé LARRIEU-") ---
for m in re.finditer(
r"Signé\s+(?!—|par\b)([A-ZÉÈÀÙÂÊÎÔÛ][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûäëïöüç\-]+)"
r"(?:\s+([A-ZÉÈÀÙÂÊÎÔÛ][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûäëïöüç\-]+))?",
full_text
):
for g in (m.group(1), m.group(2)):
if g:
tok = g.rstrip('-')
if len(tok) >= 3 and tok.lower() not in _MEDICAL_STOP_WORDS_SET:
_add_name(tok)
# --- "Signé —" + médicament + nom soignant (ex: "Signé — PARACETAMOL BBM 1000 MG INJ NARZABAL") ---
for m in re.finditer(
r"Signé\s+—\s+.*(?:INJ|COMP|GEL|PDR|SOL|PERF|SUSP|CAPS|CREM|SACHET|SIROP)\s+[-]?\s*"
r"([A-ZÉÈÀÙÂÊÎÔÛ][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûäëïöüç\-]{2,})"
r"(?:\s+([A-ZÉÈÀÙÂÊÎÔÛ][a-zéèàùâêîôûäëïöüç]{2,}))?",
full_text
):
for g in (m.group(1), m.group(2)):
if g:
tok = g.rstrip('-')
if len(tok) >= 3 and tok.lower() not in _MEDICAL_STOP_WORDS_SET:
_add_name(tok)
# --- Noms soignants après conditionnement médicament (ex: "Flacon(s) LACROUTS") ---
for m in re.finditer(
r"(?:Flacon|Ampoule|Seringue|Poche|Comprim[ée]|Gélule|Sachet)(?:\(s\))?\s+"
r"([A-ZÉÈÀÙÂÊÎÔÛ][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûäëïöüç\-]{2,})",
full_text
):
tok = m.group(1).rstrip('-')
if len(tok) >= 3 and tok.lower() not in _MEDICAL_STOP_WORDS_SET:
_add_name(tok)
# --- Noms soignants après timestamps dans activités de soins (ex: "07:00 ETCHEBARNE") ---
# Format Trackare : actions de soins suivies de "HH:MM NOM" ou "HH : MM NOM"
# Pattern restrictif : nom ALL-CAPS de 4+ lettres pour éviter FP (termes médicaux mixtes)
for m in re.finditer(
r"\d{1,2}\s*:\s*\d{2}\s+"
r"([A-ZÉÈÀÙÂÊÎÔÛ][A-ZÉÈÀÙÂÊÎÔÛ\-]{3,})"
r"(?:\s+([A-ZÉÈÀÙÂÊÎÔÛ][a-zéèàùâêîôûäëïöüç]{2,}))?",
full_text
):
for g in (m.group(1), m.group(2)):
if g:
tok = g.rstrip('-')
if len(tok) >= 4 and tok.lower() not in _MEDICAL_STOP_WORDS_SET:
_add_name(tok)
# Filtrer les tokens trop courts ou stop words (sauf noms de villes extraits explicitement) # Filtrer les tokens trop courts ou stop words (sauf noms de villes extraits explicitement)
city_tokens = {h.original for h in hits if h.kind == "VILLE"} city_tokens = {h.original for h in hits if h.kind == "VILLE"}
filtered = set() filtered = set()
@@ -1270,16 +1345,15 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> set:
continue continue
names.add(tok) names.add(tok)
# Retirer les sous-parties de noms composés avec tiret # Pour les noms composés avec tiret (ex: "LACLAU-LACROUTS"),
# Si "JEAN-PIERRE" est dans names, retirer "JEAN" et "PIERRE" individuels # ajouter aussi les parties individuelles pour capturer les occurrences standalone.
# _apply_extracted_names traite le composé en premier (plus long) puis les parties.
compound_names = {n for n in names if "-" in n} compound_names = {n for n in names if "-" in n}
parts_to_remove = set()
for compound in compound_names: for compound in compound_names:
for part in compound.split("-"): for part in compound.split("-"):
part = part.strip() part = part.strip()
if len(part) >= 2 and part in names: if len(part) >= 3 and part.lower() not in _MEDICAL_STOP_WORDS_SET:
parts_to_remove.add(part) names.add(part)
names -= parts_to_remove
return names return names
@@ -1299,11 +1373,15 @@ def _apply_extracted_names(text: str, names: set, audit: List[PiiHit]) -> str:
ctx_end = min(len(text), m.end() + 1) ctx_end = min(len(text), m.end() + 1)
if "[" in text[ctx_start:m.start()] or "]" in text[m.end():ctx_end]: if "[" in text[ctx_start:m.start()] or "]" in text[m.end():ctx_end]:
continue continue
# Ne pas remplacer si le token fait partie d'un mot composé (tiret) # 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
if m.start() > 0 and text[m.start() - 1] == "-": if m.start() > 0 and text[m.start() - 1] == "-":
continue if m.start() >= 2 and text[m.start() - 2].isalpha():
continue
if m.end() < len(text) and text[m.end()] == "-": if m.end() < len(text) and text[m.end()] == "-":
continue if m.end() + 1 < len(text) and text[m.end() + 1].isalpha():
continue
# DÉSACTIVÉ: NOM_EXTRACTED génère 3,846 FP (77.7% du total) avec 0 TP # DÉSACTIVÉ: NOM_EXTRACTED génère 3,846 FP (77.7% du total) avec 0 TP
# Cette logique d'extraction de noms est trop agressive et crée des faux positifs massifs # Cette logique d'extraction de noms est trop agressive et crée des faux positifs massifs
# audit.append(PiiHit(-1, "NOM_EXTRACTED", m.group(0), placeholder)) # audit.append(PiiHit(-1, "NOM_EXTRACTED", m.group(0), placeholder))
@@ -1563,6 +1641,11 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
protected = RE_HOPITAL_VILLE.sub(PLACEHOLDERS["ETAB"], protected) protected = RE_HOPITAL_VILLE.sub(PLACEHOLDERS["ETAB"], protected)
# Services hospitaliers # Services hospitaliers
protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected) protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected)
# Lieu de naissance / Ville de résidence (accepte tout : villes, codes INSEE, minuscules)
_re_lieu_rescan = re.compile(r"(Lieu\s+de\s+naissance\s*:\s*)(\S.+)")
protected = _re_lieu_rescan.sub(lambda m: m.group(1) + PLACEHOLDERS["VILLE"], protected)
_re_ville_rescan = re.compile(r"(Ville\s+de\s+r[ée]sidence\s*:\s*)(\S.+)")
protected = _re_ville_rescan.sub(lambda m: m.group(1) + PLACEHOLDERS["VILLE"], protected)
# Personnes contextuelles (avec whitelist) # Personnes contextuelles (avec whitelist)
wl_sections = set() wl_sections = set()
wl_phrases = set() wl_phrases = set()