fix(detect): établissements multi-ligne, CHCB en fin de phrase, ville après [ETAB] (#3 #4 #5)

Trois fixes qui font passer 009_multi_etablissements en vert et
ferment la liste des fuites identifiées par la couche 2.

#3 — `Centre Hospitalier Universitaire de Bordeaux` coupé sur deux lignes
Nouveau pattern `RE_ETAB_LINEBREAK` (strict) en pré-passe sur la page
entière, juste avant le découpage en lignes. Match `<TYPE>\n<suite>`
avec :
- TYPE limité (Centre Hospitalier, Hôpital, Clinique, Polyclinique,
  CHU, CHRU, CHS) ;
- un seul `\n` autorisé entre TYPE et suite ;
- la suite démarre obligatoirement par un connecteur typique
  (Universitaire, de, d', du, des, la, le, les) puis UN nom propre.
Évite le FP `CENTRE HOSPITALIER COTE BASQUE\nService d'anesthésie`
(le `\n` n'est pas immédiat après le type, donc pas de match).

#4 — `CHCB` en fin de phrase suivi de ` ;`
`_kv_value_only_mask` splittait `transféré au CHCB pour la rééducation ;`
sur le `;` du `SPLITTER` (`\s*[:|;\t]\s*`), produisant une value vide.
La key contenait CHCB mais n'était passée qu'à `_mask_critical_in_key`
qui ne couvre pas les force_terms admin_rules.
Fix : fallback sur `_mask_line_by_regex(line)` (qui appelle
`_apply_overrides` → force_terms) si la value est vide ou la key
dépasse 5 mots (heuristique narrative).

#5 — `Biarritz` non masqué après `[ETABLISSEMENT] à Biarritz`
`_mask_ville_gazetteers` skippait par sécurité toute ville détectée
juste après un placeholder établissement précédé de `de/du/d'/à`. Le
`à` était inclus pour éviter les FP, mais c'est la préposition de
LOCALISATION par excellence : `Clinique Aguilera à Biarritz` perd
Biarritz à tort. Restreint le skip à `de/du/d'` (qui sont des parties
de nom d'établissement type `CHU de Bordeaux`). `à` reste actif.

Couche 2 entièrement verte : 73 passed, 0 xfailed (avant : 72 + 1
xfailed). KNOWN_FAILURES vidé. La gate pytest est désormais le
contrat de non-régression sur 10 documents complets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 11:32:45 +02:00
parent ffb8006e91
commit f85659d103
4 changed files with 67 additions and 20 deletions

View File

@@ -716,6 +716,19 @@ RE_ETABLISSEMENT = re.compile(
r"(?:CHS|CH)" + _ETAB_NAME + r"+"
r")",
)
# Établissement coupé sur deux lignes par un saut PDF.
# Match strictement : <TYPE><\n><suite> où la suite démarre par un connecteur
# typique (Universitaire, de, du, la, le, les) suivi d'UN nom propre. Ne mange
# pas la ligne suivante en entier — limité à un seul token de nom propre.
# Évite les FP type `CENTRE HOSPITALIER COTE BASQUE\nService d'anesthésie` où
# le `\n` n'est PAS immédiat après le type.
RE_ETAB_LINEBREAK = re.compile(
r"\b((?i:centre\s+hospitalier|h[oô]pital|clinique|polyclinique|chu|chrhu|chs))"
r"[ \t]*\n[ \t]*"
r"((?i:universitaire\s+)?"
r"(?:(?i:de|d[']|du|des|la|le|les)\s+)?"
r"[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ][A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑa-zéèàùâêîôûäëïöüçñ\-']+)",
)
RE_HOPITAL_VILLE = re.compile(
r"(?<![Ee]xamen )"
# Type d'établissement : case-insensitive sur le groupe (?i:...) pour capturer
@@ -1661,13 +1674,17 @@ def _kv_value_only_mask(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
if structured_line != line:
return structured_line
parts = SPLITTER.split(line, maxsplit=1)
if len(parts) == 2:
# Une ligne narrative qui se termine par ` ;` ou ` :` produit un split
# avec une "value" vide. La "key" contient alors tout le narratif —
# incluant d'éventuels force_term (`CHCB`, `CONCERTATION`…) qui doivent
# être masqués. Idem si la "key" fait plus de 5 mots : c'est très
# probablement du narratif, pas un libellé `Label : valeur`.
if len(parts) == 2 and parts[1].strip() and len(parts[0].split()) <= 5:
key, value = parts
masked_key = _mask_critical_in_key(key, audit, page_idx)
masked_val = _mask_line_by_regex(value, audit, page_idx, cfg)
return f"{masked_key.strip()} : {masked_val.strip()}"
else:
return _mask_line_by_regex(line, audit, page_idx, cfg)
return _mask_line_by_regex(line, audit, page_idx, cfg)
# ----------------- Extraction globale de noms -----------------
@@ -2571,10 +2588,22 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str]
# Phase 0i : règles d'administration actives sur identifiants.
_apply_admin_identifier_hits(full_raw, audit, cfg)
# Phase 1 : masquage ligne par ligne (regex classiques)
# Phase 1 : masquage ligne par ligne (regex classiques).
# Pré-passe ciblée : fusion-masquage des établissements coupés sur deux
# lignes (`Centre Hospitalier\nUniversitaire de Bordeaux`, `CHU\nde
# Bordeaux`). La regex est stricte (un seul `\n` autorisé entre type et
# connecteur, un seul nom propre derrière) pour éviter les FP qui
# absorbaient `Service d'anesthésie-réanimation` ligne suivante.
out_pages: List[str] = []
for i, page_txt in enumerate(pages_text):
lines = [ln for ln in (page_txt or "").splitlines()]
page_txt = page_txt or ""
def _repl_etab_linebreak(m: re.Match, _page=i) -> str:
audit.append(PiiHit(_page, "ETAB", m.group(0), PLACEHOLDERS["ETAB"]))
return PLACEHOLDERS["ETAB"]
page_txt = RE_ETAB_LINEBREAK.sub(_repl_etab_linebreak, page_txt)
lines = page_txt.splitlines()
masked = [_kv_value_only_mask(ln, audit, i, cfg) for ln in lines]
out_pages.append("\n".join(masked))
table_blocks: List[str] = []
@@ -3439,9 +3468,13 @@ def _mask_ville_gazetteers(text: str) -> tuple:
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 ...)
# Vérifier proximité placeholder : `[ETABLISSEMENT] de Bordeaux` est
# une suite du nom d'établissement (CHU de Bordeaux) déjà masqué, on
# ne re-masque pas. Mais `[ETABLISSEMENT] à Bordeaux` est une
# localisation (clinique située à Bordeaux) où la ville doit être
# masquée séparément.
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):
if re.search(r"\[(VILLE|ADRESSE|ETABLISSEMENT)\]\s*(?:de\s+|du\s+|d['']\s*)?$", wide_before):
continue
# Récupérer le texte original à cette position
original_span = text[start_idx:end_idx + 1]