Compare commits
3 Commits
5dbad699bc
...
feature/q1
| Author | SHA1 | Date | |
|---|---|---|---|
| d324ada310 | |||
| 6a992d87de | |||
| b196d00813 |
@@ -29,6 +29,7 @@ datas = []
|
|||||||
for relative_path, target_dir in [
|
for relative_path, target_dir in [
|
||||||
("config", "config"),
|
("config", "config"),
|
||||||
("data/bdpm", "data/bdpm"),
|
("data/bdpm", "data/bdpm"),
|
||||||
|
("data/edsnlp", "data/edsnlp"),
|
||||||
("data/finess", "data/finess"),
|
("data/finess", "data/finess"),
|
||||||
("data/insee", "data/insee"),
|
("data/insee", "data/insee"),
|
||||||
("models/camembert-bio-deid/onnx", "models/camembert-bio-deid/onnx"),
|
("models/camembert-bio-deid/onnx", "models/camembert-bio-deid/onnx"),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ datas = []
|
|||||||
for relative_path, target_dir in [
|
for relative_path, target_dir in [
|
||||||
("config", "config"),
|
("config", "config"),
|
||||||
("data/bdpm", "data/bdpm"),
|
("data/bdpm", "data/bdpm"),
|
||||||
|
("data/edsnlp", "data/edsnlp"),
|
||||||
("data/finess", "data/finess"),
|
("data/finess", "data/finess"),
|
||||||
("data/insee", "data/insee"),
|
("data/insee", "data/insee"),
|
||||||
("models/camembert-bio-deid/onnx", "models/camembert-bio-deid/onnx"),
|
("models/camembert-bio-deid/onnx", "models/camembert-bio-deid/onnx"),
|
||||||
|
|||||||
@@ -256,24 +256,62 @@ except ImportError:
|
|||||||
APP_VERSION = "0.11.0-mvp" # incrémenter avant rebuild release
|
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:
|
def _load_edsnlp_drug_names() -> set:
|
||||||
"""Charge les noms de médicaments mono-mot depuis edsnlp/resources/drugs.json.
|
"""Charge les noms de médicaments mono-mot pour la whitelist anti-faux-positif.
|
||||||
Retourne un set lowercase. Fallback silencieux si edsnlp absent."""
|
|
||||||
|
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:
|
try:
|
||||||
import edsnlp as _edsnlp
|
import edsnlp as _edsnlp
|
||||||
drugs_path = _edsnlp.BASE_DIR / "resources" / "drugs.json"
|
drugs_path = _edsnlp.BASE_DIR / "resources" / "drugs.json"
|
||||||
if not drugs_path.exists():
|
if drugs_path.exists():
|
||||||
return set()
|
return _parse_edsnlp_drugs_json(drugs_path)
|
||||||
import json as _json
|
except Exception as exc:
|
||||||
data = _json.loads(drugs_path.read_text(encoding="utf-8"))
|
log.debug("Fallback package edsnlp indisponible : %s", exc)
|
||||||
result = set()
|
|
||||||
for _code, names in data.items():
|
# 3. Échec total : rendre la dégradation visible (risque de sur-masquage).
|
||||||
for name in names:
|
log.warning(
|
||||||
if " " not in name and len(name) >= 4:
|
"Gazetteer médicaments edsnlp indisponible (ni data/edsnlp/drugs.json "
|
||||||
result.add(name.lower())
|
"ni package edsnlp) — whitelist médicaments réduite, risque de sur-masquage"
|
||||||
return result
|
)
|
||||||
except Exception:
|
return set()
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
def _load_bdpm_medication_names() -> 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
@@ -4,7 +4,9 @@
|
|||||||
Windows torch-free (Task 8). **AUCUNE valeur PII — compteurs uniquement.**
|
Windows torch-free (Task 8). **AUCUNE valeur PII — compteurs uniquement.**
|
||||||
|
|
||||||
Les documents sont identifiés par leur **numéro de dossier** du corpus interne
|
Les documents sont identifiés par leur **numéro de dossier** du corpus interne
|
||||||
(jamais par un nom de patient). Les sorties de traitement sont restées dans `/tmp`
|
(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`
|
(non commité). Les compteurs proviennent du champ `kind` de l'audit `.audit.jsonl`
|
||||||
produit par `scripts/anonymize_cli.py` (contrat de production : burn raster +
|
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
|
texte pseudonymisé + audit JSONL). Le champ `original` (la valeur détectée) n'a
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ Depuis le Plan 3 (2026-07), le flavor `-GuiV6` :
|
|||||||
- Smoke OCR sur PDF scanné (`ocr_used=True`) : les poids OnnxTR viennent de
|
- Smoke OCR sur PDF scanné (`ocr_used=True`) : les poids OnnxTR viennent de
|
||||||
`_MEIPASS/models/onnxtr/models`, aucun téléchargement runtime.
|
`_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
|
## Important
|
||||||
|
|
||||||
- les utilisateurs finaux n'ont pas besoin d'installer Python
|
- les utilisateurs finaux n'ont pas besoin d'installer Python
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
``AppMutex`` dans installer/Anonymisation.iss (Plan 3 / D8) pour que l'installeur
|
``AppMutex`` dans installer/Anonymisation.iss (Plan 3 / D8) pour que l'installeur
|
||||||
ferme l'app avant une mise à jour.
|
ferme l'app avant une mise à jour.
|
||||||
- POSIX (dev/test) : verrou ``fcntl`` exclusif sur un fichier dans le dossier app.
|
- 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
|
from __future__ import annotations
|
||||||
|
|||||||
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)"
|
||||||
Reference in New Issue
Block a user