10 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
18 changed files with 4956 additions and 26 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:

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

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

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

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