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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user