2 Commits

Author SHA1 Message Date
8790c64cca feat(cli): add Windows single-file anonymization entrypoint
CLI de production sans GUI pour anonymiser un fichier unique, validé GO par
Qwen (revue indépendante contrat/packaging/modèles) sur de vrais PDF.

- scripts/anonymize_cli.py (NOUVEAU) : contrat positionnel
  `Anonymisation-CLI.exe <fichier> <dossier_sortie>` (+ --out compat),
  chemins espaces/accents, codes retour 0/1/2/3/4.
  Chargement modèles fail-closed : CamemBERT-bio ONNX OBLIGATOIRE (code 3 si
  absent, aucun mode dégradé silencieux) ; EDS-Pseudo + GLiNER optionnels,
  tracés au log ; --no-ner = regex seul assumé. Résolution _MEIPASS frozen
  alignée sur launcher.py. Sortie burn raster identique GUI v5.
- anonymisation_cli_onefile.spec : entrypoint basculé vers anonymize_cli.py
  (le harnais perf D-19 anonymize_batch_cli.py reste hors build).
- docs/build-windows-oneclick.md : section « CLI Windows (sans GUI) »
  (build, usage, codes retour, modèles, limitations).

Tests Linux (vrais PDF) : --help OK, fichier manquant→2, --no-ner accents→0,
NER complet→0 (CamemBERT-bio + EDS-Pseudo chargés), modèle déplacé→3.
Build/smoke Windows à suivre (séparé). Commit CLI-only strict, distinct du P0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:26:11 +02:00
87f5e48d66 feat(anonymizer): add v11.5 P0 layout-aware detectors
Trois détecteurs simples « layout/context-aware » (chantier v11.5 P0),
validés par 2 revues Codex + 10 tests adversariaux Qwen, 0 régression :

- RE_ADRESSE réécrit en grammaire de tokens (_RE_VOIE_TYPE + _RE_VOIE_TOKEN) :
  capture initiales (« J. Loeb »), voies commémoratives à chiffres
  (« 8 Mai 1945 »), apostrophes ' et ’, bornage à la ligne courante,
  arrêt sur point post-mot (anti-débordement clinique).
- _mask_ville_gazetteers : retourne toujours un tuple (texte, liste) même
  sans Aho-Corasick ; masque les communes Saint/St/Sainte/Ste multi-mots à
  espaces (« St Martin de Hinx ») entièrement, sans exiger de contexte géo.
- DATE_NAISSANCE retiré de la propagation globale + DATE_NAISSANCE_GLOBAL
  ajouté aux skip vector/raster : on ne masque plus une date nue sur tout le
  document. La DDN reste masquée en contexte fort, page par page. Les dates
  cliniques identiques à la DDN hors contexte sont préservées.

tests/unit/test_p0_layout_detectors.py : 38 tests dédiés (matrice adresse
générique, anti-FP, communes Saint, propagation DDN, 10 tests adversariaux
Qwen). Suite tests/unit complète : 147 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:28:18 +02:00
5 changed files with 830 additions and 19 deletions

View 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,
)

View File

@@ -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

View File

@@ -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
View 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())

View 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 linterne 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 linterne 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}"
)