Files
anonymisation/docs/superpowers/plans/2026-06-25-gui-v6-beta-plan-1a-socle.md
Domi31tls 55e8839613 docs(beta): plan d'implémentation 1a — socle sûreté & chaîne prod GUI V6
Plan bite-sized TDD pour les chantiers A/B/E1 : fail-close PII (P0-1), URL portail
(P0-2), binding licence souple (P0-6), log fichier V6 (E1), flag frozen ONNX (P0-5),
instance unique + mutex installeur (P0-7). 6 tâches, code complet, tests unitaires.
Plans 1b (gating cœur), 1c (UI), 2 (diagnostics), 3 (build/release) à suivre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:49:01 +02:00

20 KiB

GUI V6 bêta — Plan 1a : socle sûreté & chaîne prod

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Rendre la GUI V6 sûre (fail-close PII) et réellement connectée à la chaîne de prod (portail, binding licence, stabilité frozen, log fichier) — sans toucher au cœur de détection ni à l'UI.

Architecture: 6 corrections ciblées, toutes dans gui_v6/ + l'entrée frozen Pseudonymisation_Gui_V6.py. Chaque fix est testable en pytest sur Linux (sauf confirmation frozen de P0-5/P0-7 au smoke EXE, hors de ce plan). On suit le contrat existant : factories injectables, sessions HTTP injectables, aucun appel réseau en test.

Tech Stack: Python 3.10-3.12, pytest, logging stdlib + loguru (best-effort), ctypes/msvcrt (Windows), fcntl (POSIX).

Référence spec : docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md (chantiers A, B, E1).


Task 1 : URL portail réelle (P0-2)

Files:

  • Modify: gui_v6/app.py:12-24 (imports), :41 (défaut client), :199 (fallback télémétrie)

  • Test: tests/unit/test_gui_v6_portal_url.py

  • Step 1 : Écrire le test qui échoue

# tests/unit/test_gui_v6_portal_url.py
import pytest


def test_default_portal_url_is_prod(monkeypatch):
    monkeypatch.delenv("ANON_PORTAL_URL", raising=False)
    from gui_v6.app import DEFAULT_PORTAL_URL, resolve_portal_url
    assert DEFAULT_PORTAL_URL == "https://app.aivanov.eu"
    assert resolve_portal_url() == "https://app.aivanov.eu"


def test_portal_url_env_override(monkeypatch):
    monkeypatch.setenv("ANON_PORTAL_URL", "http://localhost:8088")
    from gui_v6.app import resolve_portal_url
    assert resolve_portal_url() == "http://localhost:8088"
  • Step 2 : Lancer le test pour le voir échouer

Run: .venv/bin/pytest tests/unit/test_gui_v6_portal_url.py -v Expected: FAIL — ImportError: cannot import name 'DEFAULT_PORTAL_URL'.

  • Step 3 : Implémenter

Dans gui_v6/app.py, ajouter import os en tête (sous from __future__ import annotations), puis après les imports (avant _TABS) :

DEFAULT_PORTAL_URL = "https://app.aivanov.eu"


def resolve_portal_url() -> str:
    """URL du portail : env ``ANON_PORTAL_URL`` sinon défaut prod."""
    return os.environ.get("ANON_PORTAL_URL", DEFAULT_PORTAL_URL)

Remplacer la ligne 41 :

        self._license_client = license_client or LicenseClient(resolve_portal_url())

Remplacer le fallback de la ligne 199 :

            base_url = getattr(self._license_client, "_base_url", "") or resolve_portal_url()
  • Step 4 : Lancer le test pour le voir passer

Run: .venv/bin/pytest tests/unit/test_gui_v6_portal_url.py -v Expected: PASS (2 tests).

  • Step 5 : Commit
git add gui_v6/app.py tests/unit/test_gui_v6_portal_url.py
git commit -m "fix(gui): connecter la GUI V6 au portail prod (P0-2, plus localhost)"

Task 2 : Fail-close si le NER obligatoire est indisponible (P0-1)

Files:

  • Modify: gui_v6/engine_bridge.py:36-41 (nouvelle exception), :222-231 (process_fn)

  • Test: tests/unit/test_gui_v6_engine_failclose.py

  • Step 1 : Écrire le test qui échoue

# tests/unit/test_gui_v6_engine_failclose.py
from pathlib import Path

import pytest

from gui_v6.engine_bridge import (
    EngineSettings,
    EngineUnavailableError,
    NerManagers,
    make_process_fn,
)


def _managers_with_broken_camembert(settings):
    def boom():
        raise RuntimeError("model.onnx absent")

    return NerManagers(
        settings,
        factories={"camembert": boom, "eds": boom, "gliner": boom},
        caps_provider=lambda: {},
    )


def test_process_fn_raises_when_mandatory_ner_unavailable():
    settings = EngineSettings(use_local_ner=True)
    managers = _managers_with_broken_camembert(settings)
    called = {"engine": False}

    def fake_engine(*a, **k):
        called["engine"] = True
        return {"pdf": "x"}

    fn = make_process_fn(settings, managers=managers, engine=fake_engine)
    with pytest.raises(EngineUnavailableError):
        fn(Path("doc.pdf"), Path("/tmp/out"))
    # Le moteur ne doit JAMAIS être appelé → aucune sortie possible.
    assert called["engine"] is False


def test_process_fn_runs_when_ner_ok():
    settings = EngineSettings(use_local_ner=True)
    managers = NerManagers(
        settings,
        factories={"camembert": lambda: object(), "eds": lambda: None, "gliner": lambda: None},
        caps_provider=lambda: {},
    )
    fn = make_process_fn(settings, managers=managers, engine=lambda *a, **k: {"pdf": "ok"})
    assert fn(Path("d.pdf"), Path("/tmp/out")) == {"pdf": "ok"}
  • Step 2 : Lancer le test pour le voir échouer

Run: .venv/bin/pytest tests/unit/test_gui_v6_engine_failclose.py -v Expected: FAIL — ImportError: cannot import name 'EngineUnavailableError'.

  • Step 3 : Implémenter

Dans gui_v6/engine_bridge.py, après la classe ManagerState (l.41) :

class EngineUnavailableError(RuntimeError):
    """Levée quand un moteur de détection OBLIGATOIRE n'a pas pu être chargé.

    Garantit le fail-close : on refuse de produire une sortie plutôt que de
    livrer un document potentiellement non anonymisé (aligné sur le code 3 du CLI).
    """

Remplacer le corps de process_fn (l.222-231) :

    def process_fn(doc_path: Path, out_dir: Path) -> dict:
        if settings.use_local_ner:
            state = managers.ensure_loaded()
            if state == ManagerState.UNAVAILABLE:
                raise EngineUnavailableError(
                    "Modèle de détection obligatoire (CamemBERT-bio) indisponible — "
                    "traitement refusé pour éviter une anonymisation incomplète."
                )
        kwargs = build_engine_kwargs(settings, managers)
        run = engine
        if run is None:
            from anonymizer_core_refactored_onnx import process_document

            run = process_document
        return run(doc_path, out_dir, **kwargs)
  • Step 4 : Lancer le test pour le voir passer

Run: .venv/bin/pytest tests/unit/test_gui_v6_engine_failclose.py -v Expected: PASS (2 tests). Le runner (processing_runner._run_impl:221) attrape déjà toute Exception → le doc est compté échec et non livré.

  • Step 5 : Vérifier la non-régression du runner

Run: .venv/bin/pytest tests/unit/test_gui_v6_processing_runner.py -v Expected: PASS (inchangé).

  • Step 6 : Commit
git add gui_v6/engine_bridge.py tests/unit/test_gui_v6_engine_failclose.py
git commit -m "fix(gui): fail-close si CamemBERT-bio indisponible (P0-1, anti-fuite PII)"

Task 3 : Binding licence ↔ poste (souple, affichage) (P0-6)

Files:

  • Modify: gui_v6/app.py (imports + _safe_local_status), nouvelle fonction bound_local_status

  • Test: tests/unit/test_gui_v6_license_binding.py

  • Step 1 : Écrire le test qui échoue

# tests/unit/test_gui_v6_license_binding.py
from gui_v6.app import bound_local_status
from gui_v6.license_client import LicenseStatus


def test_binding_flags_other_machine():
    st = LicenseStatus(valid=True, status="active", machine_id="AAAA1111")
    out = bound_local_status(st, "BBBB2222")
    assert out.valid is False
    assert out.status == "autre_poste"
    assert "autre poste" in out.message.lower()


def test_binding_ok_same_machine():
    st = LicenseStatus(valid=True, status="active", machine_id="AAAA1111")
    out = bound_local_status(st, "AAAA1111")
    assert out.valid is True
    assert out.status == "active"


def test_binding_noop_without_machine_id():
    # licence locale sans machine_id (ancien payload) → inchangée, pas de blocage.
    st = LicenseStatus(valid=True, status="active", machine_id=None)
    assert bound_local_status(st, "AAAA1111").valid is True
  • Step 2 : Lancer le test pour le voir échouer

Run: .venv/bin/pytest tests/unit/test_gui_v6_license_binding.py -v Expected: FAIL — ImportError: cannot import name 'bound_local_status'.

  • Step 3 : Implémenter

Dans gui_v6/app.py, ajouter en tête l'import from gui_v6.machine_id import default_machine_id (sous l'import de license_client). Ajouter au niveau module (après resolve_portal_url) :

def bound_local_status(status: LicenseStatus, local_machine_id: str) -> LicenseStatus:
    """Annoter le statut licence selon le binding poste.

    Souple (décision D1) : on N'EMPÊCHE PAS le traitement. Si la licence locale
    est valide mais liée à un autre ``machine_id`` que le poste courant (ex.
    ``license.json`` copié), on le **signale** par un statut non valide d'affichage.
    """
    if status.valid and status.machine_id and status.machine_id != local_machine_id:
        return LicenseStatus(
            valid=False,
            status="autre_poste",
            message="Licence liée à un autre poste",
            expires_at=status.expires_at,
            grace_days=status.grace_days,
            machine_id=status.machine_id,
            license_ref=status.license_ref,
        )
    return status

Remplacer _safe_local_status (l.81-85) :

    def _safe_local_status(self) -> LicenseStatus:
        try:
            status = self._license_client.local_status()
            return bound_local_status(status, default_machine_id())
        except Exception:
            return LicenseStatus.unavailable()
  • Step 4 : Lancer le test pour le voir passer

Run: .venv/bin/pytest tests/unit/test_gui_v6_license_binding.py -v Expected: PASS (3 tests).

  • Step 5 : Commit
git add gui_v6/app.py tests/unit/test_gui_v6_license_binding.py
git commit -m "feat(gui): binding licence-poste souple (P0-6/D-20.4, affichage sans blocage)"

Task 4 : Log fichier V6 à chemin connu (E1)

Files:

  • Create: gui_v6/logging_setup.py

  • Test: tests/unit/test_gui_v6_logging_setup.py

  • Step 1 : Écrire le test qui échoue

# tests/unit/test_gui_v6_logging_setup.py
import logging


def test_setup_file_logging_writes_to_known_path(tmp_path, monkeypatch):
    monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
    from gui_v6.logging_setup import setup_file_logging

    log_path = setup_file_logging()
    assert log_path.parent.exists()
    logging.getLogger("test.e1").warning("ligne-temoin-42")
    for h in logging.getLogger().handlers:
        h.flush()
    assert "ligne-temoin-42" in log_path.read_text(encoding="utf-8")
  • Step 2 : Lancer le test pour le voir échouer

Run: .venv/bin/pytest tests/unit/test_gui_v6_logging_setup.py -v Expected: FAIL — ModuleNotFoundError: No module named 'gui_v6.logging_setup'.

  • Step 3 : Implémenter
# gui_v6/logging_setup.py
"""Configuration du log fichier de la GUI V6 (E1).

Sans ceci, la GUI frozen fenêtrée (sans console) perd ses logs de diagnostic.
Le log est posé dans le même répertoire applicatif que la licence
(``%LOCALAPPDATA%/Aivanov/Anonymisation``) pour faciliter sa récupération (E2/E3).
"""

from __future__ import annotations

import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path

_CONFIGURED = False


def _app_data_dir() -> Path:
    base = os.environ.get("LOCALAPPDATA")
    if base:
        root = Path(base)
    else:  # Linux/dev
        root = Path.home() / ".local" / "share"
    return root / "Aivanov" / "Anonymisation"


def log_file_path() -> Path:
    return _app_data_dir() / "logs" / "anonymisation.log"


def setup_file_logging() -> Path:
    """Configure un handler fichier rotatif sur le logger racine. Idempotent."""
    global _CONFIGURED
    path = log_file_path()
    if _CONFIGURED:
        return path
    path.parent.mkdir(parents=True, exist_ok=True)
    handler = RotatingFileHandler(
        str(path), maxBytes=2_000_000, backupCount=3, encoding="utf-8"
    )
    handler.setFormatter(
        logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
    )
    root = logging.getLogger()
    root.setLevel(logging.INFO)
    root.addHandler(handler)
    # Best-effort : si le cœur utilise loguru, on ajoute aussi un sink fichier.
    try:
        from loguru import logger as _loguru

        _loguru.add(str(path), rotation="2 MB", retention=3, encoding="utf-8")
    except Exception:
        pass
    _CONFIGURED = True
    return path
  • Step 4 : Lancer le test pour le voir passer

Run: .venv/bin/pytest tests/unit/test_gui_v6_logging_setup.py -v Expected: PASS.

  • Step 5 : Commit
git add gui_v6/logging_setup.py tests/unit/test_gui_v6_logging_setup.py
git commit -m "feat(gui): log fichier rotatif V6 à chemin connu (E1)"

Task 5 : Stabilité frozen — flag legacy ONNX au plus tôt (P0-5)

Files:

  • Modify: Pseudonymisation_Gui_V6.py:12-15 (en-tête) + main (appel logging)

  • Test: tests/unit/test_gui_v6_entry_frozen_flag.py

  • Step 1 : Écrire le test qui échoue

# tests/unit/test_gui_v6_entry_frozen_flag.py
import os
import importlib


def test_entry_sets_legacy_onnx_flag_on_import(monkeypatch):
    monkeypatch.delenv("ANON_SKIP_LEGACY_ONNX_MANAGER", raising=False)
    import Pseudonymisation_Gui_V6 as entry
    importlib.reload(entry)
    assert os.environ.get("ANON_SKIP_LEGACY_ONNX_MANAGER") == "1"


def test_entry_does_not_override_explicit_flag(monkeypatch):
    monkeypatch.setenv("ANON_SKIP_LEGACY_ONNX_MANAGER", "0")
    import Pseudonymisation_Gui_V6 as entry
    importlib.reload(entry)
    assert os.environ.get("ANON_SKIP_LEGACY_ONNX_MANAGER") == "0"
  • Step 2 : Lancer le test pour le voir échouer

Run: .venv/bin/pytest tests/unit/test_gui_v6_entry_frozen_flag.py -v Expected: FAIL — le flag n'est pas posé.

  • Step 3 : Implémenter

Dans Pseudonymisation_Gui_V6.py, juste après import sys (l.14), avant toute autre logique :

import os

# Frozen Windows : désactiver le manager ONNX legacy AVANT tout import du cœur,
# pour éviter « cannot load module more than once per process » (hotfix CLI 6c6f653).
os.environ.setdefault("ANON_SKIP_LEGACY_ONNX_MANAGER", "1")

Dans main() (avant from gui_v6.app import AnonymisationApp, l.55), initialiser le log fichier :

    from gui_v6.logging_setup import setup_file_logging

    setup_file_logging()
  • Step 4 : Lancer le test pour le voir passer

Run: .venv/bin/pytest tests/unit/test_gui_v6_entry_frozen_flag.py -v Expected: PASS (2 tests).

  • Step 5 : Vérifier le self-test

Run: .venv/bin/python Pseudonymisation_Gui_V6.py --self-test Expected: GUI V6 self-test OK, exit 0.

  • Step 6 : Commit
git add Pseudonymisation_Gui_V6.py tests/unit/test_gui_v6_entry_frozen_flag.py
git commit -m "fix(gui): flag legacy ONNX + log fichier dès l'entrée frozen (P0-5/E1)"

Task 6 : Instance unique + mutex partagé installeur (P0-7)

Files:

  • Create: gui_v6/single_instance.py

  • Modify: Pseudonymisation_Gui_V6.py:main

  • Test: tests/unit/test_gui_v6_single_instance.py

  • Step 1 : Écrire le test qui échoue

# tests/unit/test_gui_v6_single_instance.py
import pytest

from gui_v6.single_instance import (
    APP_MUTEX_NAME,
    AlreadyRunningError,
    SingleInstance,
)


def test_mutex_name_is_stable():
    # Nom partagé avec l'installeur (Inno AppMutex). Ne pas changer sans MAJ .iss.
    assert APP_MUTEX_NAME == "AivanonymAnonymisationV6"


def test_second_instance_is_rejected(tmp_path, monkeypatch):
    monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
    first = SingleInstance()
    first.acquire()
    try:
        with pytest.raises(AlreadyRunningError):
            SingleInstance().acquire()
    finally:
        first.release()


def test_release_allows_reacquire(tmp_path, monkeypatch):
    monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
    a = SingleInstance()
    a.acquire()
    a.release()
    b = SingleInstance()
    b.acquire()  # ne lève pas
    b.release()
  • Step 2 : Lancer le test pour le voir échouer

Run: .venv/bin/pytest tests/unit/test_gui_v6_single_instance.py -v Expected: FAIL — ModuleNotFoundError: No module named 'gui_v6.single_instance'.

  • Step 3 : Implémenter
# gui_v6/single_instance.py
"""Protection multi-instance de la GUI V6 (P0-7).

- Windows (frozen) : mutex nommé kernel via ctypes — c'est CE nom que l'installeur
  Inno détecte (``AppMutex``) pour fermer l'app avant une mise à jour (D8).
- POSIX (dev/test) : verrou ``fcntl`` exclusif sur un fichier dans le dossier app.
"""

from __future__ import annotations

import os
import sys
from pathlib import Path

# Nom partagé avec installer/Anonymisation.iss (AppMutex). NE PAS modifier seul.
APP_MUTEX_NAME = "AivanonymAnonymisationV6"


class AlreadyRunningError(RuntimeError):
    """Une autre instance de l'application est déjà en cours d'exécution."""


def _lock_dir() -> Path:
    base = os.environ.get("LOCALAPPDATA")
    root = Path(base) if base else Path.home() / ".local" / "share"
    d = root / "Aivanov" / "Anonymisation"
    d.mkdir(parents=True, exist_ok=True)
    return d


class SingleInstance:
    def __init__(self) -> None:
        self._handle = None  # mutex Windows
        self._fh = None       # file handle POSIX

    def acquire(self) -> None:
        if sys.platform.startswith("win"):
            self._acquire_windows()
        else:
            self._acquire_posix()

    def _acquire_windows(self) -> None:  # pragma: no cover (exécuté sur Windows)
        import ctypes

        ERROR_ALREADY_EXISTS = 183
        handle = ctypes.windll.kernel32.CreateMutexW(None, False, APP_MUTEX_NAME)
        if not handle or ctypes.windll.kernel32.GetLastError() == ERROR_ALREADY_EXISTS:
            raise AlreadyRunningError("L'application est déjà ouverte.")
        self._handle = handle

    def _acquire_posix(self) -> None:
        import fcntl

        path = _lock_dir() / "instance.lock"
        fh = open(path, "w")
        try:
            fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            fh.close()
            raise AlreadyRunningError("L'application est déjà ouverte.")
        self._fh = fh

    def release(self) -> None:
        if self._handle is not None:  # pragma: no cover
            import ctypes

            ctypes.windll.kernel32.CloseHandle(self._handle)
            self._handle = None
        if self._fh is not None:
            self._fh.close()
            self._fh = None
  • Step 4 : Lancer le test pour le voir passer

Run: .venv/bin/pytest tests/unit/test_gui_v6_single_instance.py -v Expected: PASS (3 tests).

  • Step 5 : Câbler dans l'entrée

Dans Pseudonymisation_Gui_V6.py:main, après setup_file_logging() et avant AnonymisationApp() :

    from gui_v6.single_instance import AlreadyRunningError, SingleInstance

    guard = SingleInstance()
    try:
        guard.acquire()
    except AlreadyRunningError:
        try:
            import tkinter.messagebox as mb

            mb.showinfo("Anonymisation", "L'application est déjà ouverte.")
        except Exception:
            print("L'application est déjà ouverte.")
        return 0
    try:
        application = AnonymisationApp()
        application.mainloop()
    finally:
        guard.release()
    return 0

(Remplace les lignes application = AnonymisationApp() / application.mainloop() / return 0 existantes.)

  • Step 6 : Vérifier self-test + suite GUI V6

Run: .venv/bin/python Pseudonymisation_Gui_V6.py --self-test && .venv/bin/pytest tests/unit/ -k gui_v6 -q Expected: GUI V6 self-test OK + suite verte.

  • Step 7 : Commit
git add gui_v6/single_instance.py Pseudonymisation_Gui_V6.py tests/unit/test_gui_v6_single_instance.py
git commit -m "feat(gui): instance unique + mutex partagé installeur (P0-7)"

Self-review (couverture spec chantiers A/B/E1)

  • P0-1 fail-close → Task 2 ✓ · P0-2 URL → Task 1 ✓ · P0-5 frozen flag → Task 5 ✓ · P0-6 binding → Task 3 ✓ · P0-7 lock+mutex → Task 6 ✓ · E1 log fichier → Task 4 (+ câblage Task 5) ✓.
  • Hors de ce plan (par décision de découpe) : P1-2 (Plan 1b), P1-1/3/4/5 + P2 (Plan 1c), diagnostics E2-E4 (Plan 2), build/release C+F (Plan 3).
  • Cohérence types : EngineUnavailableError, ManagerState.UNAVAILABLE, LicenseStatus(machine_id=…), default_machine_id(), setup_file_logging(), SingleInstance/APP_MUTEX_NAME — tous définis ici et réutilisés de façon cohérente.
  • À confirmer au smoke EXE (Plan 3) : P0-5 (crash frozen réel) et P0-7 (mutex Windows).