- test_f5_nom_compose_orphelin.py : 13 tests (regex F5, application, scénario Trackare EJNAINI) - test_gui_batch_paths.py / test_manual_masking.py : couverture des modules - test_real_world_identifier_layouts.py : non-régression layouts réels (D-15) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
204 lines
7.9 KiB
Python
204 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Test de non-regression pour le fix F5 (commit 299bbee).
|
|
|
|
F5 : post-passe masquant la continuation orpheline d'un nom compose coupe
|
|
par un saut de ligne dans le format Trackare en colonnes.
|
|
|
|
Cas reproduit :
|
|
... 07:55 NOCENT-
|
|
EJNAINI
|
|
|
|
Le nom "NOCENT-EJNAINI" est eclate sur deux lignes. Le NER ligne par ligne
|
|
ne peut pas les assembler. Le 1er composant (NOCENT-) est masque via un
|
|
autre artefact de remplacement, mais le 2e (EJNAINI) reste orphelin en clair.
|
|
|
|
F5 ajoute une regex post-masquage qui detecte "[NOM]-\\n<TOKEN_MAJUSCULE>"
|
|
et masque le token orphelin. Le token doit etre directement apres le saut
|
|
de ligne (whitespace accepte), pas apres un autre texte.
|
|
|
|
Source : anonymizer_core_refactored_onnx.py, lignes ~4505-4516,
|
|
fonction process_pdf(), bloc "3a-bis) Nettoyage post-masquage".
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from anonymizer_core_refactored_onnx import PLACEHOLDERS
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F5 regex — reproduite ici pour test unitaire (identique a process_pdf)
|
|
# ---------------------------------------------------------------------------
|
|
_RE_NOM_ORPHAN = re.compile(
|
|
r"(\[NOM\]-\s*\n?\s*)([A-Z\u00C0-\u0178][A-Z\u00C0-\u0178'\-]{3,})\b"
|
|
)
|
|
|
|
|
|
def _apply_f5_nom_orphan(text: str) -> tuple[str, list]:
|
|
"""Applique la post-passe F5 sur une continuation orpheline de nom compose.
|
|
|
|
Retourne le texte nettoye et la liste des tokens masques (pour audit).
|
|
Logique identique a celle dans process_pdf() etape 3a-bis.
|
|
"""
|
|
hits = []
|
|
|
|
# Stop-words medicaux exclus du masquage (meme liste que process_pdf)
|
|
_MEDICAL_STOP_WORDS = {
|
|
"ampoule", "ampoules", "comprime", "comprimes", "gelule", "gelules",
|
|
"solution", "solutions", "traitement", "traitements", "injection",
|
|
"perfusions", "prescription", "posologie", "diagnostic", "examen",
|
|
"resultat", "resultats", "observation", "antibiogramme", "bacterio",
|
|
}
|
|
|
|
def _clean(m):
|
|
tok = m.group(2)
|
|
if tok.lower() in _MEDICAL_STOP_WORDS:
|
|
return m.group(0)
|
|
hits.append(tok)
|
|
return m.group(1) + PLACEHOLDERS["NOM"]
|
|
|
|
cleaned = _RE_NOM_ORPHAN.sub(_clean, text)
|
|
return cleaned, hits
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestF5NomComposeOrphelin:
|
|
"""F5 - Continuation orpheline d'un nom compose coupe par saut de ligne."""
|
|
|
|
# -- Regex seule --
|
|
|
|
def test_f5_regex_matches_nom_orphan_direct_after_dash_newline(self):
|
|
"""La regex F5 capture un token majuscule directement apres [NOM]-\\n."""
|
|
text = "[NOM]-\nEJNAINI"
|
|
match = _RE_NOM_ORPHAN.search(text)
|
|
assert match is not None
|
|
assert match.group(1) == "[NOM]-\n"
|
|
assert match.group(2) == "EJNAINI"
|
|
|
|
def test_f5_regex_matches_with_leading_spaces_on_next_line(self):
|
|
"""La regex F5 tolere des espaces en debut de ligne suivante."""
|
|
text = "[NOM]-\n EJNAINI"
|
|
match = _RE_NOM_ORPHAN.search(text)
|
|
assert match is not None
|
|
assert match.group(2) == "EJNAINI"
|
|
|
|
def test_f5_regex_matches_with_trailing_spaces_before_newline(self):
|
|
"""La regex F5 tolere des espaces avant le saut de ligne."""
|
|
text = "[NOM]- \n EJNAINI"
|
|
match = _RE_NOM_ORPHAN.search(text)
|
|
assert match is not None
|
|
assert match.group(2) == "EJNAINI"
|
|
|
|
def test_f5_regex_no_match_when_intervening_text(self):
|
|
"""La regex F5 ne matche PAS si du texte separe [NOM]-\\n du token.
|
|
C'est le cas quand le token n'est pas une continuation directe du nom
|
|
compose (ex: autre colonne du tableau Trackare)."""
|
|
text = "[NOM]-\nAmpoule(s) EJNAINI"
|
|
# "Ampoule(s)" n'est pas tout en majuscule, donc la regex ne le matche
|
|
# pas comme groupe 2, et EJNAINI n'est pas directement apres \n\s*
|
|
match = _RE_NOM_ORPHAN.search(text)
|
|
assert match is None, (
|
|
"F5 ne doit pas matcher quand du texte separe [NOM]- du token orphelin"
|
|
)
|
|
|
|
def test_f5_regex_rejects_lowercase_start(self):
|
|
"""Un token commencant par une minuscule n'est pas capture."""
|
|
match = _RE_NOM_ORPHAN.search("[NOM]-\nejnaini")
|
|
assert match is None
|
|
|
|
def test_f5_regex_minimum_length_4_chars(self):
|
|
"""Le token doit faire au moins 4 caracteres (1 + {3,})."""
|
|
assert _RE_NOM_ORPHAN.search("[NOM]-\nABC") is None, "3 chars = trop court"
|
|
assert _RE_NOM_ORPHAN.search("[NOM]-\nABCD") is not None, "4 chars = OK"
|
|
|
|
# -- Application F5 --
|
|
|
|
def test_f5_apply_masks_orphan_token(self):
|
|
"""_apply_f5_nom_orphan remplace le token orphelin par [NOM]."""
|
|
text = "[NOM]-\nEJNAINI"
|
|
cleaned, hits = _apply_f5_nom_orphan(text)
|
|
assert hits == ["EJNAINI"]
|
|
assert "[NOM]-" in cleaned
|
|
assert "EJNAINI" not in cleaned
|
|
# Les deux parties du nom compose doivent etre masquees
|
|
assert cleaned.count(PLACEHOLDERS["NOM"]) == 2
|
|
|
|
def test_f5_apply_preserves_context_around_orphan(self):
|
|
"""Le contexte autour du nom orphelin n'est pas modifie."""
|
|
text = "07:55 [NOM]-\nEJNAINI\nSuite du traitement"
|
|
cleaned, hits = _apply_f5_nom_orphan(text)
|
|
assert hits == ["EJNAINI"]
|
|
assert "07:55 " in cleaned
|
|
assert "Suite du traitement" in cleaned
|
|
assert "EJNAINI" not in cleaned
|
|
|
|
def test_f5_apply_multiple_orphans(self):
|
|
"""F5 masque plusieurs orphelines dans le meme texte."""
|
|
text = "[NOM]-\nDUPONT\nAutre [NOM]-\nMARTIN"
|
|
cleaned, hits = _apply_f5_nom_orphan(text)
|
|
assert len(hits) == 2
|
|
assert "DUPONT" not in cleaned
|
|
assert "MARTIN" not in cleaned
|
|
assert cleaned.count(PLACEHOLDERS["NOM"]) == 4 # 2 initiaux + 2 orphelins
|
|
|
|
def test_f5_no_false_positive_on_normal_text(self):
|
|
"""F5 ne modifie pas un texte sans pattern [NOM]-\\n<TOKEN>."""
|
|
text = "Patient presente le [DATE]. Traitement prescrit."
|
|
cleaned, hits = _apply_f5_nom_orphan(text)
|
|
assert hits == []
|
|
assert cleaned == text
|
|
|
|
# -- Cas reel Trackare --
|
|
|
|
def test_f5_full_trackare_scenario(self):
|
|
"""Test du cas Trackare complet : nom NOCENT-EJNAINI coupe par saut
|
|
de ligne dans l'extraction PDF en colonnes.
|
|
|
|
Format Trackare en colonnes :
|
|
Colonne nom : "07:55 NOCENT-"
|
|
Ligne suivante : "EJNAINI"
|
|
|
|
Apres masquage initial (pre-F5) :
|
|
"07:55 [NOM]-\nEJNAINI"
|
|
|
|
Apres F5 :
|
|
"07:55 [NOM]-\n[NOM]"
|
|
"""
|
|
# Input simulant le resultat pre-F5 (NOCENT masque, EJNAINI orphelin)
|
|
pre_f5 = "07:55 [NOM]-\nEJNAINI"
|
|
|
|
cleaned, hits = _apply_f5_nom_orphan(pre_f5)
|
|
|
|
# Verification : les deux composantes du nom compose sont masquees
|
|
assert "[NOM]-" in cleaned, "Le 1er composant doit rester masque"
|
|
assert "EJNAINI" not in cleaned, "Le 2e composant orphelin doit etre masque par F5"
|
|
assert "EJNAINI" not in cleaned, "Aucune fuite du nom orphelin"
|
|
assert cleaned.count(PLACEHOLDERS["NOM"]) == 2, (
|
|
"Les deux parties du nom compose doivent etre masquees"
|
|
)
|
|
assert hits == ["EJNAINI"], "EJNAINI doit etre loggue dans l'audit"
|
|
|
|
def test_f5_trackare_with_spaces_in_column_alignment(self):
|
|
"""Cas Trackare avec espaces d'alignement de colonne."""
|
|
pre_f5 = "07:55 [NOM]- \n EJNAINI \nSuite"
|
|
cleaned, hits = _apply_f5_nom_orphan(pre_f5)
|
|
assert hits == ["EJNAINI"]
|
|
assert "EJNAINI" not in cleaned
|
|
assert "Suite" in cleaned
|
|
|
|
def test_f5_nom_compose_with_apostrophe_and_dash(self):
|
|
"""Token orphelin contenant apostrophes et tirets."""
|
|
pre_f5 = "[NOM]-\nDUPONT-MARTIN"
|
|
cleaned, hits = _apply_f5_nom_orphan(pre_f5)
|
|
assert hits == ["DUPONT-MARTIN"]
|
|
assert "DUPONT-MARTIN" not in cleaned
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|