Externalize dictionaries and add anonymization review corpus
This commit is contained in:
12
tests/conftest.py
Normal file
12
tests/conftest.py
Normal 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))
|
||||
26
tests/synthetic_regression/README.md
Normal file
26
tests/synthetic_regression/README.md
Normal 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
|
||||
```
|
||||
@@ -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]"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[NOM] [NOM] [NOM]
|
||||
[DATE_NAISSANCE]
|
||||
Consultation du 14/03/2024
|
||||
@@ -0,0 +1,3 @@
|
||||
ETCHEVERRY JEAN CLAUDE
|
||||
Né le 12/03/1980
|
||||
Consultation du 14/03/2024
|
||||
@@ -0,0 +1,3 @@
|
||||
ETCHEVERRY JEAN CLAUDE
|
||||
Né le 12/03/1980
|
||||
Consultation du 14/03/2024
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"kind": "EMAIL",
|
||||
"original": "jean.dupont@example.com",
|
||||
"replacement": "[EMAIL]"
|
||||
},
|
||||
{
|
||||
"kind": "TEL",
|
||||
"original": "01 23 45 67 89",
|
||||
"replacement": "[TEL]"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
Contact : [EMAIL] ou [TEL]
|
||||
@@ -0,0 +1 @@
|
||||
Contact: jean.dupont@example.com ou 01 23 45 67 89
|
||||
@@ -0,0 +1 @@
|
||||
Contact: jean.dupont@example.com ou 01 23 45 67 89
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"kind": "NDA",
|
||||
"original": "1234567",
|
||||
"replacement": "[NDA]"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
N° venue :
|
||||
[NDA]
|
||||
Date de séjour : 14/03/2024
|
||||
@@ -0,0 +1,3 @@
|
||||
N° venue :
|
||||
1234567
|
||||
Date de séjour : 14/03/2024
|
||||
@@ -0,0 +1,3 @@
|
||||
N° venue :
|
||||
1234567
|
||||
Date de séjour : 14/03/2024
|
||||
@@ -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]"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
RPPS : [RPPS]
|
||||
FINESS : [FINESS]
|
||||
IPP : [IPP]
|
||||
N° OGC : [OGC]
|
||||
IBAN : [IBAN]
|
||||
@@ -0,0 +1,5 @@
|
||||
RPPS : 12345678901
|
||||
FINESS : 123456789
|
||||
IPP : ABC12345
|
||||
N° OGC : 12
|
||||
IBAN : FR76 3000 6000 0112 3456 7890 189
|
||||
@@ -0,0 +1,5 @@
|
||||
RPPS : 12345678901
|
||||
FINESS : 123456789
|
||||
IPP : ABC12345
|
||||
N° OGC : 12
|
||||
IBAN : FR76 3000 6000 0112 3456 7890 189
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"kind": "force_term",
|
||||
"original": "CHCB",
|
||||
"replacement": "[MASK]"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
Patient adressé au [MASK] pour avis. Retour au [MASK] demain.
|
||||
@@ -0,0 +1 @@
|
||||
Patient adressé au CHCB pour avis. Retour au CHCB demain.
|
||||
@@ -0,0 +1 @@
|
||||
Patient adressé au CHCB pour avis. Retour au CHCB demain.
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
La classification internationale reste visible. La prise en charge est correcte.
|
||||
@@ -0,0 +1 @@
|
||||
La classification internationale reste visible. La prise en charge est correcte.
|
||||
@@ -0,0 +1 @@
|
||||
La classification internationale reste visible. La prise en charge est correcte.
|
||||
@@ -0,0 +1,3 @@
|
||||
blacklist:
|
||||
force_mask_terms:
|
||||
- LOCAL_SIGLE
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"kind": "force_term",
|
||||
"original": "LOCAL_SIGLE",
|
||||
"replacement": "[MASK]"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
Réorientation vers [MASK] en urgence.
|
||||
@@ -0,0 +1 @@
|
||||
Réorientation vers LOCAL_SIGLE en urgence.
|
||||
@@ -0,0 +1 @@
|
||||
Réorientation vers LOCAL_SIGLE en urgence.
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"kind": "VILLE",
|
||||
"original": "Bayonne",
|
||||
"replacement": "[VILLE]"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
[VILLE], le 12/03/2024
|
||||
Compte rendu adressé au patient.
|
||||
@@ -0,0 +1,2 @@
|
||||
Bayonne, le 12/03/2024
|
||||
Compte rendu adressé au patient.
|
||||
@@ -0,0 +1,2 @@
|
||||
Bayonne, le 12/03/2024
|
||||
Compte rendu adressé au patient.
|
||||
@@ -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]"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
[NOM] [NOM] [NOM]
|
||||
Le patient [NOM] revient ce jour.
|
||||
@@ -0,0 +1,2 @@
|
||||
ETCHEVERRY JEAN CLAUDE
|
||||
Le patient ETCHEVERRY revient ce jour.
|
||||
@@ -0,0 +1,2 @@
|
||||
ETCHEVERRY JEAN CLAUDE
|
||||
Le patient ETCHEVERRY revient ce jour.
|
||||
@@ -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]"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
[ETABLISSEMENT]
|
||||
Service de cardiologie
|
||||
@@ -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
|
||||
@@ -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
|
||||
110
tests/synthetic_regression/manifest.json
Normal file
110
tests/synthetic_regression/manifest.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
25
tests/synthetic_regression/tests.md
Normal file
25
tests/synthetic_regression/tests.md
Normal 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>)
|
||||
26
tests/synthetic_review/README.md
Normal file
26
tests/synthetic_review/README.md
Normal 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).
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 :`.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
13
tests/synthetic_review/cases/002_imagerie_complete/test.txt
Normal file
13
tests/synthetic_review/cases/002_imagerie_complete/test.txt
Normal 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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
15
tests/synthetic_review/tests.md
Normal file
15
tests/synthetic_review/tests.md
Normal 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>)
|
||||
92
tests/unit/test_config_externalization.py
Normal file
92
tests/unit/test_config_externalization.py
Normal 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"]
|
||||
63
tests/unit/test_header_pii_detection.py
Normal file
63
tests/unit/test_header_pii_detection.py
Normal 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)
|
||||
100
tests/unit/test_synthetic_regression.py
Normal file
100
tests/unit/test_synthetic_regression.py
Normal 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
|
||||
Reference in New Issue
Block a user