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:
@@ -436,6 +436,14 @@ _MEDICAL_STOP_WORDS_SET = {
|
||||
"orthopédie", "orthopedie", "traumatologie",
|
||||
"palliatifs", "palliative", "palliatif",
|
||||
"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
|
||||
"transmissions", "transmission", "releve", "relevé",
|
||||
"objectif", "objectifs", "evaluation", "évaluation",
|
||||
@@ -533,7 +541,7 @@ RE_EXTRACT_NOM_PRENOM = re.compile(
|
||||
)
|
||||
RE_EXTRACT_LIEU_NAISSANCE = re.compile(
|
||||
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_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)
|
||||
|
||||
# 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:
|
||||
audit.append(PiiHit(page_idx, "VILLE", m.group(2).strip(), 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):
|
||||
_add_name(m.group(1).strip())
|
||||
|
||||
# Lieu de naissance: BAYONNE → 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):
|
||||
# Lieu de naissance: BAYONNE, biarritz, 64102, 99999 → masquer comme VILLE
|
||||
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()
|
||||
hits.append(PiiHit(-1, "VILLE", val, PLACEHOLDERS["VILLE"]))
|
||||
names.add(val)
|
||||
if 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
|
||||
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(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)
|
||||
city_tokens = {h.original for h in hits if h.kind == "VILLE"}
|
||||
filtered = set()
|
||||
@@ -1270,16 +1345,15 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> set:
|
||||
continue
|
||||
names.add(tok)
|
||||
|
||||
# Retirer les sous-parties de noms composés avec tiret
|
||||
# Si "JEAN-PIERRE" est dans names, retirer "JEAN" et "PIERRE" individuels
|
||||
# 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.
|
||||
compound_names = {n for n in names if "-" in n}
|
||||
parts_to_remove = set()
|
||||
for compound in compound_names:
|
||||
for part in compound.split("-"):
|
||||
part = part.strip()
|
||||
if len(part) >= 2 and part in names:
|
||||
parts_to_remove.add(part)
|
||||
names -= parts_to_remove
|
||||
if len(part) >= 3 and part.lower() not in _MEDICAL_STOP_WORDS_SET:
|
||||
names.add(part)
|
||||
|
||||
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)
|
||||
if "[" in text[ctx_start:m.start()] or "]" in text[m.end():ctx_end]:
|
||||
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] == "-":
|
||||
continue
|
||||
if m.start() >= 2 and text[m.start() - 2].isalpha():
|
||||
continue
|
||||
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
|
||||
# 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))
|
||||
@@ -1563,6 +1641,11 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
|
||||
protected = RE_HOPITAL_VILLE.sub(PLACEHOLDERS["ETAB"], protected)
|
||||
# Services hospitaliers
|
||||
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)
|
||||
wl_sections = set()
|
||||
wl_phrases = set()
|
||||
|
||||
Reference in New Issue
Block a user