17 KiB
GUI V6 bêta — Plan 1b : câblage des 7 toggles « Données à détecter » au moteur
For agentic workers: REQUIRED SUB-SKILL: subagent-driven-development / executing-plans, task-by-task. CODE SÉCURITÉ — revue Qwen post-implémentation obligatoire (P1-2). Version 2 — intègre la revue adversariale Qwen (GO-avec-réserves, F-1..F-5) ET la vérification indépendante Claude (Dom : « réfléchis et vérifie les phases critiques »).
Goal: Rendre les 7 interrupteurs « Données à détecter » réellement effectifs : décocher une catégorie la laisse en clair en sortie (texte ET PDF) et relâche le filet de sécurité pour cette catégorie — sans jamais démasquer une catégorie non décochée.
Architecture: Masquage inline éclaté (≥3 passes : détection, selective_rescan, propagation globale, VLM, burn PDF ; ≥30 sites ; pas de chokepoint). On porte un disabled_kinds: set[str] via cfg et on applique : (T1) filtre de l'audit avant le burn PDF = porteur de sûreté du livrable PDF, default-deny ; (T2/T3) gates texte à toutes les passes de masquage ; (T4) garde-fous burn indépendants de l'audit ; (QUAR) coordination du rescan résiduel pour éviter la quarantaine systématique. Catégorie d'un kind dérivée des maps sources (anti-dérive), pas d'une table figée.
Décisions/faits vérifiés (Claude) :
- EDS :
PiiHit(-1, f"EDS_{label}", …)(core:3282) ; catégorie viaEDS_LABEL_MAP(eds_pseudo_manager.py:24). - VLM :
VLM_CATEGORY_MAP(vlm_manager.py:51)label→(kind, placeholder)— source de vérité (Qwen rataitVLM_CP). _GLOBAL:PiiHit(kind=f"{kind}_GLOBAL")(core:5286) pour_CRITICAL_PII_TYPES(core:5245) — plusieurs kinds, pas seulement NIR/ADHERENT.- Burn :
_VECTOR/_RASTER_SKIP_KINDS(core:4564/4723) excluent déjàEDS_SECU/EDS_TEL/EDS_DATE_NAISSANCEdu PDF. - Décision CP/ZIP (TRANCHÉE Dom 2026-06-26) : code postal (
VLM_CP,EDS_ZIP, placeholderCODE_POSTAL) suit le toggle « Adresses » → catégorieADRESSE. Décocher « Adresses » révèle voie + CP (rendu « 12 rue X 64100 Ville »). Override explicite de la spec D2/D3 (qui listait CODE_POSTAL non-toggleable). Périmètre strict = CP uniquement :VILLEreste non-toggleable (toujours masquée), hors de cette décision.
Référence spec : docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md (chantier D, P1-2, D2/D3 : pas de plancher dur ; EMAIL/IBAN/IPP/VILLE/FAX non toggleables = toujours masqués). NB : CODE_POSTAL retiré de cette liste par décision Dom 2026-06-26 (suit « Adresses »).
Task 1 : _category_of DÉRIVÉ des maps sources + filtre audit Tier 1 (F-1)
Files: Modify anonymizer_core_refactored_onnx.py (helper near placeholders ~l.610 ; disabled_kinds kwarg on process_pdf ~l.4973 ; inject into cfg after ~l.5002 ; audit filter before PDF write ~l.5553). Test tests/unit/test_core_category_gating.py.
Approche (anti-dérive) : ne PAS hardcoder une table exhaustive (elle dérive). Catégorie d'un kind dérivée ainsi, dans l'ordre :
- suffixe
_GLOBALretiré → re-catégoriser la base (NIR_GLOBAL→NIR) ; - table explicite des kinds regex/inline non dérivables ;
- kind == un placeholder toggleable lui-même ;
VLM_*→ placeholder via reverse deVLM_CATEGORY_MAP;EDS_*→ label → placeholder viaEDS_LABEL_MAP;- sinon
None(default-deny → toujours masqué).
- Step 1 — Failing test. Create
tests/unit/test_core_category_gating.py. Couvre UN kind de CHAQUE source + default-deny + une garde anti-dérive :
import anonymizer_core_refactored_onnx as core
def test_category_of_each_source():
assert core._category_of("NOM_FORCE") == "NOM" # explicite/regex
assert core._category_of("NIR") == "NIR" # placeholder-self
assert core._category_of("NIR_GLOBAL") == "NIR" # suffixe _GLOBAL
assert core._category_of("ADHERENT_GLOBAL") == "ADHERENT"
assert core._category_of("VLM_NOM") == "NOM" # dérivé VLM
assert core._category_of("VLM_ETAB") == "ETAB"
assert core._category_of("EDS_SECU") == "NIR" # dérivé EDS (SECU→NIR)
assert core._category_of("EDS_HOPITAL") == "ETAB"
assert core._category_of("VLM_CP") == "ADRESSE" # CP suit « Adresses » (Dom 2026-06-26)
assert core._category_of("EDS_ZIP") == "ADRESSE"
def test_category_of_default_deny():
# Non toggleables → None (restent TOUJOURS masqués). Sécurité.
# NB : VILLE reste masquée ; seul CODE_POSTAL (VLM_CP/EDS_ZIP) a été basculé vers ADRESSE.
for k in ("EMAIL", "IBAN", "IPP", "VILLE", "FAX",
"VLM_VILLE", "EMAIL_GLOBAL", "INCONNU_XYZ"):
assert core._category_of(k) is None, k
def test_no_toggleable_vlm_or_eds_kind_is_uncategorised():
# ANTI-DÉRIVE : tout kind VLM/EDS dont le placeholder est une des 7 catégories
# DOIT être catégorisé (sinon toggle faussé sur ce chemin).
import vlm_manager, eds_pseudo_manager
seven = {"NOM", "DATE_NAISSANCE", "ETAB", "ADRESSE", "NIR", "TEL", "ADHERENT"}
for _label, (kind, placeholder) in vlm_manager.VLM_CATEGORY_MAP.items():
if core._placeholder_to_category(placeholder) in seven:
assert core._category_of(kind) is not None, f"VLM {kind} non catégorisé"
for label, placeholder in eds_pseudo_manager.EDS_LABEL_MAP.items():
if core._placeholder_to_category(placeholder) in seven:
assert core._category_of(f"EDS_{label}") is not None, f"EDS_{label} non catégorisé"
def test_filter_audit_drops_only_disabled():
PiiHit = core.PiiHit
audit = [PiiHit(1, "NOM", "Dupont", "[NOM]"), PiiHit(1, "NIR", "1850574", "[NIR]"),
PiiHit(1, "EMAIL", "x@y.fr", "[EMAIL]"), PiiHit(1, "NIR_GLOBAL", "1850574", "[NIR]")]
kinds = {h.kind for h in core._filter_audit_by_disabled(audit, {"NIR"})}
assert "NIR" not in kinds and "NIR_GLOBAL" not in kinds # NIR + propagation retirés
assert "NOM" in kinds and "EMAIL" in kinds # autres conservés
-
Step 2 — Run, expect FAIL.
-
Step 3 — Implement (after
PLACEHOLDERS/CRITICAL_PII_KEYS~l.610). Lirevlm_manager.VLM_CATEGORY_MAPeteds_pseudo_manager.EDS_LABEL_MAPau moment de l'implémentation pour confirmer les noms ; importer ces deux modules en tête du core (imports déjà présents pour VLM ? sinon import paresseux dans le helper). Convention admin_rules (vérifiée Qwen — documenter, pas de branche supplémentaire) :_apply_admin_identifier_hits(~l.1376) émet des kinds = clés dePLACEHOLDERS(ex. "NOM", "NIR", "TEL") → captés par la branche 3 (placeholder-self). Un admin_rule à kind custom horsPLACEHOLDERS→None→ toujours masqué (conservateur, sûr).
# 7 catégories toggleables ↔ type de placeholder. Tout autre placeholder → None (masqué).
_PLACEHOLDER_TO_CATEGORY = {
"NOM": "NOM", "DATE_NAISSANCE": "DATE_NAISSANCE", "ETAB": "ETAB",
"ADRESSE": "ADRESSE", "NIR": "NIR", "TEL": "TEL", "ADHERENT": "ADHERENT",
"CODE_POSTAL": "ADRESSE", # décision Dom 2026-06-26 : CP suit le toggle « Adresses »
}
# Kinds regex/inline non dérivables d'une map → leur catégorie explicitement.
_EXPLICIT_KIND_CATEGORY = {
"NOM_FORCE": "NOM", "NOM_EXTRACTED": "NOM", "NOM_INITIAL": "NOM",
"NER_PER": "NOM", "NER_ORG": "ETAB",
"ETAB_FINESS": "ETAB", "ETAB_SPACED": "ETAB",
"ADDR_FINESS": "ADRESSE",
}
def _placeholder_to_category(placeholder):
return _PLACEHOLDER_TO_CATEGORY.get(str(placeholder).strip("[]").upper())
def _category_of(kind):
"""Catégorie toggleable d'un kind d'audit, ou None (default-deny → masqué)."""
if not kind:
return None
if kind.endswith("_GLOBAL"):
return _category_of(kind[: -len("_GLOBAL")])
if kind in _EXPLICIT_KIND_CATEGORY:
return _EXPLICIT_KIND_CATEGORY[kind]
if kind in _PLACEHOLDER_TO_CATEGORY:
return _PLACEHOLDER_TO_CATEGORY[kind]
if kind.startswith("VLM_"):
try:
import vlm_manager
rev = {k: ph for (k, ph) in vlm_manager.VLM_CATEGORY_MAP.values()}
return _placeholder_to_category(rev.get(kind))
except Exception:
return None
if kind.startswith("EDS_"):
try:
import eds_pseudo_manager
label = kind[len("EDS_"):]
ph = eds_pseudo_manager.EDS_LABEL_MAP.get(label, label)
return _placeholder_to_category(ph)
except Exception:
return None
return None
def _filter_audit_by_disabled(audit, disabled_kinds):
if not disabled_kinds:
return audit
return [h for h in audit if _category_of(h.kind) not in disabled_kinds]
Add disabled_kinds: set = None kwarg to process_pdf (~l.4973). After cfg = load_dictionaries(config_path) (~l.5002) : cfg["disabled_kinds"] = set(disabled_kinds or ()). Before the PDF write (~l.5553) : anon.audit = _filter_audit_by_disabled(anon.audit, cfg.get("disabled_kinds") or set()) (adapter anon.audit au nom réel de la liste de PiiHit).
- Step 4 — Run, expect PASS (incl. anti-dérive).
- Step 5 — Non-régression :
.venv/bin/pytest tests/unit/ -q(defaultsNone⇒ 0 changement). - Step 6 — Commit :
git add anonymizer_core_refactored_onnx.py tests/unit/test_core_category_gating.py && git commit -m "feat(core): _category_of dérivé (anti-dérive) + filtre audit Tier 1 (P1-2/F-1)"
Task 2 : Quarantaine — coordination NIR/TEL décochés (F-4, le plus subtil)
Files: Modify anonymizer_core_refactored_onnx.py (_residual_pii_patterns ~l.5453-5458). Test extend.
Faits (F-4) : 3 pré-quarantaines + 1 piège. (a) selective_rescan re-masque NIR/TEL de force (gaté en Task 3) ; (b) propagation globale NIR_GLOBAL (gatée Task 3) ; (c) _residual_pii_patterns (seuil=0) → 1 résidu = quarantaine totale. Piège : NIR laissé en clair → le pattern TEL résiduel matche ses 10 chiffres centraux → quarantaine injustifiée.
- Step 1 — Failing test. Refactor inline patterns into
_build_residual_patterns(disabled_kinds)and test : labels include {NIR,EMAIL,IBAN,TEL} when none disabled ; NIR absent when{"NIR"}(EMAIL/IBAN restent) ; TEL absent when{"TEL"}; et quand NIR disabled, le pattern TEL ne matche PAS un NIR en clair (test :_build_residual_patterns({"NIR"})appliqué à « 1 85 05 74 123 456 78 » → 0 match). - Step 2 — Run, expect FAIL.
- Step 3 — Implement.
_build_residual_patterns(disabled): EMAIL+IBAN toujours ; NIR si"NIR" not in disabled; TEL si"TEL" not in disabled; quand NIR disabled, le pattern TEL doit exclure les spans de type NIR (13-15 chiffres groupés) — soit en pré-masquant les spans NIR-like, soit en bornant le pattern TEL. Gate la branche INSEE-names (~l.5470) sous"NOM" not in disabled. Branchercfg.get("disabled_kinds"). Seuil adaptatif (suggestion re-revue Qwen) :SEUIL_RESCAN_RESIDUEL=0est trop strict quand des catégories sont décochées (un fragment de 8 chiffres peut matcher le pattern TEL résiduel → quarantaine injustifiée) ; passer le seuil à 1 sidisabled_kindsnon vide, 0 sinon (préserve la rigueur quand tout est activé). - Step 4 — Run, expect PASS.
- Step 5 — Non-régression :
.venv/bin/pytest tests/unit/ -q. - Step 6 — Commit :
git commit -m "feat(core): coordination quarantaine résiduelle NIR/TEL décochés (P1-2/F-4)"
Task 3 : Gates texte — TOUTES les passes de masquage (F-2 + F-5)
Files: anonymizer_core_refactored_onnx.py, sites ci-dessous. Test tests/unit/test_core_category_gating_behavior.py.
Sites à gater (liste consolidée Claude+Qwen — lire chaque site avant édition ; pattern : disabled = cfg.get("disabled_kinds") or set(), sauter le sous-bloc de la catégorie désactivée) :
-
Dispatchers :
_mask_line_by_regex(~1670),_kv_value_only_mask(~2110, subs NOM/label 2098-2106), bloc PERSON-majuscules (~1942-2008→NOM). -
KV distincts (F-2) :
_mask_structured_line(~2042→ADHERENT/NOM),_mask_critical_in_key(~2004→TEL/ADRESSE),_apply_admin_identifier_hits(~1376→dynamique : OK via default-deny si kind mappable). -
Noms :
_apply_extracted_names(~2809→early-return si NOM off). -
NER — INTRA-BOUCLE par placeholder (F-5, point le plus fragile) :
_mask_with_hf(~3136) et_mask_with_eds_pseudo(~3208) — NE PAS sauter la fonction entière (perdrait ETAB/VILLE) ; sauter par hit selon_category_of(kind)/placeholder. -
Trackare (F-2) :
_apply_trackare_hits_to_text(~2909→NIR/DOSSIER) — gate NIR ;RE_TRACKARE_IAO_MULTILINE_VALUE(~3102→NOM_FORCE) — gate NOM (site ajouté re-revue Qwen ; sinon NOM faussé sur docs Trackare IAO). -
selective_rescan (~4159) : DATE_NAISSANCE(4203), ADRESSE(4205-4207), ETAB(4229-4251), ADHERENT(4200-4201), TEL(4191-4193), NIR(4187-4188).
-
Phase-0 multiline : DATE_NAISSANCE(~3014), NIR(~3034).
-
Propagation globale step 4e (F-2 #1, F-4) : boucle
_CRITICAL_PII_TYPES/{kind}_GLOBAL(~5279-5286) — ne pas propager une catégorie désactivée. -
VLM (F-2 #2, CRITIQUE scanné) :
_apply_vlm_on_scanned_pdf(~4898-4965) — masque dans texte+raster indépendamment ; gate par_category_of(vlm_kind). -
Post-mask cleanups (F-2 #6) : NOM orphan (~5098/5137/5148), TEL fragment (~5118/5128).
-
Step 1 — Failing behavioral tests. Create
tests/unit/test_core_category_gating_behavior.py: pour CHAQUE catégorie, unpages_textavec un cas clair de cette catégorie + un cas d'une AUTRE catégorie ;anonymise_document_regex(pages, [], cfg_disabled)⇒ la catégorie décochée est PRÉSENTE en clair, l'autre RESTE masquée. + baseline « tout activé = non-régression » (rien en clair). Vérifier la forme de retour réelle deanonymise_document_regexd'abord. Crafter des entrées valides en lisant les vraies regex. (Le chemin VLM se teste avec un fauxvlm_managerinjecté ou un test ciblé sur_apply_vlm_on_scanned_pdf.) -
Step 2 — Run, expect FAIL.
-
Step 3 — Implement site par site ; re-run le test de la catégorie après chaque.
-
Step 4 — Run, expect ALL PASS.
-
Step 5 — Non-régression + qualité :
.venv/bin/pytest tests/unit/ -q+.venv/bin/python scripts/evaluate_quality.py(A+ maintenu défauts). Gatesynthetic_regressionvert. -
Step 6 — Commit :
git commit -m "feat(core): gates texte par catégorie sur toutes les passes (P1-2/F-2/F-5)"
Task 4 : Garde-fous burn indépendants de l'audit + alignement SKIP_KINDS (F-3)
Files: anonymizer_core_refactored_onnx.py (_search_pdf_address_lines calls ~4575/4746 ; _VECTOR/_RASTER_SKIP_KINDS ~4564/4723).
Faits (F-3) : burn dérive 100% de l'audit POUR les PII détectées (donc T1 suffit), SAUF 3 chemins indépendants — adresse (à gater), images/barcodes (conservateurs, hors scope toggle, à documenter). _SKIP_KINDS exclut déjà EDS_SECU/EDS_TEL/EDS_DATE_NAISSANCE du PDF (cohérent avec un futur toggle).
- Step 1 — Failing test : ADRESSE désactivé ⇒
_search_pdf_address_linesskippé. (Lire commentdisabled_kindsatteintredact_pdf_*— passer le set en param ou viacfg.) - Step 2 — Run, expect FAIL.
- Step 3 — Implement : guard les 2 appels
_search_pdf_address_lines(page)sousif "ADRESSE" not in disabled_kinds:. Documenter en commentaire que images/barcodes restent conservateurs (sur-masquage assumé, jamais de fuite). Vérifier que_SKIP_KINDSn'entre pas en conflit avec le toggle DATE_NAISSANCE (sinon ajuster). - Step 4 — Run, expect PASS.
- Step 5 — Non-régression :
.venv/bin/pytest tests/unit/ -q. - Step 6 — Commit :
git commit -m "feat(core): garde-fou adresse burn + doc chemins conservateurs (P1-2/F-3)"
Task 5 : Câblage GUI — 7 booléens → moteur
(Inchangé vs v1 — voir historique git ; gui_v6/config_state.py 7 bool + disabled_kinds, engine_bridge.EngineSettings/build_engine_kwargs, tab_config.py les 7 _mini_toggle câblés.)
- Tests
tests/unit/test_gui_v6_category_toggles.py(défaut tous ON ⇒disabled_kinds == frozenset(); décocher NIR+ETAB ⇒{"NIR","ETAB"};build_engine_kwargspropage). Implémenter, self-test, non-régression GUI, commitfeat(gui): câbler les 7 toggles catégories au moteur (P1-2).
Self-review (couverture spec + revue Qwen + vérif Claude)
- F-1 :
_category_ofdérivé (VLM_CATEGORY_MAP + EDS_LABEL_MAP + suffixe _GLOBAL) + test anti-dérive → couvre les 15 kinds de Qwen ET ceux qu'il a ratés. Default-deny. CODE_POSTAL→ADRESSE (décision Dom 2026-06-26). ✓ - F-2 : Task 3 liste consolidée incl. propagation globale, VLM, Trackare, structured/critical, cleanups. ✓
- F-3 : Task 4 adresse gaté + images/barcodes documentés conservateurs + SKIP_KINDS vérifiés. ✓
- F-4 : Task 2 coordonne résiduel + exclusion NIR-like du pattern TEL + gate selective_rescan/propagation (Task 3). ✓
- F-5 : Task 3 impose le gating NER intra-boucle (per-hit), jamais per-function. ✓
- Risque résiduel : un site oublié ⇒ catégorie reste masquée (test rouge), JAMAIS fuite croisée (default-deny + filtre par catégorie). Livrable PDF garanti par T1 seul.
- Décision Dom 2026-06-26 (TRANCHÉE) : CODE_POSTAL (CP/ZIP) → catégorie ADRESSE (suit le toggle « Adresses »). VILLE reste toujours masquée (hors décision).
- Re-revue Qwen post-implémentation obligatoire (Tasks 1-4).