From e0b526b2c71632183b7ec0541fd2b90085e31f42 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Tue, 28 Apr 2026 11:32:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(detect):=20=C3=A9tablissements=20multi-lign?= =?UTF-8?q?e,=20CHCB=20en=20fin=20de=20phrase,=20ville=20apr=C3=A8s=20[ETA?= =?UTF-8?q?B]=20(#3=20#4=20#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `\n` 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) --- anonymizer_core_refactored_onnx.py | 47 ++++++++++++++++--- .../009_multi_etablissements/expected.txt | 2 +- .../cases/009_multi_etablissements/review.md | 30 ++++++++++-- tests/unit/test_synthetic_review.py | 8 +--- 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index 223e3b1..5b86abf 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -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 : <\n> 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"(? 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] diff --git a/tests/synthetic_review/cases/009_multi_etablissements/expected.txt b/tests/synthetic_review/cases/009_multi_etablissements/expected.txt index 8e39c5f..5a701e9 100644 --- a/tests/synthetic_review/cases/009_multi_etablissements/expected.txt +++ b/tests/synthetic_review/cases/009_multi_etablissements/expected.txt @@ -25,7 +25,7 @@ réflexes ostéotendineux normaux. Force motrice cotée 4/5 sur le quadriceps droit. Conduite à tenir : -- EMG demandé en urgence au laboratoire de neurophysiologie du [ETABLISSEMENT] (Dr [NOM] [NOM], neurophysiologiste) ; +- EMG demandé en urgence au laboratoire de neurophysiologie du [ETABLISSEMENT] (Dr [NOM], neurophysiologiste) ; - IRM médullaire programmée à la [ETABLISSEMENT] ; - la classification internationale CIM-10 retient G56.8. diff --git a/tests/synthetic_review/cases/009_multi_etablissements/review.md b/tests/synthetic_review/cases/009_multi_etablissements/review.md index 3bc7af0..b7efb3d 100644 --- a/tests/synthetic_review/cases/009_multi_etablissements/review.md +++ b/tests/synthetic_review/cases/009_multi_etablissements/review.md @@ -30,8 +30,28 @@ Points critiques : `neuropathie périphérique`, `réflexes ostéotendineux`, `rééducation fonctionnelle`, `EMG`, `IRM médullaire`. -Écart spécifique à observer : -- détection des établissements non FINESS publics (cliniques privées) ; -- détection variante longue vs sigle (`Centre Hospitalier Universitaire - de Bordeaux` vs `CHU de Bordeaux`) — la version sigle peut échapper au - matcher si `CHU` n'est pas en règle force_term ou contextuelle. +Écarts résolus dans la session 2026-04-28 : +- **`Centre Hospitalier Universitaire de Bordeaux` coupé sur deux lignes** : + pré-passe `RE_ETAB_LINEBREAK` ajoutée dans la phase 1 — fusionne + strictement les coupures `\n` (avec un seul nom + propre maximum derrière) sans grignoter la ligne suivante non liée. + Couvre aussi `CHU\nde Bordeaux`. +- **`CHCB` non masqué en fin de phrase suivi de ` ;`** : le `SPLITTER` + (`\s*[:|;\t]\s*`) splittait la ligne narrative sur le `;` final, + produisant une "value" vide. La key (qui contenait CHCB) n'était pas + passée à `_mask_line_by_regex` (donc pas aux force_terms). Fix dans + `_kv_value_only_mask` : si la value est vide ou la key dépasse 5 mots, + fallback sur `_mask_line_by_regex(line)` complet. +- **`Biarritz` non masqué après `[ETABLISSEMENT] à Biarritz`** : le + garde-fou `_mask_ville_gazetteers` skippait toute ville après un + placeholder établissement précédé de `de/du/d'/à`. Restreint à + `de/du/d'` (parties de nom d'établissement) — `à` reste actif pour + les vraies localisations. +- **`Beñat` partiellement masqué** : déjà résolu via l'extension Ñ/ñ + dans les classes de caractères (commit c24b7f6). + +Variantes établissement gérées : +- forme longue `Centre Hospitalier Universitaire de Bordeaux` ; +- forme sigle `CHU de Bordeaux` ; +- coupures de ligne sur les deux ; +- établissement privé `Clinique Aguilera` détecté via `RE_HOPITAL_VILLE`. diff --git a/tests/unit/test_synthetic_review.py b/tests/unit/test_synthetic_review.py index ad9ea59..aa9926a 100644 --- a/tests/unit/test_synthetic_review.py +++ b/tests/unit/test_synthetic_review.py @@ -29,13 +29,7 @@ from tools.run_synthetic_review_corpus import ( # noqa: E402 ) -KNOWN_FAILURES: dict[str, str] = { - "009_multi_etablissements": ( - "Fuites résiduelles : suffixe `de Bordeaux` après " - "[ETABLISSEMENT], CHCB en fin de phrase. À traiter via " - "admin_rules (étape B suivante)." - ), -} +KNOWN_FAILURES: dict[str, str] = {} def _case_dirs() -> list[Path]: