Externalize dictionaries and add anonymization review corpus

This commit is contained in:
2026-04-21 10:32:57 +02:00
parent 39db675052
commit 34dcf8f360
99 changed files with 1805 additions and 805 deletions

12
tests/conftest.py Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env python3
"""
Configuration pytest partagée pour les imports du dépôt.
"""
import sys
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parent.parent
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))

View File

@@ -0,0 +1,26 @@
# Tests synthétiques de non-régression
Cette suite fournit 10 cas synthétiques courts, relisibles et diffables, pensés
comme première barrière de sécurité avant la revue humaine.
Principe :
- `test.txt` contient le document synthétique d'entrée à relire ou diff-er.
- `expected.txt` contient la sortie anonymisée attendue, normalisée.
- `expected.audit.json` contient un résumé stable de l'audit attendu.
- `config_overlay.yml` est optionnel et permet de tester une surcharge locale.
Objectif :
- bloquer les régressions évidentes sur les règles critiques ;
- rendre les écarts lisibles dans un diff Git ou dans la sortie de `pytest` ;
- compléter, et non remplacer, la validation humaine sur corpus réel.
Portée de cette première version :
- texte uniquement ;
- pas encore de PDF/OCR/layout ;
- pas encore de cas `xfail` pour les bugs connus.
Exécution :
```bash
pytest -q tests/unit/test_synthetic_regression.py
```

View File

@@ -0,0 +1,22 @@
[
{
"kind": "DATE_NAISSANCE",
"original": "Né le 12/03/1980",
"replacement": "[DATE_NAISSANCE]"
},
{
"kind": "NOM_GLOBAL",
"original": "ETCHEVERRY",
"replacement": "[NOM]"
},
{
"kind": "NOM_GLOBAL",
"original": "CLAUDE",
"replacement": "[NOM]"
},
{
"kind": "NOM_GLOBAL",
"original": "JEAN",
"replacement": "[NOM]"
}
]

View File

@@ -0,0 +1,3 @@
[NOM] [NOM] [NOM]
[DATE_NAISSANCE]
Consultation du 14/03/2024

View File

@@ -0,0 +1,3 @@
ETCHEVERRY JEAN CLAUDE
Né le 12/03/1980
Consultation du 14/03/2024

View File

@@ -0,0 +1,3 @@
ETCHEVERRY JEAN CLAUDE
Né le 12/03/1980
Consultation du 14/03/2024

View File

@@ -0,0 +1,12 @@
[
{
"kind": "EMAIL",
"original": "jean.dupont@example.com",
"replacement": "[EMAIL]"
},
{
"kind": "TEL",
"original": "01 23 45 67 89",
"replacement": "[TEL]"
}
]

View File

@@ -0,0 +1 @@
Contact : [EMAIL] ou [TEL]

View File

@@ -0,0 +1 @@
Contact: jean.dupont@example.com ou 01 23 45 67 89

View File

@@ -0,0 +1 @@
Contact: jean.dupont@example.com ou 01 23 45 67 89

View File

@@ -0,0 +1,7 @@
[
{
"kind": "NDA",
"original": "1234567",
"replacement": "[NDA]"
}
]

View File

@@ -0,0 +1,3 @@
N° venue :
[NDA]
Date de séjour : 14/03/2024

View File

@@ -0,0 +1,3 @@
N° venue :
1234567
Date de séjour : 14/03/2024

View File

@@ -0,0 +1,3 @@
N° venue :
1234567
Date de séjour : 14/03/2024

View File

@@ -0,0 +1,27 @@
[
{
"kind": "RPPS",
"original": "12345678901",
"replacement": "[RPPS]"
},
{
"kind": "FINESS",
"original": "123456789",
"replacement": "[FINESS]"
},
{
"kind": "IPP",
"original": "ABC12345",
"replacement": "[IPP]"
},
{
"kind": "OGC",
"original": "12",
"replacement": "[OGC]"
},
{
"kind": "IBAN",
"original": "FR76 3000 6000 0112 3456 7890 189",
"replacement": "[IBAN]"
}
]

View File

@@ -0,0 +1,5 @@
RPPS : [RPPS]
FINESS : [FINESS]
IPP : [IPP]
N° OGC : [OGC]
IBAN : [IBAN]

View File

@@ -0,0 +1,5 @@
RPPS : 12345678901
FINESS : 123456789
IPP : ABC12345
N° OGC : 12
IBAN : FR76 3000 6000 0112 3456 7890 189

View File

@@ -0,0 +1,5 @@
RPPS : 12345678901
FINESS : 123456789
IPP : ABC12345
N° OGC : 12
IBAN : FR76 3000 6000 0112 3456 7890 189

View File

@@ -0,0 +1,7 @@
[
{
"kind": "force_term",
"original": "CHCB",
"replacement": "[MASK]"
}
]

View File

@@ -0,0 +1 @@
Patient adressé au [MASK] pour avis. Retour au [MASK] demain.

View File

@@ -0,0 +1 @@
Patient adressé au CHCB pour avis. Retour au CHCB demain.

View File

@@ -0,0 +1 @@
Patient adressé au CHCB pour avis. Retour au CHCB demain.

View File

@@ -0,0 +1 @@
La classification internationale reste visible. La prise en charge est correcte.

View File

@@ -0,0 +1 @@
La classification internationale reste visible. La prise en charge est correcte.

View File

@@ -0,0 +1 @@
La classification internationale reste visible. La prise en charge est correcte.

View File

@@ -0,0 +1,3 @@
blacklist:
force_mask_terms:
- LOCAL_SIGLE

View File

@@ -0,0 +1,7 @@
[
{
"kind": "force_term",
"original": "LOCAL_SIGLE",
"replacement": "[MASK]"
}
]

View File

@@ -0,0 +1 @@
Réorientation vers [MASK] en urgence.

View File

@@ -0,0 +1 @@
Réorientation vers LOCAL_SIGLE en urgence.

View File

@@ -0,0 +1 @@
Réorientation vers LOCAL_SIGLE en urgence.

View File

@@ -0,0 +1,7 @@
[
{
"kind": "VILLE",
"original": "Bayonne",
"replacement": "[VILLE]"
}
]

View File

@@ -0,0 +1,2 @@
[VILLE], le 12/03/2024
Compte rendu adressé au patient.

View File

@@ -0,0 +1,2 @@
Bayonne, le 12/03/2024
Compte rendu adressé au patient.

View File

@@ -0,0 +1,2 @@
Bayonne, le 12/03/2024
Compte rendu adressé au patient.

View File

@@ -0,0 +1,17 @@
[
{
"kind": "NOM_GLOBAL",
"original": "ETCHEVERRY",
"replacement": "[NOM]"
},
{
"kind": "NOM_GLOBAL",
"original": "CLAUDE",
"replacement": "[NOM]"
},
{
"kind": "NOM_GLOBAL",
"original": "JEAN",
"replacement": "[NOM]"
}
]

View File

@@ -0,0 +1,2 @@
[NOM] [NOM] [NOM]
Le patient [NOM] revient ce jour.

View File

@@ -0,0 +1,2 @@
ETCHEVERRY JEAN CLAUDE
Le patient ETCHEVERRY revient ce jour.

View File

@@ -0,0 +1,2 @@
ETCHEVERRY JEAN CLAUDE
Le patient ETCHEVERRY revient ce jour.

View File

@@ -0,0 +1,7 @@
[
{
"kind": "ETAB_SPACED",
"original": "C E N T R E H O S P I T A L I E R D E L A C O T E B A S Q U E",
"replacement": "[ETABLISSEMENT]"
}
]

View File

@@ -0,0 +1,2 @@
[ETABLISSEMENT]
Service de cardiologie

View File

@@ -0,0 +1,2 @@
C E N T R E H O S P I T A L I E R D E L A C O T E B A S Q U E
Service de cardiologie

View File

@@ -0,0 +1,2 @@
C E N T R E H O S P I T A L I E R D E L A C O T E B A S Q U E
Service de cardiologie

View File

@@ -0,0 +1,110 @@
{
"001_patient_header_and_birth": {
"description": "En-tête patient en majuscules avec date de naissance masquée et date de soin conservée.",
"must_contain": [
"[DATE_NAISSANCE]",
"Consultation du 14/03/2024"
],
"must_not_contain": [
"ETCHEVERRY",
"JEAN",
"CLAUDE",
"12/03/1980"
]
},
"002_contact_bundle": {
"description": "Email et téléphone dans une même ligne de contact.",
"must_contain": [
"[EMAIL]",
"[TEL]"
],
"must_not_contain": [
"jean.dupont@example.com",
"01 23 45 67 89"
]
},
"003_multiline_venue_number": {
"description": "Numéro de venue éclaté sur deux lignes.",
"must_contain": [
"N° venue :",
"[NDA]",
"Date de séjour : 14/03/2024"
],
"must_not_contain": [
"1234567"
]
},
"004_identifier_bundle": {
"description": "Bloc d'identifiants structurés variés.",
"must_contain": [
"[RPPS]",
"[FINESS]",
"[IPP]",
"[OGC]",
"[IBAN]"
],
"must_not_contain": [
"12345678901",
"123456789",
"ABC12345",
"FR76 3000 6000 0112 3456 7890 189"
]
},
"005_force_mask_default_term": {
"description": "Terme forcé par la configuration par défaut.",
"must_contain": [
"[MASK]"
],
"must_not_contain": [
"CHCB"
]
},
"006_whitelist_phrases_preserved": {
"description": "Expressions métier explicitement préservées.",
"must_contain": [
"classification internationale",
"prise en charge"
],
"must_not_contain": []
},
"007_overlay_force_mask_local": {
"description": "Terme local masqué via surcharge runtime.",
"must_contain": [
"[MASK]"
],
"must_not_contain": [
"LOCAL_SIGLE"
]
},
"008_ville_header": {
"description": "Ville en en-tête de courrier, date conservée.",
"must_contain": [
"[VILLE], le 12/03/2024"
],
"must_not_contain": [
"Bayonne"
]
},
"009_header_and_repeated_name": {
"description": "Propagation globale d'un nom vu dans l'en-tête.",
"must_contain": [
"Le patient [NOM] revient ce jour."
],
"must_not_contain": [
"ETCHEVERRY",
"JEAN",
"CLAUDE"
]
},
"010_spaced_establishment_header": {
"description": "En-tête d'établissement avec lettres espacées.",
"must_contain": [
"[ETABLISSEMENT]",
"Service de cardiologie"
],
"must_not_contain": [
"C E N T R E",
"H O S P I T A L I E R"
]
}
}

View File

@@ -0,0 +1,25 @@
# Jeux de tests synthétiques
Ces fichiers sont les cas de test relisibles à la main. Chaque dossier contient :
- `test.txt` : document synthétique d'entrée
- `expected.txt` : sortie anonymisée attendue
- `expected.audit.json` : résumé d'audit attendu
Cas disponibles :
- `001_patient_header_and_birth`
- `002_contact_bundle`
- `003_multiline_venue_number`
- `004_identifier_bundle`
- `005_force_mask_default_term`
- `006_whitelist_phrases_preserved`
- `007_overlay_force_mask_local`
- `008_ville_header`
- `009_header_and_repeated_name`
- `010_spaced_establishment_header`
Exemples de fichiers à ouvrir :
- [001 test](</home/dom/ai/anonymisation/tests/synthetic_regression/cases/001_patient_header_and_birth/test.txt:1>)
- [001 attendu](</home/dom/ai/anonymisation/tests/synthetic_regression/cases/001_patient_header_and_birth/expected.txt:1>)
- [004 test](</home/dom/ai/anonymisation/tests/synthetic_regression/cases/004_identifier_bundle/test.txt:1>)
- [004 attendu](</home/dom/ai/anonymisation/tests/synthetic_regression/cases/004_identifier_bundle/expected.txt:1>)
- [007 surcharge locale](</home/dom/ai/anonymisation/tests/synthetic_regression/cases/007_overlay_force_mask_local/config_overlay.yml:1>)

View File

@@ -0,0 +1,26 @@
# Corpus synthétique de revue humaine
Ce corpus ne remplace pas les tests unitaires. Il sert à valider des documents
complets, relus par un humain, avec un vrai diff entre :
- `test.txt` : document synthétique source
- `expected.txt` : anonymisation attendue selon la règle métier
- `actual/` : sortie réellement produite par le moteur
Objectif :
- détecter les régressions de composition sur des documents réalistes ;
- rendre visibles les écarts de comportement du moteur ;
- préparer une validation humaine avant promotion éventuelle en suite bloquante.
Commande :
```bash
python3 tools/run_synthetic_review_corpus.py
```
Chaque exécution écrit :
- `actual.txt`
- `actual.audit.json`
- `actual.summary.json`
- `diff.txt`
Sous [actual](/home/dom/ai/anonymisation/tests/synthetic_review/actual).

View File

@@ -0,0 +1,31 @@
{
"required_kinds": [
"ADRESSE",
"CODE_POSTAL",
"DATE_NAISSANCE",
"EMAIL",
"ETAB",
"IPP",
"NDA",
"NOM_FORCE",
"TEL",
"VILLE",
"force_term"
],
"must_contain": [
"classification internationale",
"prise en charge",
"Service de cardiologie"
],
"must_not_contain": [
"ETCHEVERRY",
"JEAN",
"CLAUDE",
"12/03/1980",
"06 12 34 56 78",
"jean.claude.etcheverry@example.com",
"ABC12345",
"1234567",
"CHCB"
]
}

View File

@@ -0,0 +1,19 @@
[ETABLISSEMENT]
[VILLE], le 14/03/2024
COMPTE RENDU D'HOSPITALISATION
Patient : [NOM] [NOM] [NOM]
[DATE_NAISSANCE]
Adresse : [ADRESSE]
Code postal : [CODE_POSTAL]
Ville de résidence : [VILLE]
Téléphone : [TEL]
Mail : [EMAIL]
IPP : [IPP]
N° venue :
[NDA]
Le patient [NOM] [NOM] [NOM] est adressé au [MASK] pour bilan.
La classification internationale et la prise en charge sont discutées.
Service de cardiologie.

View File

@@ -0,0 +1,10 @@
# Revue 001
Points critiques :
- le patient doit être masqué partout, y compris en reprise narrative ;
- la date de naissance doit être masquée, pas la date de soin ;
- l'adresse, le code postal, la ville, le téléphone, le mail, l'IPP et le numéro de venue doivent disparaître ;
- `classification internationale`, `prise en charge` et `Service de cardiologie` doivent rester lisibles.
Écart attendu aujourd'hui :
- ce cas doit mettre en évidence si le moteur perd des labels structurés comme `Code postal :` ou `N° venue :`.

View File

@@ -0,0 +1,19 @@
CENTRE HOSPITALIER DE LA COTE BASQUE
Bayonne, le 14/03/2024
COMPTE RENDU D'HOSPITALISATION
Patient : ETCHEVERRY JEAN CLAUDE
Né le 12/03/1980
Adresse : 14 rue des Lilas
Code postal : 64100
Ville de résidence : Bayonne
Téléphone : 06 12 34 56 78
Mail : jean.claude.etcheverry@example.com
IPP : ABC12345
N° venue :
1234567
Le patient ETCHEVERRY JEAN CLAUDE est adressé au CHCB pour bilan.
La classification internationale et la prise en charge sont discutées.
Service de cardiologie.

View File

@@ -0,0 +1,26 @@
{
"required_kinds": [
"DATE_NAISSANCE",
"DOSSIER",
"ETAB_SPACED",
"FINESS",
"IBAN",
"NOM_FORCE",
"OGC",
"RPPS"
],
"must_contain": [
"Service de radiologie",
"classification internationale"
],
"must_not_contain": [
"DUPONT",
"MARIE",
"PAULE",
"01/02/1975",
"23L35781",
"12345678901",
"123456789",
"FR76 3000 6000 0112 3456 7890 189"
]
}

View File

@@ -0,0 +1,13 @@
[ETABLISSEMENT]
Service de radiologie
Compte rendu d'imagerie
Patient : [NOM] [NOM] [NOM]
[DATE_NAISSANCE]
N° examen : [DOSSIER]
RPPS : [RPPS]
FINESS : [FINESS]
N° OGC : [OGC]
IBAN : [IBAN]
Le dossier de [NOM] [NOM] [NOM] est revu ce jour.
La classification internationale est conservée.

View File

@@ -0,0 +1,7 @@
# Revue 002
Points critiques :
- l'en-tête d'établissement espacé doit être réduit à un placeholder ;
- le numéro d'examen, le RPPS, le FINESS, l'OGC et l'IBAN doivent disparaître ;
- le nom du patient doit être masqué dans le champ structuré et dans la phrase narrative ;
- `Service de radiologie` et `classification internationale` doivent rester visibles.

View File

@@ -0,0 +1,13 @@
C E N T R E H O S P I T A L I E R D E L A C O T E B A S Q U E
Service de radiologie
Compte rendu d'imagerie
Patient : DUPONT MARIE PAULE
Née le 01/02/1975
N° examen : 23L35781
RPPS : 12345678901
FINESS : 123456789
N° OGC : 12
IBAN : FR76 3000 6000 0112 3456 7890 189
Le dossier de DUPONT MARIE PAULE est revu ce jour.
La classification internationale est conservée.

View File

@@ -0,0 +1,29 @@
{
"required_kinds": [
"DATE_NAISSANCE",
"EMAIL",
"ETAB",
"IPP",
"NOM_FORCE",
"RPPS",
"TEL",
"VILLE",
"force_term"
],
"must_contain": [
"prise en charge en hôpital de jour"
],
"must_not_contain": [
"LAFITTE",
"ANNE",
"MARIE",
"18/07/1968",
"Bordeaux",
"Anglet",
"anne.lafitte@example.com",
"01 23 45 67 89",
"10987654321",
"ZXC98765",
"CHCB"
]
}

View File

@@ -0,0 +1,14 @@
[ETABLISSEMENT]
[VILLE], le 22/05/2024
CONSULTATION DE SUIVI
Patient : [NOM] [NOM] [NOM]
[DATE_NAISSANCE]
Lieu de naissance : [VILLE]
Ville de résidence : [VILLE]
Contact : [EMAIL] ou [TEL]
RPPS : [RPPS]
IPP : [IPP]
Le patient [NOM] [NOM] [NOM] est adressé au [MASK].
La prise en charge en hôpital de jour est maintenue.

View File

@@ -0,0 +1,7 @@
# Revue 003
Points critiques :
- la ville d'en-tête, le lieu de naissance et la ville de résidence doivent être masqués ;
- le contact mail/téléphone, le RPPS et l'IPP doivent être masqués ;
- la reprise narrative du nom du patient doit être masquée ;
- `prise en charge en hôpital de jour` doit rester visible.

View File

@@ -0,0 +1,14 @@
CLINIQUE ATLANTIQUE
Biarritz, le 22/05/2024
CONSULTATION DE SUIVI
Patient : LAFITTE ANNE MARIE
Née le 18/07/1968
Lieu de naissance : Bordeaux
Ville de résidence : Anglet
Contact : anne.lafitte@example.com ou 01 23 45 67 89
RPPS : 10987654321
IPP : ZXC98765
Le patient LAFITTE ANNE MARIE est adressé au CHCB.
La prise en charge en hôpital de jour est maintenue.

View File

@@ -0,0 +1,27 @@
{
"required_kinds": [
"EMAIL",
"FINESS",
"IPP",
"NOM_GLOBAL",
"OGC",
"RPPS",
"TEL",
"VILLE",
"force_term"
],
"must_not_contain": [
"ETCHEVERRY",
"JEAN",
"CLAUDE",
"ABC12345",
"123456789",
"12345678901",
"Bayonne",
"Bordeaux",
"Anglet",
"06 11 22 33 44",
"jean.dupont@example.com",
"CHCB"
]
}

View File

@@ -0,0 +1,11 @@
[NOM] [NOM] [NOM]
IPP : [IPP]
FINESS : [FINESS]
RPPS : [RPPS]
[VILLE], le 12/03/2024
Lieu de naissance : [VILLE]
Ville de résidence : [VILLE]
Téléphone : [TEL]
Mail : [EMAIL]
N° OGC : [OGC]
Patient adressé au [MASK] pour avis. Retour au [MASK] demain.

View File

@@ -0,0 +1,7 @@
# Revue 004
Points critiques :
- les identifiants structurés doivent être masqués même quand le label et la valeur sont séparés ;
- la ville d'en-tête et les villes structurées doivent disparaître ;
- le nom de patient en en-tête doit être propagé ;
- les deux occurrences de `CHCB` doivent être masquées.

View File

@@ -0,0 +1,12 @@
ETCHEVERRY JEAN CLAUDE
IPP
ABC12345
FINESS : 123456789
RPPS : 12345678901
Bayonne, le 12/03/2024
Lieu de naissance : Bordeaux
Ville de résidence : Anglet
Téléphone : 06 11 22 33 44
Mail : jean.dupont@example.com
N° OGC : 12
Patient adressé au CHCB pour avis. Retour au CHCB demain.

View File

@@ -0,0 +1,15 @@
# Index du corpus de revue
Cas complets disponibles :
- [001 source](</home/dom/ai/anonymisation/tests/synthetic_review/cases/001_crh_hospitalisation_complete/test.txt:1>)
- [001 attendu](</home/dom/ai/anonymisation/tests/synthetic_review/cases/001_crh_hospitalisation_complete/expected.txt:1>)
- [001 revue](</home/dom/ai/anonymisation/tests/synthetic_review/cases/001_crh_hospitalisation_complete/review.md:1>)
- [002 source](</home/dom/ai/anonymisation/tests/synthetic_review/cases/002_imagerie_complete/test.txt:1>)
- [002 attendu](</home/dom/ai/anonymisation/tests/synthetic_review/cases/002_imagerie_complete/expected.txt:1>)
- [002 revue](</home/dom/ai/anonymisation/tests/synthetic_review/cases/002_imagerie_complete/review.md:1>)
- [003 source](</home/dom/ai/anonymisation/tests/synthetic_review/cases/003_consultation_complete/test.txt:1>)
- [003 attendu](</home/dom/ai/anonymisation/tests/synthetic_review/cases/003_consultation_complete/expected.txt:1>)
- [003 revue](</home/dom/ai/anonymisation/tests/synthetic_review/cases/003_consultation_complete/review.md:1>)
- [004 source](</home/dom/ai/anonymisation/tests/synthetic_review/cases/004_structured_admin_complete/test.txt:1>)
- [004 attendu](</home/dom/ai/anonymisation/tests/synthetic_review/cases/004_structured_admin_complete/expected.txt:1>)
- [004 revue](</home/dom/ai/anonymisation/tests/synthetic_review/cases/004_structured_admin_complete/review.md:1>)

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
Tests de non-régression pour la config externalisée.
"""
from pathlib import Path
import anonymizer_core_refactored_onnx as core
from config_defaults import (
deep_merge_dict,
ensure_runtime_dictionaries_config,
load_effective_dictionaries_dict,
read_default_dictionaries_text,
read_runtime_dictionaries_overlay_text,
)
def test_default_config_template_is_externalized():
text = read_default_dictionaries_text()
assert "blacklist:" in text
assert "whitelist_phrases:" in text
cfg = core.load_dictionaries(None)
assert "CHCB" in cfg["blacklist"]["force_mask_terms"]
def test_runtime_overlay_template_is_minimal():
text = read_runtime_dictionaries_overlay_text()
assert "dictionnaires.default.yml" in text
assert "{}" in text
def test_deep_merge_dict_preserves_nested_defaults():
base = {
"whitelist": {
"sections_titres": ["DIM"],
"org_gpe_keep": False,
},
"flags": {
"case_insensitive": True,
"regex_engine": "python",
},
}
override = {
"whitelist": {
"sections_titres": ["GHM"],
"org_gpe_keep": True,
},
"flags": {
"regex_engine": "re2",
},
}
merged = deep_merge_dict(base, override)
assert merged["whitelist"]["sections_titres"] == ["DIM", "GHM"]
assert merged["whitelist"]["org_gpe_keep"] is True
assert merged["flags"]["case_insensitive"] is True
assert merged["flags"]["regex_engine"] == "re2"
def test_additional_stopwords_refresh_and_reset(tmp_path: Path):
cfg_path = tmp_path / "cfg.yml"
cfg_path.write_text("additional_stopwords:\n - xyzzymed\n", encoding="utf-8")
core.load_dictionaries(cfg_path)
assert "xyzzymed" in core._MEDICAL_STOP_WORDS_SET
assert "xyzzymed" in core._MEDICAL_STOP_WORDS
core.load_dictionaries(None)
assert "xyzzymed" not in core._MEDICAL_STOP_WORDS_SET
assert "xyzzymed" not in core._MEDICAL_STOP_WORDS
def test_runtime_overlay_is_created_and_effective_merge_works(tmp_path: Path):
cfg_path = tmp_path / "dictionnaires.yml"
created = ensure_runtime_dictionaries_config(cfg_path)
assert created == cfg_path
assert cfg_path.exists()
effective = load_effective_dictionaries_dict(cfg_path)
assert "CHCB" in effective["blacklist"]["force_mask_terms"]
cfg_path.write_text(
"blacklist:\n force_mask_terms:\n - LOCAL_SIGLE\n",
encoding="utf-8",
)
effective = load_effective_dictionaries_dict(cfg_path)
assert "CHCB" in effective["blacklist"]["force_mask_terms"]
assert "LOCAL_SIGLE" in effective["blacklist"]["force_mask_terms"]

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
Tests de non-régression pour les fuites en en-tête de document.
"""
from anonymizer_core_refactored_onnx import (
RE_NUM_ACCESSION_HEADER,
RE_NUM_EXAMEN_PATIENT,
anonymise_document_regex,
load_dictionaries,
selective_rescan,
)
class TestHeaderPiiDetection:
"""Cas réels vus en production: nom patient en capitales + numéro d'examen compact."""
def test_uppercase_patient_header_is_masked(self):
cfg = load_dictionaries(None)
anon = anonymise_document_regex(["ETCHEVERRY JEAN CLAUDE"], [[]], cfg)
assert "ETCHEVERRY" not in anon.text_out
assert "JEAN" not in anon.text_out
assert "CLAUDE" not in anon.text_out
assert anon.text_out == "[NOM] [NOM] [NOM]"
def test_compact_exam_number_matches_labeled_pattern(self):
match = RE_NUM_EXAMEN_PATIENT.search("N° examen : 23L35781")
assert match is not None
assert match.group(1) == "23L35781"
def test_bare_header_accession_number_is_added_to_audit(self):
cfg = load_dictionaries(None)
text = (
"N° 23L35781\n"
"Prélevé le 26/07/2023\n"
"Enregistré le 27/07/2023\n"
)
match = RE_NUM_ACCESSION_HEADER.search(text)
assert match is not None
assert match.group(1) == "23L35781"
anon = anonymise_document_regex([text], [[]], cfg)
assert any(h.kind == "DOSSIER" and h.original == "23L35781" for h in anon.audit)
def test_labeled_exam_number_is_masked_in_text_and_audit(self):
cfg = load_dictionaries(None)
anon = anonymise_document_regex(["N° examen : 23L35781"], [[]], cfg)
text = selective_rescan(anon.text_out, cfg)
assert text == "N° examen : [DOSSIER]"
assert any(h.kind == "DOSSIER" and h.original == "23L35781" for h in anon.audit)
def test_structured_code_postal_preserves_label_and_audit(self):
cfg = load_dictionaries(None)
anon = anonymise_document_regex(["Code postal : 64100"], [[]], cfg)
text = selective_rescan(anon.text_out, cfg)
assert text == "Code postal : [CODE_POSTAL]"
assert any(h.kind == "CODE_POSTAL" and h.original == "64100" for h in anon.audit)

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Tests synthétiques de non-régression pour l'anonymisation.
"""
import json
from pathlib import Path
import pytest
from anonymizer_core_refactored_onnx import (
anonymise_document_regex,
load_dictionaries,
selective_rescan,
)
from evaluation.leak_scanner import LeakScanner
SUITE_DIR = Path(__file__).resolve().parents[1] / "synthetic_regression"
CASES_DIR = SUITE_DIR / "cases"
MANIFEST_PATH = SUITE_DIR / "manifest.json"
LEAK_SCANNER = LeakScanner()
def _normalize_text(text: str) -> str:
text = text.replace("\r\n", "\n").replace("\r", "\n")
return "\n".join(line.rstrip() for line in text.strip().splitlines())
def _load_manifest() -> dict:
return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
def _case_dirs() -> list[Path]:
return sorted(path for path in CASES_DIR.iterdir() if path.is_dir())
def _normalize_audit(audit: list) -> list[dict]:
return [
{
"kind": hit.kind,
"original": hit.original,
"replacement": hit.placeholder,
}
for hit in audit
]
def _load_case_cfg(case_dir: Path):
overlay_path = case_dir / "config_overlay.yml"
return load_dictionaries(overlay_path if overlay_path.exists() else None)
def _assertions_for(case_name: str) -> dict:
manifest = _load_manifest()
return manifest[case_name]
def test_synthetic_regression_inventory():
assert MANIFEST_PATH.exists()
assert len(_case_dirs()) == 10
assert len(_load_manifest()) == 10
@pytest.mark.parametrize("case_dir", _case_dirs(), ids=lambda path: path.name)
def test_synthetic_regression_case(case_dir: Path):
cfg = _load_case_cfg(case_dir)
case_rules = _assertions_for(case_dir.name)
input_path = case_dir / "test.txt"
if not input_path.exists():
input_path = case_dir / "input.txt"
input_text = input_path.read_text(encoding="utf-8")
expected_text = _normalize_text((case_dir / "expected.txt").read_text(encoding="utf-8"))
expected_audit = json.loads((case_dir / "expected.audit.json").read_text(encoding="utf-8"))
result = anonymise_document_regex([input_text], [[]], cfg)
actual_text = _normalize_text(selective_rescan(result.text_out, cfg))
actual_audit = _normalize_audit(result.audit)
assert actual_text == expected_text
assert actual_audit == expected_audit
for required in case_rules.get("must_contain", []):
assert required in actual_text
for forbidden in case_rules.get("must_not_contain", []):
assert forbidden not in actual_text
leaks = LEAK_SCANNER.scan_text(
actual_text,
[
{
"kind": item["kind"],
"original": item["original"],
}
for item in actual_audit
],
)
assert not leaks