#!/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" 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.""" 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"])