Compare commits
2 Commits
0af71caffe
...
8790c64cca
| Author | SHA1 | Date | |
|---|---|---|---|
| 8790c64cca | |||
| 87f5e48d66 |
120
anonymisation_cli_onefile.spec
Normal file
120
anonymisation_cli_onefile.spec
Normal file
@@ -0,0 +1,120 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Spec CLI frozen — EXE de PRODUCTION (anonymisation fichier unique sans GUI).
|
||||
# Même moteur / mêmes datas que anonymisation_onefile.spec, mais :
|
||||
# - entrypoint = scripts/anonymize_cli.py (CLI production, pas launcher.py)
|
||||
# Contrat : Anonymisation-CLI.exe <fichier> <dossier_sortie>
|
||||
# Modèle CamemBERT-bio ONNX OBLIGATOIRE (fail-closed, code 3 si absent).
|
||||
# - console=True (CLI), pas de Splash
|
||||
# - name = Anonymisation-CLI -> ne remplace pas dist/Anonymisation.exe
|
||||
# (Le harnais perf D-19 reste scripts/anonymize_batch_cli.py, non buildé ici.)
|
||||
|
||||
block_cipher = None
|
||||
|
||||
project_dir = Path(globals().get("SPECPATH", os.getcwd())).resolve()
|
||||
|
||||
|
||||
def _data_entry(relative_path: str, target_dir: str | None = None):
|
||||
src = project_dir / relative_path
|
||||
if not src.exists():
|
||||
return None
|
||||
return (str(src), target_dir or relative_path)
|
||||
|
||||
|
||||
datas = []
|
||||
for relative_path, target_dir in [
|
||||
("config", "config"),
|
||||
("data/bdpm", "data/bdpm"),
|
||||
("data/finess", "data/finess"),
|
||||
("data/insee", "data/insee"),
|
||||
("models/camembert-bio-deid/onnx", "models/camembert-bio-deid/onnx"),
|
||||
("detectors", "detectors"),
|
||||
("scripts", "scripts"),
|
||||
("assets", "assets"),
|
||||
]:
|
||||
entry = _data_entry(relative_path, target_dir)
|
||||
if entry is not None:
|
||||
datas.append(entry)
|
||||
|
||||
for relative_path in [
|
||||
"data/stopwords_manuels.txt",
|
||||
"data/villes_blacklist.txt",
|
||||
"data/dpi_labels_blacklist.txt",
|
||||
"data/companion_blacklist.txt",
|
||||
]:
|
||||
entry = _data_entry(relative_path, "data")
|
||||
if entry is not None:
|
||||
datas.append(entry)
|
||||
|
||||
|
||||
hiddenimports = [
|
||||
"anonymizer_core_refactored_onnx",
|
||||
"admin_rules",
|
||||
"config_defaults",
|
||||
"profile_defaults",
|
||||
"gui_batch_paths",
|
||||
"manual_masking",
|
||||
"pdf_mask_designer",
|
||||
"format_converter",
|
||||
"ner_manager_onnx",
|
||||
"camembert_ner_manager",
|
||||
"eds_pseudo_manager",
|
||||
"gliner_manager",
|
||||
"vlm_manager",
|
||||
"build_info",
|
||||
"doctr",
|
||||
"doctr.io",
|
||||
"doctr.models",
|
||||
"doctr.models.detection",
|
||||
"doctr.models.recognition",
|
||||
"cv2",
|
||||
"torchvision",
|
||||
"edsnlp",
|
||||
"edsnlp.pipes",
|
||||
"edsnlp.pipes.ner",
|
||||
"edsnlp.pipes.ner.pseudo",
|
||||
"spacy",
|
||||
"spacy.lang.fr",
|
||||
"gliner",
|
||||
"onnxruntime",
|
||||
"transformers",
|
||||
"tokenizers",
|
||||
"torch",
|
||||
"pdfplumber",
|
||||
"fitz",
|
||||
"PIL",
|
||||
"yaml",
|
||||
"loguru",
|
||||
"regex",
|
||||
"optimum",
|
||||
"optimum.onnxruntime",
|
||||
"optimum.pipelines",
|
||||
"optimum.modeling_base",
|
||||
"optimum.exporters.onnx",
|
||||
]
|
||||
|
||||
|
||||
a = Analysis(
|
||||
[str(project_dir / "scripts" / "anonymize_cli.py")],
|
||||
pathex=[str(project_dir)],
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name="Anonymisation-CLI",
|
||||
debug=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
console=True,
|
||||
)
|
||||
@@ -890,11 +890,27 @@ RE_DATE = re.compile(
|
||||
r"\b(\d{1,2})\s+" + _MOIS_FR + r"\s+(\d{4})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Adresse contextuelle (v11.5 P0) — ancre forte « numéro + type de voie », puis
|
||||
# nom de voie décrit par une GRAMMAIRE DE TOKENS généralisée (pas un cas précis) :
|
||||
# - mot/chiffre : lettres accentuées, chiffres (voies commémoratives « 8 Mai 1945 »,
|
||||
# « 11 Novembre »), apostrophe droite ' et typographique ’, traits d'union ;
|
||||
# - initiale : une seule lettre suivie d'un point (« J. », « A. ») — couvre les voies
|
||||
# nommées d'après une personne (« avenue de l'interne J. Loeb »).
|
||||
# Bornage à la LIGNE COURANTE : séparateurs `[ \t]` (jamais `\n`). Un point qui suit un
|
||||
# mot de plusieurs lettres N'est PAS un token initiale -> on s'arrête (évite d'avaler la
|
||||
# phrase clinique suivante : « rue des Lilas. Le patient… » s'arrête après « Lilas »).
|
||||
_RE_VOIE_TYPE = (
|
||||
r"(?:rue|avenue|av\.?|boulevard|bd\.?|place|chemin|all[ée]e|impasse|route|cours"
|
||||
r"|passage|square|r[ée]sidence|lotissement|lot\.?|cit[ée]|hameau|quartier|voie"
|
||||
r"|parvis|esplanade|promenade|côte)"
|
||||
)
|
||||
_RE_VOIE_TOKEN = (
|
||||
r"(?:[A-Za-zÀ-ÿ]\.|[A-Za-zÀ-ÿ0-9'’]+(?:-[A-Za-zÀ-ÿ0-9'’]+)*)"
|
||||
)
|
||||
RE_ADRESSE = re.compile(
|
||||
r"\b\d{1,4}[\s,]*(?:bis|ter)?\s*,?\s*"
|
||||
r"(?:rue|avenue|av\.?|boulevard|bd\.?|place|chemin|all[ée]e|impasse|route|cours|passage|square|r[ée]sidence"
|
||||
r"|lotissement|lot\.?|cit[ée]|hameau|quartier|voie|parvis|esplanade|promenade|côte)"
|
||||
r"\s+[A-ZÉÈÀÙÂÊÎÔÛÑa-zéèàùâêîôûñäëïöüçñ\s\-']{2,}",
|
||||
r"\b\d{1,4}[ \t]*,?[ \t]*(?:bis|ter)?[ \t]*,?[ \t]*"
|
||||
+ _RE_VOIE_TYPE +
|
||||
r"[ \t]+" + _RE_VOIE_TOKEN + r"(?:[ \t]+" + _RE_VOIE_TOKEN + r"){0,9}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
RE_CODE_POSTAL = re.compile(
|
||||
@@ -3685,7 +3701,9 @@ def _mask_ville_gazetteers(text: str) -> tuple:
|
||||
if _VILLE_AC is None:
|
||||
_build_ville_ac()
|
||||
if _VILLE_AC is None:
|
||||
return text
|
||||
# Contrat : toujours retourner un tuple (texte, liste), même si
|
||||
# Aho-Corasick est indisponible (sinon les appelants/tests cassent).
|
||||
return text, []
|
||||
|
||||
normalized = _normalize_positional(text)
|
||||
placeholder = PLACEHOLDERS["VILLE"]
|
||||
@@ -3775,7 +3793,16 @@ def _mask_ville_gazetteers(text: str) -> tuple:
|
||||
# sauf pour les villes composées avec trait d'union (Saint-Palais,
|
||||
# Mont-de-Marsan) qui sont très peu ambiguës.
|
||||
is_compound_hyphen = ("-" in original_span and word_count >= 2)
|
||||
if not is_compound_hyphen:
|
||||
# Communes composées préfixées Saint/St/Sainte/Ste écrites avec des
|
||||
# espaces (ex. « St Martin de Hinx ») : aussi peu ambiguës que les
|
||||
# formes à tiret -> masquage sans exiger de contexte géographique, et
|
||||
# masquage de la commune ENTIÈRE (pas de relâchement partiel).
|
||||
_norm_span = _normalize_positional(original_span)
|
||||
is_saint_compound = (
|
||||
word_count >= 2
|
||||
and re.match(r"(?:st|ste|saint|sainte)[\s\-]", _norm_span) is not None
|
||||
)
|
||||
if not (is_compound_hyphen or is_saint_compound):
|
||||
before_ctx = text[max(0, start_idx - 40):start_idx]
|
||||
if not _RE_GEO_BEFORE.search(before_ctx):
|
||||
continue
|
||||
@@ -4219,7 +4246,7 @@ def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, oc
|
||||
by_page.setdefault(h.page, []).append(h)
|
||||
# Kinds à ne pas chercher dans le PDF (dates masquées uniquement dans le texte,
|
||||
# pas dans le PDF où elles rendent les tableaux illisibles)
|
||||
_VECTOR_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL"}
|
||||
_VECTOR_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL", "DATE_NAISSANCE_GLOBAL"}
|
||||
# Kinds sensibles au substring matching : utiliser _search_whole_word
|
||||
_VECTOR_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM",
|
||||
"EDS_HOPITAL", "EDS_VILLE", "ETAB", "ETAB_GLOBAL",
|
||||
@@ -4378,7 +4405,7 @@ def redact_pdf_raster(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, dp
|
||||
raise RuntimeError("PyMuPDF non disponible – installez pymupdf.")
|
||||
doc = fitz.open(str(original_pdf))
|
||||
all_rects: Dict[int, List["fitz.Rect"]] = {}
|
||||
_RASTER_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL"}
|
||||
_RASTER_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL", "DATE_NAISSANCE_GLOBAL"}
|
||||
# Kinds sensibles au substring matching : utiliser _search_whole_word
|
||||
_RASTER_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM",
|
||||
"EDS_HOPITAL", "EDS_VILLE", "ETAB", "ETAB_GLOBAL",
|
||||
@@ -4892,7 +4919,11 @@ def process_pdf(
|
||||
# 4b) Propagation globale SÉLECTIVE : uniquement pour les PII critiques
|
||||
# Les PII critiques (DATE_NAISSANCE, NIR, IPP, EMAIL) sont propagés sur toutes les pages
|
||||
# pour éviter les fuites sur les documents multi-pages (ex: CRO)
|
||||
_CRITICAL_PII_TYPES = {"DATE_NAISSANCE", "NIR", "IPP", "EMAIL", "force_term", "force_regex", "FINESS", "DOSSIER", "NDA", "EPISODE"}
|
||||
# (v11.5 P0) DATE_NAISSANCE retiré de la propagation globale : on ne masque
|
||||
# plus une date nue sur tout le document (ni texte, ni audit, ni PDF/raster).
|
||||
# La DDN reste masquée en contexte fort, page par page (RE_DATE_NAISSANCE +
|
||||
# multiligne). Cela évite de masquer une date clinique égale à la DDN.
|
||||
_CRITICAL_PII_TYPES = {"NIR", "IPP", "EMAIL", "force_term", "force_regex", "FINESS", "DOSSIER", "NDA", "EPISODE"}
|
||||
|
||||
_global_pii: Dict[str, set] = {}
|
||||
for h in anon.audit:
|
||||
@@ -4965,17 +4996,16 @@ def process_pdf(
|
||||
# [\s/.\-]+ accepte : espace, slash, point, tiret (un ou plusieurs)
|
||||
date_pattern = rf'{day}[\s/.\-]+{month}[\s/.\-]+{year}'
|
||||
|
||||
# Multi-pass replacement pour couvrir tous les cas
|
||||
# Pass 1 : Avec contexte "Né(e) le" (case-insensitive)
|
||||
# Propagation globale UNIQUEMENT en contexte fort de naissance.
|
||||
# (v11.5 P0) On NE propage plus la date nue sur tout le PDF :
|
||||
# une date cliniquement identique à la DDN mais hors contexte
|
||||
# (tableau de surveillance, prélèvement, acte) doit être
|
||||
# préservée. Les contextes forts complémentaires (DDN, date de
|
||||
# naissance) sont déjà couverts ligne par ligne (RE_DATE_NAISSANCE)
|
||||
# et en multiligne ; ici on ne couvre que la propagation
|
||||
# inter-pages du motif « Né(e) le <date> ».
|
||||
final_text = re.sub(
|
||||
rf'Né(?:e)?\s+le\s+{date_pattern}',
|
||||
h.placeholder,
|
||||
final_text,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
# Pass 2 : Sans contexte (date seule)
|
||||
final_text = re.sub(
|
||||
rf'\b{date_pattern}\b',
|
||||
rf'N[ée](?:e)?\s+le\s+{date_pattern}',
|
||||
h.placeholder,
|
||||
final_text,
|
||||
flags=re.IGNORECASE
|
||||
|
||||
@@ -71,6 +71,81 @@ powershell -ExecutionPolicy Bypass -File .\scripts\build_windows_oneclick.ps1 -S
|
||||
- le build doit être lancé depuis Windows
|
||||
- le modèle ONNX embarqué requis doit exister localement dans :
|
||||
`models\camembert-bio-deid\onnx\model.onnx`
|
||||
- limitation MVP frozen : voir `docs/limitations-frozen-mvp.md` pour les moteurs
|
||||
effectivement embarqués, notamment l'absence d'EDS-Pseudo dans le paquet MVP.
|
||||
|
||||
## CLI Windows (sans GUI) — fichier unique
|
||||
|
||||
En plus de la GUI, un exécutable **CLI de production** permet d'anonymiser un
|
||||
fichier (ou un dossier) en ligne de commande, sans interface graphique.
|
||||
|
||||
- entrypoint : `scripts/anonymize_cli.py`
|
||||
- spec PyInstaller : `anonymisation_cli_onefile.spec`
|
||||
- exécutable produit : `dist\Anonymisation-CLI.exe` (ne remplace pas
|
||||
`Anonymisation.exe`)
|
||||
|
||||
### Build CLI
|
||||
|
||||
Sur la machine Windows de build, dans le venv de build :
|
||||
|
||||
```powershell
|
||||
pyinstaller --noconfirm --clean anonymisation_cli_onefile.spec
|
||||
```
|
||||
|
||||
Le `.spec` embarque les mêmes ressources que la GUI : `config\`, `data\`,
|
||||
`models\camembert-bio-deid\onnx\`, `detectors\`, `assets\`, plus les
|
||||
hiddenimports NER / docTR / ONNX. Le modèle ONNX obligatoire
|
||||
`models\camembert-bio-deid\onnx\model.onnx` doit exister localement avant le
|
||||
build (sinon il ne sera pas embarqué et le CLI échouera au lancement).
|
||||
|
||||
### Utilisation
|
||||
|
||||
```powershell
|
||||
Anonymisation-CLI.exe "C:\chemin\document.pdf" "C:\chemin\sortie"
|
||||
Anonymisation-CLI.exe --help
|
||||
```
|
||||
|
||||
- argument 1 : fichier unique existant (ou dossier parcouru récursivement) ;
|
||||
- argument 2 : dossier de sortie (créé si absent) ; `--out` reste accepté ;
|
||||
- chemins avec espaces et accents supportés ;
|
||||
- options : `--no-ner` (regex seul), `--gliner` (vote croisé optionnel),
|
||||
`--limit N`, `--config <dictionnaires.yml>`.
|
||||
|
||||
Sorties produites dans le dossier demandé (identiques à la GUI v5, burn raster) :
|
||||
`<doc>.redacted_raster.pdf`, `<doc>.pseudonymise.txt`, `<doc>.audit.jsonl`.
|
||||
Un log lisible est écrit à côté de l'EXE : `anonymisation_cli.log`.
|
||||
|
||||
### Codes retour
|
||||
|
||||
| Code | Signification |
|
||||
|------|---------------|
|
||||
| `0` | anonymisation terminée, sortie produite |
|
||||
| `1` | erreur de traitement (exception) |
|
||||
| `2` | entrée manquante (fichier/dossier introuvable, aucun document) |
|
||||
| `3` | modèle OBLIGATOIRE absent / illisible (fail-closed, pas de mode dégradé) |
|
||||
| `4` | sortie non produite (quarantaine résiduelle ou PDF absent) |
|
||||
|
||||
### Modèles (dernière version du moteur)
|
||||
|
||||
- **OBLIGATOIRE** : CamemBERT-bio ONNX (`models\camembert-bio-deid\onnx\model.onnx`).
|
||||
Embarqué dans le build. S'il est absent/illisible et que le NER est demandé,
|
||||
le CLI **échoue clairement (code 3)** — il n'affiche jamais « OK » en mode
|
||||
dégradé silencieux.
|
||||
- **OPTIONNELS** : EDS-Pseudo, GLiNER. Chargés best effort et **tracés dans le
|
||||
log** ; leur absence est signalée explicitement, jamais masquée. ⚠️ EDS-Pseudo
|
||||
peut ne pas être embarqué dans le paquet MVP frozen — voir
|
||||
`docs/limitations-frozen-mvp.md`. Dans ce cas le log indique
|
||||
« EDS-Pseudo (optionnel) INDISPONIBLE » et le traitement se poursuit avec
|
||||
CamemBERT-bio ONNX (impact qualité faible, validé en bêta interne).
|
||||
- `--no-ner` : mode regex seul **assumé** par l'opérateur (aucun modèle
|
||||
obligatoire), à n'utiliser qu'en connaissance de cause.
|
||||
|
||||
### Limitations CLI frozen
|
||||
|
||||
- pas d'EDS-Pseudo garanti dans le MVP frozen (cf. ci-dessus) ;
|
||||
- pas de dépendance internet : tous les modèles déclarés obligatoires sont
|
||||
locaux/embarqués ;
|
||||
- rastérisation séquentielle en mode frozen (cf. limitations GUI).
|
||||
|
||||
## Blocage Windows / SmartScreen
|
||||
|
||||
|
||||
252
scripts/anonymize_cli.py
Normal file
252
scripts/anonymize_cli.py
Normal file
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""CLI de production — anonymise UN fichier (ou un dossier) sans GUI.
|
||||
|
||||
Contrat principal (demande Dom 2026-06-10) :
|
||||
|
||||
Anonymisation-CLI.exe <fichier> <dossier_sortie>
|
||||
|
||||
- argument 1 : fichier unique existant (ou dossier parcouru récursivement) ;
|
||||
- argument 2 : dossier de sortie (créé si absent) ; `--out` reste accepté en
|
||||
compatibilité ;
|
||||
- chemins avec espaces et accents supportés ;
|
||||
- pas de GUI ;
|
||||
- log lisible à côté de l'EXE (frozen) ou dans le cwd (venv).
|
||||
|
||||
Codes retour :
|
||||
- 0 : anonymisation terminée, sortie produite ;
|
||||
- 2 : entrée manquante (fichier/dossier introuvable ou aucun document) ;
|
||||
- 3 : modèle OBLIGATOIRE indisponible (échec fail-closed, pas de mode dégradé
|
||||
silencieux) ;
|
||||
- 4 : sortie non produite (mise en quarantaine résiduelle ou fichier absent) ;
|
||||
- 1 : erreur de traitement (exception).
|
||||
|
||||
Modèles (alignés sur la dernière version du moteur, comme la GUI v5) :
|
||||
- OBLIGATOIRE : CamemBERT-bio ONNX (`models/camembert-bio-deid/onnx/model.onnx`).
|
||||
Si absent/illisible et NER demandé -> le CLI ÉCHOUE (code 3), il n'affiche
|
||||
jamais « OK » en mode dégradé.
|
||||
- OPTIONNELS : EDS-Pseudo, GLiNER. Chargés best effort, tracés dans le log ;
|
||||
leur absence est explicitement signalée, jamais masquée.
|
||||
- `--no-ner` : mode regex seul ASSUMÉ par l'opérateur -> aucun modèle obligatoire.
|
||||
|
||||
Compatible frozen (PyInstaller) ET venv : en frozen, se positionne dans
|
||||
`sys._MEIPASS` (comme launcher.py) pour que le core retrouve config/ data/
|
||||
models/ ; écrit le log à côté de l'EXE ; résout les chemins entrée/sortie
|
||||
contre le cwd de lancement.
|
||||
|
||||
Sortie de production (identique à la GUI v5) : burn raster
|
||||
(`make_vector_redaction=False`, `also_make_raster_burn=True`), plus le texte
|
||||
pseudonymisé (`.pseudonymise.txt`) et l'audit (`.audit.jsonl`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Console Windows (cp1252) : forcer UTF-8 pour éviter UnicodeEncodeError sur les
|
||||
# accents (le FileHandler est déjà en utf-8).
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# cwd de lancement, capturé AVANT tout chdir (sert à résoudre entrée/sortie).
|
||||
_LAUNCH_CWD = Path.cwd()
|
||||
|
||||
# Résolution des chemins en frozen, alignée sur launcher.py : les datas
|
||||
# (config/, data/, models/) sont extraites dans _MEIPASS ; on s'y positionne
|
||||
# pour que le core les trouve. Le log va à côté de l'EXE.
|
||||
if getattr(sys, "frozen", False):
|
||||
_APP_DIR = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
_EXE_DIR = Path(sys.executable).parent
|
||||
sys.path.insert(0, str(_APP_DIR))
|
||||
os.chdir(str(_APP_DIR))
|
||||
_LOG_PATH = _EXE_DIR / "anonymisation_cli.log"
|
||||
else:
|
||||
_APP_DIR = Path(__file__).resolve().parent.parent
|
||||
if str(_APP_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_APP_DIR))
|
||||
_LOG_PATH = Path("anonymisation_cli.log")
|
||||
|
||||
# Extensions acceptées (le core convertit les formats non-PDF en amont via
|
||||
# format_converter ; ici on collecte large et on laisse le moteur trancher).
|
||||
_SUPPORTED_EXT = {
|
||||
".pdf", ".docx", ".odt", ".rtf", ".txt", ".html", ".htm",
|
||||
".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp",
|
||||
}
|
||||
|
||||
|
||||
def _resolve(p: str) -> Path:
|
||||
"""Résout un chemin relatif contre le cwd de lancement (pas _MEIPASS)."""
|
||||
path = Path(p)
|
||||
return path if path.is_absolute() else (_LAUNCH_CWD / path)
|
||||
|
||||
|
||||
def _mandatory_model_path() -> Path:
|
||||
"""Chemin attendu du modèle ONNX obligatoire (relatif à l'app/_MEIPASS)."""
|
||||
return _APP_DIR / "models" / "camembert-bio-deid" / "onnx" / "model.onnx"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="Anonymisation-CLI",
|
||||
description="Anonymise un fichier (ou dossier) sans GUI.",
|
||||
)
|
||||
ap.add_argument("input", help="Fichier unique existant (ou dossier parcouru récursivement)")
|
||||
ap.add_argument(
|
||||
"output", nargs="?", default=None,
|
||||
help="Dossier de sortie (forme positionnelle). Créé si absent.",
|
||||
)
|
||||
ap.add_argument("--out", default=None, help="Dossier de sortie (compatibilité). Équivaut à l'argument positionnel.")
|
||||
ap.add_argument("--limit", type=int, default=0, help="Nombre max de documents (0 = tous ; utile pour un dossier)")
|
||||
ap.add_argument("--no-ner", action="store_true", help="Mode regex seul : désactive EDS-Pseudo + CamemBERT (aucun modèle obligatoire)")
|
||||
ap.add_argument("--gliner", action="store_true", help="Active aussi GLiNER (optionnel, vote croisé)")
|
||||
ap.add_argument("--config", default=None, help="Chemin config dictionnaires.yml (défaut: runtime)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(str(_LOG_PATH), encoding="utf-8"),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger("anon_cli")
|
||||
log.info("CLI: démarrage frozen=%s python=%s log=%s",
|
||||
bool(getattr(sys, "frozen", False)), sys.version.split()[0], _LOG_PATH)
|
||||
|
||||
# Dossier de sortie : positionnel prioritaire, puis --out, sinon défaut explicite.
|
||||
out_arg = args.output or args.out or "anonymise_out"
|
||||
out_root = _resolve(out_arg)
|
||||
|
||||
# --- Entrée : fichier unique (contrat principal) ou dossier ---
|
||||
inp = _resolve(args.input)
|
||||
if not inp.exists():
|
||||
log.error("CLI: entrée introuvable: %s", inp)
|
||||
return 2
|
||||
if inp.is_file():
|
||||
docs = [inp]
|
||||
else:
|
||||
docs = sorted(p for p in inp.rglob("*") if p.is_file() and p.suffix.lower() in _SUPPORTED_EXT)
|
||||
if args.limit > 0:
|
||||
docs = docs[: args.limit]
|
||||
if not docs:
|
||||
log.error("CLI: aucun document supporté trouvé sous %s", inp)
|
||||
return 2
|
||||
|
||||
import anonymizer_core_refactored_onnx as core
|
||||
|
||||
# H1 : aligne les threads torch (idempotent).
|
||||
if hasattr(core, "_configure_torch_threads"):
|
||||
core._configure_torch_threads()
|
||||
|
||||
# --- Modèles ---
|
||||
# OBLIGATOIRE (sauf --no-ner) : CamemBERT-bio ONNX. Fail-closed.
|
||||
eds_mgr = camembert_mgr = gliner_mgr = None
|
||||
if not args.no_ner:
|
||||
model_path = _mandatory_model_path()
|
||||
if not model_path.exists():
|
||||
log.error("CLI: MODÈLE OBLIGATOIRE absent: %s. "
|
||||
"Build incomplet ou ressource manquante. Abandon (pas de mode dégradé). "
|
||||
"Utilisez --no-ner pour forcer le mode regex seul.", model_path)
|
||||
return 3
|
||||
try:
|
||||
from camembert_ner_manager import CamembertNerManager
|
||||
camembert_mgr = CamembertNerManager()
|
||||
camembert_mgr.load()
|
||||
log.info("CLI: CamemBERT-bio ONNX chargé (obligatoire) ✓")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("CLI: échec chargement du modèle OBLIGATOIRE CamemBERT-bio ONNX: %s. "
|
||||
"Abandon (pas de mode dégradé silencieux). "
|
||||
"Utilisez --no-ner pour forcer le mode regex seul.", e)
|
||||
return 3
|
||||
|
||||
# OPTIONNEL : EDS-Pseudo (best effort, tracé).
|
||||
try:
|
||||
from eds_pseudo_manager import EdsPseudoManager
|
||||
eds_mgr = EdsPseudoManager()
|
||||
eds_mgr.load()
|
||||
log.info("CLI: EDS-Pseudo chargé (optionnel) ✓")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("CLI: EDS-Pseudo (optionnel) INDISPONIBLE: %s — traitement poursuivi sans.", e)
|
||||
|
||||
# OPTIONNEL : GLiNER (sur demande).
|
||||
if args.gliner:
|
||||
try:
|
||||
from gliner_manager import GlinerManager
|
||||
gliner_mgr = GlinerManager()
|
||||
gliner_mgr.load()
|
||||
log.info("CLI: GLiNER chargé (optionnel) ✓")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("CLI: GLiNER (optionnel) INDISPONIBLE: %s — traitement poursuivi sans.", e)
|
||||
else:
|
||||
log.warning("CLI: --no-ner -> MODE REGEX SEUL assumé (aucun modèle NER). "
|
||||
"Qualité réduite : à n'utiliser qu'en connaissance de cause.")
|
||||
|
||||
use_ner = bool(eds_mgr or gliner_mgr or camembert_mgr)
|
||||
log.info("CLI: %d document(s), ner=%s (camembert=%s eds=%s gliner=%s) -> sortie=%s",
|
||||
len(docs), use_ner, bool(camembert_mgr), bool(eds_mgr), bool(gliner_mgr), out_root)
|
||||
|
||||
config_path = _resolve(args.config) if args.config else None
|
||||
process_fn = getattr(core, "process_document", None) or core.process_pdf
|
||||
path_key = "doc_path" if process_fn.__name__ == "process_document" else "pdf_path"
|
||||
out_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
ok = ko = quarantined = no_output = 0
|
||||
for i, doc in enumerate(docs, 1):
|
||||
td = time.perf_counter()
|
||||
try:
|
||||
res = process_fn(
|
||||
**{path_key: doc},
|
||||
out_dir=out_root,
|
||||
make_vector_redaction=False,
|
||||
also_make_raster_burn=True,
|
||||
config_path=config_path,
|
||||
use_hf=use_ner,
|
||||
ner_manager=eds_mgr,
|
||||
gliner_manager=gliner_mgr,
|
||||
camembert_manager=camembert_mgr,
|
||||
ogc_label=None,
|
||||
)
|
||||
dt = time.perf_counter() - td
|
||||
if isinstance(res, dict) and res.get("status") == "quarantined":
|
||||
quarantined += 1
|
||||
log.warning("CLI: [%d/%d] %s QUARANTAINE reason=%s %.2fs — sortie NON produite.",
|
||||
i, len(docs), doc.name, res.get("reason", "?"), dt)
|
||||
continue
|
||||
# Vérifie qu'une sortie anonymisée a bien été produite.
|
||||
produced = []
|
||||
if isinstance(res, dict):
|
||||
produced = [v for k, v in res.items() if k.startswith("pdf") and v and Path(v).exists()]
|
||||
if not produced:
|
||||
no_output += 1
|
||||
log.error("CLI: [%d/%d] %s AUCUNE sortie PDF produite (%.2fs).", i, len(docs), doc.name, dt)
|
||||
continue
|
||||
ok += 1
|
||||
log.info("CLI: [%d/%d] %s OK %.2fs -> %s", i, len(docs), doc.name, dt,
|
||||
", ".join(Path(p).name for p in produced))
|
||||
except Exception as e: # noqa: BLE001
|
||||
ko += 1
|
||||
log.error("CLI: [%d/%d] %s ERREUR: %s", i, len(docs), doc.name, e)
|
||||
|
||||
total = time.perf_counter() - t0
|
||||
log.info("CLI: DONE total=%.1fs ok=%d quarantined=%d no_output=%d ko=%d",
|
||||
total, ok, quarantined, no_output, ko)
|
||||
|
||||
# Codes retour : priorité aux échecs durs.
|
||||
if ko:
|
||||
return 1
|
||||
if quarantined or no_output:
|
||||
return 4
|
||||
return 0 if ok else 4
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
334
tests/unit/test_p0_layout_detectors.py
Normal file
334
tests/unit/test_p0_layout_detectors.py
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests P0 — 3 détecteurs simples du chantier v11.5 (GO Dom 2026-06-09).
|
||||
|
||||
Périmètre strict (sans Docling/ML/dépendance) :
|
||||
1. Adresse contextuelle : numéro + type de voie + suite, le contexte de voie
|
||||
(avenue/rue/...) prime sur les stopwords médicaux ; capture des noms de
|
||||
voie contenant des initiales (ex. "avenue de l'interne J. Loeb").
|
||||
2. Communes composées : alias Saint/St/Sainte/Ste avec espaces/tirets/connecteurs,
|
||||
masquage de la commune entière (pas de relâchement partiel).
|
||||
3. Contexte date : la date de naissance n'est masquée qu'en contexte fort
|
||||
(Né le / DDN / date de naissance) ; plus de propagation globale d'une date
|
||||
nue ; les dates cliniques (tableaux, surveillance) sont préservées.
|
||||
|
||||
Toutes les valeurs ci-dessous sont FICTIVES (aucune PII réelle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import anonymizer_core_refactored_onnx as core
|
||||
from anonymizer_core_refactored_onnx import (
|
||||
PLACEHOLDERS,
|
||||
_AHO_AVAILABLE,
|
||||
_mask_line_by_regex,
|
||||
_mask_ville_gazetteers,
|
||||
load_dictionaries,
|
||||
)
|
||||
|
||||
CFG = load_dictionaries(None)
|
||||
|
||||
# Le masquage des communes repose sur Aho-Corasick (pyahocorasick). Si la
|
||||
# dépendance est absente de l'environnement, ces tests sont SKIP (pas FAIL) —
|
||||
# résultat reproductible. La dépendance est requise en production (cf. mémoire).
|
||||
requires_aho = pytest.mark.skipif(not _AHO_AVAILABLE, reason="pyahocorasick indisponible")
|
||||
|
||||
|
||||
def _mask_line(line: str):
|
||||
audit = []
|
||||
out = _mask_line_by_regex(line, audit, 0, CFG)
|
||||
return out, audit
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Adresse contextuelle
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAdresseContextuelle:
|
||||
|
||||
def test_adresse_avec_initiale_et_nom_voie_complet(self):
|
||||
"""Cas bloquant Dom : la voie nommée d'après une personne (initiale + nom)
|
||||
doit être masquée ENTIÈREMENT — aucun fragment de nom ne doit fuiter."""
|
||||
out, audit = _mask_line("Domicile : 13, avenue de l'interne J. Loeb")
|
||||
assert PLACEHOLDERS["ADRESSE"] in out
|
||||
assert "Loeb" not in out, f"Fuite du nom de voie : {out!r}"
|
||||
assert "interne" not in out # le mot médical fait partie de l'adresse masquée
|
||||
|
||||
def test_adresse_apostrophe_typographique(self):
|
||||
"""Cas réel OCG 18 : apostrophe typographique ’ (U+2019) doit être
|
||||
couverte comme l'apostrophe droite."""
|
||||
out, _ = _mask_line("Domicile : 13, avenue de l’interne J. Loeb")
|
||||
assert PLACEHOLDERS["ADRESSE"] in out
|
||||
assert "Loeb" not in out, f"Fuite (apostrophe typographique) : {out!r}"
|
||||
assert "interne" not in out
|
||||
|
||||
def test_adresse_simple_non_regression(self):
|
||||
out, _ = _mask_line("12 rue de la Paix")
|
||||
assert out.strip() == PLACEHOLDERS["ADRESSE"]
|
||||
|
||||
def test_adresse_avenue_simple(self):
|
||||
out, _ = _mask_line("3 avenue des Fleurs Bleues")
|
||||
assert PLACEHOLDERS["ADRESSE"] in out
|
||||
assert "Fleurs" not in out
|
||||
|
||||
# --- Matrice générique (demande Codex : famille d'adresses, pas un cas) ---
|
||||
@pytest.mark.parametrize("adresse,reste_visible", [
|
||||
("24 rue du docteur A. Martin", "Martin"), # titre + initiale + nom
|
||||
("5 boulevard du professeur P. Bernard", "Bernard"),
|
||||
("7 place du Général de Gaulle", "Gaulle"), # titre historique
|
||||
("9 rue Jean Jaurès", "Jaurès"),
|
||||
("11 avenue du Président Wilson", "Wilson"),
|
||||
("18 allée des Frères Lumière", "Lumière"),
|
||||
("4 rue du 8 Mai 1945", "1945"), # commémoratif (chiffres)
|
||||
("2 rue du 11 Novembre", "Novembre"),
|
||||
("13, avenue de l’interne J. Loeb", "Loeb"), # apostrophe typographique
|
||||
])
|
||||
def test_adresse_matrice_generique(self, adresse, reste_visible):
|
||||
out, _ = _mask_line(adresse)
|
||||
assert PLACEHOLDERS["ADRESSE"] in out, f"non masqué: {adresse!r} -> {out!r}"
|
||||
assert reste_visible not in out, f"fuite résiduelle: {adresse!r} -> {out!r}"
|
||||
|
||||
@pytest.mark.parametrize("ligne_clinique", [
|
||||
"3 mg/L de CRP",
|
||||
"TA 12/8 mmHg",
|
||||
"Paracétamol 1000 mg, 3 fois par jour",
|
||||
"FC 72 bpm, SpO2 98%",
|
||||
"2 comprimés matin et soir",
|
||||
])
|
||||
def test_adresse_anti_fp_clinique(self, ligne_clinique):
|
||||
out, _ = _mask_line(ligne_clinique)
|
||||
assert PLACEHOLDERS["ADRESSE"] not in out, f"faux masquage adresse: {out!r}"
|
||||
|
||||
def test_adresse_ne_deborde_pas_sur_phrase_clinique(self):
|
||||
"""Un point après un mot (pas une initiale) borne l'adresse : la phrase
|
||||
clinique qui suit sur la même ligne n'est pas avalée."""
|
||||
out, _ = _mask_line("Adresse 5 rue des Lilas. Le patient va bien")
|
||||
assert PLACEHOLDERS["ADRESSE"] in out
|
||||
assert "Le patient va bien" in out, f"débordement: {out!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Communes composées
|
||||
# ---------------------------------------------------------------------------
|
||||
@requires_aho
|
||||
class TestCommunesComposees:
|
||||
|
||||
def test_st_martin_de_hinx_espaces(self):
|
||||
"""Cas bloquant Dom : commune composée préfixée 'St' écrite avec des
|
||||
espaces doit être masquée entièrement, sans laisser 'Martin' visible,
|
||||
même sans contexte géographique explicite."""
|
||||
out, _ = _mask_ville_gazetteers("St Martin de Hinx")
|
||||
assert PLACEHOLDERS["VILLE"] in out, f"Commune non masquée : {out!r}"
|
||||
assert "Martin" not in out, f"Relâchement partiel : {out!r}"
|
||||
assert "Hinx" not in out
|
||||
|
||||
def test_commune_composee_tiret_non_regression(self):
|
||||
out, _ = _mask_ville_gazetteers("Saint-Martin-de-Hinx")
|
||||
assert PLACEHOLDERS["VILLE"] in out
|
||||
assert "Martin" not in out
|
||||
|
||||
def test_mot_courant_non_masque_sans_contexte(self):
|
||||
"""Garde-fou anti-FP : un mot homonyme de commune (mono-mot, sans
|
||||
contexte géo) ne doit pas être masqué abusivement."""
|
||||
out, _ = _mask_ville_gazetteers("Les signes vitaux sont stables.")
|
||||
assert PLACEHOLDERS["VILLE"] not in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Contexte date
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestContexteDate:
|
||||
|
||||
def test_date_naissance_contexte_fort_masquee(self):
|
||||
out, audit = _mask_line("Né le 14/03/1956")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] in out
|
||||
assert "1956" not in out
|
||||
assert any(h.kind == "DATE_NAISSANCE" for h in audit)
|
||||
|
||||
def test_date_naissance_variantes_contexte(self):
|
||||
for line in ("Date de naissance : 01/02/1944",
|
||||
"DDN 1/2/1944",
|
||||
"Née le 2 mars 1944"):
|
||||
out, _ = _mask_line(line)
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] in out, f"non masqué: {line!r} -> {out!r}"
|
||||
|
||||
def test_date_clinique_sans_contexte_preservee(self):
|
||||
"""Une date sans contexte de naissance (acte/suivi) ne doit PAS être
|
||||
masquée."""
|
||||
out, _ = _mask_line("Intervention réalisée le 14/03/2025")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] not in out
|
||||
assert "14/03/2025" in out
|
||||
|
||||
def test_date_tableau_clinique_preservee(self):
|
||||
out, _ = _mask_line("08:00 | 120/80 | 37.1 | 12/03/2024")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] not in out
|
||||
assert "12/03/2024" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Anti-régression : pas de propagation globale d'une date nue
|
||||
# (DATE_NAISSANCE_GLOBAL pass 2 retirée)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _make_pdf(tmp_path: Path, lines: list[str]) -> Path:
|
||||
import fitz
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
y = 72
|
||||
for ln in lines:
|
||||
page.insert_text((72, y), ln, fontsize=11)
|
||||
y += 18
|
||||
p = tmp_path / "synthetic_dob.pdf"
|
||||
doc.save(str(p))
|
||||
doc.close()
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Tests adversariaux Qwen (T-A1 → T-A10) — validation des 3 détecteurs
|
||||
# Valeurs 100% fictives.
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAdversarialQwen:
|
||||
|
||||
def test_TA1_adresse_voie_personne(self):
|
||||
out, _ = _mask_line("13, avenue de l'interne J. Loeb")
|
||||
assert out.strip() == PLACEHOLDERS["ADRESSE"]
|
||||
|
||||
def test_TA2_ligne_clinique_non_masquee(self):
|
||||
"""Une ligne clinique « 3 mg/L de CRP » ne doit pas être prise pour une
|
||||
adresse (le détecteur exige un type de voie comme ancre)."""
|
||||
out, _ = _mask_line("3 mg/L de CRP")
|
||||
assert PLACEHOLDERS["ADRESSE"] not in out
|
||||
assert out == "3 mg/L de CRP"
|
||||
|
||||
@requires_aho
|
||||
def test_TA3_commune_composee_bloc_adresse(self):
|
||||
out, _ = _mask_ville_gazetteers("Adresse : St Martin de Hinx")
|
||||
assert PLACEHOLDERS["VILLE"] in out
|
||||
assert "Martin" not in out
|
||||
|
||||
@requires_aho
|
||||
def test_TA4_dr_martin_pas_commune(self):
|
||||
"""« Dr Martin » ne doit pas être masqué comme VILLE par le gazetteer
|
||||
communes (le masquage du nom relève du pipeline NOM, hors de ce test)."""
|
||||
out, _ = _mask_ville_gazetteers("Dr Martin")
|
||||
assert PLACEHOLDERS["VILLE"] not in out
|
||||
|
||||
@requires_aho
|
||||
def test_TA5_martin_seul_pas_masque_commune(self):
|
||||
out, _ = _mask_ville_gazetteers("Martin")
|
||||
assert PLACEHOLDERS["VILLE"] not in out
|
||||
|
||||
def test_TA6_ddn_bloc_identite(self):
|
||||
out, _ = _mask_line("Né le 14/03/1956")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] in out
|
||||
|
||||
def test_TA7_date_clinique_tableau_non_masquee(self):
|
||||
out, _ = _mask_line("TA 120/80 | FC 72 | 14/03/1956")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] not in out
|
||||
assert "14/03/1956" in out
|
||||
|
||||
def test_TA8_date_pres_label_naissance(self):
|
||||
out, _ = _mask_line("Date de naissance 14/03/2025")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] in out
|
||||
|
||||
def test_TA9_ddn_label_court(self):
|
||||
out, _ = _mask_line("DDN: 02/08/1980")
|
||||
assert PLACEHOLDERS["DATE_NAISSANCE"] in out
|
||||
|
||||
|
||||
def _make_pdf_2pages(tmp_path: Path, page1: list[str], page2: list[str]) -> Path:
|
||||
import fitz
|
||||
doc = fitz.open()
|
||||
for lines in (page1, page2):
|
||||
page = doc.new_page()
|
||||
y = 72
|
||||
for ln in lines:
|
||||
page.insert_text((72, y), ln, fontsize=11)
|
||||
y += 18
|
||||
p = tmp_path / "synthetic_2p.pdf"
|
||||
doc.save(str(p))
|
||||
doc.close()
|
||||
return p
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_TA10_date_meme_que_ddn_page3_tableau_non_masquee(tmp_path):
|
||||
"""T-A10 : une date identique à la DDN mais dans un tableau d'une autre page
|
||||
(hors contexte de naissance) ne doit PAS être masquée. Valeurs fictives."""
|
||||
pdf = _make_pdf_2pages(
|
||||
tmp_path,
|
||||
page1=[
|
||||
"Compte rendu de consultation fictif pour test automatise.",
|
||||
"Patient FICTIF TESTNOM, dossier numero 000000.",
|
||||
"Ne le 14/03/1956. Motif : controle de routine sans particularite.",
|
||||
"Antecedents : aucun signale dans ce document de test synthetique.",
|
||||
],
|
||||
page2=[
|
||||
"Tableau de suivi clinique (donnees fictives de test).",
|
||||
"Controle realise le 14/03/1956 RAS, parametres stables.",
|
||||
"Conclusion : surveillance simple, prochain rendez-vous a definir.",
|
||||
],
|
||||
)
|
||||
out_dir = tmp_path / "out2"
|
||||
out_dir.mkdir()
|
||||
core.process_pdf(pdf, out_dir, CFG)
|
||||
txt = (out_dir / "synthetic_2p.pseudonymise.txt").read_text(encoding="utf-8")
|
||||
assert "Controle realise le 14/03/1956" in txt, f"date clinique masquée à tort:\n{txt}"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_date_clinique_non_masquee_apres_dob_detectee_ailleurs(tmp_path):
|
||||
"""Anti-régression DATE_NAISSANCE_GLOBAL : une date cliniquement identique à
|
||||
la date de naissance, mais hors contexte de naissance, ne doit PAS être
|
||||
masquée globalement sur tout le document. Valeurs 100% fictives."""
|
||||
pdf = _make_pdf(tmp_path, [
|
||||
"Patient FICTIF TESTNOM",
|
||||
"Ne le 12/03/1990",
|
||||
"Tableau de surveillance :",
|
||||
"Prelevement realise le 12/03/1990 - bilan stable",
|
||||
])
|
||||
out_dir = tmp_path / "out"
|
||||
out_dir.mkdir()
|
||||
res = core.process_pdf(pdf, out_dir, CFG)
|
||||
txt_path = out_dir / "synthetic_dob.pseudonymise.txt"
|
||||
assert txt_path.exists(), f"sortie absente (status={res.get('status') if isinstance(res, dict) else res})"
|
||||
text = txt_path.read_text(encoding="utf-8")
|
||||
# La ligne "Prelevement ... le 12/03/1990" ne doit PAS avoir été masquée
|
||||
assert "Prelevement realise le 12/03/1990" in text, (
|
||||
f"date clinique masquée à tort par propagation globale :\n{text}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_date_globale_non_masquee_dans_pdf_redacted(tmp_path):
|
||||
"""Codex blocage #3 : la neutralisation de DATE_NAISSANCE_GLOBAL doit valoir
|
||||
aussi pour le PDF redacted (vector), pas seulement le .txt. On vérifie que la
|
||||
date clinique reste présente dans le texte du PDF caviardé. Valeurs fictives."""
|
||||
import fitz
|
||||
pdf = _make_pdf(tmp_path, [
|
||||
"Compte rendu fictif pour test automatise de non regression.",
|
||||
"Patient FICTIF TESTNOM, dossier 000000, sans particularite.",
|
||||
"Ne le 12/03/1990. Motif de consultation : controle de routine.",
|
||||
"Tableau de surveillance clinique (donnees fictives) :",
|
||||
"Prelevement realise le 12/03/1990, parametres stables, RAS.",
|
||||
])
|
||||
out_dir = tmp_path / "outpdf"
|
||||
out_dir.mkdir()
|
||||
core.process_pdf(pdf, out_dir, CFG)
|
||||
redacted = out_dir / "synthetic_dob.redacted_vector.pdf"
|
||||
if not redacted.exists():
|
||||
# fallback raster éventuel
|
||||
redacted = out_dir / "synthetic_dob.redacted_raster.pdf"
|
||||
assert redacted.exists(), "PDF caviardé absent"
|
||||
doc = fitz.open(str(redacted))
|
||||
pdf_text = "\n".join(p.get_text("text") for p in doc)
|
||||
doc.close()
|
||||
# Le PDF caviardé en mode vector retire le texte sous les boîtes. La date
|
||||
# clinique ne doit PAS avoir été retirée par une propagation globale.
|
||||
if "redacted_vector" in redacted.name:
|
||||
assert "12/03/1990" in pdf_text, (
|
||||
f"date clinique retirée du PDF par propagation globale :\n{pdf_text}"
|
||||
)
|
||||
Reference in New Issue
Block a user