fix(phase2): Corrections audit 30 fichiers — FP stop words, villes, établissements, noms composés

- Ajout 10 stop words FP (bouffee, discontinue, respimat, lyoc, probnp, bpco, colle, gsc, masse, selle)
- Ajout 8 villes stop words (saint-palais, tarnos, hendaye, dax, orthez, oloron, pau, cambo)
- Protection "Examen Clinique" contre masquage [ETABLISSEMENT] (lookbehind négatif)
- Ajout Pharmacie et Centre Médical dans RE_HOPITAL_VILLE
- Masquage "Ville, le [date]" dans en-têtes courrier (Bayonne, le 12/03/2024)
- Noms composés avec espace (DI LULLO, LE MOIGNE) via _add_compound
- Contacts Trackare lowercase + capture 3e token (vandestock/michele)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 22:45:26 +01:00
parent 19e089ea38
commit 4488a1d4a0

View File

@@ -538,6 +538,7 @@ _MEDICAL_STOP_WORDS_SET = {
# FP audit OGC 17 CRH # FP audit OGC 17 CRH
"mode", "retraitee", "retraité", "retraitée", "régression", "regression", "tel", "mode", "retraitee", "retraité", "retraitée", "régression", "regression", "tel",
"strasbourg", "bordeaux", "toulouse", "paris", "lyon", "marseille", "bayonne", "anglet", "strasbourg", "bordeaux", "toulouse", "paris", "lyon", "marseille", "bayonne", "anglet",
"saint-palais", "tarnos", "hendaye", "dax", "orthez", "oloron", "pau", "cambo",
# Spécialités/services récurrents comme FP NOM # Spécialités/services récurrents comme FP NOM
"cancérologie", "cancerologie", "réanimation", "reanimation", "cancérologie", "cancerologie", "réanimation", "reanimation",
"urologie", "néphrologie", "nephrologie", "hématologie", "hematologie", "urologie", "néphrologie", "nephrologie", "hématologie", "hematologie",
@@ -630,6 +631,11 @@ _MEDICAL_STOP_WORDS_SET = {
"bronchite", "accueil", "cadre", "transfert", "relecture", "examens", "bronchite", "accueil", "cadre", "transfert", "relecture", "examens",
"traitements", "traitement", "infectiologie", "cancérologie", "cancerologie", "traitements", "traitement", "infectiologie", "cancérologie", "cancerologie",
"maternité", "orale", "sachet", "absence", "maternité", "orale", "sachet", "absence",
# FP audit 30 fichiers Phase 2 (mars 2026)
"bouffee", "bouffée", "discontinue", "respimat", "lyoc",
"probnp", "pro-bnp", "nt-probnp",
"bpco", "colle", "gsc", "masse",
"selle", "selles",
} }
# Enrichissement automatique avec les ~4000 noms de médicaments d'edsnlp # Enrichissement automatique avec les ~4000 noms de médicaments d'edsnlp
_MEDICAL_STOP_WORDS_SET.update(_load_edsnlp_drug_names()) _MEDICAL_STOP_WORDS_SET.update(_load_edsnlp_drug_names())
@@ -790,9 +796,10 @@ RE_ETABLISSEMENT = re.compile(
r")", r")",
) )
RE_HOPITAL_VILLE = re.compile( RE_HOPITAL_VILLE = re.compile(
r"(?<![Ee]xamen )"
r"\b((?:[Hh]ôpital|[Cc]linique|[Pp]olyclinique|[Cc]entre\s+[Hh]ospitalier" r"\b((?:[Hh]ôpital|[Cc]linique|[Pp]olyclinique|[Cc]entre\s+[Hh]ospitalier"
r"|[Cc]entre\s+[Dd]e\s+[Ss]oins|[Mm]aison\s+[Dd]e\s+[Ss]anté" r"|[Cc]entre\s+[Mm][ée]dical|[Cc]entre\s+[Dd]e\s+[Ss]oins|[Mm]aison\s+[Dd]e\s+[Ss]anté"
r"|[Mm]aison\s+[Dd]e\s+[Rr]etraite|[Rr]ésidence|[Ff]oyer)" r"|[Mm]aison\s+[Dd]e\s+[Rr]etraite|[Rr]ésidence|[Ff]oyer|[Pp]harmacie)"
r"\s+(?:de\s+|d['']\s*|du\s+|des\s+)?(?:la\s+|le\s+|l['']\s*|les\s+)?" r"\s+(?:de\s+|d['']\s*|du\s+|des\s+)?(?:la\s+|le\s+|l['']\s*|les\s+)?"
r"(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ][A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇa-zéèàùâêîôûäëïöüç\-']+)" r"(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ][A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇa-zéèàùâêîôûäëïöüç\-']+)"
r"(?:\s+(?:de\s+|d['']\s*|du\s+|des\s+)?(?:la\s+|le\s+|l['']\s*|les\s+)?" r"(?:\s+(?:de\s+|d['']\s*|du\s+|des\s+)?(?:la\s+|le\s+|l['']\s*|les\s+)?"
@@ -1230,6 +1237,23 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
return PLACEHOLDERS["MASK"] return PLACEHOLDERS["MASK"]
line = RE_SERVICE.sub(_repl_service, line) line = RE_SERVICE.sub(_repl_service, line)
# Ville en en-tête de courrier : "Bayonne, le 12/03/2024" → masquer la ville
# Le contexte "Mot, le [date]" est fiable (virgule obligatoire)
# Autorise les mots de liaison minuscules (de, du, la, sur, en, lès)
_re_ville_date = re.compile(
r"^(\s*)"
r"([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ][a-zéèàùâêîôûäëïöüç\-]+"
r"(?:\s+(?:de|du|la|sur|en|lès|les|l['']\s*)?"
r"[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇa-zéèàùâêîôûäëïöüç\-]+)*)"
r"(\s*,\s+le\s+\d{1,2})",
re.MULTILINE,
)
def _repl_ville_date(m: re.Match) -> str:
ville = m.group(2).strip()
audit.append(PiiHit(page_idx, "VILLE", ville, PLACEHOLDERS["VILLE"]))
return m.group(1) + PLACEHOLDERS["VILLE"] + m.group(3)
line = _re_ville_date.sub(_repl_ville_date, 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*)(\S.+)") _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:
@@ -1341,10 +1365,17 @@ def _extract_trackare_identity(full_text: str) -> Tuple[set, List[PiiHit]]:
force_names: set = set() # noms issus de contextes structurés (DR., Signé, etc.) → bypass stop words force_names: set = set() # noms issus de contextes structurés (DR., Signé, etc.) → bypass stop words
def _add_name(s: str): def _add_name(s: str):
for tok in s.split(): s = s.strip()
parts = s.split()
for tok in parts:
tok = tok.strip(" .-'(),") tok = tok.strip(" .-'(),")
if len(tok) >= 2 and tok[0].isupper(): if len(tok) >= 2 and tok[0].isupper():
names.add(tok) names.add(tok)
# Garder aussi le nom composé complet (DI LULLO, LE MOIGNE, etc.)
if len(parts) >= 2:
compound = " ".join(t.strip(" .-'(),") for t in parts if len(t.strip(" .-'(),")) >= 2)
if len(compound) >= 5:
names.add(compound)
# Termes non-noms fréquents dans les contextes Signé/DR./Note d'évolution # Termes non-noms fréquents dans les contextes Signé/DR./Note d'évolution
_FORCE_EXCLUDE = _MEDICATION_WHITELIST | { _FORCE_EXCLUDE = _MEDICATION_WHITELIST | {
@@ -1429,15 +1460,29 @@ def _extract_trackare_identity(full_text: str) -> Tuple[set, List[PiiHit]]:
# --- Contacts structurés --- # --- Contacts structurés ---
# Pattern: Relation NOM PRENOM [ADRESSE] [TEL] # Pattern: Relation NOM PRENOM [ADRESSE] [TEL]
# Accepte les minuscules (Trackare écrit parfois "Conjoint vandestock michele")
# Capture jusqu'à 3 tokens pour les noms composés (le moigne christophe)
for m in re.finditer( for m in re.finditer(
r"(?:Conjoint|Concubin|Epoux|Epouse|Parent|Père|Mère|Fils|Fille|Frère|Soeur|Tuteur)\s+" r"(?:Conjoint|Concubin|Epoux|Epouse|Parent|Père|Mère|Fils|Fille|Frère|Soeur|Tuteur)\s+"
r"([A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôû][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûä\-']+)" r"([A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôû][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûä\-']+)"
r"(?:\s+([A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôû][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûä\-']+))?"
r"(?:\s+([A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôû][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûä\-']+))?", r"(?:\s+([A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôû][A-ZÉÈÀÙÂÊÎÔÛa-zéèàùâêîôûä\-']+))?",
full_text, full_text,
): ):
_add_name(m.group(1)) contact_parts = [g.strip(" .-'(),") for g in (m.group(1), m.group(2), m.group(3)) if g]
if m.group(2): # Ajouter chaque token >= 3 chars (pas les articles courts comme "le", "di")
_add_name(m.group(2)) for tok in contact_parts:
if len(tok) >= 3 and tok.lower() not in _MEDICAL_STOP_WORDS_SET:
names.add(tok)
if tok[0].islower():
names.add(tok.capitalize())
# Ajouter aussi le composé complet (pour "le moigne", "di lullo")
if len(contact_parts) >= 2:
compound = " ".join(contact_parts)
if len(compound) >= 5:
names.add(compound)
# Version capitalisée pour propagation
names.add(" ".join(t.capitalize() for t in compound.split()))
# --- Prescripteurs / Exécutants (trackare) --- # --- Prescripteurs / Exécutants (trackare) ---
for m in re.finditer( for m in re.finditer(
@@ -1592,7 +1637,16 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s
names: set = set() names: set = set()
force_names: set = set() force_names: set = set()
def _add_compound(match_str: str):
"""Ajoute le nom composé complet en plus des tokens individuels (DI LULLO, LE MOIGNE)."""
parts = [t.strip(" .-'") for t in match_str.split() if len(t.strip(" .-'")) >= 2]
if len(parts) >= 2:
compound = " ".join(parts)
if len(compound) >= 5:
names.add(compound)
def _add_tokens(match_str: str): def _add_tokens(match_str: str):
_add_compound(match_str)
for token in match_str.split(): for token in match_str.split():
token = token.strip(" .-'") token = token.strip(" .-'")
if len(token) < 3: if len(token) < 3:
@@ -1605,6 +1659,7 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s
def _add_tokens_force_all(match_str: str): def _add_tokens_force_all(match_str: str):
"""Bypass stop words pour TOUS les tokens (contexte Patient: très fiable).""" """Bypass stop words pour TOUS les tokens (contexte Patient: très fiable)."""
_add_compound(match_str)
for token in match_str.split(): for token in match_str.split():
token = token.strip(" .-'") token = token.strip(" .-'")
if len(token) < 2: if len(token) < 2:
@@ -1616,6 +1671,7 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s
def _add_tokens_force_first(match_str): def _add_tokens_force_first(match_str):
"""Comme _add_tokens mais force le 1er token (contexte Dr/Mme fort).""" """Comme _add_tokens mais force le 1er token (contexte Dr/Mme fort)."""
_add_compound(match_str)
tokens = match_str.split() tokens = match_str.split()
for i, token in enumerate(tokens): for i, token in enumerate(tokens):
token = token.strip(" .-'") token = token.strip(" .-'")