23 Commits

Author SHA1 Message Date
d324ada310 test(core): verrouiller le fallback edsnlp sur drugs.json corrompu (revue qualité)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 09:42:15 +02:00
6a992d87de fix(core): charger gazetteer médicaments edsnlp depuis data/ (torch-free) + log si absent
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 09:36:08 +02:00
b196d00813 docs(build): réserves revue Qwen Plan 3 — MAJ app gelée (D8), précondition tray, note RGPD identifiants indirects 2026-07-03 09:20:53 +02:00
5dbad699bc test(beta): référence détections sur 6 documents réels avant build torch-free (Plan 3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:18:42 +02:00
7dba4014c4 docs(build): torch-free + précache OnnxTR + version unifiée + runbook URL portail (P0-4/P1-8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:13:01 +02:00
2c48d95c1f build(installer): AppMutex + CloseApplications pour MAJ en place (D8)
Synchronise l'installeur GUI avec gui_v6/single_instance.py:APP_MUTEX_NAME
afin que Inno Setup ferme l'application avant de remplacer l'EXE lors d'une
mise à jour en place (AppId fixe). Test anti-dérive dynamique inclus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:10:20 +02:00
f9b6f21923 build: purge torch venv GUI V6 + précache OnnxTR + version release unifiée (P0-3/P0-4/P1-7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:25:47 +02:00
e4bc9166be test(build): échec bruyant si bloc hiddenimports introuvable (revue Task 2)
- _hiddenimport_strings assert désormais si le bloc hiddenimports=[...] est
  absent (renommage/refonte de spec) au lieu de retourner [] silencieusement,
  ce qui faisait passer le test à vide.
- Docstring corrigée : la regex s'arrête au PREMIER `]` où qu'il soit (pas
  « ligne seule ») — limitation réelle documentée.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:23:51 +02:00
65c97a39b3 build: specs GUI V6 + CLI torch-free — retrait optimum, excludes explicites (P0-3)
Suppression des 5 hiddenimports optimum* dans les deux specs V6/CLI.
Ajout de EXCLUDED_TORCH_STACK + excludes=EXCLUDED_TORCH_STACK dans Analysis()
pour éviter que PyInstaller embarque torch (~+2 Go) via optimum à l'analyse
statique. Spec GUI V5 legacy inchangée (garde optimum légitimement).
Test anti-dérive ajouté (5 cas). Correctif import pytest inutilisé (version.py).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:11:07 +02:00
5f05ba0fb8 feat(build): version release résolue au build avec repli dev (P1-7)
Crée gui_v6/version.py (DEFAULT_VERSION + resolve_version()) qui tente
d'importer gui_v6._build_version (généré au build Windows, non commité).
Câble gui_v6.__version__ sur resolve_version(). Ajoute gui_v6/_build_version.py
au .gitignore et aux hiddenimports du spec PyInstaller. 4 tests TDD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:02:57 +02:00
8f9107a27f feat(gui): câblage upload diagnostics en fin de run (E3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:44:02 +02:00
8eb8cf9999 feat(gui): client diagnostics non bloquant + spool best-effort (E3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:39:25 +02:00
4b7a31b9df feat(gui): module diagnostics — payload liste-blanche RGPD (E2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:36:16 +02:00
4412512d4b test(gui): vérifier chaque branche de classify_error_code + anti-dérive (E2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:34:11 +02:00
952a1c6ca0 feat(gui): DocResult porte type+catégorie d'erreur RGPD-safe (E2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:28:42 +02:00
675e328d8c docs(plan): Plan 1c honnêteté UI (P1-4/NER/P1-5/P1-3/P1-1/P1-6/P1-11)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:42:16 +02:00
4813f9439e fix(core): corriger la provenance OCR de l'audit (docTR → OnnxTR, P1-11)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:36:58 +02:00
ee1f86d55e feat(gui): échec amont clair si dossier de sortie non inscriptible (P1-6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:33:16 +02:00
3a981eb15a feat(gui): dropzone cliquable + libellé honnête (P1-1, DnD natif différé)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:29:12 +02:00
d3189d5bb7 feat(gui): recâbler import/export de configuration par email (P1-3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:27:33 +02:00
1d65d42430 feat(gui): localiser les documents livrés + bouton ouvrir le dossier (P1-5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:15:28 +02:00
416b347d7f feat(gui): confirmation explicite avant anonymisation regex-only (NER off)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:07:38 +02:00
880a75873d feat(gui): charger le dictionnaires.yml externe éditable en frozen (P1-4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:00:49 +02:00
33 changed files with 7017 additions and 40 deletions

4
.gitignore vendored
View File

@@ -49,6 +49,10 @@ models/
# build_info.py : régénéré automatiquement par scripts/rebuild_anon.ps1
# avec date/commit/branch. Ne pas versionner.
build_info.py
# gui_v6/_build_version.py : généré au build Windows par build_windows_oneclick.ps1
# (contient BUILD_VERSION = "2026.MM.JJ.HHMM"). Ne pas commiter.
gui_v6/_build_version.py
*.mp3
*.wav
*.mp4

View File

@@ -29,6 +29,7 @@ datas = []
for relative_path, target_dir in [
("config", "config"),
("data/bdpm", "data/bdpm"),
("data/edsnlp", "data/edsnlp"),
("data/finess", "data/finess"),
("data/insee", "data/insee"),
("models/camembert-bio-deid/onnx", "models/camembert-bio-deid/onnx"),
@@ -114,11 +115,6 @@ hiddenimports = [
"yaml",
"loguru",
"regex",
"optimum",
"optimum.onnxruntime",
"optimum.pipelines",
"optimum.modeling_base",
"optimum.exporters.onnx",
]
@@ -153,12 +149,24 @@ for _package_name in [
_collect_optional_package(_package_name)
# P0-3 (Plan 3) : exclusion dure de la pile torch. Le core fait un
# `import torch` lazy (try/except no-op) dans _configure_torch_threads que
# l'analyse statique suivrait ; en frozen l'ImportError est attendue et gérée.
EXCLUDED_TORCH_STACK = [
"torch",
"torchvision",
"optimum",
"doctr",
]
a = Analysis(
[str(project_dir / "scripts" / "anonymize_cli.py")],
pathex=[str(project_dir)],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
excludes=EXCLUDED_TORCH_STACK,
cipher=block_cipher,
noarchive=False,
)

View File

@@ -26,6 +26,7 @@ datas = []
for relative_path, target_dir in [
("config", "config"),
("data/bdpm", "data/bdpm"),
("data/edsnlp", "data/edsnlp"),
("data/finess", "data/finess"),
("data/insee", "data/insee"),
("models/camembert-bio-deid/onnx", "models/camembert-bio-deid/onnx"),
@@ -78,6 +79,8 @@ hiddenimports = [
"gui_v6.machine_id",
"gui_v6.engine_bridge",
"gui_v6.config_state",
"gui_v6.version",
"gui_v6._build_version",
"gui_v6.processing_runner",
"gui_v6.tabs",
"gui_v6.tabs.tab_about",
@@ -133,11 +136,16 @@ hiddenimports = [
"yaml",
"loguru",
"regex",
]
# P0-3 (Plan 3) : exclusion dure de la pile torch. Le core fait un
# `import torch` lazy (try/except no-op) dans _configure_torch_threads que
# l'analyse statique suivrait ; en frozen l'ImportError est attendue et gérée.
EXCLUDED_TORCH_STACK = [
"torch",
"torchvision",
"optimum",
"optimum.onnxruntime",
"optimum.pipelines",
"optimum.modeling_base",
"optimum.exporters.onnx",
"doctr",
]
@@ -146,6 +154,7 @@ a = Analysis(
pathex=[str(project_dir)],
datas=datas,
hiddenimports=hiddenimports,
excludes=EXCLUDED_TORCH_STACK,
cipher=block_cipher,
noarchive=False,
)

View File

@@ -256,24 +256,62 @@ except ImportError:
APP_VERSION = "0.11.0-mvp" # incrémenter avant rebuild release
# Gazetteer médicaments extrait de edsnlp/resources/drugs.json et versionné dans
# le dépôt (data/edsnlp/drugs.json). Lu EN PREMIER au runtime pour rester complet
# dans le build Windows torch-free (Plan 3), où edsnlp — qui importe torch en dur —
# n'est pas disponible. En mode frozen, __file__ pointe vers _MEIPASS, donc ce
# chemin résout le fichier embarqué (cf _load_bdpm_medication_names).
_EDSNLP_DRUGS_DATA_PATH = Path(__file__).parent / "data" / "edsnlp" / "drugs.json"
def _parse_edsnlp_drugs_json(path: Path) -> set:
"""Parse un drugs.json edsnlp (code ATC → liste de noms).
Retourne le set des noms mono-mot de longueur >= 4, en minuscules.
Parsing IDENTIQUE à l'historique (garantie de non-régression du gazetteer)."""
import json as _json
data = _json.loads(path.read_text(encoding="utf-8"))
result = set()
for _code, names in data.items():
for name in names:
if " " not in name and len(name) >= 4:
result.add(name.lower())
return result
def _load_edsnlp_drug_names() -> set:
"""Charge les noms de médicaments mono-mot depuis edsnlp/resources/drugs.json.
Retourne un set lowercase. Fallback silencieux si edsnlp absent."""
"""Charge les noms de médicaments mono-mot pour la whitelist anti-faux-positif.
Ordre de résolution (torch-free) :
1. data/edsnlp/drugs.json (fichier versionné, 0 dépendance edsnlp/torch) ;
2. fallback : package edsnlp (BASE_DIR/resources/drugs.json), comportement
historique en mode dev ;
3. échec total → log.warning explicite + set() (dégradation rendue visible).
Retourne un set lowercase."""
# 1. Fichier data versionné (disponible aussi en frozen torch-free).
try:
if _EDSNLP_DRUGS_DATA_PATH.exists():
return _parse_edsnlp_drugs_json(_EDSNLP_DRUGS_DATA_PATH)
except Exception as exc: # fichier corrompu → on tente le fallback
log.debug("Lecture %s échouée : %s", _EDSNLP_DRUGS_DATA_PATH, exc)
# 2. Fallback package edsnlp (dev).
try:
import edsnlp as _edsnlp
drugs_path = _edsnlp.BASE_DIR / "resources" / "drugs.json"
if not drugs_path.exists():
return set()
import json as _json
data = _json.loads(drugs_path.read_text(encoding="utf-8"))
result = set()
for _code, names in data.items():
for name in names:
if " " not in name and len(name) >= 4:
result.add(name.lower())
return result
except Exception:
return set()
if drugs_path.exists():
return _parse_edsnlp_drugs_json(drugs_path)
except Exception as exc:
log.debug("Fallback package edsnlp indisponible : %s", exc)
# 3. Échec total : rendre la dégradation visible (risque de sur-masquage).
log.warning(
"Gazetteer médicaments edsnlp indisponible (ni data/edsnlp/drugs.json "
"ni package edsnlp) — whitelist médicaments réduite, risque de sur-masquage"
)
return set()
def _load_bdpm_medication_names() -> set:
@@ -5679,7 +5717,7 @@ def process_pdf(
# Log OCR dans l'audit
if ocr_used:
anon.audit.insert(0, PiiHit(page=-1, kind="OCR_USED", original="docTR", placeholder=""))
anon.audit.insert(0, PiiHit(page=-1, kind="OCR_USED", original="OnnxTR", placeholder=""))
# Filtrer les faux positifs hospitaliers
if _HOSPITAL_FILTER_AVAILABLE:

32
data/edsnlp/README.md Normal file
View File

@@ -0,0 +1,32 @@
# data/edsnlp — Gazetteer médicaments (extrait de edsnlp)
## Contenu
- `drugs.json` : dictionnaire code ATC → liste de noms de médicaments (1968 codes),
extrait de **edsnlp 0.20.0**, fichier `edsnlp/resources/drugs.json`.
## Usage
Ce fichier alimente `_load_edsnlp_drug_names()` dans
`anonymizer_core_refactored_onnx.py`. Les noms mono-mot de longueur ≥ 4 sont
chargés (en minuscules) comme **gazetteer anti-faux-positif** : ils empêchent
que des noms de médicaments (ex. « elisor », « kessar », « muse », « sirop »)
soient pris à tort pour des noms de personnes et sur-masqués.
Il est versionné dans le dépôt (et non lu depuis le package `edsnlp` au
runtime) afin que la whitelist médicaments reste complète dans le build Windows
**torch-free** (Plan 3), où `edsnlp` — qui importe `torch` en dur — n'est pas
disponible.
## Attribution / Licence
`drugs.json` provient du projet **edsnlp**, distribué sous licence
**BSD-3-Clause**.
> Copyright (c) 2021, Assistance Publique - Hôpitaux de Paris
>
> Redistribution and use in source and binary forms, with or without
> modification, are permitted under the terms of the BSD-3-Clause license.
Source : https://github.com/aphp/edsnlp — `edsnlp/resources/drugs.json`
(version 0.20.0).

4398
data/edsnlp/drugs.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
# Référence détections — 6 documents réels (Plan 3, avant build torch-free)
Établie sur HEAD `7dba401` (.venv Linux, chemin dev). Sert de comparaison au smoke
Windows torch-free (Task 8). **AUCUNE valeur PII — compteurs uniquement.**
Les documents sont identifiés par leur **numéro de dossier** du corpus interne
(jamais par un nom de patient). ⚠️ **RGPD** : ces numéros de dossier sont des
**identifiants indirects** — usage **interne** uniquement. Avant toute diffusion
externe de ce fichier, les remplacer par des étiquettes neutres (`DOC-A`, `DOC-B`…). Les sorties de traitement sont restées dans `/tmp`
(non commité). Les compteurs proviennent du champ `kind` de l'audit `.audit.jsonl`
produit par `scripts/anonymize_cli.py` (contrat de production : burn raster +
texte pseudonymisé + audit JSONL). Le champ `original` (la valeur détectée) n'a
jamais été lu ni recopié.
## Environnement de référence
- `pytest tests/unit` = **499 passed / 0 failed** (7 warnings, 0 fail).
- `pytest -k synthetic_regression` = **PASS** (11 passed, 488 deselected) — gate strict de non-régression du masquage.
- CamemBERT-bio ONNX v3 chargé (obligatoire) ✓ ; EDS-Pseudo chargé (optionnel) ✓ ; GLiNER désactivé (défaut).
- Chemin dev : `torch=2.10.0+cu128` présent dans l'environnement (c'est précisément ce que le build Windows torch-free retirera). CamemBERT-bio tourne déjà via onnxruntime, indépendant de torch.
## Détections par document
| Doc (n° dossier) | Type | Pages | ocr_used | Code | Total dét. | Détections par type (kind) |
|------------------|--------|-------|----------|------|------------|-----------------------------|
| 102_23056463 | natif | 2 | False | 0 | 104 | NOM:31 RPPS:24 ETAB:18 TEL:10 VILLE_GAZ:4 CODE_POSTAL:3 DATE_NAISSANCE:3 EMAIL:2 EPISODE:2 FAX:2 FINESS:2 IPP:2 VILLE:1 |
| 101_23041413 | natif | 1 | False | 0 | 22 | NOM:7 CODE_POSTAL:6 NOM_INITIAL:4 ADRESSE:2 NOM_FORCE:2 VILLE:1 |
| 103_23056749 | natif | 2 | False | 0 | 109 | NOM:33 RPPS:24 ETAB:18 TEL:10 VILLE_GAZ:5 CODE_POSTAL:3 DATE_NAISSANCE:3 EMAIL:2 EPISODE:2 FAX:2 FINESS:2 IPP:2 ADRESSE:1 ETAB_FINESS:1 VILLE:1 |
| 192_23132490 | scanné | 1 | True | 0 | 17 | NOM:7 CODE_POSTAL:3 ETAB_FINESS:2 TEL:2 DATE_NAISSANCE:1 NOM_FORCE:1 URL:1 |
| 19_23103383 | scanné | 1 | True | 0 | 15 | NOM:4 CODE_POSTAL:2 ETAB_FINESS:2 TEL:4 DATE_NAISSANCE:1 NOM_FORCE:1 VILLE_GAZ:1 |
| 258_23208848 | scanné | 1 | True | 0 | 16 | ETAB:4 NOM:3 AGE:2 CODE_POSTAL:2 NOM_FORCE:2 ETAB_FINESS:1 TEL:1 VILLE:1 |
## Notes
- **3 natifs / 3 scannés confirmés** : les 3 natifs ont `ocr_used=False` (texte extractible directement) ; les 3 scannés ont `ocr_used=True` (image-only → OCR docTR/OnnxTR). Les 6 codes retour CLI = **0**.
- Tous les documents ont `quarantine_flags=[]` (aucune mise en quarantaine).
- **Tout écart de compteur au smoke Windows (Task 8) = signal de régression torch-free à investiguer.** La comparaison doit se faire par (doc, type de kind, nombre), pas seulement sur le total.
- **Point de vigilance edsnlp/drugs.json (revue Task 2)** : si des compteurs de type médicament (ou une variation des NOM/ETAB liée au filtrage médicaments) diffèrent en frozen, c'est potentiellement la perte du gazetteer edsnlp — voir mission Qwen. Aucun `kind` explicitement « médicament » n'apparaît dans l'audit de ces 6 docs (les médicaments servent de stop-words/whitelist, pas de type détecté), donc surveiller surtout une **hausse anormale de NOM/ETAB** en frozen (faux positifs par perte du filtre).
- Rappel : les compteurs d'audit incluent l'ensemble du pipeline (NER multi-signal + regex + gazetteers après cross-validation), ce qui explique un total supérieur au seul « NER-first: N détections » visible dans les logs.

View File

@@ -20,6 +20,15 @@ récupérer la GUI et d'activer sa licence.
(le cookie Secure confirme que le fix `884661a` tourne — APP_ENV=production).
## 1. Publier l'installateur GUI comme artefact actif
**Avant l'upload — vérifications obligatoires :**
- [ ] Vérifier l'URL portail embarquée dans l'EXE fraîchement buildé :
lancer l'EXE avec `--self-test` et contrôler dans le log que
`resolve_portal_url()` retourne `https://app.aivanov.eu` (pas localhost).
- [ ] Mettre à jour le SHA-256 dans `note-beta-client.md` (le SHA change à
chaque rebuild — l'ancienne note devient caduque, P1-8).
**Pré-requis : l'EXE Windows doit d'abord être copié sur le serveur Linux** (il est
aujourd'hui sur la machine de build Windows, non diffusé). Une fois sur le serveur,
depuis `/home/dom/ai/app_aivanov` avec l'environnement prod chargé :

View File

@@ -94,6 +94,45 @@ Sorties attendues identiques :
diffusion ;
- `release\Anonymisation.exe.sha256.txt` : hash de l'exécutable.
## Build GUI V6 torch-free (Plan 3)
Depuis le Plan 3 (2026-07), le flavor `-GuiV6` :
1. **Purge torch/optimum du venv de build** (P0-3) : `optimum[onnxruntime]`
(requirements.txt) tire `torch>=1.11` en dépendance cœur ; la GUI V6 ne
l'utilise jamais (NER = onnxruntime brut, OCR = OnnxTR). Le script échoue
si `torch` reste importable après purge. La spec legacy V5
(`anonymisation_onefile.spec`) garde torch — ne pas builder V5 et V6 dans
le même venv sans réinstaller les requirements.
2. **Précache les poids OnnxTR** (P0-4) : `db_resnet50` + `crnn_vgg16_bn`
téléchargés explicitement avant PyInstaller (la spec raise s'ils manquent).
Le build ne dépend plus du cache résiduel de la machine.
3. **Injecte la version release** (P1-7) : `yyyy.MM.dd.HHmm` calculée une fois,
écrite dans `build_info.py` (BUILD_VERSION), `gui_v6/_build_version.py`
(affichage GUI + télémétrie) et l'installeur (`/DAppVersion`). En dev,
`gui_v6.__version__` retombe sur `6.0.0-dev`.
### Validation torch-free (à chaque build)
- Taille EXE mesurée et comparée au build précédent (~697 MB avec torch ;
attendu nettement inférieur — consigner la valeur).
- `Select-String -Path build\anonymisation_gui_v6_onefile\xref-*.html -Pattern "torch|optimum"`
→ 0 résultat (l'arbo PyInstaller fait foi, pas le diff de la spec).
- Smoke OCR sur PDF scanné (`ocr_used=True`) : les poids OnnxTR viennent de
`_MEIPASS/models/onnxtr/models`, aucun téléchargement runtime.
### Mise à jour en place (D8) — comportement de l'installeur
- L'installeur pose `AppMutex=AivanonymAnonymisationV6` (= `gui_v6/single_instance.py:APP_MUTEX_NAME`)
et `CloseApplications=yes` : Inno Setup envoie `WM_CLOSE` à l'app en cours et attend
sa fermeture avant de remplacer l'EXE.
- **Cas où l'app ne se ferme pas seule** : si l'application est gelée (ne répond plus au
`WM_CLOSE`), Inno Setup n'effectue **pas** de force-kill silencieux — il affiche un
**dialogue à l'utilisateur** (forcer la fermeture / annuler la MAJ). Il n'y a donc pas
d'échec silencieux, mais la MAJ requiert une action manuelle dans ce cas.
- Précondition : la GUI V6 n'a **pas** de réduction en zone de notification (tray). Si une
telle fonctionnalité était ajoutée, revoir D8 (un process en tray survivrait au `WM_CLOSE`).
## Important
- les utilisateurs finaux n'ont pas besoin d'installer Python

View File

@@ -0,0 +1,900 @@
# GUI V6 → bêta — Plan 1c : honnêteté UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Supprimer les derniers contrôles UI trompeurs / non fonctionnels de GUI V6 pour la bêta, et garantir le comportement honnête attendu d'un outil médical (config établissement réellement chargée, avertissement avant de dégrader la détection, erreurs localisables, échange de config par email recâblé).
**Architecture :** Chaque correctif est isolé derrière une **fonction pure testable sans display ni modèle** (résolution de chemin, décision de confirmation, formatage de message, export/import JSON), le widget se contentant de câbler cette fonction. Aucune logique de détection n'est touchée (le gating catégories est déjà livré en Plan 1b). On suit le pattern V5 existant pour la config externe (`Pseudonymisation_Gui_V5._resolve_config`) et le format d'échange JSON consommé par `scripts/merge_params.merge_params`.
**Tech Stack :** Python 3.10-3.12, customtkinter (CTk), tkinter.messagebox/filedialog, pytest (tests purs + `pytest.importorskip("customtkinter")` pour les rares smokes widget), PyYAML.
**Portée (spec chantier D — `docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md`) :**
- Task 1 — **P1-4** : `config_path` (dictionnaires.yml) réellement résolu et chargé en frozen.
- Task 2 — **Confirmation NER off** (décision Dom 2026-06-29) : dialogue avant de tomber en regex-only.
- Task 3 — **P1-5** : erreurs / quarantaine localisables + bouton « Ouvrir le dossier ».
- Task 4 — **P1-3** : Import / Export de configuration recâblés (workflow email V5).
- Task 5 — **P1-1** : dropzone honnête (cliquable) — DnD natif différé.
- Task 6 (optionnel) — **P1-6** : validation d'inscriptibilité du dossier de sortie.
- Task 7 (optionnel) — **P1-11** : provenance audit `original="docTR"``"OnnxTR"`.
**Hors portée (différé, acté) :** DnD natif `tkinterdnd2` (lib native tkdnd à bundler — repoussé pour ne pas alourdir le build torch-free du Plan 3, voir Task 5) ; P2-1/P2-2 (progression/cartes format) traités au mieux mais non bloquants.
**Convention de commit :** un commit atomique par task (core+test ensemble), préfixe conventionnel, finir par `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Brancher sur `feature/q1-quarantine-mvp`, pousser sur `gitea` (jamais `git add -A` — stager par chemin).
**Commande de test de référence :**
`.venv/bin/python -m pytest tests/unit/test_gui_v6_*.py -q` (+ gate `synthetic_regression` avant push).
---
### Task 1 : P1-4 — Config externe `dictionnaires.yml` réellement résolue en frozen
**Problème :** `AnonymisationApp` crée `UsageTab` **sans** `config_path` (`gui_v6/app.py:186-193`), donc `to_engine_settings(self._config_path)` reçoit `None` (`tab_usage.py:176`). En frozen, le `dictionnaires.yml` éditable à côté de l'EXE n'est jamais chargé → personnalisations établissement ignorées.
**Files:**
- Create: `gui_v6/config_paths.py`
- Create: `tests/unit/test_gui_v6_config_paths.py`
- Modify: `gui_v6/app.py` (import + `__init__` + appel `UsageTab(...)`)
- [ ] **Step 1 : Écrire les tests (échouent — module absent)**
`tests/unit/test_gui_v6_config_paths.py` :
```python
"""Résolution du dictionnaires.yml externe éditable (P1-4).
Pur : on simule frozen via monkeypatch (sys.frozen / sys.executable / _MEIPASS),
aucun display, aucun modèle.
"""
from __future__ import annotations
from pathlib import Path
import gui_v6.config_paths as cp
def _make_bundle(tmp_path: Path) -> Path:
bundle = tmp_path / "bundle"
(bundle / "config").mkdir(parents=True)
(bundle / "config" / "dictionnaires.yml").write_text("whitelist_phrases: []\n", encoding="utf-8")
return bundle
def test_dev_returns_repo_config_when_present(monkeypatch):
# En dev (non frozen) : pointe la config embarquée si elle existe.
monkeypatch.setattr(cp.sys, "frozen", False, raising=False)
path = cp.resolve_user_config_path()
assert path is not None
assert path.name == "dictionnaires.yml"
assert path.exists()
def test_frozen_copies_bundle_on_first_launch(tmp_path, monkeypatch):
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
exe_dir.mkdir()
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
out = cp.resolve_user_config_path()
expected = exe_dir / "config" / "dictionnaires.yml"
assert out == expected
assert expected.exists() # copié depuis le bundle au 1er lancement
assert expected.read_text(encoding="utf-8") == "whitelist_phrases: []\n"
def test_frozen_keeps_existing_user_config(tmp_path, monkeypatch):
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
(exe_dir / "config").mkdir(parents=True)
user_cfg = exe_dir / "config" / "dictionnaires.yml"
user_cfg.write_text("whitelist_phrases: [HOPITAL_LOCAL]\n", encoding="utf-8")
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
out = cp.resolve_user_config_path()
assert out == user_cfg
# Ne JAMAIS écraser la perso établissement existante.
assert "HOPITAL_LOCAL" in out.read_text(encoding="utf-8")
```
- [ ] **Step 2 : Lancer → vérifier l'échec**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_paths.py -q`
Expected: FAIL (`ModuleNotFoundError: No module named 'gui_v6.config_paths'`).
- [ ] **Step 3 : Implémenter `gui_v6/config_paths.py`**
```python
"""Résolution du fichier de configuration externe éditable (dictionnaires.yml).
En frozen (PyInstaller), la config doit vivre À CÔTÉ de l'exécutable pour que
l'établissement puisse l'éditer sans recompiler ; on copie la version embarquée
au premier lancement si elle est absente. En développement, on pointe directement
la config du dépôt. Aligné sur le pattern V5
(``Pseudonymisation_Gui_V5._resolve_config``), best-effort (jamais de crash).
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
from typing import Optional
_CONFIG_RELATIVE = Path("config") / "dictionnaires.yml"
def _frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def _bundled_config() -> Path:
"""Config embarquée : ``_MEIPASS`` en frozen, racine du dépôt en dev."""
if _frozen():
base = Path(getattr(sys, "_MEIPASS"))
else:
base = Path(__file__).resolve().parent.parent
return base / _CONFIG_RELATIVE
def resolve_user_config_path() -> Optional[Path]:
"""Chemin du ``dictionnaires.yml`` éditable par l'utilisateur.
- dev : la config du dépôt (éditable en place) ;
- frozen : ``<dossier de l'exe>/config/dictionnaires.yml`` ; copie la version
embarquée au premier lancement si absente, sans jamais écraser une config
existante (perso établissement).
Renvoie ``None`` si rien n'est résoluble (le moteur retombe alors sur sa
config runtime par défaut).
"""
if not _frozen():
bundled = _bundled_config()
return bundled if bundled.exists() else None
user_cfg = Path(sys.executable).resolve().parent / _CONFIG_RELATIVE
if user_cfg.exists():
return user_cfg
try:
user_cfg.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(_bundled_config(), user_cfg)
return user_cfg
except Exception:
bundled = _bundled_config()
return bundled if bundled.exists() else None
```
- [ ] **Step 4 : Câbler dans `gui_v6/app.py`**
En tête (après les imports `gui_v6`), ajouter :
```python
from gui_v6.config_paths import resolve_user_config_path
```
Dans `AnonymisationApp.__init__`, après `self._config = ConfigState()` :
```python
self._user_config_path = resolve_user_config_path()
```
Dans `_create_tab`, branche `"use"`, ajouter le kwarg à `UsageTab(...)` :
```python
return UsageTab(
self._content,
palette=p,
config_provider=lambda: self._config,
config_path=self._user_config_path,
on_theme_change=self.set_theme,
current_theme=self._theme_name,
usage_reporter=self._report_usage,
)
```
- [ ] **Step 5 : Lancer → vérifier le succès + non-régression socle**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_paths.py tests/unit/test_gui_v6_app_shell.py -q`
Expected: PASS (3 nouveaux + app_shell inchangés).
- [ ] **Step 6 : Commit**
```bash
git add gui_v6/config_paths.py gui_v6/app.py tests/unit/test_gui_v6_config_paths.py
git commit -m "feat(gui): charger le dictionnaires.yml externe éditable en frozen (P1-4)"
```
---
### Task 2 : Confirmation avant désactivation du NER (regex-only) — décision Dom 2026-06-29
**Problème :** Couper le toggle CamemBERT-bio (`tab_config.py:393-395`, callback `_on_ner:893`) bascule en anonymisation **regex seule** sans avertissement. Sur un outil médical, on confirme explicitement.
**Files:**
- Modify: `gui_v6/tabs/tab_config.py` (constante + helper pur + `_on_ner`)
- Create: `tests/unit/test_gui_v6_ner_confirm.py`
- [ ] **Step 1 : Écrire les tests (échouent — symbole absent)**
`tests/unit/test_gui_v6_ner_confirm.py` :
```python
"""Confirmation avant de désactiver le NER (regex-only) — outil médical.
Pur : la décision est isolée dans ``confirm_ner_disable(asker)`` ; ``asker`` est
injecté (pas de messagebox réel, pas de display).
"""
from __future__ import annotations
from gui_v6.tabs.tab_config import NER_DISABLE_WARNING, confirm_ner_disable
def test_confirm_true_when_user_accepts():
assert confirm_ner_disable(lambda: True) is True
def test_confirm_false_when_user_declines():
assert confirm_ner_disable(lambda: False) is False
def test_warning_text_is_explicit_for_medical_use():
txt = NER_DISABLE_WARNING.lower()
# L'avertissement DOIT nommer la dégradation : règles/regex + risque noms.
assert "règles" in txt or "regex" in txt
assert "nom" in txt
assert "recommand" in txt # « fortement recommandé »
```
- [ ] **Step 2 : Lancer → vérifier l'échec**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_ner_confirm.py -q`
Expected: FAIL (`ImportError: cannot import name 'confirm_ner_disable'`).
- [ ] **Step 3 : Ajouter la constante + le helper pur dans `tab_config.py`**
Après les imports (sous `from tkinter import filedialog, messagebox`), au niveau module :
```python
NER_DISABLE_WARNING = (
"Vous allez désactiver le moteur d'intelligence artificielle "
"(CamemBERT-bio).\n\n"
"Sans lui, la détection des NOMS de personnes repose uniquement sur des "
"règles (expressions régulières) : des noms peuvent rester EN CLAIR dans "
"les documents.\n\n"
"Pour un usage médical, garder ce moteur activé est fortement recommandé.\n\n"
"Confirmer la désactivation ?"
)
def confirm_ner_disable(asker) -> bool:
"""Décision de désactivation du NER.
``asker`` est une fonction ``() -> bool`` (ex. ``messagebox.askyesno``),
injectée pour rester testable sans display. Retourne True si l'utilisateur
CONFIRME la désactivation (regex-only), False sinon.
"""
return bool(asker())
```
- [ ] **Step 4 : Câbler `_on_ner` (intercepter le passage à OFF)**
Remplacer `_on_ner` (`tab_config.py:893-894`) par :
```python
def _on_ner(self) -> None:
new_value = self._tog_ner.get()
if not new_value:
confirmed = confirm_ner_disable(
lambda: messagebox.askyesno(
"Moteur de détection", NER_DISABLE_WARNING, icon="warning"
)
)
if not confirmed:
# Refus : rétablir l'affichage du switch et garder le NER actif.
self._tog_ner.var.set(True)
self._state.use_local_ner = True
return
self._state.use_local_ner = new_value
```
- [ ] **Step 5 : Lancer → vérifier le succès**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_ner_confirm.py tests/unit/test_gui_v6_config_mockup_sections.py -q`
Expected: PASS (3 nouveaux + sections inchangées).
- [ ] **Step 6 : Commit**
```bash
git add gui_v6/tabs/tab_config.py tests/unit/test_gui_v6_ner_confirm.py
git commit -m "feat(gui): confirmation explicite avant anonymisation regex-only (NER off)"
```
---
### Task 3 : P1-5 — Erreurs / quarantaine localisables + « Ouvrir le dossier »
**Problème :** En fin de run, `_finish` (`tab_usage.py:271-283`) n'indique pas OÙ trouver les documents livrés ni que les documents en échec/quarantaine ne sont **pas** anonymisés. Le testeur reste bloqué.
**Files:**
- Create: `gui_v6/fsutil.py` (ouverture cross-plateforme du dossier)
- Create: `tests/unit/test_gui_v6_result_hint.py`
- Modify: `gui_v6/tabs/tab_usage.py` (helper pur `failure_hint`, mémoriser le dossier de sortie, afficher hint + bouton)
- [ ] **Step 1 : Écrire les tests (échouent)**
`tests/unit/test_gui_v6_result_hint.py` :
```python
"""Message d'aide localisant les documents non livrés (P1-5) + ouverture dossier.
Pur : pas de display. ``failure_hint`` formate un texte ; ``open_in_file_manager``
dispatch vers la bonne commande OS (monkeypatchée).
"""
from __future__ import annotations
from pathlib import Path
import gui_v6.fsutil as fsutil
from gui_v6.processing_runner import RunSummary
from gui_v6.tabs.tab_usage import failure_hint
def test_no_hint_when_all_ok():
s = RunSummary(total=3, succeeded=3, failed=0)
assert failure_hint(s, Path("/out")) is None
def test_hint_when_failures_mentions_output_dir():
s = RunSummary(total=3, succeeded=2, failed=1)
hint = failure_hint(s, Path("/out/anonymise"))
assert hint is not None
assert "/out/anonymise" in hint
# Honnêteté : préciser que les échecs ne sont PAS anonymisés.
assert "pas" in hint.lower()
def test_hint_when_stopped():
s = RunSummary(total=3, succeeded=1, failed=0, stopped=True)
assert failure_hint(s, Path("/out")) is not None
def test_no_hint_without_output_dir():
s = RunSummary(total=1, succeeded=0, failed=1)
assert failure_hint(s, None) is None
def test_open_in_file_manager_dispatches(monkeypatch):
calls = {}
monkeypatch.setattr(fsutil.sys, "platform", "linux")
monkeypatch.setattr(fsutil.subprocess, "Popen", lambda args, **k: calls.setdefault("args", args))
fsutil.open_in_file_manager(Path("/out"))
assert calls["args"][0] == "xdg-open"
assert calls["args"][1] == "/out"
```
- [ ] **Step 2 : Lancer → vérifier l'échec**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_result_hint.py -q`
Expected: FAIL (`ModuleNotFoundError: gui_v6.fsutil` / `failure_hint` absent).
- [ ] **Step 3 : Implémenter `gui_v6/fsutil.py`**
```python
"""Ouverture du gestionnaire de fichiers sur un dossier (cross-plateforme).
Best-effort : ne lève jamais (un échec d'ouverture ne doit pas casser l'UI).
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
def open_in_file_manager(path) -> None:
"""Ouvre ``path`` dans l'explorateur de fichiers du système."""
target = str(Path(path))
try:
if sys.platform.startswith("win"):
import os
os.startfile(target) # type: ignore[attr-defined] # noqa: S606
elif sys.platform == "darwin":
subprocess.Popen(["open", target])
else:
subprocess.Popen(["xdg-open", target])
except Exception:
pass
```
- [ ] **Step 4 : Ajouter `failure_hint` (pur) dans `tab_usage.py`**
Au niveau module (après `_HELP_USAGE`) :
```python
def failure_hint(summary, output_dir) -> str | None:
"""Message localisant les documents livrés, ou None si run nominal.
Honnête : les documents en échec / quarantaine ne sont PAS anonymisés et
ne sont donc pas écrits dans le dossier de sortie.
"""
if summary is None or output_dir is None:
return None
if summary.failed == 0 and not getattr(summary, "stopped", False):
return None
return (
f"Documents anonymisés écrits dans : {output_dir}\n"
"Les documents en échec ou en quarantaine ne sont PAS anonymisés et "
"n'ont pas été écrits."
)
```
- [ ] **Step 5 : Mémoriser le dossier de sortie effectif + afficher hint/bouton**
Dans `_start`, après `run_runner, run_output_dir = self._build_run_runner()` :
```python
self._last_output_dir = run_output_dir or default_output_dir(self._input_path)
```
Dans `_finish`, à la fin (après `self._show_results(summary)`), ajouter l'appel :
```python
self._show_failure_hint(summary)
```
Puis ajouter la méthode (après `_show_results`) :
```python
def _show_failure_hint(self, summary) -> None:
hint = failure_hint(summary, getattr(self, "_last_output_dir", None))
if hint is None:
return
p = self._p
row = ctk.CTkFrame(self._rsec, fg_color="transparent")
row.pack(fill="x", padx=16, pady=(0, 12))
ctk.CTkLabel(
row, text=hint, text_color=p["text_dim"], font=ui_kit.font(11),
anchor="w", justify="left",
).pack(side="left", fill="x", expand=True)
ui_kit.secondary_button(
row, p, "📂 Ouvrir le dossier",
command=lambda: open_in_file_manager(self._last_output_dir),
).pack(side="right")
```
Ajouter en tête de `tab_usage.py` :
```python
from gui_v6.fsutil import open_in_file_manager
```
- [ ] **Step 6 : Lancer → vérifier le succès**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_result_hint.py tests/unit/test_gui_v6_processing_runner.py -q`
Expected: PASS.
- [ ] **Step 7 : Commit**
```bash
git add gui_v6/fsutil.py gui_v6/tabs/tab_usage.py tests/unit/test_gui_v6_result_hint.py
git commit -m "feat(gui): localiser les documents livrés + bouton ouvrir le dossier (P1-5)"
```
---
### Task 4 : P1-3 — Import / Export de configuration recâblés (workflow email V5)
**Problème :** Les boutons Partage sont des `_mockup_button` désactivés « (à venir) » (`tab_config.py:847,855`), alors que l'échange de config par email (export JSON → `merge_params` → renvoi YAML) est un workflow produit clé (CLAUDE.md). En V6, les listes whitelist/blacklist vivent dans le **profil actif** (`_pro_term_lists`, `tab_config.py:721-722,747-748`).
**Décision de modèle (à confirmer en revue) :** l'export produit le format V5 consommé par `merge_params` : `{"version", "date_export", "whitelist_phrases", "blacklist_force_mask_terms"}`, alimenté par les listes du **profil actuellement chargé dans l'éditeur** (`self._pro_term_lists`). L'import fusionne le JSON reçu dans le `dictionnaires.yml` utilisateur résolu (Task 1) via `merge_params`.
**Files:**
- Create: `gui_v6/config_share.py` (sérialisation pure export/import)
- Create: `tests/unit/test_gui_v6_config_share.py`
- Modify: `gui_v6/tabs/tab_config.py` (`_build_partage` : vrais boutons + handlers ; passer `config_path`)
- Modify: `gui_v6/app.py` (passer `config_path=self._user_config_path` à `ConfigTab`)
- [ ] **Step 1 : Écrire les tests (échouent)**
`tests/unit/test_gui_v6_config_share.py` :
```python
"""Export / import de configuration (P1-3) — format compatible merge_params.
Pur : sérialisation/désérialisation et fusion, sans display ni filedialog.
"""
from __future__ import annotations
import json
from pathlib import Path
from gui_v6.config_share import build_export_payload, import_config_file
def test_export_payload_has_v5_schema():
payload = build_export_payload(
whitelist=["Dr Métier", "Service ORL"],
blacklist=["DUPONT"],
version="2026.06.29",
)
assert payload["version"] == "2026.06.29"
assert "date_export" in payload
assert payload["whitelist_phrases"] == ["Dr Métier", "Service ORL"]
assert payload["blacklist_force_mask_terms"] == ["DUPONT"]
def test_export_payload_is_json_serializable():
payload = build_export_payload(whitelist=["A"], blacklist=["B"], version="1")
json.dumps(payload) # ne doit pas lever
def test_import_merges_into_user_config(tmp_path, monkeypatch):
# Config utilisateur YAML minimale.
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text("whitelist_phrases: [Existant]\n", encoding="utf-8")
# JSON reçu d'un établissement.
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({
"version": "1", "date_export": "2026-06-29",
"whitelist_phrases": ["Nouveau"],
"blacklist_force_mask_terms": ["MASQUERMOI"],
}),
encoding="utf-8",
)
added = import_config_file(incoming, cfg)
assert added is True
import yaml
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
assert "Existant" in merged["whitelist_phrases"]
assert "Nouveau" in merged["whitelist_phrases"]
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]
def test_import_returns_false_when_nothing_new(tmp_path):
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text("whitelist_phrases: [Deja]\n", encoding="utf-8")
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({"whitelist_phrases": ["Deja"], "blacklist_force_mask_terms": []}),
encoding="utf-8",
)
assert import_config_file(incoming, cfg) is False
```
- [ ] **Step 2 : Lancer → vérifier l'échec**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_share.py -q`
Expected: FAIL (`ModuleNotFoundError: gui_v6.config_share`).
- [ ] **Step 3 : Implémenter `gui_v6/config_share.py`**
```python
"""Échange de configuration par fichier JSON (workflow email V5, P1-3).
- ``build_export_payload`` : produit le dict V5 (consommé par
``scripts/merge_params.merge_params``) à partir des listes du profil courant ;
- ``import_config_file`` : fusionne un JSON reçu dans le ``dictionnaires.yml``
utilisateur, sans écraser l'existant (réutilise ``merge_params``).
Aucune dépendance à un widget : testable en pur Python.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
EXPORT_VERSION = "1"
def build_export_payload(
whitelist: Iterable[str], blacklist: Iterable[str], version: str = EXPORT_VERSION
) -> dict:
"""Construit la charge utile d'export au format consommé par merge_params."""
return {
"version": version,
"date_export": datetime.now(timezone.utc).isoformat(),
"whitelist_phrases": [str(t) for t in whitelist],
"blacklist_force_mask_terms": [str(t) for t in blacklist],
}
def _yaml_lists(config_path: Path) -> tuple[set, set]:
import yaml
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
wl = set(cfg.get("whitelist_phrases", []) or [])
bl = set((cfg.get("blacklist", {}) or {}).get("force_mask_terms", []) or [])
return wl, bl
def import_config_file(json_path, config_path) -> bool:
"""Fusionne ``json_path`` dans ``config_path`` (YAML). Retourne True si la
config a changé, False si rien de nouveau.
Fusion autonome (union des listes, jamais d'écrasement) — volontairement
SANS dépendance à ``scripts/merge_params`` (non bundlé en frozen). Même
sémantique : ``whitelist_phrases`` et ``blacklist.force_mask_terms``.
"""
import json
import yaml
json_path = Path(json_path)
config_path = Path(config_path)
before_wl, before_bl = _yaml_lists(config_path)
data = json.loads(json_path.read_text(encoding="utf-8"))
incoming_wl = {str(t).strip() for t in data.get("whitelist_phrases", []) if str(t).strip()}
incoming_bl = {str(t).strip() for t in data.get("blacklist_force_mask_terms", []) if str(t).strip()}
after_wl = before_wl | incoming_wl
after_bl = before_bl | incoming_bl
if after_wl == before_wl and after_bl == before_bl:
return False
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
cfg["whitelist_phrases"] = sorted(after_wl)
cfg.setdefault("blacklist", {})
cfg["blacklist"]["force_mask_terms"] = sorted(after_bl)
config_path.write_text(
yaml.dump(cfg, allow_unicode=True, default_flow_style=False, sort_keys=False),
encoding="utf-8",
)
return True
```
> Note implémentation : fusion autonome (pas d'import de `scripts/`), donc robuste en frozen. Le format JSON (`whitelist_phrases` / `blacklist_force_mask_terms`) reste celui consommé par `scripts/merge_params.py:52-58` → compatibilité email V5 préservée.
- [ ] **Step 4 : Recâbler `_build_partage` (vrais boutons)**
Remplacer les deux `self._mockup_button(...)` de `_build_partage` (`tab_config.py:847,855`) par de vrais boutons :
```python
ui_kit.secondary_button(
export, p, "⬇ Exporter (.json)", command=self._on_export_config
).pack(anchor="w", padx=12, pady=(0, 12))
...
ui_kit.secondary_button(
import_card, p, "⬆ Importer (.json)", command=self._on_import_config
).pack(anchor="w", padx=12, pady=(0, 12))
```
Ajouter les handlers (section « callbacks réglages », après `_on_manual_mask_template`) :
```python
def _on_export_config(self) -> None:
from gui_v6.config_share import build_export_payload
import json
lists = getattr(self, "_pro_term_lists", {})
wl = lists["whitelist"].terms() if "whitelist" in lists else []
bl = lists["blacklist"].terms() if "blacklist" in lists else []
path = filedialog.asksaveasfilename(
title="Exporter la configuration", defaultextension=".json",
filetypes=[("Configuration JSON", "*.json")],
)
if not path:
return
try:
payload = build_export_payload(wl, bl)
Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
messagebox.showinfo("Export", f"Configuration exportée :\n{path}")
except Exception as exc: # pragma: no cover - chemin UI
messagebox.showerror("Export", f"Échec de l'export : {exc}")
def _on_import_config(self) -> None:
from gui_v6.config_share import import_config_file
if self._config_path is None:
messagebox.showerror("Import", "Aucune configuration cible résolue.")
return
path = filedialog.askopenfilename(
title="Importer une configuration",
filetypes=[("Configuration JSON", "*.json")],
)
if not path:
return
try:
changed = import_config_file(path, self._config_path)
if changed:
messagebox.showinfo("Import", "Configuration fusionnée. Redémarrez pour appliquer.")
else:
messagebox.showinfo("Import", "Rien de nouveau à fusionner.")
except Exception as exc: # pragma: no cover - chemin UI
messagebox.showerror("Import", f"Échec de l'import : {exc}")
```
- [ ] **Step 5 : Donner à `ConfigTab` le `config_path`**
Dans `ConfigTab.__init__`, accepter et stocker `config_path` (signature : ajouter `config_path: Path | None = None`, et `self._config_path = config_path`). Dans `gui_v6/app.py:_create_tab`, branche `"cfg"` :
```python
if key == "cfg":
return ConfigTab(self._content, palette=p, state=self._config, config_path=self._user_config_path)
```
- [ ] **Step 6 : Lancer → vérifier le succès + non-régression**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_config_share.py tests/unit/test_gui_v6_config_mockup_sections.py tests/unit/test_gui_v6_app_shell.py -q`
Expected: PASS (le test `test_config_mockup_sections` reste vert : ne pas changer `CONFIG_MOCKUP_SECTIONS`, seulement le câblage des boutons Partage).
- [ ] **Step 7 : Commit**
```bash
git add gui_v6/config_share.py gui_v6/tabs/tab_config.py gui_v6/app.py tests/unit/test_gui_v6_config_share.py
git commit -m "feat(gui): recâbler import/export de configuration par email (P1-3)"
```
---
### Task 5 : P1-1 — Dropzone honnête (cliquable)
**Problème :** La zone « Choisissez vos fichiers » (`tab_usage.py:103-115`) suggère un glisser-déposer inexistant. Le DnD natif (`tkinterdnd2`) impose la lib native tkdnd à bundler dans le frozen → **différé** (ne pas alourdir le build torch-free du Plan 3). Fix bêta : rendre la zone **cliquable** (ouvre le sélecteur de fichier) + libellé honnête. Les boutons Fichier/Dossier existants restent.
**Files:**
- Modify: `gui_v6/tabs/tab_usage.py` (rendre la dropzone cliquable + libellé)
- [ ] **Step 1 : Rendre la dropzone cliquable + libellé honnête**
Dans `_build`, remplacer le label « Choisissez vos fichiers » et lier le clic. Après la création de `dz` (`tab_usage.py:103-106`), lier le clic de la zone et de ses labels à `_pick_file` :
```python
dz.bind("<Button-1>", lambda _e: self._pick_file())
ctk.CTkLabel(dz, text="⬆️", font=ui_kit.font(30)).pack(pady=(20, 4))
zone_lbl = ctk.CTkLabel(dz, text="Cliquez pour choisir un fichier", text_color=p["text"], font=ui_kit.font(14))
zone_lbl.pack()
zone_lbl.bind("<Button-1>", lambda _e: self._pick_file())
ctk.CTkLabel(dz, text="PDF · Word · Images · Texte", text_color=p["text_muted"], font=ui_kit.font(12)).pack(pady=(2, 10))
```
(la ligne `ctk.CTkLabel(dz, text="⬆️"...)` d'origine est remplacée par le bloc ci-dessus ; les boutons `acts` restent inchangés).
- [ ] **Step 2 : Smoke import (pas de régression d'import)**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py -q && .venv/bin/python Pseudonymisation_Gui_V6.py --self-test`
Expected: PASS + `GUI V6 self-test OK`.
- [ ] **Step 3 : Commit**
```bash
git add gui_v6/tabs/tab_usage.py
git commit -m "feat(gui): dropzone cliquable + libellé honnête (P1-1, DnD natif différé)"
```
---
### Task 6 (optionnel) : P1-6 — Valider l'inscriptibilité du dossier de sortie
**Problème :** Un dossier de sortie en lecture seule fait échouer **chaque** document avec un message cryptique (`processing_runner.py:215` `mkdir` puis échec par doc). Tester l'inscriptibilité en amont donne un message clair unique.
**Files:**
- Modify: `gui_v6/processing_runner.py` (`_run_impl` : test d'écriture amont)
- Create/Modify: `tests/unit/test_gui_v6_processing_runner.py` (nouveau test)
- [ ] **Step 1 : Test (échoue)**
Ajouter à `tests/unit/test_gui_v6_processing_runner.py` :
```python
def test_run_fails_fast_when_output_not_writable(tmp_path, monkeypatch):
from gui_v6.processing_runner import ProcessingRunner, OutputNotWritableError
src = tmp_path / "in"
src.mkdir()
(src / "a.txt").write_text("x", encoding="utf-8")
out = tmp_path / "ro"
out.mkdir()
def boom(*a, **k):
raise PermissionError("read-only")
monkeypatch.setattr("gui_v6.processing_runner.Path.mkdir", boom)
runner = ProcessingRunner(process_fn=lambda d, o: {})
import pytest
with pytest.raises(OutputNotWritableError):
runner.run(src, out)
```
- [ ] **Step 2 : Lancer → vérifier l'échec**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py::test_run_fails_fast_when_output_not_writable -q`
Expected: FAIL (`OutputNotWritableError` absent).
- [ ] **Step 3 : Implémenter le garde-fou**
Dans `gui_v6/processing_runner.py`, ajouter la classe d'erreur (près des dataclasses) :
```python
class OutputNotWritableError(RuntimeError):
"""Le dossier de sortie n'est pas inscriptible (échec amont, message clair)."""
```
Dans `_run_impl`, après le calcul de `out_root` (`:186`) et **avant** la boucle, vérifier une fois :
```python
try:
out_root.mkdir(parents=True, exist_ok=True)
probe = out_root / ".anon_write_test"
probe.write_text("", encoding="utf-8")
probe.unlink()
except Exception as exc:
raise OutputNotWritableError(
f"Dossier de sortie non inscriptible : {out_root} ({exc})"
) from exc
```
- [ ] **Step 4 : Lancer → vérifier le succès + non-régression runner**
Run: `.venv/bin/python -m pytest tests/unit/test_gui_v6_processing_runner.py -q`
Expected: PASS (nouveau + existants).
> Note UI : `UsageTab._handle_event`/`work()` capture déjà `Exception` → le message d'`OutputNotWritableError` s'affiche dans le journal. Vérifier qu'il remonte lisible (pas de doublon par document).
- [ ] **Step 5 : Commit**
```bash
git add gui_v6/processing_runner.py tests/unit/test_gui_v6_processing_runner.py
git commit -m "feat(gui): échec amont clair si dossier de sortie non inscriptible (P1-6)"
```
---
### Task 7 (optionnel) : P1-11 — Provenance audit `docTR` → `OnnxTR`
**Problème :** L'audit JSONL trace `original="docTR"` (`anonymizer_core_refactored_onnx.py:5356`) alors que l'OCR est OnnxTR depuis la migration. Trace périmée visible dans le livrable client.
**Files:**
- Modify: `anonymizer_core_refactored_onnx.py:5356`
- Test: gate `synthetic_regression` (pas de nouveau test dédié — provenance cosmétique).
- [ ] **Step 1 : Localiser et corriger**
Run: `.venv/bin/python - <<'PY'\nimport re,io\np="anonymizer_core_refactored_onnx.py"\ns=open(p,encoding="utf-8").read()\nprint([l for l in s.splitlines() if 'docTR' in l and 'original' in l][:3])\nPY`
Remplacer la valeur de provenance `"docTR"` par `"OnnxTR"` à la ligne d'audit identifiée (uniquement la chaîne de provenance OCR, ne pas toucher aux commentaires/docstrings cosmétiques P2-3).
- [ ] **Step 2 : Non-régression**
Run: `.venv/bin/python -m pytest tests/unit -q -k "synthetic_regression or audit"`
Expected: PASS (sortie d'anonymisation inchangée ; seule la provenance change).
- [ ] **Step 3 : Commit**
```bash
git add anonymizer_core_refactored_onnx.py
git commit -m "fix(core): corriger la provenance OCR de l'audit (docTR → OnnxTR, P1-11)"
```
---
## Clôture du Plan 1c
- [ ] **Suite complète + gate**
Run: `.venv/bin/python -m pytest tests/unit -q` (attendu : 436 + nouveaux tests, 0 régression) puis le gate `synthetic_regression` du dépôt.
- [ ] **Self-test GUI**
Run: `.venv/bin/python Pseudonymisation_Gui_V6.py --self-test`
Expected: `GUI V6 self-test OK`.
- [ ] **Revue + push**
Revue Qwen **courte** recommandée (1c = honnêteté UI, hors cœur sécurité masquage — pas de revue bloquante comme 1b, mais signaler la décision « confirmation NER » et le modèle export profil-actif). Puis push `gitea/feature/q1-quarantine-mvp`. Mettre à jour la mémoire projet `gui_v6_beta_prod_chantier.md`.
---
## Self-Review (auteur du plan)
**Couverture spec (chantier D) :** P1-2 = déjà livré (Plan 1b, hors périmètre ici) ✓ ; P1-1 = Task 5 (cliquable, DnD différé — **déviation assumée** vs spec « tkinterdnd2 », à valider Dom) ; P1-3 = Task 4 ; P1-4 = Task 1 ; P1-5 = Task 3 ; confirmation NER = Task 2 (décision Dom 2026-06-29) ; P1-6 = Task 6 (optionnel) ; P1-11 = Task 7 (optionnel). P2-1/P2-2 non traités (best-effort, non bloquants) — **signalé**.
**Placeholders :** aucun « TODO / à compléter » ; chaque step porte le code réel.
**Cohérence des types/symboles :** `resolve_user_config_path` (Task 1) réutilisé en Task 4 (Step 5) ; `failure_hint`/`open_in_file_manager` (Task 3) cohérents avec leur test ; `build_export_payload`/`import_config_file` (Task 4) ⇄ schéma `merge_params` (`whitelist_phrases` / `blacklist_force_mask_terms`) vérifié contre `scripts/merge_params.py:52-58`. `self._tog_ner.var` confirmé exposé par `_mini_toggle` (`tab_config.py:1107`).
**Hypothèses à confirmer en revue :**
1. **Task 4** exporte les listes du **profil chargé dans l'éditeur** (`_pro_term_lists`). Si l'attendu est « toutes les listes globales » ou « le profil par défaut », ajuster la source des listes.
2. **Task 5** dévie de la spec (DnD natif → cliquable). Justification : éviter la lib native tkdnd dans le build torch-free. À confirmer.
*(Hypothèse « scripts importable » levée : la fusion d'import est désormais autonome, frozen-safe — vérifié `scripts/__init__.py` absent et `scripts/` non bundlé.)*

View File

@@ -8,4 +8,6 @@ Lot G1 (socle) : thème, client/stockage licence, shell minimal, onglet À propo
__all__ = ["__version__"]
__version__ = "6.0.0-g1"
from gui_v6.version import resolve_version
__version__ = resolve_version()

View File

@@ -18,6 +18,7 @@ import customtkinter as ctk
from gui_v6 import theme as theme_mod
from gui_v6 import ui_kit
from gui_v6.config_paths import resolve_user_config_path
from gui_v6.config_state import ConfigState
from gui_v6.license_client import LicenseClient, LicenseStatus
from gui_v6.machine_id import default_machine_id
@@ -70,6 +71,7 @@ class AnonymisationApp(ctk.CTk):
self._theme_name = theme_name
self._license_client = license_client or LicenseClient(resolve_portal_url())
self._config = ConfigState()
self._user_config_path = resolve_user_config_path()
self._active = "use"
self._tab_buttons: dict = {}
self._tab_frames: dict = {}
@@ -187,12 +189,14 @@ class AnonymisationApp(ctk.CTk):
self._content,
palette=p,
config_provider=lambda: self._config,
config_path=self._user_config_path,
on_theme_change=self.set_theme,
current_theme=self._theme_name,
usage_reporter=self._report_usage,
diag_reporter=self._report_diagnostics,
)
if key == "cfg":
return ConfigTab(self._content, palette=p, state=self._config)
return ConfigTab(self._content, palette=p, state=self._config, config_path=self._user_config_path)
return AboutTab(
self._content,
palette=p,
@@ -240,6 +244,36 @@ class AnonymisationApp(ctk.CTk):
except Exception:
pass
def _report_diagnostics(self, summary) -> None:
"""Envoie les diagnostics en fin de run (non bloquant, best-effort).
N'envoie rien si aucune licence locale valide. Ne lève jamais.
"""
try:
from gui_v6 import __version__ as gui_version
from gui_v6 import diagnostics
from gui_v6.logging_setup import log_file_path
from gui_v6.machine_id import default_machine_id
session = self._usage_session()
if session is None:
return
status = self._safe_local_status()
base_url = getattr(self._license_client, "_base_url", "") or resolve_portal_url()
spool = log_file_path().parent / "diagnostics_spool.jsonl"
diagnostics.report_run_diagnostics(
summary,
base_url=base_url,
license_ref=getattr(status, "license_ref", None),
machine_id=default_machine_id(),
session=session,
app_name="gui_v6",
app_version=gui_version,
spool_path=spool,
)
except Exception:
pass
def _show(self, key: str) -> None:
self._active = key
self._refresh_tabbar()

62
gui_v6/config_paths.py Normal file
View File

@@ -0,0 +1,62 @@
"""Résolution du fichier de configuration externe éditable (dictionnaires.yml).
En frozen (PyInstaller), la config doit vivre À CÔTÉ de l'exécutable pour que
l'établissement puisse l'éditer sans recompiler ; on copie la version embarquée
au premier lancement si elle est absente. En développement, on pointe directement
la config du dépôt. Aligné sur le pattern V5
(``Pseudonymisation_Gui_V5._resolve_config``), best-effort (jamais de crash).
"""
from __future__ import annotations
import logging
import shutil
import sys
from pathlib import Path
from typing import Optional
log = logging.getLogger(__name__)
_CONFIG_RELATIVE = Path("config") / "dictionnaires.yml"
def _frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def _bundled_config() -> Path:
"""Config embarquée : ``_MEIPASS`` en frozen, racine du dépôt en dev."""
if _frozen():
base = Path(getattr(sys, "_MEIPASS"))
else:
base = Path(__file__).resolve().parent.parent
return base / _CONFIG_RELATIVE
def resolve_user_config_path() -> Optional[Path]:
"""Chemin du ``dictionnaires.yml`` éditable par l'utilisateur.
- dev : la config du dépôt (éditable en place) ; on ne crée jamais le fichier
(contrairement à la V5) : si absent, on renvoie ``None`` (le moteur retombe
sur sa config par défaut) ;
- frozen : ``<dossier de l'exe>/config/dictionnaires.yml`` ; copie la version
embarquée au premier lancement si absente, sans jamais écraser une config
existante (perso établissement).
Renvoie ``None`` si rien n'est résoluble (le moteur retombe alors sur sa
config runtime par défaut).
"""
if not _frozen():
bundled = _bundled_config()
return bundled if bundled.exists() else None
user_cfg = Path(sys.executable).resolve().parent / _CONFIG_RELATIVE
if user_cfg.exists():
return user_cfg
try:
user_cfg.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(_bundled_config(), user_cfg)
return user_cfg
except Exception as exc:
log.warning("copie de la configuration externe échouée (%s) : %s", user_cfg, exc)
bundled = _bundled_config()
return bundled if bundled.exists() else None

72
gui_v6/config_share.py Normal file
View File

@@ -0,0 +1,72 @@
"""Échange de configuration par fichier JSON (workflow email V5, P1-3).
- ``build_export_payload`` : produit le dict V5 (format consommé par
``scripts/merge_params.merge_params``) à partir des listes du profil courant ;
- ``import_config_file`` : fusionne un JSON reçu dans le ``dictionnaires.yml``
utilisateur, sans écraser l'existant.
Aucune dépendance à un widget : testable en pur Python.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
EXPORT_VERSION = "1"
def build_export_payload(
whitelist: Iterable[str], blacklist: Iterable[str], version: str = EXPORT_VERSION
) -> dict:
"""Construit la charge utile d'export au format consommé par merge_params."""
return {
"version": version,
"date_export": datetime.now(timezone.utc).isoformat(),
"whitelist_phrases": [str(t) for t in whitelist],
"blacklist_force_mask_terms": [str(t) for t in blacklist],
}
def _yaml_lists(config_path: Path) -> tuple[set, set]:
import yaml
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
wl = set(cfg.get("whitelist_phrases", []) or [])
bl = set((cfg.get("blacklist", {}) or {}).get("force_mask_terms", []) or [])
return wl, bl
def import_config_file(json_path, config_path) -> bool:
"""Fusionne ``json_path`` dans ``config_path`` (YAML). Retourne True si la
config a changé, False si rien de nouveau.
Fusion autonome (union des listes, jamais d'écrasement) — volontairement
SANS dépendance à ``scripts/merge_params`` (non bundlé en frozen). Même
sémantique : ``whitelist_phrases`` et ``blacklist.force_mask_terms``.
"""
import json
import yaml
json_path = Path(json_path)
config_path = Path(config_path)
before_wl, before_bl = _yaml_lists(config_path)
data = json.loads(json_path.read_text(encoding="utf-8"))
incoming_wl = {str(t).strip() for t in data.get("whitelist_phrases", []) if str(t).strip()}
incoming_bl = {str(t).strip() for t in data.get("blacklist_force_mask_terms", []) if str(t).strip()}
after_wl = before_wl | incoming_wl
after_bl = before_bl | incoming_bl
if after_wl == before_wl and after_bl == before_bl:
return False
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
cfg["whitelist_phrases"] = sorted(after_wl)
cfg.setdefault("blacklist", {})
cfg["blacklist"]["force_mask_terms"] = sorted(after_bl)
config_path.write_text(
yaml.dump(cfg, allow_unicode=True, default_flow_style=False, sort_keys=False),
encoding="utf-8",
)
return True

190
gui_v6/diagnostics.py Normal file
View File

@@ -0,0 +1,190 @@
"""Diagnostics structurés de la GUI V6 (E2/E3) — RGPD strict.
On n'émet QUE des métadonnées techniques liste-blanche : type d'exception
(nom de classe), catégorie d'erreur d'un ensemble fermé, statut, ordinal,
durée. JAMAIS de nom/chemin/texte de document, ni de message d'exception brut.
L'envoi est non bloquant : un échec réseau n'interrompt jamais le traitement.
Patron : gui_v6/usage_telemetry.py (télémétrie d'usage).
"""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import Any, Callable, Iterable, Optional
# Clés autorisées par item de diagnostic (filtre RGPD appliqué à la construction).
_ALLOWED_ITEM_KEYS = {"ordinal", "status", "error_type", "error_code", "duration_ms"}
REPORT_PATH = "/api/v1/diagnostics/report"
def new_run_id() -> str:
return uuid.uuid4().hex
def items_from_summary(summary: Any) -> list[dict]:
"""Extrait les items de diagnostic (RGPD-safe) d'un ``RunSummary``.
Ne lit que les attributs autorisés ; aucun nom/chemin/message n'est lu.
"""
items: list[dict] = []
for item in getattr(summary, "documents", None) or []:
items.append(
{
"ordinal": getattr(item, "ordinal", 0),
"status": getattr(item, "status", "success"),
"error_type": getattr(item, "error_type", None),
"error_code": getattr(item, "error_code", None),
"duration_ms": getattr(item, "duration_ms", None),
}
)
return items
def build_diagnostics_payload(
*,
run_id: str,
app_name: str,
app_version: Optional[str],
license_ref: Optional[str],
machine_id: Optional[str],
duration_ms: Optional[int],
items: Iterable[dict],
) -> dict:
"""Construit le payload diagnostic. Chaque item est filtré aux seules clés
autorisées → aucun nom/chemin/message ne peut fuir, même fourni par erreur."""
clean_items: list[dict] = []
succeeded = failed = 0
for raw in items:
it = {k: raw[k] for k in _ALLOWED_ITEM_KEYS if k in raw}
status = it.get("status")
if status == "success":
succeeded += 1
elif status == "failed":
failed += 1
clean_items.append(it)
return {
"run_id": run_id,
"license_ref": license_ref,
"machine_id": machine_id,
"app_name": app_name,
"app_version": app_version,
"duration_ms": duration_ms,
"document_count": len(clean_items),
"succeeded_count": succeeded,
"failed_count": failed,
"items": clean_items,
}
class DiagnosticsClient:
"""Envoie un payload diagnostic au portail. Non bloquant : capture toute erreur."""
def __init__(
self,
base_url: str,
session: Any,
timeout: float = 4.0,
logger: Optional[Callable[[str], None]] = None,
) -> None:
self._url = base_url.rstrip("/") + REPORT_PATH
self._session = session
self._timeout = timeout
self._log = logger or (lambda _msg: None)
def report(self, payload: dict) -> bool:
try:
resp = self._session.post(self._url, json=payload, timeout=self._timeout)
status = getattr(resp, "status_code", 0)
ok = 200 <= int(status) < 300
if not ok:
self._log(f"diagnostics report refusé (HTTP {status})")
return ok
except Exception as exc: # réseau absent, timeout, etc.
self._log(f"diagnostics report échec (non bloquant) : {exc}")
return False
def report_run_diagnostics(
summary: Any,
*,
base_url: str,
license_ref: Optional[str],
machine_id: Optional[str],
session: Any,
app_name: str = "gui_v6",
app_version: Optional[str] = None,
duration_ms: Optional[int] = None,
run_id: Optional[str] = None,
spool_path: Any = None,
logger: Optional[Callable[[str], None]] = None,
) -> bool:
"""Construit le payload depuis un ``RunSummary`` et l'envoie (non bloquant).
N'envoie RIEN si ``license_ref`` est absent. En cas d'échec réseau, spoole
le payload (si ``spool_path``) pour un rejeu ultérieur. Ne lève jamais.
"""
log = logger or (lambda _msg: None)
if not license_ref:
log("diagnostics ignorés : aucune licence locale valide")
return False
payload = build_diagnostics_payload(
run_id=run_id or new_run_id(),
app_name=app_name,
app_version=app_version,
license_ref=license_ref,
machine_id=machine_id,
duration_ms=duration_ms,
items=items_from_summary(summary),
)
client = DiagnosticsClient(base_url, session=session, logger=log)
ok = client.report(payload)
if not ok and spool_path is not None:
spool_payload(spool_path, payload)
return ok
def spool_payload(path: Any, payload: dict) -> None:
"""Ajoute un payload à la file JSONL locale (ne lève pas)."""
try:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
except Exception:
pass
def flush_spool(path: Any, client: "DiagnosticsClient") -> int:
"""Tente d'envoyer chaque payload en file ; conserve ceux qui échouent.
Retourne le nombre de payloads envoyés. Ne lève jamais.
"""
p = Path(path)
if not p.exists():
return 0
try:
lines = [ln for ln in p.read_text(encoding="utf-8").splitlines() if ln.strip()]
except Exception:
return 0
remaining: list[str] = []
sent = 0
for line in lines:
try:
payload = json.loads(line)
except Exception:
continue
if client.report(payload):
sent += 1
else:
remaining.append(line)
try:
if remaining:
p.write_text("\n".join(remaining) + "\n", encoding="utf-8")
else:
p.unlink(missing_ok=True)
except Exception:
pass
return sent

25
gui_v6/fsutil.py Normal file
View File

@@ -0,0 +1,25 @@
"""Ouverture du gestionnaire de fichiers sur un dossier (cross-plateforme).
Best-effort : ne lève jamais (un échec d'ouverture ne doit pas casser l'UI).
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
def open_in_file_manager(path) -> None:
"""Ouvre ``path`` dans l'explorateur de fichiers du système."""
target = str(Path(path))
try:
if sys.platform.startswith("win"):
import os
os.startfile(target) # type: ignore[attr-defined] # noqa: S606
elif sys.platform == "darwin":
subprocess.Popen(["open", target])
else:
subprocess.Popen(["xdg-open", target])
except Exception:
pass

View File

@@ -87,6 +87,32 @@ def _engine_result_error(result: object) -> str | None:
return None
# Ensemble FERMÉ de catégories d'erreur (aucune PII ne peut y entrer).
_ERROR_CODES = ("ner_unavailable", "quarantined", "no_output", "processing_error")
def classify_error_code(exc: Exception) -> str:
"""Catégorise une exception de run en une valeur de l'ensemble fermé _ERROR_CODES.
Lit le type et d'éventuels préfixes de message GÉNÉRÉS PAR NOUS pour classer ;
ne renvoie JAMAIS le message lui-même (RGPD). Inconnu → 'processing_error'.
"""
name = type(exc).__name__
if name == "EngineUnavailableError":
return "ner_unavailable"
msg = str(exc)
# ⚠ ANTI-DÉRIVE : ces littéraux DOIVENT rester synchronisés avec les messages
# produits par `_engine_result_error` ci-dessus ("Document mis en quarantaine :"
# et "Aucune sortie PDF anonymisée produite."). Si l'un est reformulé sans
# mettre à jour l'autre, l'erreur retombe silencieusement en 'processing_error'
# (couvert par les tests test_classify_error_code_*).
if "quarantaine" in msg:
return "quarantined"
if "Aucune sortie" in msg:
return "no_output"
return "processing_error"
def discover_documents(input_path, extensions: Optional[Sequence[str]] = None) -> list[Path]:
"""Liste les documents à traiter (fichier unique ou dossier récursif)."""
path = Path(input_path)
@@ -99,6 +125,10 @@ def discover_documents(input_path, extensions: Optional[Sequence[str]] = None) -
return []
class OutputNotWritableError(RuntimeError):
"""Le dossier de sortie n'est pas inscriptible (échec amont, message clair)."""
@dataclass
class DocResult:
"""Détail anonymisé d'un document traité (pour la télémétrie d'usage).
@@ -111,6 +141,10 @@ class DocResult:
status: str # "success" | "failed"
duration_ms: Optional[int]
extension: Optional[str]
# Diagnostics RGPD-safe : nom de classe d'exception + catégorie fermée.
# JAMAIS le message d'exception (str(exc)) ni nom/chemin de document.
error_type: Optional[str] = None
error_code: Optional[str] = None
@dataclass
@@ -195,6 +229,19 @@ class ProcessingRunner:
log("Aucun document supporté détecté.")
return summary
# Sonde amont : on vérifie une seule fois que le dossier de sortie est
# inscriptible AVANT la boucle, pour un échec clair et unique (P1-6)
# plutôt qu'une erreur cryptique répétée à chaque document.
try:
out_root.mkdir(parents=True, exist_ok=True)
probe = out_root / ".anon_write_test"
probe.write_text("", encoding="utf-8")
probe.unlink()
except Exception as exc:
raise OutputNotWritableError(
f"Dossier de sortie non inscriptible : {out_root} ({exc})"
) from exc
for index, doc in enumerate(docs, start=1):
if stop_event is not None and stop_event.is_set():
summary.stopped = True
@@ -207,6 +254,8 @@ class ProcessingRunner:
page_count = page_count_for(doc)
started = time.monotonic()
status = "success"
error_type = None
error_code = None
try:
if input_path.is_dir():
doc_out = build_batch_output_dir(root_dir, out_root, doc)
@@ -221,6 +270,8 @@ class ProcessingRunner:
log(f"OK : {doc.name}")
except Exception as exc: # un échec n'interrompt pas le lot
status = "failed"
error_type = type(exc).__name__
error_code = classify_error_code(exc)
summary.failed += 1
summary.errors.append((doc.name, str(exc)))
log(f"ÉCHEC : {doc.name}{exc}")
@@ -231,6 +282,8 @@ class ProcessingRunner:
status=status,
duration_ms=int((time.monotonic() - started) * 1000),
extension=extension,
error_type=error_type,
error_code=error_code,
)
)
if on_progress:

View File

@@ -4,6 +4,10 @@
``AppMutex`` dans installer/Anonymisation.iss (Plan 3 / D8) pour que l'installeur
ferme l'app avant une mise à jour.
- POSIX (dev/test) : verrou ``fcntl`` exclusif sur un fichier dans le dossier app.
Précondition D8 : la GUI n'a pas de réduction en zone de notification (tray). L'AppMutex
+ ``CloseApplications`` de l'installeur suffisent car fermer la fenêtre termine le process.
Si un mode tray était ajouté, le process survivrait au ``WM_CLOSE`` et D8 devrait être revu.
"""
from __future__ import annotations

View File

@@ -20,6 +20,31 @@ from gui_v6 import ui_kit
from gui_v6.config_state import ConfigState, default_profile_key, list_profile_keys
from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_template_label
NER_DISABLE_WARNING = (
"Vous allez désactiver le moteur d'intelligence artificielle "
"(CamemBERT-bio).\n\n"
"Sans lui, la détection des NOMS de personnes repose uniquement sur des "
"règles (expressions régulières) : des noms peuvent rester EN CLAIR dans "
"les documents.\n\n"
"Pour un usage médical, garder ce moteur activé est fortement recommandé.\n\n"
"Confirmer la désactivation ?"
)
def confirm_ner_disable(asker) -> bool:
"""Décision de désactivation du NER.
``asker`` est une fonction ``() -> bool`` (ex. ``messagebox.askyesno``),
injectée pour rester testable sans display. Retourne True si l'utilisateur
CONFIRME la désactivation (regex-only). Toute erreur de l'asker est traitée
comme un refus (sens sûr : le NER reste actif).
"""
try:
return bool(asker())
except Exception:
return False
_SUBTABS = [
("reg", "⚙️ Réglages"),
("pro", "👤 Profils"),
@@ -145,12 +170,15 @@ _HELP_PROFIL_MOTS = (
"Pour de longues listes, utilisez le tableau des termes afin de rechercher et vérifier plus facilement."
)
_HELP_EXPORT_CONFIG = (
"Exporte uniquement les réglages de l'application : profils, listes locales, règles et style de masque.\n\n"
"Exporte vos deux listes locales — termes à toujours conserver et termes à toujours "
"masquer — dans un fichier .json à envoyer à un autre poste ou à l'administrateur.\n\n"
"Les documents patients, résultats d'anonymisation et audits ne sont pas exportés."
)
_HELP_IMPORT_CONFIG = (
"Importe des réglages reçus d'un administrateur ou d'un autre poste.\n\n"
"L'import ne lit pas de documents patients. Vérifiez toujours le profil actif après import."
"Importe les deux listes locales (termes à conserver, termes à masquer) reçues d'un "
"autre poste ou de l'administrateur. La fusion ajoute ces termes à votre configuration "
"sans rien écraser.\n\n"
"L'import ne lit pas de documents patients. Redémarrez l'application pour appliquer."
)
CONFIG_MOCKUP_SECTIONS = {
@@ -198,10 +226,11 @@ def _app_base_dir() -> Path:
class ConfigTab(ctk.CTkFrame):
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs):
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, config_path: Path | None = None, **kwargs):
self._p = palette or theme_mod.get_palette(theme_mod.DEFAULT_THEME)
super().__init__(master, fg_color=self._p["bg"], **kwargs)
self._state = state if state is not None else ConfigState()
self._config_path = config_path
self._sub = "reg"
self._sub_buttons: dict[str, ctk.CTkButton] = {}
self._panels: dict[str, ctk.CTkFrame] = {}
@@ -843,16 +872,20 @@ class ConfigTab(ctk.CTkFrame):
help_text=_HELP_EXPORT_CONFIG, help_title="Exporter la configuration",
)
export.pack(fill="both", expand=True)
self._note(export, "Listes locales, règles admin, style de masquage et template actif.")
self._mockup_button(export, "⬇ Exporter (.json)").pack(anchor="w", padx=12, pady=(0, 12))
self._note(export, "Vos listes locales : termes à toujours conserver et termes à toujours masquer.")
ui_kit.secondary_button(
export, p, "⬇ Exporter (.json)", command=self._on_export_config
).pack(anchor="w", padx=12, pady=(0, 12))
import_card = ui_kit.Card(
cols[1], p, title="📥 Importer une configuration",
help_text=_HELP_IMPORT_CONFIG, help_title="Importer une configuration",
)
import_card.pack(fill="both", expand=True)
self._note(import_card, "Fusionne la configuration reçue avec vos réglages locaux.")
self._mockup_button(import_card, "⬆ Importer (.json)").pack(anchor="w", padx=12, pady=(0, 12))
self._note(import_card, "Fusionne les listes reçues (à conserver / à masquer) avec vos listes locales.")
ui_kit.secondary_button(
import_card, p, "⬆ Importer (.json)", command=self._on_import_config
).pack(anchor="w", padx=12, pady=(0, 12))
# -- helpers aide / maquette -----------------------------------------
@@ -891,7 +924,19 @@ class ConfigTab(ctk.CTkFrame):
setattr(self._state, field_name, bool(toggle.get()))
def _on_ner(self) -> None:
self._state.use_local_ner = self._tog_ner.get()
new_value = self._tog_ner.get()
if not new_value:
confirmed = confirm_ner_disable(
lambda: messagebox.askyesno(
"Moteur de détection", NER_DISABLE_WARNING, icon="warning"
)
)
if not confirmed:
# Refus : rétablir l'affichage du switch et garder le NER actif.
self._tog_ner.var.set(True)
self._state.use_local_ner = True
return
self._state.use_local_ner = new_value
def _on_eds(self) -> None:
self._state.enable_eds = self._tog_eds.get()
@@ -905,6 +950,47 @@ class ConfigTab(ctk.CTkFrame):
def _on_manual_mask_template(self, label: str) -> None:
self._state.manual_mask_template = self._manual_mask_templates.get(label)
def _on_export_config(self) -> None:
from gui_v6.config_share import build_export_payload
import json
lists = getattr(self, "_pro_term_lists", {})
wl = lists["whitelist"].terms() if "whitelist" in lists else []
bl = lists["blacklist"].terms() if "blacklist" in lists else []
path = filedialog.asksaveasfilename(
title="Exporter la configuration", defaultextension=".json",
filetypes=[("Configuration JSON", "*.json")],
)
if not path:
return
try:
payload = build_export_payload(wl, bl)
Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
messagebox.showinfo("Export", f"Configuration exportée :\n{path}")
except Exception as exc: # pragma: no cover - chemin UI
messagebox.showerror("Export", f"Échec de l'export : {exc}")
def _on_import_config(self) -> None:
from gui_v6.config_share import import_config_file
if self._config_path is None:
messagebox.showerror("Import", "Aucune configuration cible résolue.")
return
path = filedialog.askopenfilename(
title="Importer une configuration",
filetypes=[("Configuration JSON", "*.json")],
)
if not path:
return
try:
changed = import_config_file(path, self._config_path)
if changed:
messagebox.showinfo("Import", "Configuration fusionnée. Redémarrez pour appliquer.")
else:
messagebox.showinfo("Import", "Rien de nouveau à fusionner.")
except Exception as exc: # pragma: no cover - chemin UI
messagebox.showerror("Import", f"Échec de l'import : {exc}")
def _pick_output(self) -> None:
path = filedialog.askdirectory(title="Dossier de sortie")
if path:

View File

@@ -18,6 +18,7 @@ import customtkinter as ctk
from gui_v6 import theme as theme_mod
from gui_v6 import ui_kit
from gui_v6.fsutil import open_in_file_manager
from gui_v6.processing_runner import ProcessingRunner, default_output_dir
_STEPS = ["📖 Extraction", "🧠 Détection", "🔒 Masquage", "📄 PDF final"]
@@ -33,6 +34,29 @@ _HELP_USAGE = (
)
def failure_hint(summary, output_dir) -> str | None:
"""Message localisant les documents livrés, ou None si run nominal.
Honnête : les documents en échec / quarantaine ne sont PAS anonymisés et
ne sont donc pas écrits. Si AUCUN document n'a abouti, on ne prétend pas
qu'un dossier de sortie contient des documents anonymisés.
"""
if summary is None or output_dir is None:
return None
if summary.failed == 0 and not getattr(summary, "stopped", False):
return None
if summary.succeeded == 0:
return (
"Aucun document n'a été anonymisé. Les documents en échec ou en "
"quarantaine ne sont PAS anonymisés et n'ont pas été écrits."
)
return (
f"Documents anonymisés écrits dans : {output_dir}\n"
"Les documents en échec ou en quarantaine ne sont PAS anonymisés et "
"n'ont pas été écrits."
)
class UsageTab(ctk.CTkFrame):
def __init__(
self,
@@ -44,6 +68,7 @@ class UsageTab(ctk.CTkFrame):
on_theme_change=None,
current_theme: str = theme_mod.DEFAULT_THEME,
usage_reporter=None,
diag_reporter=None,
**kwargs,
):
self._p = palette or theme_mod.get_palette(current_theme)
@@ -56,9 +81,13 @@ class UsageTab(ctk.CTkFrame):
# Callback(summary) appelé en fin de run pour la télémétrie d'usage
# (envoi non bloquant, injecté par l'app avec le contexte licence).
self._usage_reporter = usage_reporter
# Callback(summary) appelé en fin de run pour les diagnostics RGPD
# (envoi non bloquant, injecté par l'app avec le contexte licence).
self._diag_reporter = diag_reporter
self._input_path: Path | None = None
self._output_dir: Path | None = None
self._last_output_dir: Path | None = None
self._stop_event: threading.Event | None = None
self._is_running = False
self._events: "queue.Queue[tuple]" = queue.Queue()
@@ -104,9 +133,19 @@ class UsageTab(ctk.CTkFrame):
docs, fg_color=p["divider"], border_color=p["card_border"], border_width=2, corner_radius=8
)
dz.pack(fill="x", padx=16, pady=(0, 8))
ctk.CTkLabel(dz, text="⬆️", font=ui_kit.font(30)).pack(pady=(20, 4))
ctk.CTkLabel(dz, text="Choisissez vos fichiers", text_color=p["text"], font=ui_kit.font(14)).pack()
ctk.CTkLabel(dz, text="PDF · Word · Images · Texte", text_color=p["text_muted"], font=ui_kit.font(12)).pack(pady=(2, 10))
# Zone réellement cliquable (ouvre le sélecteur de fichier). Le DnD natif
# est différé ; on ne laisse pas une métaphore « déposer » trompeuse.
dz.configure(cursor="hand2")
dz.bind("<Button-1>", lambda _e: self._pick_file())
for _txt, _color, _size, _pady in (
("⬆️", p["text"], 30, (20, 4)),
("Cliquez pour choisir un fichier", p["text"], 14, 0),
("PDF · Word · Images · Texte", p["text_muted"], 12, (2, 10)),
):
_lbl = ctk.CTkLabel(dz, text=_txt, text_color=_color, font=ui_kit.font(_size))
_lbl.configure(cursor="hand2")
_lbl.bind("<Button-1>", lambda _e: self._pick_file())
_lbl.pack(pady=_pady)
acts = ctk.CTkFrame(dz, fg_color="transparent")
acts.pack(pady=(0, 20))
ui_kit.secondary_button(acts, p, "📄 Fichier", command=self._pick_file).pack(side="left", padx=4)
@@ -158,6 +197,7 @@ class UsageTab(ctk.CTkFrame):
self._stats_row = ctk.CTkFrame(self._rsec, fg_color="transparent")
self._stats_row.pack(fill="x", padx=16, pady=(0, 14))
self._result_built = False
self._hint_row = None
# -- thème ------------------------------------------------------------
@@ -211,6 +251,7 @@ class UsageTab(ctk.CTkFrame):
return
self._is_running = True
run_runner, run_output_dir = self._build_run_runner()
self._last_output_dir = run_output_dir or default_output_dir(self._input_path)
self._stop_event = threading.Event()
self._run_btn.configure(state="disabled")
self._psec.pack(fill="x", padx=14, pady=7)
@@ -281,7 +322,9 @@ class UsageTab(ctk.CTkFrame):
self._progress.set(1.0)
self._set_status(f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}.")
self._show_results(summary)
self._show_failure_hint(summary)
self._send_usage_telemetry(summary)
self._send_diagnostics(summary)
def _send_usage_telemetry(self, summary) -> None:
"""Envoie la télémétrie d'usage en fin de run, sans bloquer l'UI ni le run."""
@@ -297,6 +340,20 @@ class UsageTab(ctk.CTkFrame):
threading.Thread(target=work, daemon=True).start()
def _send_diagnostics(self, summary) -> None:
"""Envoie les diagnostics en fin de run, sans bloquer l'UI ni le run."""
reporter = self._diag_reporter
if reporter is None:
return
def work():
try:
reporter(summary)
except Exception:
pass # un échec diagnostic ne doit jamais remonter
threading.Thread(target=work, daemon=True).start()
def _show_results(self, summary) -> None:
p = self._p
for w in self._stats_row.winfo_children():
@@ -311,6 +368,28 @@ class UsageTab(ctk.CTkFrame):
ui_kit.StatCard(self._stats_row, p, value, label, value_color=color).pack(side="left", expand=True, fill="x", padx=4)
self._rsec.pack(fill="x", padx=14, pady=(7, 14))
def _show_failure_hint(self, summary) -> None:
# Nettoyage inconditionnel : éviter l'empilement et les hints périmés.
if getattr(self, "_hint_row", None) is not None:
self._hint_row.destroy()
self._hint_row = None
hint = failure_hint(summary, getattr(self, "_last_output_dir", None))
if hint is None:
return
p = self._p
self._hint_row = ctk.CTkFrame(self._rsec, fg_color="transparent")
self._hint_row.pack(fill="x", padx=16, pady=(0, 12))
ctk.CTkLabel(
self._hint_row, text=hint, text_color=p["text_dim"], font=ui_kit.font(11),
anchor="w", justify="left",
).pack(side="left", fill="x", expand=True)
# Bouton « ouvrir » uniquement s'il y a réellement des documents livrés.
if summary is not None and summary.succeeded > 0:
ui_kit.secondary_button(
self._hint_row, p, "📂 Ouvrir le dossier",
command=lambda: open_in_file_manager(self._last_output_dir),
).pack(side="right")
# -- helpers widgets --------------------------------------------------
def _set_status(self, text: str) -> None:

18
gui_v6/version.py Normal file
View File

@@ -0,0 +1,18 @@
"""Résolution de la version affichée/télémesurée de la GUI V6 (P1-7, Plan 3).
Au build Windows, scripts/build_windows_oneclick.ps1 génère gui_v6/_build_version.py
contenant BUILD_VERSION = "2026.MM.JJ.HHMM" (même valeur que l'AppVersion de
l'installeur et que build_info.BUILD_VERSION). Ce fichier n'est PAS commité
(.gitignore). En dev, repli sur DEFAULT_VERSION.
"""
DEFAULT_VERSION = "6.0.0-dev"
def resolve_version(default: str = DEFAULT_VERSION) -> str:
try:
from gui_v6._build_version import BUILD_VERSION
except Exception:
return default
version = str(BUILD_VERSION).strip()
return version if version else default

View File

@@ -24,6 +24,11 @@ SolidCompression=yes
WizardStyle=modern
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
; D8 (Plan 3) : MAJ propre — ferme l'app avant remplacement de l'EXE.
; AppMutex = gui_v6/single_instance.py:APP_MUTEX_NAME (NE PAS désynchroniser).
AppMutex=AivanonymAnonymisationV6
CloseApplications=yes
RestartApplications=no
[Languages]
Name: "french"; MessagesFile: "compiler:Languages\French.isl"

View File

@@ -266,6 +266,11 @@ Require-Path -PathValue $VenvPython -Label "Python du venv"
Push-Location $ProjectRoot
try {
# P1-7 (Plan 3) : version release unique, réutilisée par build_info.py,
# gui_v6/_build_version.py et l'installeur Inno Setup (/DAppVersion).
$ReleaseVersion = (Get-Date -Format "yyyy.MM.dd.HHmm")
Write-Host "Version release : $ReleaseVersion"
Write-Step "Installation des dépendances de build"
& $VenvPython -m pip install --upgrade pip setuptools wheel
if (-not $SkipRequirements) {
@@ -273,6 +278,28 @@ try {
}
& $VenvPython -m pip install pyinstaller
if ($GuiV6) {
Write-Step "Purge torch/optimum du venv de build (P0-3, GUI V6 torch-free)"
# optimum[onnxruntime] (requirements.txt) tire torch en dépendance cœur ;
# la GUI V6 ne l'utilise jamais (NER = onnxruntime brut, OCR = OnnxTR).
# La spec legacy V5 garde torch : purge limitée au flavor GUI V6.
& $VenvPython -m pip uninstall -y torch torchvision optimum 2>$null
& $VenvPython -c "import importlib.util,sys; sys.exit(1 if importlib.util.find_spec('torch') else 0)"
if ($LASTEXITCODE -ne 0) {
throw "torch encore importable dans le venv de build : purge P0-3 échouée."
}
Write-Host "Venv de build torch-free : OK"
}
Write-Step "Précache des poids OnnxTR (P0-4)"
# La spec PyInstaller raise FileNotFoundError si db_resnet50/crnn_vgg16_bn
# sont absents du cache : on les télécharge explicitement au lieu de
# dépendre du cache résiduel de la machine.
& $VenvPython -c "from onnxtr.models import ocr_predictor; ocr_predictor(det_arch='db_resnet50', reco_arch='crnn_vgg16_bn'); print('Poids OnnxTR en cache : OK')"
if ($LASTEXITCODE -ne 0) {
throw "Précache OnnxTR échoué (réseau ? proxy ?) : le build frozen échouerait sur les poids manquants."
}
Write-Step "Génération de build_info.py"
$commit = "local"
$branch = "local"
@@ -291,10 +318,21 @@ BUILD_DATE = "$buildDate"
BUILD_COMMIT = "$commit"
BUILD_BRANCH = "$branch"
BUILD_FLAVOR = "$BuildFlavor"
BUILD_VERSION = "$ReleaseVersion"
"@
Set-Content -Path $BuildInfoPath -Value $buildInfo -Encoding UTF8
Write-Host "Build info : $buildDate / $branch / $commit"
if ($GuiV6) {
$BuildVersionPath = Join-Path $ProjectRoot "gui_v6\_build_version.py"
$buildVersionContent = @"
"""Version release - généré automatiquement par build_windows_oneclick.ps1 (P1-7)."""
BUILD_VERSION = "$ReleaseVersion"
"@
Set-Content -Path $BuildVersionPath -Value $buildVersionContent -Encoding UTF8
Write-Host "gui_v6/_build_version.py : $ReleaseVersion"
}
Write-Step "Nettoyage des anciens artefacts"
foreach ($PathValue in @($BuildDir, $DistDir, $PackageDir)) {
if (Test-Path $PathValue) {
@@ -367,8 +405,7 @@ Build :
$innoCompiler = Resolve-InnoCompiler
if ($innoCompiler) {
Write-Host "Inno Setup Compiler : $innoCompiler"
$installerVersion = (Get-Date -Format "yyyy.MM.dd.HHmm")
& $innoCompiler "/DAppVersion=$installerVersion" $InstallerScriptPath
& $innoCompiler "/DAppVersion=$ReleaseVersion" $InstallerScriptPath
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup a échoué avec le code $LASTEXITCODE."
}

View File

@@ -0,0 +1,71 @@
"""Anti-dérive P0-3 (Plan 3) : les specs frozen GUI V6 et CLI doivent être torch-free.
On vérifie le TEXTE des specs (pas d'exécution PyInstaller sous Linux) :
- aucun hiddenimport optimum*/torch*/doctr* ;
- excludes explicites présents (torch, torchvision, optimum, doctr) — ceinture
et bretelles : même si le venv de build contient torch, l'analyse l'exclut
(le core fait un `import torch` lazy dans _configure_torch_threads, que
l'analyse statique de PyInstaller suivrait sans excludes).
La spec GUI V5 legacy (anonymisation_onefile.spec) n'est PAS concernée.
"""
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TORCH_FREE_SPECS = [
ROOT / "anonymisation_gui_v6_onefile.spec",
ROOT / "anonymisation_cli_onefile.spec",
]
FORBIDDEN_HIDDEN = ("optimum", "torch", "torchvision", "doctr")
REQUIRED_EXCLUDES = ("torch", "torchvision", "optimum", "doctr")
def _hiddenimport_strings(text, spec_name):
"""Retourne les chaînes littérales présentes dans la section hiddenimports=[...].
On extrait la portion entre `hiddenimports = [` et le PREMIER `]` rencontré
où qu'il soit (`[^\\]]*` ne gère pas l'imbrication : un `]` dans un
commentaire au sein de la liste tronquerait le bloc — acceptable ici, les
specs n'en contiennent pas). Cette restriction évite les faux positifs du
bloc EXCLUDED_TORCH_STACK qui contient légitimement "torch", "optimum", etc.
Garde-fou : si le bloc hiddenimports est introuvable (renommage, refonte de
la spec), on échoue BRUYAMMENT au lieu de retourner [] — sinon le test
passerait à vide sans plus rien vérifier.
"""
match = re.search(r"hiddenimports\s*=\s*\[([^\]]*)\]", text, re.DOTALL)
assert match is not None, f"bloc hiddenimports introuvable dans {spec_name}"
block = match.group(1)
return re.findall(r"[\"']([A-Za-z0-9_.]+)[\"']", block)
@pytest.mark.parametrize("spec_path", TORCH_FREE_SPECS, ids=lambda p: p.name)
def test_spec_sans_hiddenimport_torch_optimum_doctr(spec_path):
text = spec_path.read_text(encoding="utf-8")
hits = [
s for s in _hiddenimport_strings(text, spec_path.name)
if s.split(".")[0] in FORBIDDEN_HIDDEN
]
assert hits == [], f"{spec_path.name} référence encore : {hits}"
@pytest.mark.parametrize("spec_path", TORCH_FREE_SPECS, ids=lambda p: p.name)
def test_spec_declare_excludes_torch(spec_path):
text = spec_path.read_text(encoding="utf-8")
assert "excludes=EXCLUDED_TORCH_STACK" in text, (
f"{spec_path.name} : Analysis() sans excludes=EXCLUDED_TORCH_STACK"
)
for name in REQUIRED_EXCLUDES:
assert f'"{name}"' in text.split("EXCLUDED_TORCH_STACK")[1].split("]")[0], (
f"{spec_path.name} : '{name}' absent de EXCLUDED_TORCH_STACK"
)
def test_spec_legacy_v5_garde_optimum():
text = (ROOT / "anonymisation_onefile.spec").read_text(encoding="utf-8")
assert '"optimum"' in text, (
"anonymisation_onefile.spec (GUI V5 legacy) doit GARDER optimum — "
"si ce test casse, quelqu'un a modifié la spec legacy par erreur."
)

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Tests — chargement du gazetteer médicaments edsnlp depuis data/ (torch-free).
Contexte : le build Windows torch-free (Plan 3) retire torch. Or edsnlp importe
torch en dur → en frozen, `import edsnlp` échoue et l'ancienne
`_load_edsnlp_drug_names()` retournait silencieusement set() → whitelist
médicaments amputée de ~4206 noms → sur-masquage de médicaments pris pour des
personnes.
Correctif (Option A+B) :
A. charger d'abord depuis data/edsnlp/drugs.json (versionné, 0 dépendance) ;
fallback sur le package edsnlp (dev).
B. log.warning explicite si NI le fichier data NI le package ne sont dispo.
Aucun mock du contenu du gazetteer : on utilise le VRAI fichier data extrait.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import anonymizer_core_refactored_onnx as core
ROOT_DIR = Path(__file__).resolve().parents[2]
DATA_DRUGS = ROOT_DIR / "data" / "edsnlp" / "drugs.json"
# Nombre exact de noms mono-mot (len>=4, lowercase) issus de drugs.json 0.20.0.
# C'est aussi le compte historique produit par l'ancienne fonction en dev :
# la garantie de non-régression est que la whitelist n'est PAS réduite.
EXPECTED_COUNT = 4206
# Noms de médicaments réellement présents dans le gazetteer extrait et qui
# entrent en conflit avec des noms/prénoms INSEE (vérifiés, pas inventés).
CONFLICT_NAMES = ["elisor", "kessar", "panos", "muse", "sirop"]
def test_data_file_present_and_parses():
"""Le fichier data doit exister et contenir 1968 codes ATC."""
import json
assert DATA_DRUGS.exists(), f"fichier data manquant : {DATA_DRUGS}"
data = json.loads(DATA_DRUGS.read_text(encoding="utf-8"))
assert len(data) == 1968
def test_load_from_data_exact_count():
"""Chargement depuis data/edsnlp/drugs.json → set de 4206 noms exactement."""
result = core._load_edsnlp_drug_names()
assert isinstance(result, set)
assert len(result) == EXPECTED_COUNT
def test_load_contains_conflict_names():
"""Les noms-conflits INSEE vérifiés doivent être dans le set (anti-sur-masquage)."""
result = core._load_edsnlp_drug_names()
for name in CONFLICT_NAMES:
assert name in result, f"{name!r} absent du gazetteer médicaments"
def test_fallback_to_package_when_data_absent(monkeypatch, tmp_path):
"""Si le fichier data est absent mais edsnlp importable → fallback package,
même résultat (4206)."""
pytest.importorskip("edsnlp")
# Pointer la constante de chemin data vers un dossier vide → fichier absent.
missing = tmp_path / "drugs.json"
monkeypatch.setattr(core, "_EDSNLP_DRUGS_DATA_PATH", missing)
assert not missing.exists()
result = core._load_edsnlp_drug_names()
assert len(result) == EXPECTED_COUNT
for name in CONFLICT_NAMES:
assert name in result
def test_warning_when_both_sources_absent(monkeypatch, tmp_path, caplog):
"""Si le fichier data est absent ET edsnlp non importable → set() + log.warning."""
import builtins
missing = tmp_path / "drugs.json"
monkeypatch.setattr(core, "_EDSNLP_DRUGS_DATA_PATH", missing)
_real_import = builtins.__import__
def _fake_import(name, *args, **kwargs):
if name == "edsnlp" or name.startswith("edsnlp."):
raise ImportError("edsnlp indisponible (torch-free)")
return _real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _fake_import)
with caplog.at_level("WARNING", logger=core.log.name):
result = core._load_edsnlp_drug_names()
assert result == set()
assert any(
"edsnlp" in rec.message.lower() and rec.levelname == "WARNING"
for rec in caplog.records
), "aucun log.warning émis lors de l'échec total"
def test_data_source_matches_package_source(monkeypatch, tmp_path):
"""Le set chargé depuis data doit être IDENTIQUE à celui du fallback package
(garantie que l'extraction n'altère pas le gazetteer)."""
pytest.importorskip("edsnlp")
from_data = core._load_edsnlp_drug_names()
missing = tmp_path / "drugs.json"
monkeypatch.setattr(core, "_EDSNLP_DRUGS_DATA_PATH", missing)
from_package = core._load_edsnlp_drug_names()
assert from_data == from_package
def test_corrupted_data_falls_back_to_package(monkeypatch, tmp_path):
"""Fichier data PRÉSENT mais corrompu + edsnlp DISPONIBLE → fail-safe :
retombe sur le package et retourne 4206 (jamais un set partiel/tronqué)."""
pytest.importorskip("edsnlp")
corrupt = tmp_path / "drugs.json"
corrupt.write_text("{ invalide", encoding="utf-8")
monkeypatch.setattr(core, "_EDSNLP_DRUGS_DATA_PATH", corrupt)
result = core._load_edsnlp_drug_names()
assert len(result) == EXPECTED_COUNT
for name in CONFLICT_NAMES:
assert name in result
def test_corrupted_data_and_no_package_warns(monkeypatch, tmp_path, caplog):
"""Fichier data corrompu ET edsnlp INDISPONIBLE → set() vide + log.warning
(dégradation rendue visible, pas de silence)."""
import builtins
corrupt = tmp_path / "drugs.json"
corrupt.write_text("{ invalide", encoding="utf-8")
monkeypatch.setattr(core, "_EDSNLP_DRUGS_DATA_PATH", corrupt)
_real_import = builtins.__import__
def _fake_import(name, *args, **kwargs):
if name == "edsnlp" or name.startswith("edsnlp."):
raise ImportError("edsnlp indisponible (torch-free)")
return _real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _fake_import)
with caplog.at_level("WARNING", logger=core.log.name):
result = core._load_edsnlp_drug_names()
assert result == set()
assert any(
"edsnlp" in rec.message.lower() and rec.levelname == "WARNING"
for rec in caplog.records
), "aucun log.warning émis lors de l'échec total (data corrompu + pas de package)"

View File

@@ -0,0 +1,82 @@
"""Résolution du dictionnaires.yml externe éditable (P1-4).
Pur : on simule frozen via monkeypatch (sys.frozen / sys.executable / _MEIPASS),
aucun display, aucun modèle.
"""
from __future__ import annotations
from pathlib import Path
import gui_v6.config_paths as cp
def _make_bundle(tmp_path: Path) -> Path:
bundle = tmp_path / "bundle"
(bundle / "config").mkdir(parents=True)
(bundle / "config" / "dictionnaires.yml").write_text("whitelist_phrases: []\n", encoding="utf-8")
return bundle
def test_dev_returns_repo_config_when_present(monkeypatch):
# En dev (non frozen) : pointe la config embarquée si elle existe.
monkeypatch.setattr(cp.sys, "frozen", False, raising=False)
path = cp.resolve_user_config_path()
assert path is not None
assert path.name == "dictionnaires.yml"
assert path.exists()
def test_frozen_copies_bundle_on_first_launch(tmp_path, monkeypatch):
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
exe_dir.mkdir()
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
out = cp.resolve_user_config_path()
expected = exe_dir / "config" / "dictionnaires.yml"
assert out == expected
assert expected.exists() # copié depuis le bundle au 1er lancement
assert expected.read_text(encoding="utf-8") == "whitelist_phrases: []\n"
def test_frozen_keeps_existing_user_config(tmp_path, monkeypatch):
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
(exe_dir / "config").mkdir(parents=True)
user_cfg = exe_dir / "config" / "dictionnaires.yml"
user_cfg.write_text("whitelist_phrases: [HOPITAL_LOCAL]\n", encoding="utf-8")
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
out = cp.resolve_user_config_path()
assert out == user_cfg
# Ne JAMAIS écraser la perso établissement existante.
assert "HOPITAL_LOCAL" in out.read_text(encoding="utf-8")
def test_dev_returns_none_when_config_missing(tmp_path, monkeypatch):
# En dev, si la config embarquée est absente : on renvoie None (pas de création).
monkeypatch.setattr(cp.sys, "frozen", False, raising=False)
monkeypatch.setattr(cp, "_bundled_config", lambda: tmp_path / "absent" / "dictionnaires.yml")
assert cp.resolve_user_config_path() is None
def test_frozen_copy_failure_falls_back(tmp_path, monkeypatch):
# En frozen, si la copie échoue (ex. droits) : fallback sur la config embarquée, sans crash.
bundle = _make_bundle(tmp_path)
exe_dir = tmp_path / "exe"
exe_dir.mkdir()
monkeypatch.setattr(cp.sys, "frozen", True, raising=False)
monkeypatch.setattr(cp.sys, "_MEIPASS", str(bundle), raising=False)
monkeypatch.setattr(cp.sys, "executable", str(exe_dir / "Anonymisation.exe"), raising=False)
def _boom(*_args, **_kwargs):
raise PermissionError("accès refusé")
monkeypatch.setattr(cp.shutil, "copyfile", _boom)
out = cp.resolve_user_config_path()
assert out == cp._bundled_config()

View File

@@ -0,0 +1,95 @@
"""Export / import de configuration (P1-3) — format compatible merge_params.
Pur : sérialisation/désérialisation et fusion, sans display ni filedialog.
"""
from __future__ import annotations
import json
from pathlib import Path
from gui_v6.config_share import build_export_payload, import_config_file
def test_export_payload_has_v5_schema():
payload = build_export_payload(
whitelist=["Dr Métier", "Service ORL"],
blacklist=["DUPONT"],
version="2026.06.29",
)
assert payload["version"] == "2026.06.29"
assert "date_export" in payload
assert payload["whitelist_phrases"] == ["Dr Métier", "Service ORL"]
assert payload["blacklist_force_mask_terms"] == ["DUPONT"]
def test_export_payload_is_json_serializable():
payload = build_export_payload(whitelist=["A"], blacklist=["B"], version="1")
json.dumps(payload) # ne doit pas lever
def test_import_merges_into_user_config(tmp_path, monkeypatch):
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text("whitelist_phrases: [Existant]\n", encoding="utf-8")
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({
"version": "1", "date_export": "2026-06-29",
"whitelist_phrases": ["Nouveau"],
"blacklist_force_mask_terms": ["MASQUERMOI"],
}),
encoding="utf-8",
)
added = import_config_file(incoming, cfg)
assert added is True
import yaml
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
assert "Existant" in merged["whitelist_phrases"]
assert "Nouveau" in merged["whitelist_phrases"]
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]
def test_import_returns_false_when_nothing_new(tmp_path):
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text("whitelist_phrases: [Deja]\n", encoding="utf-8")
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({"whitelist_phrases": ["Deja"], "blacklist_force_mask_terms": []}),
encoding="utf-8",
)
assert import_config_file(incoming, cfg) is False
def test_import_preserves_unmanaged_yaml_keys(tmp_path):
# Une config riche : l'import ne doit toucher QUE whitelist/blacklist,
# et préserver toutes les autres sections (anti-perte de données).
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text(
"version: 3\n"
"whitelist_phrases: [Existant]\n"
"blacklist:\n"
" force_mask_terms: [DEJA]\n"
" autre_sous_cle: [GARDER]\n"
"regex_overrides:\n"
" - rule_a\n"
"flags:\n"
" strict: true\n",
encoding="utf-8",
)
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({"whitelist_phrases": ["Nouveau"], "blacklist_force_mask_terms": ["MASQUERMOI"]}),
encoding="utf-8",
)
assert import_config_file(incoming, cfg) is True
import yaml
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
# Clés non gérées intactes
assert merged["version"] == 3
assert merged["regex_overrides"] == ["rule_a"]
assert merged["flags"] == {"strict": True}
assert merged["blacklist"]["autre_sous_cle"] == ["GARDER"]
# Listes gérées fusionnées
assert "Existant" in merged["whitelist_phrases"]
assert "Nouveau" in merged["whitelist_phrases"]
assert "DEJA" in merged["blacklist"]["force_mask_terms"]
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]

View File

@@ -0,0 +1,136 @@
import json
from types import SimpleNamespace
from gui_v6 import diagnostics
def _doc(**kw):
base = dict(ordinal=0, status="success", error_type=None, error_code=None, duration_ms=12)
base.update(kw)
return SimpleNamespace(**base)
def test_new_run_id_is_hex():
rid = diagnostics.new_run_id()
assert isinstance(rid, str) and len(rid) >= 16
def test_items_from_summary_whitelist_only():
summary = SimpleNamespace(documents=[
_doc(ordinal=0, status="success"),
_doc(ordinal=1, status="failed", error_type="ValueError", error_code="processing_error"),
])
items = diagnostics.items_from_summary(summary)
assert items[1]["error_type"] == "ValueError"
assert set(items[0]) <= {"ordinal", "status", "error_type", "error_code", "duration_ms"}
def test_build_payload_counts_and_no_pii_leak():
# On INJECTE de la PII via des clés interdites + un faux message d'erreur :
raw_docs = [
{"ordinal": 0, "status": "success", "duration_ms": 5,
"filename": "LETTRE Dupont 1980.pdf", "path": "/home/dom/secret.pdf"},
{"ordinal": 1, "status": "failed", "error_type": "ValueError",
"error_code": "processing_error", "error_message": "patient Dupont Jean"},
]
payload = diagnostics.build_diagnostics_payload(
run_id="r" * 16, app_name="gui_v6", app_version="6.0.0-g1",
license_ref="LIC-1", machine_id="m" * 12, duration_ms=999, items=raw_docs,
)
assert payload["document_count"] == 2
assert payload["succeeded_count"] == 1 and payload["failed_count"] == 1
blob = json.dumps(payload).lower()
for forbidden in ("filename", "path", "secret", "dupont", "lettre", "error_message", "patient"):
assert forbidden not in blob, f"fuite RGPD : {forbidden}"
for item in payload["items"]:
assert set(item) <= {"ordinal", "status", "error_type", "error_code", "duration_ms"}
class _FakeResp:
def __init__(self, status_code):
self.status_code = status_code
class _FakeSession:
def __init__(self, status_code=200, raise_exc=None):
self.status_code = status_code
self.raise_exc = raise_exc
self.calls = []
def post(self, url, json=None, timeout=None):
self.calls.append((url, json, timeout))
if self.raise_exc:
raise self.raise_exc
return _FakeResp(self.status_code)
def test_client_report_ok_on_2xx():
sess = _FakeSession(status_code=200)
client = diagnostics.DiagnosticsClient("https://app.aivanov.eu/", session=sess)
assert client.report({"run_id": "r"}) is True
assert sess.calls[0][0] == "https://app.aivanov.eu/api/v1/diagnostics/report"
def test_client_report_false_on_network_error_without_raising():
sess = _FakeSession(raise_exc=RuntimeError("no network"))
client = diagnostics.DiagnosticsClient("https://app.aivanov.eu", session=sess)
assert client.report({"run_id": "r"}) is False # ne lève pas
def test_report_run_diagnostics_no_send_without_license(tmp_path):
sess = _FakeSession()
ok = diagnostics.report_run_diagnostics(
SimpleNamespace(documents=[]), base_url="https://app.aivanov.eu",
license_ref=None, machine_id="m" * 12, session=sess,
spool_path=tmp_path / "spool.jsonl",
)
assert ok is False and sess.calls == []
def test_report_run_diagnostics_network_down_spools(tmp_path):
sess = _FakeSession(raise_exc=RuntimeError("down"))
spool = tmp_path / "spool.jsonl"
summary = SimpleNamespace(documents=[_doc(ordinal=0, status="failed",
error_type="ValueError", error_code="processing_error")])
ok = diagnostics.report_run_diagnostics(
summary, base_url="https://app.aivanov.eu", license_ref="LIC-1",
machine_id="m" * 12, session=sess, spool_path=spool,
)
assert ok is False and spool.exists()
line = json.loads(spool.read_text(encoding="utf-8").splitlines()[0])
assert line["failed_count"] == 1
def test_flush_spool_sends_and_clears(tmp_path):
spool = tmp_path / "spool.jsonl"
diagnostics.spool_payload(spool, {"run_id": "r1"})
diagnostics.spool_payload(spool, {"run_id": "r2"})
sent = diagnostics.flush_spool(spool, diagnostics.DiagnosticsClient(
"https://app.aivanov.eu", session=_FakeSession(status_code=200)))
assert sent == 2 and not spool.exists()
def test_tab_send_diagnostics_calls_reporter():
import threading
from gui_v6.tabs.tab_usage import UsageTab
tab = object.__new__(UsageTab) # pas de Tk : on teste juste le helper
seen = {}
done = threading.Event()
def reporter(summary):
seen["summary"] = summary
done.set()
tab._diag_reporter = reporter
tab._send_diagnostics(SimpleNamespace(documents=[], failed=0))
assert done.wait(timeout=2.0)
assert seen["summary"] is not None
def test_tab_send_diagnostics_noop_without_reporter():
from gui_v6.tabs.tab_usage import UsageTab
tab = object.__new__(UsageTab)
tab._diag_reporter = None
tab._send_diagnostics(SimpleNamespace(documents=[])) # ne lève pas

View File

@@ -0,0 +1,31 @@
"""Confirmation avant de désactiver le NER (regex-only) — outil médical.
Pur : la décision est isolée dans ``confirm_ner_disable(asker)`` ; ``asker`` est
injecté (pas de messagebox réel, pas de display).
"""
from __future__ import annotations
from gui_v6.tabs.tab_config import NER_DISABLE_WARNING, confirm_ner_disable
def test_confirm_true_when_user_accepts():
assert confirm_ner_disable(lambda: True) is True
def test_confirm_false_when_user_declines():
assert confirm_ner_disable(lambda: False) is False
def test_confirm_false_when_asker_raises():
def boom():
raise RuntimeError("Tk indisponible")
# Sens sûr : une erreur de dialogue ne désactive jamais le NER.
assert confirm_ner_disable(boom) is False
def test_warning_text_is_explicit_for_medical_use():
txt = NER_DISABLE_WARNING.lower()
# L'avertissement DOIT nommer la dégradation : règles/regex + risque noms.
assert "règles" in txt or "regex" in txt
assert "nom" in txt
assert "recommand" in txt # « fortement recommandé »

View File

@@ -197,6 +197,23 @@ def test_progress_callbacks(tmp_path):
assert (2, 2) in events # progression finale atteinte
def test_run_fails_fast_when_output_not_writable(tmp_path, monkeypatch):
from gui_v6.processing_runner import ProcessingRunner, OutputNotWritableError
src = tmp_path / "in"
src.mkdir()
(src / "a.txt").write_text("x", encoding="utf-8")
out = tmp_path / "ro"
out.mkdir()
def boom(*a, **k):
raise PermissionError("read-only")
monkeypatch.setattr("gui_v6.processing_runner.Path.mkdir", boom)
runner = ProcessingRunner(process_fn=lambda d, o: {})
with pytest.raises(OutputNotWritableError):
runner.run(src, out)
def test_no_double_run(tmp_path):
_touch(tmp_path / "a.pdf")
started = threading.Event()
@@ -244,3 +261,70 @@ def test_run_records_per_document_details(tmp_path):
assert not hasattr(doc, "path")
assert not hasattr(doc, "filename")
assert not hasattr(doc, "name")
# -- diagnostics d'erreur RGPD-safe (E2) -----------------------------------
def test_failed_doc_carries_rgpd_safe_error_fields(tmp_path):
from gui_v6.processing_runner import ProcessingRunner
secret = "Dupont Jean 1980" # simulacre de PII dans un message d'exception
def boom(_inp, _out):
raise ValueError(f"échec sur patient {secret}")
inp = tmp_path / "in"; inp.mkdir()
(inp / "a.pdf").write_bytes(b"%PDF-1.4\n")
out = tmp_path / "out"; out.mkdir()
runner = ProcessingRunner(process_fn=boom)
summary = runner.run(inp, out)
assert summary.failed == 1
doc = summary.documents[0]
assert doc.error_type == "ValueError"
assert doc.error_code in {"ner_unavailable", "quarantined", "no_output", "processing_error"}
blob = repr(vars(doc)).lower()
assert "dupont" not in blob and "patient" not in blob and secret.lower() not in blob
def test_success_doc_has_no_error_fields(tmp_path):
from gui_v6.processing_runner import ProcessingRunner
def ok(_inp, out_dir):
# process_fn reçoit le DOSSIER de sortie : on y écrit un PDF livrable.
pdf = out_dir / "a.redacted_raster.pdf"
pdf.write_bytes(b"%PDF-1.4\n")
return {"status": "ok", "pdf_raster": str(pdf)}
inp = tmp_path / "in"; inp.mkdir()
(inp / "a.pdf").write_bytes(b"%PDF-1.4\n")
out = tmp_path / "out"; out.mkdir()
summary = ProcessingRunner(process_fn=ok).run(inp, out)
doc = summary.documents[0]
assert doc.status == "success"
assert doc.error_type is None and doc.error_code is None
# -- classification d'erreur : une assertion par branche (mapping vérifié) -
def test_classify_error_code_ner_unavailable():
from gui_v6.processing_runner import classify_error_code
from gui_v6.engine_bridge import EngineUnavailableError # import the REAL class
# importing the real class means a future rename breaks this test (intended guard)
assert classify_error_code(EngineUnavailableError("modèle indispo")) == "ner_unavailable"
def test_classify_error_code_quarantined():
from gui_v6.processing_runner import classify_error_code
assert classify_error_code(RuntimeError("Document mis en quarantaine : texte trop court")) == "quarantined"
def test_classify_error_code_no_output():
from gui_v6.processing_runner import classify_error_code
assert classify_error_code(RuntimeError("Aucune sortie PDF anonymisée produite")) == "no_output"
def test_classify_error_code_processing_error_default():
from gui_v6.processing_runner import classify_error_code, _ERROR_CODES
assert classify_error_code(ValueError("patient Dupont")) == "processing_error"
assert classify_error_code(ValueError("x")) in _ERROR_CODES

View File

@@ -0,0 +1,118 @@
"""Message d'aide localisant les documents non livrés (P1-5) + ouverture dossier.
Pur : pas de display. ``failure_hint`` formate un texte ; ``open_in_file_manager``
dispatch vers la bonne commande OS (monkeypatchée).
"""
from __future__ import annotations
from pathlib import Path
import pytest
import gui_v6.fsutil as fsutil
from gui_v6.processing_runner import RunSummary
from gui_v6.tabs.tab_usage import failure_hint
def test_no_hint_when_all_ok():
s = RunSummary(total=3, succeeded=3, failed=0)
assert failure_hint(s, Path("/out")) is None
def test_hint_when_failures_mentions_output_dir():
s = RunSummary(total=3, succeeded=2, failed=1)
hint = failure_hint(s, Path("/out/anonymise"))
assert hint is not None
assert "/out/anonymise" in hint
# Honnêteté : préciser que les échecs ne sont PAS anonymisés.
assert "pas" in hint.lower()
def test_hint_when_stopped():
s = RunSummary(total=3, succeeded=1, failed=0, stopped=True)
assert failure_hint(s, Path("/out")) is not None
def test_no_hint_without_output_dir():
s = RunSummary(total=1, succeeded=0, failed=1)
assert failure_hint(s, None) is None
def test_open_in_file_manager_dispatches(monkeypatch):
calls = {}
monkeypatch.setattr(fsutil.sys, "platform", "linux")
monkeypatch.setattr(fsutil.subprocess, "Popen", lambda args, **k: calls.setdefault("args", args))
fsutil.open_in_file_manager(Path("/out"))
assert calls["args"][0] == "xdg-open"
assert calls["args"][1] == "/out"
def test_no_claim_written_when_zero_succeeded():
"""0 succès : ne pas prétendre qu'un dossier contient des documents écrits."""
s = RunSummary(total=2, succeeded=0, failed=2)
hint = failure_hint(s, Path("/out"))
assert hint is not None
assert "écrits dans" not in hint
assert "Aucun document" in hint
def test_hint_with_path_when_some_succeeded():
"""≥1 succès : localiser le dossier de sortie effectif."""
s = RunSummary(total=3, succeeded=2, failed=1)
hint = failure_hint(s, Path("/out"))
assert hint is not None
assert "/out" in hint
assert "écrits dans" in hint
# -- garde anti-régression du bug Critical (empilement de widgets) -----------
@pytest.fixture
def usage_tab():
"""``UsageTab`` headless (Xvfb) — skip propre si pas de display."""
pytest.importorskip("customtkinter")
try:
import customtkinter as ctk
from gui_v6.tabs.tab_usage import UsageTab
root = ctk.CTk()
root.withdraw()
tab = UsageTab(root)
except Exception as exc: # pas de display Tk
pytest.skip(f"display Tk indisponible: {exc}")
try:
yield tab
finally:
try:
root.destroy()
except Exception:
pass
def test_show_failure_hint_does_not_accumulate(usage_tab):
"""Bug Critical : deux runs en échec ne doivent pas empiler de hints sous
``_rsec`` (handle ``_hint_row`` détruit inconditionnellement)."""
usage_tab._last_output_dir = Path("/out")
summary = RunSummary(total=3, succeeded=2, failed=1)
usage_tab._show_failure_hint(summary)
count_after_first = len(usage_tab._rsec.winfo_children())
usage_tab._show_failure_hint(summary)
count_after_second = len(usage_tab._rsec.winfo_children())
assert count_after_second == count_after_first
def test_show_failure_hint_clears_stale_hint(usage_tab):
"""Un run en échec suivi d'un run nominal ne doit pas laisser de hint périmé."""
usage_tab._last_output_dir = Path("/out")
usage_tab._show_failure_hint(RunSummary(total=3, succeeded=2, failed=1))
with_hint = len(usage_tab._rsec.winfo_children())
usage_tab._show_failure_hint(RunSummary(total=3, succeeded=3, failed=0))
without_hint = len(usage_tab._rsec.winfo_children())
assert without_hint == with_hint - 1

View File

@@ -0,0 +1,35 @@
"""Tests de la résolution de version GUI V6 (P1-7, Plan 3).
La version release (schéma 2026.MM.JJ.HHMM) est générée au build Windows dans
gui_v6/_build_version.py (non commité). En dev, repli sur la version par défaut.
"""
import sys
import types
from gui_v6.version import DEFAULT_VERSION, resolve_version
def test_resolve_version_sans_module_build_retourne_defaut(monkeypatch):
monkeypatch.setitem(sys.modules, "gui_v6._build_version", None)
# sys.modules[name] = None => ImportError au `from ... import`
assert resolve_version() == DEFAULT_VERSION
def test_resolve_version_avec_module_build_retourne_version_injectee(monkeypatch):
fake = types.ModuleType("gui_v6._build_version")
fake.BUILD_VERSION = "2026.07.02.1130"
monkeypatch.setitem(sys.modules, "gui_v6._build_version", fake)
assert resolve_version() == "2026.07.02.1130"
def test_resolve_version_build_version_vide_retourne_defaut(monkeypatch):
fake = types.ModuleType("gui_v6._build_version")
fake.BUILD_VERSION = ""
monkeypatch.setitem(sys.modules, "gui_v6._build_version", fake)
assert resolve_version() == DEFAULT_VERSION
def test_dunder_version_est_cable_sur_resolve_version():
import gui_v6
# En dev (pas de _build_version généré), __version__ == défaut.
assert gui_v6.__version__ == DEFAULT_VERSION

View File

@@ -0,0 +1,27 @@
"""Anti-dérive D8 (Plan 3) : l'installeur GUI doit fermer l'app avant MAJ.
AppMutex DOIT valoir gui_v6.single_instance.APP_MUTEX_NAME (P0-7) — le commentaire
de single_instance.py:15 exige la synchro. CloseApplications ferme l'app qui
verrouille l'EXE pendant l'upgrade en place (AppId fixe).
"""
from pathlib import Path
from gui_v6.single_instance import APP_MUTEX_NAME
ISS = Path(__file__).resolve().parents[2] / "installer" / "Anonymisation.iss"
def test_appmutex_synchronise_avec_single_instance():
text = ISS.read_text(encoding="utf-8")
assert f"AppMutex={APP_MUTEX_NAME}" in text
def test_closeapplications_actif():
text = ISS.read_text(encoding="utf-8")
assert "CloseApplications=yes" in text
def test_appid_fixe_inchange():
# L'upgrade en place repose sur l'AppId stable — ne jamais le régénérer.
text = ISS.read_text(encoding="utf-8")
assert "AppId={{6D11E4F8-26D8-4CFB-9F19-5A81E0637F56}" in text