Compare commits
15 Commits
675e328d8c
...
feature/q1
| Author | SHA1 | Date | |
|---|---|---|---|
| d324ada310 | |||
| 6a992d87de | |||
| b196d00813 | |||
| 5dbad699bc | |||
| 7dba4014c4 | |||
| 2c48d95c1f | |||
| f9b6f21923 | |||
| e4bc9166be | |||
| 65c97a39b3 | |||
| 5f05ba0fb8 | |||
| 8f9107a27f | |||
| 8eb8cf9999 | |||
| 4b7a31b9df | |||
| 4412512d4b | |||
| 952a1c6ca0 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
32
data/edsnlp/README.md
Normal file
32
data/edsnlp/README.md
Normal 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
4398
data/edsnlp/drugs.json
Normal file
File diff suppressed because it is too large
Load Diff
39
docs/beta/2026-07-02_plan3-reference-docs-reels.md
Normal file
39
docs/beta/2026-07-02_plan3-reference-docs-reels.md
Normal 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.
|
||||
@@ -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é :
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -193,6 +193,7 @@ class AnonymisationApp(ctk.CTk):
|
||||
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, config_path=self._user_config_path)
|
||||
@@ -243,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()
|
||||
|
||||
190
gui_v6/diagnostics.py
Normal file
190
gui_v6/diagnostics.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -115,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
|
||||
@@ -224,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)
|
||||
@@ -238,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}")
|
||||
@@ -248,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,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)
|
||||
@@ -80,6 +81,9 @@ 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
|
||||
@@ -320,6 +324,7 @@ class UsageTab(ctk.CTkFrame):
|
||||
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."""
|
||||
@@ -335,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():
|
||||
|
||||
18
gui_v6/version.py
Normal file
18
gui_v6/version.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
71
tests/unit/test_build_specs_torch_free.py
Normal file
71
tests/unit/test_build_specs_torch_free.py
Normal 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."
|
||||
)
|
||||
155
tests/unit/test_edsnlp_drugs_static.py
Normal file
155
tests/unit/test_edsnlp_drugs_static.py
Normal 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)"
|
||||
136
tests/unit/test_gui_v6_diagnostics.py
Normal file
136
tests/unit/test_gui_v6_diagnostics.py
Normal 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
|
||||
@@ -261,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
|
||||
|
||||
35
tests/unit/test_gui_v6_version.py
Normal file
35
tests/unit/test_gui_v6_version.py
Normal 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
|
||||
27
tests/unit/test_installer_iss_d8.py
Normal file
27
tests/unit/test_installer_iss_d8.py
Normal 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
|
||||
Reference in New Issue
Block a user