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",
|
"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()
|
||||||
|
|||||||
Reference in New Issue
Block a user