9 Commits

Author SHA1 Message Date
4357a58d7d fix(core): exempter les hits forcés (overrides) du filtre catégorie — anti-fuite PDF (P1-2/T1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:38:19 +02:00
5663966938 docs(coord): verdict Qwen GO — re-revue post-impl Tasks 1-4 gating catégories
§FAX: aucun autre type non-toggleable dépend d'une passe gatée.
§Divergence: accord seuil 0 strict + premask scopé (retire suggestion adaptatif).
§Task 3b: report acceptable pour beta (fail-closed).
1 trouble mineur T1: override user-defined nommé avec catégorie toggleable.
2026-06-27 11:27:24 +02:00
bf832e12f0 feat(gui): câbler les 7 toggles catégories au moteur (P1-2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:33:13 +02:00
daec1f53bd feat(core): garde-fou adresse burn + doc chemins conservateurs (P1-2/F-3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:13:42 +02:00
a02bca516d feat(core): gates texte par catégorie sur toutes les passes (P1-2/F-2/F-5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:03:04 +02:00
dd392c4a50 docs(beta): plan 1b — Task 2 seuil résiduel strict 0 + premask scopé TEL (revue qualité) + Task 3 span-aware
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:23:40 +02:00
2a3aab117d feat(core): coordination quarantaine résiduelle NIR/TEL décochés (P1-2/F-4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:21:27 +02:00
b15d0da141 feat(core): _category_of dérivé (anti-dérive) + filtre audit Tier 1 (P1-2/F-1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:04:03 +02:00
c93dc34a70 docs(beta): plan 1b — décision Dom CODE_POSTAL suit le toggle Adresses (catégorie ADRESSE)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:50:07 +02:00
13 changed files with 1558 additions and 185 deletions

View File

@@ -36,7 +36,7 @@ for _env in ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS",
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import List, Dict, Tuple, Optional, Any from typing import List, Dict, Tuple, Optional, Any, Set
def _bundle_root() -> Path: def _bundle_root() -> Path:
@@ -609,6 +609,89 @@ PLACEHOLDERS = {
CRITICAL_PII_KEYS = {"EMAIL", "TEL", "IBAN", "NIR", "IPP", "DATE_NAISSANCE"} CRITICAL_PII_KEYS = {"EMAIL", "TEL", "IBAN", "NIR", "IPP", "DATE_NAISSANCE"}
# ---------------------------------------------------------------------------
# Gating par catégorie (Plan 1b — P1-2/F-1)
# ---------------------------------------------------------------------------
# Les 7 catégories toggleables de la GUI ↔ type de placeholder. Tout autre
# placeholder → None ⇒ default-deny (reste TOUJOURS masqué). Dérivation
# anti-dérive : on lit les maps sources (VLM/EDS) plutôt qu'une table figée.
_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.
# ⚠ Table manuelle : ajouter ici tout nouveau kind regex/inline d'une des 7
# catégories (non couvert par le test anti-dérive, qui ne vérifie que VLM/EDS).
_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):
"""Type de placeholder → catégorie toggleable (ou None)."""
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é).
Ordre de dérivation anti-dérive :
1. suffixe ``_GLOBAL`` → recatégoriser la base ;
2. table explicite des kinds regex/inline ;
3. kind = placeholder toggleable lui-même ;
4. ``VLM_*`` → placeholder via l'inverse de ``VLM_CATEGORY_MAP`` ;
5. ``EDS_*`` → label → placeholder via ``EDS_LABEL_MAP`` ;
6. sinon ``None`` (default-deny → toujours 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):
"""Retire de l'audit les hits dont la CATÉGORIE (sortie de _category_of) est désactivée.
``disabled_kinds`` = set des CATÉGORIES désactivées (les 7 toggles :
"NOM", "DATE_NAISSANCE", "ETAB", "ADRESSE", "NIR", "TEL", "ADHERENT"),
PAS des kinds bruts. Le nom du paramètre reste ``disabled_kinds`` par
cohérence avec le plan / la GUI.
No-op si ``disabled_kinds`` est vide/None (garantie de non-régression).
"""
if not disabled_kinds:
return audit
# P1-2/T1 (anti-fuite PDF) : un hit FORCÉ (override utilisateur / blacklist
# force-mask) est TOUJOURS conservé → toujours gravé, quel que soit le toggle
# de catégorie. getattr défensif au cas où un PiiHit serait construit ailleurs
# sans le champ (le default du dataclass couvre déjà ce cas).
return [h for h in audit if getattr(h, "forced", False) or _category_of(h.kind) not in disabled_kinds]
# Baseline regex # Baseline regex
RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
RE_URL = re.compile(r"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=\-%]+", re.IGNORECASE) RE_URL = re.compile(r"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=\-%]+", re.IGNORECASE)
@@ -786,6 +869,88 @@ def validate_nir(nir_raw: str) -> bool:
return False return False
return key_int == (97 - (body_int % 97)) return key_int == (97 - (body_int % 97))
# === Plan 1b (P1-2/F-4) — patterns de rescan résiduel gatés par catégorie ====
# Le rescan M5 (cf. process_pdf) re-scanne le texte masqué à la recherche de PII
# résiduelles ; toute occurrence (seuil 0) met le document en quarantaine full.
# Quand une catégorie est volontairement décochée (laissée en clair), elle ne
# doit PAS déclencher la quarantaine — ni directement, ni via le pattern résiduel
# d'une AUTRE catégorie. Piège connu : un NIR laissé en clair (`1 85 05 74 123
# 456 78`) fait matcher le pattern résiduel TEL sur son bloc central de chiffres
# (le `0` de `05…` amorce l'ancre `(?:\+33|0)`). On résout ce couplage en
# pré-masquant les blocs de type NIR AVANT d'appliquer les patterns, mais
# UNIQUEMENT quand NIR est décoché. Quand rien n'est désactivé, le pré-masquage
# est l'identité et la liste de patterns est byte-for-byte celle d'avant.
# Pattern d'un bloc « type NIR » : 13 à 15 chiffres groupés (espaces/points/
# tirets optionnels), tel qu'écrit dans un document (`1 85 05 74 123 456 78`).
# Utilisé uniquement pour neutraliser un NIR laissé EN CLAIR avant le rescan TEL.
_RE_NIR_LIKE_SPAN = re.compile(
r"\b\d(?:[\s.\-]?\d){12,14}\b"
)
def _residual_premask_text(text: str, disabled_kinds: Optional[Set[str]] = None) -> str:
"""Neutralise les blocs « type NIR » du texte AVANT le rescan résiduel,
uniquement si la catégorie NIR est désactivée (laissée en clair).
But : empêcher qu'un NIR en clair ne fasse matcher le pattern résiduel TEL
(ou IBAN) sur son bloc central de chiffres et ne déclenche une quarantaine
injustifiée. Quand NIR n'est PAS désactivé, retourne le texte inchangé
(identité) → comportement byte-for-byte préservé.
"""
disabled = disabled_kinds or set()
if "NIR" not in disabled:
return text
# Remplace chaque bloc type-NIR par des espaces de même longueur : aucune
# frontière de chiffres n'est créée/détruite, et le pattern TEL ne peut plus
# s'amorcer sur ces chiffres laissés en clair.
return _RE_NIR_LIKE_SPAN.sub(lambda m: " " * len(m.group(0)), text)
def _build_residual_patterns(
disabled_kinds: Optional[Set[str]] = None,
) -> List[Tuple["re.Pattern", str]]:
"""Construit la liste `(regex compilée, label)` des patterns de rescan
résiduel, en retirant les catégories décochées.
`disabled_kinds` contient des noms de CATÉGORIE (les 7 toggles : "NIR",
"TEL", "NOM", …), pas des kinds bruts.
Règles :
- EMAIL et IBAN : toujours inclus.
- NIR : inclus seulement si "NIR" non désactivé.
- TEL : inclus seulement si "TEL" non désactivé. L'exclusion des blocs
type-NIR du pattern TEL est gérée en amont par `_residual_premask_text`
(appliqué au texte au call-site), pas dans le pattern lui-même → quand
NIR est activé, le pattern TEL est strictement identique à avant.
Non-régression : `_build_residual_patterns(set())` produit EXACTEMENT la
liste historique (NIR, EMAIL, IBAN, TEL, dans cet ordre).
NB : les littéraux EMAIL/IBAN/TEL ci-dessous sont des filets résiduels
INDÉPENDANTS et volontairement plus LARGES que les masqueurs canoniques
(`RE_EMAIL`/`RE_IBAN`/`RE_TEL`). Ils doivent rester littéraux ici : les
basculer sur les constantes canoniques changerait le comportement résiduel
et casserait la non-régression byte-for-byte du chemin par défaut.
"""
disabled = disabled_kinds or set()
patterns: List[Tuple["re.Pattern", str]] = []
if "NIR" not in disabled:
patterns.append(
(re.compile(RE_NIR.pattern if hasattr(RE_NIR, "pattern") else r"\b\d{15}\b"), "NIR")
)
patterns.append((re.compile(r"\b[\w.%+-]+@[\w.-]+\.\w{2,}\b"), "EMAIL"))
patterns.append(
(re.compile(r"\b(?:FR\d{2})?\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2,3}\b"), "IBAN")
)
if "TEL" not in disabled:
patterns.append(
(re.compile(r"\b(?:\+33|0)[\s.\-]?\d[\s.\-]?(?:\d[\s.\-]?){8}\b"), "TEL")
)
return patterns
# Mots médicaux/techniques/courants qui ne sont pas des noms de personnes. # Mots médicaux/techniques/courants qui ne sont pas des noms de personnes.
# Source de vérité externalisée dans data/stopwords_manuels.txt + BDPM/edsnlp. # Source de vérité externalisée dans data/stopwords_manuels.txt + BDPM/edsnlp.
_MEDICAL_STOP_WORDS_FALLBACK = { _MEDICAL_STOP_WORDS_FALLBACK = {
@@ -1170,6 +1335,10 @@ class PiiHit:
original: str original: str
placeholder: str placeholder: str
bbox_hint: Optional[Tuple[float, float, float, float]] = None bbox_hint: Optional[Tuple[float, float, float, float]] = None
# P1-2/T1 (anti-fuite PDF) : un hit FORCÉ (override utilisateur / blacklist
# force-mask) est TOUJOURS gravé, jamais retiré par un toggle de catégorie.
# Default False ⇒ no-op strict pour tous les call sites existants.
forced: bool = False
@dataclass @dataclass
class AnonResult: class AnonResult:
@@ -1608,7 +1777,7 @@ def _apply_overrides(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[st
except Exception: except Exception:
continue continue
def _rep(m: re.Match): def _rep(m: re.Match):
audit.append(PiiHit(page_idx, name, m.group(0), placeholder)) audit.append(PiiHit(page_idx, name, m.group(0), placeholder, forced=True))
return placeholder return placeholder
line = rx.sub(_rep, line) line = rx.sub(_rep, line)
# force-mask literals # force-mask literals
@@ -1616,7 +1785,7 @@ def _apply_overrides(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[st
if not term: continue if not term: continue
word_rx = re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE) word_rx = re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE)
if word_rx.search(line): if word_rx.search(line):
audit.append(PiiHit(page_idx, "force_term", term, PLACEHOLDERS["MASK"])) audit.append(PiiHit(page_idx, "force_term", term, PLACEHOLDERS["MASK"], forced=True))
line = word_rx.sub(PLACEHOLDERS["MASK"], line) line = word_rx.sub(PLACEHOLDERS["MASK"], line)
# force-mask regex # force-mask regex
for pat in (cfg.get("blacklist", {}).get("force_mask_regex", []) or []): for pat in (cfg.get("blacklist", {}).get("force_mask_regex", []) or []):
@@ -1625,7 +1794,7 @@ def _apply_overrides(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[st
except Exception: except Exception:
continue continue
def _repl_force_regex(m: re.Match, _pat=pat): def _repl_force_regex(m: re.Match, _pat=pat):
audit.append(PiiHit(page_idx, "force_regex", m.group(0), PLACEHOLDERS["MASK"])) audit.append(PiiHit(page_idx, "force_regex", m.group(0), PLACEHOLDERS["MASK"], forced=True))
return PLACEHOLDERS["MASK"] return PLACEHOLDERS["MASK"]
line = rx.sub(_repl_force_regex, line) line = rx.sub(_repl_force_regex, line)
return line return line
@@ -1668,6 +1837,12 @@ def _mask_admin_label(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[s
def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[str, Any]) -> str: def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[str, Any]) -> str:
# Plan 1b (P1-2/F-2/F-5) — catégories décochées (les 7 toggles). Vide ⇒ no-op
# byte-for-byte. Chaque sous-bloc d'une catégorie toggleable est sauté si sa
# catégorie est désactivée (la valeur reste alors EN CLAIR dans le texte).
# Les kinds non toggleables (EMAIL, IBAN, FINESS, IPP, VILLE, …) → None ⇒
# default-deny ⇒ TOUJOURS masqués.
disabled = cfg.get("disabled_kinds") or set()
# EMAIL avant les overrides : les force_terms (ex: CHUXX) casseraient sinon l'adresse # EMAIL avant les overrides : les force_terms (ex: CHUXX) casseraient sinon l'adresse
def _repl_email(m: re.Match) -> str: def _repl_email(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "EMAIL", m.group(0), PLACEHOLDERS["EMAIL"])) audit.append(PiiHit(page_idx, "EMAIL", m.group(0), PLACEHOLDERS["EMAIL"]))
@@ -1696,13 +1871,14 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
return raw # faux positif, on ne masque pas return raw # faux positif, on ne masque pas
audit.append(PiiHit(page_idx, "NIR", raw, PLACEHOLDERS["NIR"])) audit.append(PiiHit(page_idx, "NIR", raw, PLACEHOLDERS["NIR"]))
return PLACEHOLDERS["NIR"] return PLACEHOLDERS["NIR"]
line = RE_NIR.sub(_repl_nir, line)
# NIR 13 chiffres sans clé, STRICTEMENT après label (pas de validation modulo # NIR 13 chiffres sans clé, STRICTEMENT après label (pas de validation modulo
# possible sans la clé ; l'ancre label suffit à éviter les faux positifs). # possible sans la clé ; l'ancre label suffit à éviter les faux positifs).
def _repl_nir_no_key(m: re.Match) -> str: def _repl_nir_no_key(m: re.Match) -> str:
val = m.group(1) val = m.group(1)
audit.append(PiiHit(page_idx, "NIR", val, PLACEHOLDERS["NIR"])) audit.append(PiiHit(page_idx, "NIR", val, PLACEHOLDERS["NIR"]))
return m.group(0).replace(val, PLACEHOLDERS["NIR"]) return m.group(0).replace(val, PLACEHOLDERS["NIR"])
if "NIR" not in disabled:
line = RE_NIR.sub(_repl_nir, line)
line = RE_NIR_NO_KEY.sub(_repl_nir_no_key, line) line = RE_NIR_NO_KEY.sub(_repl_nir_no_key, line)
# FAX (label-ancré) AVANT TEL : un numéro de fax doit devenir [FAX], pas [TEL]. # FAX (label-ancré) AVANT TEL : un numéro de fax doit devenir [FAX], pas [TEL].
@@ -1716,6 +1892,7 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
def _repl_tel(m: re.Match) -> str: def _repl_tel(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "TEL", m.group(0), PLACEHOLDERS["TEL"])) audit.append(PiiHit(page_idx, "TEL", m.group(0), PLACEHOLDERS["TEL"]))
return PLACEHOLDERS["TEL"] return PLACEHOLDERS["TEL"]
if "TEL" not in disabled:
line = RE_TEL_SLASH.sub(_repl_tel, line) # slash d'abord (plus spécifique) line = RE_TEL_SLASH.sub(_repl_tel, line) # slash d'abord (plus spécifique)
line = RE_TEL.sub(_repl_tel, line) line = RE_TEL.sub(_repl_tel, line)
line = RE_TEL_COMPACT.sub(_repl_tel, line) line = RE_TEL_COMPACT.sub(_repl_tel, line)
@@ -1744,12 +1921,13 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
def _repl_date_naissance(m: re.Match) -> str: def _repl_date_naissance(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "DATE_NAISSANCE", m.group(0), PLACEHOLDERS["DATE_NAISSANCE"])) audit.append(PiiHit(page_idx, "DATE_NAISSANCE", m.group(0), PLACEHOLDERS["DATE_NAISSANCE"]))
return PLACEHOLDERS["DATE_NAISSANCE"] return PLACEHOLDERS["DATE_NAISSANCE"]
line = RE_DATE_NAISSANCE.sub(_repl_date_naissance, line)
# « Né en 1972 » (année seule de naissance) → [DATE_NAISSANCE] # « Né en 1972 » (année seule de naissance) → [DATE_NAISSANCE]
def _repl_date_naissance_annee(m: re.Match) -> str: def _repl_date_naissance_annee(m: re.Match) -> str:
val = m.group(1) val = m.group(1)
audit.append(PiiHit(page_idx, "DATE_NAISSANCE", val, PLACEHOLDERS["DATE_NAISSANCE"])) audit.append(PiiHit(page_idx, "DATE_NAISSANCE", val, PLACEHOLDERS["DATE_NAISSANCE"]))
return m.group(0).replace(val, PLACEHOLDERS["DATE_NAISSANCE"]) return m.group(0).replace(val, PLACEHOLDERS["DATE_NAISSANCE"])
if "DATE_NAISSANCE" not in disabled:
line = RE_DATE_NAISSANCE.sub(_repl_date_naissance, line)
line = RE_DATE_NAISSANCE_ANNEE.sub(_repl_date_naissance_annee, line) line = RE_DATE_NAISSANCE_ANNEE.sub(_repl_date_naissance_annee, line)
# DATE générique — désactivé : seules les dates de naissance sont masquées # DATE générique — désactivé : seules les dates de naissance sont masquées
@@ -1758,22 +1936,22 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
# return PLACEHOLDERS["DATE"] # return PLACEHOLDERS["DATE"]
# line = RE_DATE.sub(_repl_date, line) # line = RE_DATE.sub(_repl_date, line)
# ADRESSE # ADRESSE — la catégorie « Adresses » couvre voie, BP et code postal
# (décision Dom 2026-06-26 : CODE_POSTAL suit le toggle ADRESSE).
def _repl_adresse(m: re.Match) -> str: def _repl_adresse(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"])) audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"]))
return PLACEHOLDERS["ADRESSE"] return PLACEHOLDERS["ADRESSE"]
line = RE_ADRESSE.sub(_repl_adresse, line)
# BOITE POSTALE (BP) # BOITE POSTALE (BP)
def _repl_bp(m: re.Match) -> str: def _repl_bp(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"])) audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"]))
return PLACEHOLDERS["ADRESSE"] return PLACEHOLDERS["ADRESSE"]
line = RE_BP.sub(_repl_bp, line)
# CODE_POSTAL # CODE_POSTAL
def _repl_code_postal(m: re.Match) -> str: def _repl_code_postal(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "CODE_POSTAL", m.group(0), PLACEHOLDERS["CODE_POSTAL"])) audit.append(PiiHit(page_idx, "CODE_POSTAL", m.group(0), PLACEHOLDERS["CODE_POSTAL"]))
return PLACEHOLDERS["CODE_POSTAL"] return PLACEHOLDERS["CODE_POSTAL"]
if "ADRESSE" not in disabled:
line = RE_ADRESSE.sub(_repl_adresse, line)
line = RE_BP.sub(_repl_bp, line)
line = RE_CODE_POSTAL.sub(_repl_code_postal, line) line = RE_CODE_POSTAL.sub(_repl_code_postal, line)
# AGE # AGE
@@ -1798,8 +1976,8 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
def _repl_lieu_dit(m: re.Match) -> str: def _repl_lieu_dit(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"])) audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"]))
return PLACEHOLDERS["ADRESSE"] return PLACEHOLDERS["ADRESSE"]
if "ADRESSE" not in disabled:
line = RE_ADRESSE_LIEU_DIT.sub(_repl_lieu_dit, line) line = RE_ADRESSE_LIEU_DIT.sub(_repl_lieu_dit, line)
# Lieux-dits courants seuls sur une ligne (ex: "Le BOURG", "Le Village") # Lieux-dits courants seuls sur une ligne (ex: "Le BOURG", "Le Village")
line = RE_LIEU_DIT_SEUL.sub( line = RE_LIEU_DIT_SEUL.sub(
lambda m: (audit.append(PiiHit(page_idx, "ADRESSE", m.group(1), PLACEHOLDERS["ADRESSE"])) or PLACEHOLDERS["ADRESSE"]), lambda m: (audit.append(PiiHit(page_idx, "ADRESSE", m.group(1), PLACEHOLDERS["ADRESSE"])) or PLACEHOLDERS["ADRESSE"]),
@@ -1829,6 +2007,7 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
audit.append(PiiHit(page_idx, "ADHERENT", val, PLACEHOLDERS["ADHERENT"])) audit.append(PiiHit(page_idx, "ADHERENT", val, PLACEHOLDERS["ADHERENT"]))
full = m.group(0) full = m.group(0)
return full[:full.find(val)] + PLACEHOLDERS["ADHERENT"] return full[:full.find(val)] + PLACEHOLDERS["ADHERENT"]
if "ADHERENT" not in disabled:
line = RE_NUM_ADHERENT.sub(_repl_adherent, line) line = RE_NUM_ADHERENT.sub(_repl_adherent, line)
line = RE_NUM_MUTUELLE.sub(_repl_adherent, line) line = RE_NUM_MUTUELLE.sub(_repl_adherent, line)
@@ -1836,6 +2015,7 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
def _repl_etab(m: re.Match) -> str: def _repl_etab(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "ETAB", m.group(0), PLACEHOLDERS["ETAB"])) audit.append(PiiHit(page_idx, "ETAB", m.group(0), PLACEHOLDERS["ETAB"]))
return PLACEHOLDERS["ETAB"] return PLACEHOLDERS["ETAB"]
if "ETAB" not in disabled:
line = RE_ETABLISSEMENT.sub(_repl_etab, line) line = RE_ETABLISSEMENT.sub(_repl_etab, line)
line = RE_HOPITAL_VILLE.sub(_repl_etab, line) line = RE_HOPITAL_VILLE.sub(_repl_etab, line)
@@ -1846,6 +2026,7 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
audit.append(PiiHit(page_idx, "ETAB_FINESS", matched_name, PLACEHOLDERS["ETAB"])) audit.append(PiiHit(page_idx, "ETAB_FINESS", matched_name, PLACEHOLDERS["ETAB"]))
# Adresses par gazetteer Aho-Corasick FINESS (28K noms de voie) # Adresses par gazetteer Aho-Corasick FINESS (28K noms de voie)
if "ADRESSE" not in disabled:
line, addr_matched = _mask_finess_addresses(line, return_matched_names=True) line, addr_matched = _mask_finess_addresses(line, return_matched_names=True)
for matched_addr in addr_matched: for matched_addr in addr_matched:
audit.append(PiiHit(page_idx, "ADDR_FINESS", matched_addr, PLACEHOLDERS["ADRESSE"])) audit.append(PiiHit(page_idx, "ADDR_FINESS", matched_addr, PLACEHOLDERS["ADRESSE"]))
@@ -1867,7 +2048,7 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
"CENTRE", "ETABLISSEMENT", "MAISON", "RESIDENCE", "CENTRE", "ETABLISSEMENT", "MAISON", "RESIDENCE",
"EHPAD", "SSR", "USLD", "CHU", "CHRU", "EHPAD", "SSR", "USLD", "CHU", "CHRU",
} }
spaced_matches = list(_RE_SPACED_TEXT.finditer(line)) spaced_matches = list(_RE_SPACED_TEXT.finditer(line)) if "ETAB" not in disabled else []
if spaced_matches: if spaced_matches:
# Vérifier si au moins un segment contient un mot-clé d'établissement # Vérifier si au moins un segment contient un mot-clé d'établissement
has_etab_keyword = False has_etab_keyword = False
@@ -1907,6 +2088,7 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
return full_match return full_match
audit.append(PiiHit(page_idx, "ETAB", full_match, PLACEHOLDERS["MASK"])) audit.append(PiiHit(page_idx, "ETAB", full_match, PLACEHOLDERS["MASK"]))
return PLACEHOLDERS["MASK"] return PLACEHOLDERS["MASK"]
if "ETAB" not in disabled:
line = RE_SERVICE.sub(_repl_service, line) line = RE_SERVICE.sub(_repl_service, line)
# Ville en en-tête de courrier : "Chicago, le 12/03/2024" → masquer la ville # Ville en en-tête de courrier : "Chicago, le 12/03/2024" → masquer la ville
@@ -1967,14 +2149,15 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
audit.append(PiiHit(page_idx, "NOM", cleaned, PLACEHOLDERS["NOM"])) audit.append(PiiHit(page_idx, "NOM", cleaned, PLACEHOLDERS["NOM"]))
return raw.replace(cleaned, PLACEHOLDERS["NOM"]) return raw.replace(cleaned, PLACEHOLDERS["NOM"])
line = RE_PERSON_CONTEXT.sub(_repl_person_ctx, line)
# Mr/Mme + initiale isolée : "Mme Z", "Mr R" → masquer la lettre # Mr/Mme + initiale isolée : "Mme Z", "Mr R" → masquer la lettre
def _repl_civilite_init(m: re.Match) -> str: def _repl_civilite_init(m: re.Match) -> str:
prefix = m.group(1) prefix = m.group(1)
lettre = m.group(2) lettre = m.group(2)
audit.append(PiiHit(page_idx, "NOM", lettre, PLACEHOLDERS["NOM"])) audit.append(PiiHit(page_idx, "NOM", lettre, PLACEHOLDERS["NOM"]))
return prefix + PLACEHOLDERS["NOM"] return prefix + PLACEHOLDERS["NOM"]
if "NOM" not in disabled:
line = RE_PERSON_CONTEXT.sub(_repl_person_ctx, line)
line = RE_CIVILITE_INITIALE.sub(_repl_civilite_init, line) line = RE_CIVILITE_INITIALE.sub(_repl_civilite_init, line)
# Passe supplémentaire : noms dans des listes virgulées après "Dr" # Passe supplémentaire : noms dans des listes virgulées après "Dr"
@@ -2001,12 +2184,19 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
return line return line
def _mask_critical_in_key(key: str, audit: List[PiiHit], page_idx: int) -> str: def _mask_critical_in_key(key: str, audit: List[PiiHit], page_idx: int,
disabled: Optional[Set[str]] = None) -> str:
"""Masque les TEL, EMAIL, ADRESSE, CODE_POSTAL même dans la partie 'clé' d'une ligne clé:valeur. """Masque les TEL, EMAIL, ADRESSE, CODE_POSTAL même dans la partie 'clé' d'une ligne clé:valeur.
Nécessaire car des lignes comme '13 avenue ... CHICAGO - Tel : 0XXX' sont splitées sur ':'.""" Nécessaire car des lignes comme '13 avenue ... CHICAGO - Tel : 0XXX' sont splitées sur ':'.
Plan 1b (P1-2/F-2) : TEL et ADRESSE sont gatés par catégorie (EMAIL → toujours masqué).
FAX (non toggleable) est masqué+audité INCONDITIONNELLEMENT, hors gate TEL."""
disabled = disabled or set()
# FAX d'abord et SANS condition : si le numéro+libellé fax atterrit côté clé.
key = _mask_fax_unconditional(key, audit, page_idx)
def _repl_tel(m: re.Match) -> str: def _repl_tel(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "TEL", m.group(0), PLACEHOLDERS["TEL"])) audit.append(PiiHit(page_idx, "TEL", m.group(0), PLACEHOLDERS["TEL"]))
return PLACEHOLDERS["TEL"] return PLACEHOLDERS["TEL"]
if "TEL" not in disabled:
key = RE_TEL_SLASH.sub(_repl_tel, key) key = RE_TEL_SLASH.sub(_repl_tel, key)
key = RE_TEL.sub(_repl_tel, key) key = RE_TEL.sub(_repl_tel, key)
key = RE_TEL_COMPACT.sub(_repl_tel, key) key = RE_TEL_COMPACT.sub(_repl_tel, key)
@@ -2018,11 +2208,12 @@ def _mask_critical_in_key(key: str, audit: List[PiiHit], page_idx: int) -> str:
def _repl_adresse(m: re.Match) -> str: def _repl_adresse(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"])) audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"]))
return PLACEHOLDERS["ADRESSE"] return PLACEHOLDERS["ADRESSE"]
key = RE_ADRESSE.sub(_repl_adresse, key)
# CODE_POSTAL (inclut la ville) # CODE_POSTAL (inclut la ville)
def _repl_cp(m: re.Match) -> str: def _repl_cp(m: re.Match) -> str:
audit.append(PiiHit(page_idx, "CODE_POSTAL", m.group(0), PLACEHOLDERS["CODE_POSTAL"])) audit.append(PiiHit(page_idx, "CODE_POSTAL", m.group(0), PLACEHOLDERS["CODE_POSTAL"]))
return PLACEHOLDERS["CODE_POSTAL"] return PLACEHOLDERS["CODE_POSTAL"]
if "ADRESSE" not in disabled:
key = RE_ADRESSE.sub(_repl_adresse, key)
key = RE_CODE_POSTAL.sub(_repl_cp, key) key = RE_CODE_POSTAL.sub(_repl_cp, key)
# FINESS adresses Aho-Corasick # FINESS adresses Aho-Corasick
key, addr_matched = _mask_finess_addresses(key, return_matched_names=True) key, addr_matched = _mask_finess_addresses(key, return_matched_names=True)
@@ -2039,8 +2230,12 @@ def _replace_captured_value(full_match: str, captured_value: str, placeholder: s
return full_match[:start] + placeholder + full_match[end:] return full_match[:start] + placeholder + full_match[end:]
def _mask_structured_line(line: str, audit: List[PiiHit], page_idx: int) -> str: def _mask_structured_line(line: str, audit: List[PiiHit], page_idx: int,
"""Masque les champs structurés dont la détection dépend du libellé de la ligne.""" disabled: Optional[Set[str]] = None) -> str:
"""Masque les champs structurés dont la détection dépend du libellé de la ligne.
Plan 1b (P1-2/F-2) : CODE_POSTAL→ADRESSE, ADHERENT, et les libellés NOM sont
gatés par catégorie. DOSSIER/NDA/VILLE → toujours masqués (non toggleables)."""
disabled = disabled or set()
def _repl_code_postal(m: re.Match) -> str: def _repl_code_postal(m: re.Match) -> str:
original = m.group(1) or m.group(2) or m.group(0) original = m.group(1) or m.group(2) or m.group(0)
@@ -2089,12 +2284,20 @@ def _mask_structured_line(line: str, audit: List[PiiHit], page_idx: int) -> str:
audit.append(PiiHit(page_idx, "NOM_INITIAL", m.group(3), PLACEHOLDERS["NOM"])) audit.append(PiiHit(page_idx, "NOM_INITIAL", m.group(3), PLACEHOLDERS["NOM"]))
return m.group(1) + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"] return m.group(1) + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"]
masked = RE_CODE_POSTAL.sub(_repl_code_postal, line) # CODE_POSTAL → catégorie ADRESSE (décision Dom 2026-06-26).
masked = line
if "ADRESSE" not in disabled:
masked = RE_CODE_POSTAL.sub(_repl_code_postal, masked)
# DOSSIER / NDA → toujours masqués (non toggleables).
masked = RE_NUM_EXAMEN_PATIENT.sub(_repl_num_examen, masked) masked = RE_NUM_EXAMEN_PATIENT.sub(_repl_num_examen, masked)
masked = RE_NUMERO_DOSSIER.sub(_repl_dossier, masked) masked = RE_NUMERO_DOSSIER.sub(_repl_dossier, masked)
masked = RE_VENUE_SEJOUR.sub(_repl_venue, masked) masked = RE_VENUE_SEJOUR.sub(_repl_venue, masked)
# N° adhérent → catégorie ADHERENT.
if "ADHERENT" not in disabled:
masked = RE_NUM_ADHERENT.sub(_repl_adherent, masked) masked = RE_NUM_ADHERENT.sub(_repl_adherent, masked)
masked = RE_NUM_MUTUELLE.sub(_repl_adherent, masked) masked = RE_NUM_MUTUELLE.sub(_repl_adherent, masked)
# Libellés NOM (NOM_FORCE / NOM_INITIAL) → catégorie NOM.
if "NOM" not in disabled:
masked = RE_LABEL_NOM_VARIANTES.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) masked = RE_LABEL_NOM_VARIANTES.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked)
masked = RE_LABEL_PRENOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) masked = RE_LABEL_PRENOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked)
masked = RE_LABEL_NOM_PROFESSIONNEL.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) masked = RE_LABEL_NOM_PROFESSIONNEL.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked)
@@ -2103,13 +2306,32 @@ def _mask_structured_line(line: str, audit: List[PiiHit], page_idx: int) -> str:
masked = RE_STANDALONE_COMPOUND_PERSON_LINE.sub(_repl_whole_line_with_placeholder("NOM_FORCE", "NOM"), masked) masked = RE_STANDALONE_COMPOUND_PERSON_LINE.sub(_repl_whole_line_with_placeholder("NOM_FORCE", "NOM"), masked)
masked = RE_MODIFIED_BY_NOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked) masked = RE_MODIFIED_BY_NOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), masked)
masked = RE_REF_INITIALS_INLINE.sub(_repl_ref_initials, masked) masked = RE_REF_INITIALS_INLINE.sub(_repl_ref_initials, masked)
# Ville → toujours masquée (non toggleable).
masked = RE_LABEL_VILLE.sub(_repl_label_with_placeholder("VILLE", "VILLE"), masked) masked = RE_LABEL_VILLE.sub(_repl_label_with_placeholder("VILLE", "VILLE"), masked)
return masked return masked
def _mask_fax_unconditional(line: str, audit: List[PiiHit], page_idx: int) -> str:
"""FAX est NON toggleable (`_category_of("FAX")` → None) ⇒ toujours masqué ET
inscrit à l'audit, indépendamment du toggle TEL. ``RE_FAX`` est ancré au libellé
("Fax :"/"Télécopie :") collé au numéro : il doit donc tourner sur la LIGNE
COMPLÈTE, avant le split clé/valeur (qui sépare le libellé du numéro et
empêcherait toute détection). Le hit FAX doit atteindre ``anon.audit`` pour que
le burn PDF (vector+raster, dérivé de l'audit) masque le numéro."""
def _repl_fax(m: re.Match) -> str:
num = m.group(1)
audit.append(PiiHit(page_idx, "FAX", num, PLACEHOLDERS["FAX"]))
return m.group(0).replace(num, PLACEHOLDERS["FAX"])
return RE_FAX.sub(_repl_fax, line)
def _kv_value_only_mask(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[str, Any]) -> str: def _kv_value_only_mask(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[str, Any]) -> str:
disabled = cfg.get("disabled_kinds") or set()
# FAX non toggleable : masquage+audit sur la ligne complète AVANT toute autre
# passe (le split clé/valeur sépare « Fax » du numéro → détection impossible).
line = _mask_fax_unconditional(line, audit, page_idx)
line = _mask_admin_label(line, audit, page_idx, cfg) line = _mask_admin_label(line, audit, page_idx, cfg)
structured_line = _mask_structured_line(line, audit, page_idx) structured_line = _mask_structured_line(line, audit, page_idx, disabled)
if structured_line != line: if structured_line != line:
return structured_line return structured_line
parts = SPLITTER.split(line, maxsplit=1) parts = SPLITTER.split(line, maxsplit=1)
@@ -2120,7 +2342,7 @@ def _kv_value_only_mask(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
# probablement du narratif, pas un libellé `Label : valeur`. # probablement du narratif, pas un libellé `Label : valeur`.
if len(parts) == 2 and parts[1].strip() and len(parts[0].split()) <= 5: if len(parts) == 2 and parts[1].strip() and len(parts[0].split()) <= 5:
key, value = parts key, value = parts
masked_key = _mask_critical_in_key(key, audit, page_idx) masked_key = _mask_critical_in_key(key, audit, page_idx, disabled)
masked_val = _mask_line_by_regex(value, audit, page_idx, cfg) masked_val = _mask_line_by_regex(value, audit, page_idx, cfg)
return f"{masked_key.strip()} : {masked_val.strip()}" return f"{masked_key.strip()} : {masked_val.strip()}"
return _mask_line_by_regex(line, audit, page_idx, cfg) return _mask_line_by_regex(line, audit, page_idx, cfg)
@@ -2806,8 +3028,13 @@ def _cross_validate_name_candidates(
return validated_names, validated_force_names return validated_names, validated_force_names
def _apply_extracted_names(text: str, names: set, audit: List[PiiHit], force_names: set = None) -> str: def _apply_extracted_names(text: str, names: set, audit: List[PiiHit], force_names: set = None,
"""Remplace globalement chaque nom extrait dans le texte.""" disabled: Optional[Set[str]] = None) -> str:
"""Remplace globalement chaque nom extrait dans le texte.
Plan 1b (P1-2/F-2) : si la catégorie NOM est décochée, ne masque RIEN
(les noms restent en clair). No-op aussi quand ``names`` est vide."""
if disabled and "NOM" in disabled:
return text
placeholder = PLACEHOLDERS["NOM"] placeholder = PLACEHOLDERS["NOM"]
_force = force_names or set() _force = force_names or set()
@@ -2902,10 +3129,17 @@ def _apply_trackare_hits_to_text(text: str, audit: List[PiiHit], cfg: Dict[str,
kind = rule.get("kind") kind = rule.get("kind")
if kind: if kind:
_APPLY_KINDS.add(str(kind)) _APPLY_KINDS.add(str(kind))
# Plan 1b (P1-2/F-2) : ne pas réappliquer dans le texte les hits dont la
# catégorie est décochée (ex: NIR, ou un kind admin mappé à une des 7).
# Default-deny : si _category_of renvoie None (kind non toggleable), on
# masque toujours. No-op byte-for-byte quand disabled est vide.
disabled = (cfg or {}).get("disabled_kinds") or set()
# Collecter les valeurs à remplacer, groupées par placeholder # Collecter les valeurs à remplacer, groupées par placeholder
replacements: Dict[str, str] = {} # original → placeholder replacements: Dict[str, str] = {} # original → placeholder
for h in audit: for h in audit:
if h.kind in _APPLY_KINDS and h.original and len(h.original.strip()) >= 4: if h.kind in _APPLY_KINDS and h.original and len(h.original.strip()) >= 4:
if disabled and _category_of(h.kind) in disabled:
continue
replacements[h.original.strip()] = h.placeholder replacements[h.original.strip()] = h.placeholder
# Remplacer les plus longs d'abord (éviter les remplacements partiels) # Remplacer les plus longs d'abord (éviter les remplacements partiels)
for original in sorted(replacements, key=len, reverse=True): for original in sorted(replacements, key=len, reverse=True):
@@ -2942,6 +3176,9 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str]
if _is_practitioner_council_recoding_form(full_raw): if _is_practitioner_council_recoding_form(full_raw):
cfg = dict(cfg) cfg = dict(cfg)
cfg["_preserve_practitioner_council_ogc"] = True cfg["_preserve_practitioner_council_ogc"] = True
# Plan 1b (P1-2/F-2/F-5) — catégories décochées (7 toggles). Vide ⇒ no-op
# byte-for-byte. Chaque passe de masquage TEXTE saute sa catégorie si décochée.
disabled = cfg.get("disabled_kinds") or set()
extracted_names, doc_force_names, doc_candidates = _extract_document_names(full_raw, cfg) extracted_names, doc_force_names, doc_candidates = _extract_document_names(full_raw, cfg)
# Phase 0b : si document Trackare, extraction renforcée des PII structurés # Phase 0b : si document Trackare, extraction renforcée des PII structurés
@@ -3017,6 +3254,7 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str]
r"(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})", r"(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})",
re.IGNORECASE, re.IGNORECASE,
) )
if "DATE_NAISSANCE" not in disabled:
for m in _RE_DATE_NAISSANCE_MULTILINE.finditer(full_raw): for m in _RE_DATE_NAISSANCE_MULTILINE.finditer(full_raw):
audit.append(PiiHit(-1, "DATE_NAISSANCE", m.group(1), PLACEHOLDERS["DATE_NAISSANCE"])) audit.append(PiiHit(-1, "DATE_NAISSANCE", m.group(1), PLACEHOLDERS["DATE_NAISSANCE"]))
@@ -3036,6 +3274,7 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str]
r"(\d(?:[\s.\-]?\d){12})\b", r"(\d(?:[\s.\-]?\d){12})\b",
re.IGNORECASE, re.IGNORECASE,
) )
if "NIR" not in disabled:
for m in _RE_NIR_NO_KEY_MULTILINE.finditer(full_raw): for m in _RE_NIR_NO_KEY_MULTILINE.finditer(full_raw):
audit.append(PiiHit(-1, "NIR", m.group(1), PLACEHOLDERS["NIR"])) audit.append(PiiHit(-1, "NIR", m.group(1), PLACEHOLDERS["NIR"]))
@@ -3093,12 +3332,14 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str]
def _repl_etab_linebreak(m: re.Match, _page=i) -> str: def _repl_etab_linebreak(m: re.Match, _page=i) -> str:
audit.append(PiiHit(_page, "ETAB", m.group(0), PLACEHOLDERS["ETAB"])) audit.append(PiiHit(_page, "ETAB", m.group(0), PLACEHOLDERS["ETAB"]))
return PLACEHOLDERS["ETAB"] return PLACEHOLDERS["ETAB"]
if "ETAB" not in disabled:
page_txt = RE_ETAB_LINEBREAK.sub(_repl_etab_linebreak, page_txt) page_txt = RE_ETAB_LINEBREAK.sub(_repl_etab_linebreak, page_txt)
def _repl_iao_multiline(m: re.Match, _page=i) -> str: def _repl_iao_multiline(m: re.Match, _page=i) -> str:
value = m.group(2).strip() value = m.group(2).strip()
audit.append(PiiHit(_page, "NOM_FORCE", value, PLACEHOLDERS["NOM"])) audit.append(PiiHit(_page, "NOM_FORCE", value, PLACEHOLDERS["NOM"]))
return m.group(1) + PLACEHOLDERS["NOM"] return m.group(1) + PLACEHOLDERS["NOM"]
if "NOM" not in disabled:
page_txt = RE_TRACKARE_IAO_MULTILINE_VALUE.sub(_repl_iao_multiline, page_txt) page_txt = RE_TRACKARE_IAO_MULTILINE_VALUE.sub(_repl_iao_multiline, page_txt)
lines = page_txt.splitlines() lines = page_txt.splitlines()
@@ -3124,7 +3365,8 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str]
# Phase 2 : application globale des noms extraits (rattrapage) # Phase 2 : application globale des noms extraits (rattrapage)
# Utilise all_names (validé par NER-first si disponible, sinon extracted_names original) # Utilise all_names (validé par NER-first si disponible, sinon extracted_names original)
if all_names: if all_names:
text_out = _apply_extracted_names(text_out, all_names, audit, force_names=all_force_names) text_out = _apply_extracted_names(text_out, all_names, audit, force_names=all_force_names,
disabled=disabled)
# Phase 2b : application globale des PiiHit (EPISODE, RPPS, FINESS) # Phase 2b : application globale des PiiHit (EPISODE, RPPS, FINESS)
text_out = _apply_trackare_hits_to_text(text_out, audit, cfg) text_out = _apply_trackare_hits_to_text(text_out, audit, cfg)
@@ -3136,6 +3378,9 @@ def anonymise_document_regex(pages_text: List[str], tables_lines: List[List[str]
def _mask_with_hf(text: str, ents: List[Dict[str, Any]], cfg: Dict[str, Any], audit: List[PiiHit]) -> str: def _mask_with_hf(text: str, ents: List[Dict[str, Any]], cfg: Dict[str, Any], audit: List[PiiHit]) -> str:
# remplace via regex sur les 'word' détectés (approche pragmatique) # remplace via regex sur les 'word' détectés (approche pragmatique)
keep_org_gpe = bool((cfg.get("whitelist", {}) or {}).get("org_gpe_keep", False)) keep_org_gpe = bool((cfg.get("whitelist", {}) or {}).get("org_gpe_keep", False))
# Plan 1b (P1-2/F-5) : gating PER-HIT (jamais skip toute la fonction, sinon
# on perdrait les catégories encore actives). Default-deny via _category_of.
disabled = cfg.get("disabled_kinds") or set()
def repl_once(s: str, old: str, new: str) -> str: def repl_once(s: str, old: str, new: str) -> str:
return re.sub(rf"\b{re.escape(old)}\b", new, s) return re.sub(rf"\b{re.escape(old)}\b", new, s)
out = text out = text
@@ -3146,11 +3391,15 @@ def _mask_with_hf(text: str, ents: List[Dict[str, Any]], cfg: Dict[str, Any], au
if len(w) <= 2: # trop court if len(w) <= 2: # trop court
continue continue
if grp in {"PER", "PERSON"}: if grp in {"PER", "PERSON"}:
if disabled and _category_of("NER_PER") in disabled: # catégorie NOM décochée
continue
audit.append(PiiHit(-1, "NER_PER", w, PLACEHOLDERS["NOM"])) audit.append(PiiHit(-1, "NER_PER", w, PLACEHOLDERS["NOM"]))
out = repl_once(out, w, PLACEHOLDERS["NOM"]) out = repl_once(out, w, PLACEHOLDERS["NOM"])
elif grp in {"ORG"}: elif grp in {"ORG"}:
if keep_org_gpe: if keep_org_gpe:
continue continue
if disabled and _category_of("NER_ORG") in disabled: # catégorie ETAB décochée
continue
audit.append(PiiHit(-1, "NER_ORG", w, PLACEHOLDERS["ETAB"])) audit.append(PiiHit(-1, "NER_ORG", w, PLACEHOLDERS["ETAB"]))
out = repl_once(out, w, PLACEHOLDERS["ETAB"]) out = repl_once(out, w, PLACEHOLDERS["ETAB"])
elif grp in {"LOC"}: elif grp in {"LOC"}:
@@ -3207,6 +3456,9 @@ def apply_hf_ner_on_narrative(text_out: str, cfg: Dict[str, Any], manager: Optio
def _mask_with_eds_pseudo(text: str, ents: List[Dict[str, Any]], cfg: Dict[str, Any], audit: List[PiiHit]) -> str: def _mask_with_eds_pseudo(text: str, ents: List[Dict[str, Any]], cfg: Dict[str, Any], audit: List[PiiHit]) -> str:
"""Masque les entités détectées par EDS-Pseudo en utilisant le mapping eds_mapped_key.""" """Masque les entités détectées par EDS-Pseudo en utilisant le mapping eds_mapped_key."""
# Plan 1b (P1-2/F-5) : gating PER-HIT via la catégorie du kind EDS_{label}.
# Jamais de skip global (sinon perte des catégories actives). Default-deny.
disabled = cfg.get("disabled_kinds") or set()
def repl_once(s: str, old: str, new: str) -> str: def repl_once(s: str, old: str, new: str) -> str:
return re.sub(rf"\b{re.escape(old)}\b", new, s) return re.sub(rf"\b{re.escape(old)}\b", new, s)
out = text out = text
@@ -3278,6 +3530,9 @@ def _mask_with_eds_pseudo(text: str, ents: List[Dict[str, Any]], cfg: Dict[str,
continue continue
if w.upper() in _STRUCTURAL_WORDS: if w.upper() in _STRUCTURAL_WORDS:
continue continue
# Gating per-hit (F-5) : catégorie décochée → laisser en clair.
if disabled and _category_of(f"EDS_{label}") in disabled:
continue
placeholder = PLACEHOLDERS.get(mapped_key, PLACEHOLDERS["MASK"]) placeholder = PLACEHOLDERS.get(mapped_key, PLACEHOLDERS["MASK"])
audit.append(PiiHit(-1, f"EDS_{label}", w, placeholder)) audit.append(PiiHit(-1, f"EDS_{label}", w, placeholder))
out = repl_once(out, w, placeholder) out = repl_once(out, w, placeholder)
@@ -4158,6 +4413,11 @@ def _apply_whitelist(text: str, phrases: List[str], audit: List[PiiHit]) -> str:
def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str: def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
"""Rescan de sécurité : re-détecte les PII critiques qui auraient échappé au premier passage.""" """Rescan de sécurité : re-détecte les PII critiques qui auraient échappé au premier passage."""
# Plan 1b (P1-2/F-2) — filet de sécurité aussi gaté par catégorie : une
# catégorie décochée ne doit pas être re-masquée ici (sinon la valeur,
# laissée en clair plus haut, serait masquée par le rescan). Vide ⇒ no-op
# byte-for-byte. Default-deny conservé pour tout kind non toggleable.
disabled = (cfg or {}).get("disabled_kinds") or set()
# enlève TABLES du scope # enlève TABLES du scope
def strip_tables(s: str): def strip_tables(s: str):
kept = [] kept = []
@@ -4184,10 +4444,12 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
# espacé soit consommé par RE_TEL. # espacé soit consommé par RE_TEL.
def _rescan_nir(m: re.Match) -> str: def _rescan_nir(m: re.Match) -> str:
return PLACEHOLDERS["NIR"] if validate_nir(m.group(0)) else m.group(0) return PLACEHOLDERS["NIR"] if validate_nir(m.group(0)) else m.group(0)
if "NIR" not in disabled:
protected = RE_NIR.sub(_rescan_nir, protected) protected = RE_NIR.sub(_rescan_nir, protected)
protected = RE_NIR_NO_KEY.sub(PLACEHOLDERS["NIR"], protected) # 13 chiffres label-ancré protected = RE_NIR_NO_KEY.sub(PLACEHOLDERS["NIR"], protected) # 13 chiffres label-ancré
# FAX avant TEL pour que le numéro de fax devienne [FAX] et non [TEL]. # FAX avant TEL pour que le numéro de fax devienne [FAX] et non [TEL].
protected = RE_FAX.sub(PLACEHOLDERS["FAX"], protected) protected = RE_FAX.sub(PLACEHOLDERS["FAX"], protected)
if "TEL" not in disabled:
protected = RE_TEL_SLASH.sub(PLACEHOLDERS["TEL"], protected) protected = RE_TEL_SLASH.sub(PLACEHOLDERS["TEL"], protected)
protected = RE_TEL.sub(PLACEHOLDERS["TEL"], protected) protected = RE_TEL.sub(PLACEHOLDERS["TEL"], protected)
protected = RE_TEL_COMPACT.sub(PLACEHOLDERS["TEL"], protected) protected = RE_TEL_COMPACT.sub(PLACEHOLDERS["TEL"], protected)
@@ -4197,19 +4459,22 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
protected = RE_BIC.sub(PLACEHOLDERS["IBAN"], protected) protected = RE_BIC.sub(PLACEHOLDERS["IBAN"], protected)
protected = RE_ADELI.sub(PLACEHOLDERS["ADELI"], protected) protected = RE_ADELI.sub(PLACEHOLDERS["ADELI"], protected)
protected = RE_OGC.sub(PLACEHOLDERS["OGC"], protected) protected = RE_OGC.sub(PLACEHOLDERS["OGC"], protected)
if "ADHERENT" not in disabled:
protected = RE_NUM_ADHERENT.sub(PLACEHOLDERS["ADHERENT"], protected) protected = RE_NUM_ADHERENT.sub(PLACEHOLDERS["ADHERENT"], protected)
protected = RE_NUM_MUTUELLE.sub(PLACEHOLDERS["ADHERENT"], protected) protected = RE_NUM_MUTUELLE.sub(PLACEHOLDERS["ADHERENT"], protected)
# Nouvelles regex : dates de naissance, dates, adresses, codes postaux # Nouvelles regex : dates de naissance, dates, adresses, codes postaux
if "DATE_NAISSANCE" not in disabled:
protected = RE_DATE_NAISSANCE.sub(PLACEHOLDERS["DATE_NAISSANCE"], protected) protected = RE_DATE_NAISSANCE.sub(PLACEHOLDERS["DATE_NAISSANCE"], protected)
# protected = RE_DATE.sub(PLACEHOLDERS["DATE"], protected) # désactivé # protected = RE_DATE.sub(PLACEHOLDERS["DATE"], protected) # désactivé
protected = RE_ADRESSE.sub(PLACEHOLDERS["ADRESSE"], protected)
protected = RE_ADRESSE_LIEU_DIT.sub(PLACEHOLDERS["ADRESSE"], protected)
protected = RE_BP.sub(PLACEHOLDERS["ADRESSE"], protected)
def _rescan_code_postal(m: re.Match) -> str: def _rescan_code_postal(m: re.Match) -> str:
if m.group(1): if m.group(1):
return _replace_captured_value(m.group(0), m.group(1), PLACEHOLDERS["CODE_POSTAL"]) return _replace_captured_value(m.group(0), m.group(1), PLACEHOLDERS["CODE_POSTAL"])
return PLACEHOLDERS["CODE_POSTAL"] return PLACEHOLDERS["CODE_POSTAL"]
if "ADRESSE" not in disabled:
protected = RE_ADRESSE.sub(PLACEHOLDERS["ADRESSE"], protected)
protected = RE_ADRESSE_LIEU_DIT.sub(PLACEHOLDERS["ADRESSE"], protected)
protected = RE_BP.sub(PLACEHOLDERS["ADRESSE"], protected)
protected = RE_CODE_POSTAL.sub(_rescan_code_postal, protected) protected = RE_CODE_POSTAL.sub(_rescan_code_postal, protected)
# N° Episode # N° Episode
protected = RE_EPISODE.sub(PLACEHOLDERS["EPISODE"], protected) protected = RE_EPISODE.sub(PLACEHOLDERS["EPISODE"], protected)
@@ -4225,13 +4490,12 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
def _rescan_finess(m: re.Match) -> str: def _rescan_finess(m: re.Match) -> str:
return PLACEHOLDERS["FINESS"] if m.group(1).upper() in _FINESS_NUMBERS else m.group(0) return PLACEHOLDERS["FINESS"] if m.group(1).upper() in _FINESS_NUMBERS else m.group(0)
protected = RE_BARE_9DIGITS.sub(_rescan_finess, protected) protected = RE_BARE_9DIGITS.sub(_rescan_finess, protected)
# Établissements (regex) # Établissements (regex + gazetteer + texte espacé) → catégorie ETAB.
if "ETAB" not in disabled:
protected = RE_ETABLISSEMENT.sub(PLACEHOLDERS["ETAB"], protected) protected = RE_ETABLISSEMENT.sub(PLACEHOLDERS["ETAB"], protected)
protected = RE_HOPITAL_VILLE.sub(PLACEHOLDERS["ETAB"], protected) protected = RE_HOPITAL_VILLE.sub(PLACEHOLDERS["ETAB"], protected)
# Établissements (gazetteer Aho-Corasick FINESS — 116K noms distinctifs) # Établissements (gazetteer Aho-Corasick FINESS — 116K noms distinctifs)
protected = _mask_finess_establishments(protected) protected = _mask_finess_establishments(protected)
# Adresses (gazetteer Aho-Corasick FINESS — 28K noms de voie)
protected = _mask_finess_addresses(protected)
# Texte espacé d'en-tête : "C E N T R E H O S P I T A L I E R" → [ETABLISSEMENT] # Texte espacé d'en-tête : "C E N T R E H O S P I T A L I E R" → [ETABLISSEMENT]
_re_spaced = re.compile(r'(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ]\s){4,}[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ]') _re_spaced = re.compile(r'(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ]\s){4,}[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ]')
_spaced_kw = {"HOSPITALIER", "HOSPITALIERE", "HOSPITALIERES", "HOSPITALIERS", _spaced_kw = {"HOSPITALIER", "HOSPITALIERE", "HOSPITALIERES", "HOSPITALIERS",
@@ -4242,12 +4506,16 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
collapsed = m_sp.group(0).replace(" ", "").upper() collapsed = m_sp.group(0).replace(" ", "").upper()
if any(kw in collapsed for kw in _spaced_kw): if any(kw in collapsed for kw in _spaced_kw):
protected = protected.replace(m_sp.group(0), PLACEHOLDERS["ETAB"], 1) protected = protected.replace(m_sp.group(0), PLACEHOLDERS["ETAB"], 1)
# Adresses (gazetteer Aho-Corasick FINESS — 28K noms de voie) → catégorie ADRESSE.
if "ADRESSE" not in disabled:
protected = _mask_finess_addresses(protected)
# Villes (gazetteer Aho-Corasick — INSEE + FINESS) # Villes (gazetteer Aho-Corasick — INSEE + FINESS)
if _VILLE_AC is None: if _VILLE_AC is None:
_build_ville_ac() _build_ville_ac()
if _VILLE_AC is not None: if _VILLE_AC is not None:
protected, _ = _mask_ville_gazetteers(protected) protected, _ = _mask_ville_gazetteers(protected)
# Services hospitaliers # Services hospitaliers → catégorie ETAB.
if "ETAB" not in disabled:
protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected) protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected)
# Lieu de naissance / Ville de résidence (accepte tout : villes, codes INSEE, minuscules) # Lieu de naissance / Ville de résidence (accepte tout : villes, codes INSEE, minuscules)
_re_lieu_rescan = re.compile(r"(Lieu\s+de\s+naissance\s*:\s*)(\S.+)") _re_lieu_rescan = re.compile(r"(Lieu\s+de\s+naissance\s*:\s*)(\S.+)")
@@ -4272,6 +4540,7 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
if not clean: if not clean:
return raw return raw
return raw.replace(span, PLACEHOLDERS["NOM"]) return raw.replace(span, PLACEHOLDERS["NOM"])
if "NOM" not in disabled:
protected = RE_PERSON_CONTEXT.sub(_rescan_person, protected) protected = RE_PERSON_CONTEXT.sub(_rescan_person, protected)
# Mr/Mme + initiale isolée : "Mme Z", "Mr R" → masquer # Mr/Mme + initiale isolée : "Mme Z", "Mr R" → masquer
protected = RE_CIVILITE_INITIALE.sub( protected = RE_CIVILITE_INITIALE.sub(
@@ -4551,9 +4820,14 @@ def _apply_pseudo_xmp_metadata(doc) -> None:
pass pass
def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, ocr_word_map: OcrWordMap = None) -> None: def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, ocr_word_map: OcrWordMap = None, disabled_kinds: Optional[Set[str]] = None) -> None:
if fitz is None: if fitz is None:
raise RuntimeError("PyMuPDF non disponible installez pymupdf.") raise RuntimeError("PyMuPDF non disponible installez pymupdf.")
# Plan 1b (P1-2/F-3) — `disabled_kinds` = set des CATÉGORIES décochées
# (les 7 toggles). Sert UNIQUEMENT à gater les chemins de burn qui ne
# dérivent PAS de l'audit (le filtre audit de la Task 1 couvre déjà tout le
# reste). Vide/None ⇒ comportement par défaut byte-for-byte (non-régression).
disabled = disabled_kinds or set()
doc = fitz.open(str(original_pdf)) doc = fitz.open(str(original_pdf))
# index hits par page; page==-1 → rechercher sur toutes pages # index hits par page; page==-1 → rechercher sur toutes pages
by_page: Dict[int, List[PiiHit]] = {} by_page: Dict[int, List[PiiHit]] = {}
@@ -4561,6 +4835,14 @@ def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, oc
by_page.setdefault(h.page, []).append(h) by_page.setdefault(h.page, []).append(h)
# Kinds à ne pas chercher dans le PDF (dates masquées uniquement dans le texte, # Kinds à ne pas chercher dans le PDF (dates masquées uniquement dans le texte,
# pas dans le PDF où elles rendent les tableaux illisibles) # pas dans le PDF où elles rendent les tableaux illisibles)
# Plan 1b (P1-2/F-3) — _VECTOR_SKIP_KINDS : exclusion TOUJOURS appliquée
# (indépendante des toggles). Les dates EDS et la propagation globale DDN ne
# sont jamais brûlées dans le PDF (elles rendraient les tableaux illisibles),
# seulement masquées dans le texte. C'est un RETRAIT du burn, jamais un ajout :
# il ne peut donc pas réintroduire une catégorie décochée (déjà retirée de
# l'audit par _filter_audit_by_disabled / Task 1) ni faire brûler une catégorie
# cochée absente de ce set. Aucun conflit avec le toggle DATE_NAISSANCE :
# activer DATE_NAISSANCE ne fait PAS brûler ces kinds dans le PDF, c'est voulu.
_VECTOR_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL", "DATE_NAISSANCE_GLOBAL"} _VECTOR_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL", "DATE_NAISSANCE_GLOBAL"}
# Kinds sensibles au substring matching : utiliser _search_whole_word # Kinds sensibles au substring matching : utiliser _search_whole_word
_VECTOR_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM", _VECTOR_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM",
@@ -4569,7 +4851,10 @@ def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, oc
for pno in range(len(doc)): for pno in range(len(doc)):
page = doc[pno] page = doc[pno]
hits = by_page.get(pno, []) + by_page.get(-1, []) hits = by_page.get(pno, []) + by_page.get(-1, [])
all_rects = _search_pdf_address_lines(page) # Plan 1b (P1-2/F-3) — Chemin de burn INDÉPENDANT de l'audit : recherche
# géométrique des lignes d'adresse. Le filtre audit (Task 1) ne le couvre
# pas, donc on le gate explicitement sous la catégorie ADRESSE.
all_rects = [] if "ADRESSE" in disabled else _search_pdf_address_lines(page)
if not hits and not all_rects: if not hits and not all_rects:
continue continue
# Dédupliquer les tokens : (token, kind) → rechercher une seule fois par page # Dédupliquer les tokens : (token, kind) → rechercher une seule fois par page
@@ -4668,6 +4953,13 @@ def _rasterize_page(args):
if rx1 > rx0: if rx1 > rx0:
draw.rectangle([rx0, ry0, rx1, ry1], fill=(0, 0, 0)) draw.rectangle([rx0, ry0, rx1, ry1], fill=(0, 0, 0))
# Noircir les images embarquées (logos, signatures, captures d'écran) # Noircir les images embarquées (logos, signatures, captures d'écran)
# Plan 1b (P1-2/F-3) — Choix DÉLIBÉRÉ : les blackouts d'images et de
# codes-barres/QR (ci-dessous) restent TOUJOURS actifs, NON toggleables.
# Une image ou un code-barres peut encoder n'importe quelle PII (nom dans un
# logo signé, NIR/IPP dans un code-barres). Ces masques sont conservateurs :
# ils peuvent sur-masquer, mais ne doivent JAMAIS laisser fuir. Les gater
# sous une catégorie (NOM/ADRESSE/…) serait impossible à décider de façon
# sûre depuis la seule géométrie → on les garde inconditionnels.
for (x0, y0, x1, y1) in image_rects_tuples: for (x0, y0, x1, y1) in image_rects_tuples:
rx0 = x0 * zoom rx0 = x0 * zoom
ry0 = y0 * zoom ry0 = y0 * zoom
@@ -4715,11 +5007,18 @@ def _rasterize_page(args):
return pno, buf.getvalue(), rect_w, rect_h return pno, buf.getvalue(), rect_w, rect_h
def redact_pdf_raster(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, dpi: int = 120, ogc_label: Optional[str] = None, ocr_word_map: OcrWordMap = None, jpeg_quality: int = 80) -> None: def redact_pdf_raster(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, dpi: int = 120, ogc_label: Optional[str] = None, ocr_word_map: OcrWordMap = None, jpeg_quality: int = 80, disabled_kinds: Optional[Set[str]] = None) -> None:
if fitz is None: if fitz is None:
raise RuntimeError("PyMuPDF non disponible installez pymupdf.") raise RuntimeError("PyMuPDF non disponible installez pymupdf.")
# Plan 1b (P1-2/F-3) — voir redact_pdf_vector : `disabled_kinds` gate les
# chemins de burn hors-audit. Vide/None ⇒ comportement par défaut.
disabled = disabled_kinds or set()
doc = fitz.open(str(original_pdf)) doc = fitz.open(str(original_pdf))
all_rects: Dict[int, List["fitz.Rect"]] = {} all_rects: Dict[int, List["fitz.Rect"]] = {}
# Plan 1b (P1-2/F-3) — _RASTER_SKIP_KINDS : exclusion TOUJOURS appliquée
# (cf. _VECTOR_SKIP_KINDS). Retrait du burn uniquement, jamais d'ajout :
# aucun conflit avec le toggle DATE_NAISSANCE (ces kinds restent hors PDF
# quel que soit l'état du toggle, c'est voulu — dates illisibles en tableau).
_RASTER_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL", "DATE_NAISSANCE_GLOBAL"} _RASTER_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL", "DATE_NAISSANCE_GLOBAL"}
# Kinds sensibles au substring matching : utiliser _search_whole_word # Kinds sensibles au substring matching : utiliser _search_whole_word
_RASTER_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM", _RASTER_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM",
@@ -4741,7 +5040,9 @@ def redact_pdf_raster(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, dp
rects.append(fitz.Rect(margin, margin, page.rect.width - margin, page.rect.height - margin)) rects.append(fitz.Rect(margin, margin, page.rect.width - margin, page.rect.height - margin))
all_rects[pno] = rects all_rects[pno] = rects
continue continue
rects = _search_pdf_address_lines(page) # Plan 1b (P1-2/F-3) — Chemin de burn INDÉPENDANT de l'audit (recherche
# géométrique d'adresse), gaté sous la catégorie ADRESSE comme en vector.
rects = [] if "ADRESSE" in disabled else _search_pdf_address_lines(page)
for h in hits: for h in hits:
token = h.original.strip() token = h.original.strip()
if not token or h.kind in _RASTER_SKIP_KINDS: if not token or h.kind in _RASTER_SKIP_KINDS:
@@ -4895,10 +5196,14 @@ def redact_pdf_raster(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, dp
# ----------------- VLM pour PDFs scannés ----------------- # ----------------- VLM pour PDFs scannés -----------------
def _apply_vlm_on_scanned_pdf(pdf_path: Path, anon: AnonResult, ocr_word_map: OcrWordMap, vlm_manager) -> None: def _apply_vlm_on_scanned_pdf(pdf_path: Path, anon: AnonResult, ocr_word_map: OcrWordMap, vlm_manager,
disabled: Optional[Set[str]] = None) -> None:
"""Utilise un VLM (Ollama) pour détecter visuellement les PII sur chaque page d'un PDF scanné. """Utilise un VLM (Ollama) pour détecter visuellement les PII sur chaque page d'un PDF scanné.
Les entités détectées sont ajoutées à anon.audit et au texte pseudonymisé. Les entités détectées sont ajoutées à anon.audit et au texte pseudonymisé.
Auto-rotation : si une page a peu de mots OCR, essaie 4 orientations.""" Auto-rotation : si une page a peu de mots OCR, essaie 4 orientations.
Plan 1b (P1-2/F-2) : gating PER-HIT via _category_of(kind). Une catégorie
décochée n'est ni ajoutée à l'audit ni masquée dans le texte. Default-deny."""
disabled = disabled or set()
from vlm_manager import VLM_CATEGORY_MAP from vlm_manager import VLM_CATEGORY_MAP
doc = fitz.open(str(pdf_path)) doc = fitz.open(str(pdf_path))
# Collecter les PII déjà détectés pour contexte VLM # Collecter les PII déjà détectés pour contexte VLM
@@ -4942,6 +5247,10 @@ def _apply_vlm_on_scanned_pdf(pdf_path: Path, anon: AnonResult, ocr_word_map: Oc
if cat not in VLM_CATEGORY_MAP: if cat not in VLM_CATEGORY_MAP:
continue continue
kind, placeholder_key = VLM_CATEGORY_MAP[cat] kind, placeholder_key = VLM_CATEGORY_MAP[cat]
# Gating per-hit (F-2) : catégorie décochée → laisser en clair
# (ni audit, ni texte, ni raster). Default-deny si non toggleable.
if disabled and _category_of(kind) in disabled:
continue
placeholder = PLACEHOLDERS.get(placeholder_key, PLACEHOLDERS["MASK"]) placeholder = PLACEHOLDERS.get(placeholder_key, PLACEHOLDERS["MASK"])
if cat in _SPLIT_CATS: if cat in _SPLIT_CATS:
@@ -4984,6 +5293,7 @@ def process_pdf(
gliner_manager=None, gliner_manager=None,
camembert_manager=None, camembert_manager=None,
quarantine_mgr: Optional["QuarantineManager"] = None, quarantine_mgr: Optional["QuarantineManager"] = None,
disabled_kinds: Optional[Set[str]] = None,
) -> Dict[str, str]: ) -> Dict[str, str]:
perf_t0 = time.perf_counter() perf_t0 = time.perf_counter()
last_mark = perf_t0 last_mark = perf_t0
@@ -5000,6 +5310,10 @@ def process_pdf(
_log_env_banner() _log_env_banner()
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
cfg = load_dictionaries(config_path) cfg = load_dictionaries(config_path)
# Plan 1b (P1-2/F-1) : catégories désactivées dans la GUI. Vide par défaut
# ⇒ no-op (aucun changement de comportement vs aujourd'hui).
# NB: catégories (sortie de _category_of), pas kinds bruts.
cfg["disabled_kinds"] = set(disabled_kinds or ())
_perf_mark("load_config") _perf_mark("load_config")
pages_text, tables_lines, ocr_used, ocr_word_map = extract_text_with_fallback_ocr(pdf_path) pages_text, tables_lines, ocr_used, ocr_word_map = extract_text_with_fallback_ocr(pdf_path)
_perf_mark("extract_text_ocr") _perf_mark("extract_text_ocr")
@@ -5063,13 +5377,17 @@ def process_pdf(
if ocr_used and vlm_manager is not None and VlmManager is not None: if ocr_used and vlm_manager is not None and VlmManager is not None:
try: try:
if vlm_manager.is_loaded(): if vlm_manager.is_loaded():
_apply_vlm_on_scanned_pdf(pdf_path, anon, ocr_word_map, vlm_manager) _apply_vlm_on_scanned_pdf(pdf_path, anon, ocr_word_map, vlm_manager,
disabled=(cfg.get("disabled_kinds") or set()))
_perf_mark("vlm_scan") _perf_mark("vlm_scan")
except Exception: except Exception:
pass # dégradation gracieuse pass # dégradation gracieuse
# 2) NER (optionnel) — sur le narratif # 2) NER (optionnel) — sur le narratif
final_text = anon.text_out final_text = anon.text_out
# Plan 1b (P1-2/F-2) — catégories décochées pour les passes post-masquage
# (cleanups + propagation globale). Vide ⇒ no-op byte-for-byte.
_disabled_cats = cfg.get("disabled_kinds") or set()
hf_hits: List[PiiHit] = [] hf_hits: List[PiiHit] = []
if use_hf and ner_manager is not None and ner_manager.is_loaded(): if use_hf and ner_manager is not None and ner_manager.is_loaded():
# Détecter le type de manager et appeler la bonne fonction # Détecter le type de manager et appeler la bonne fonction
@@ -5097,6 +5415,7 @@ def process_pdf(
return m.group(0) return m.group(0)
anon.audit.append(PiiHit(-1, "NOM_GLOBAL", tok, PLACEHOLDERS["NOM"])) anon.audit.append(PiiHit(-1, "NOM_GLOBAL", tok, PLACEHOLDERS["NOM"]))
return m.group(1) + PLACEHOLDERS["NOM"] return m.group(1) + PLACEHOLDERS["NOM"]
if "NOM" not in _disabled_cats:
final_text = _re_nom_orphan.sub(_clean_nom_orphan, final_text) final_text = _re_nom_orphan.sub(_clean_nom_orphan, final_text)
# 3b) Nettoyage post-masquage : codes postaux orphelins (5 chiffres collés à un placeholder) # 3b) Nettoyage post-masquage : codes postaux orphelins (5 chiffres collés à un placeholder)
@@ -5105,6 +5424,7 @@ def process_pdf(
def _clean_cp_orphan(m): def _clean_cp_orphan(m):
anon.audit.append(PiiHit(-1, "CODE_POSTAL", m.group(2), PLACEHOLDERS["CODE_POSTAL"])) anon.audit.append(PiiHit(-1, "CODE_POSTAL", m.group(2), PLACEHOLDERS["CODE_POSTAL"]))
return m.group(1) + PLACEHOLDERS["CODE_POSTAL"] return m.group(1) + PLACEHOLDERS["CODE_POSTAL"]
if "ADRESSE" not in _disabled_cats: # CODE_POSTAL suit le toggle ADRESSE
final_text = _re_cp_orphan.sub(_clean_cp_orphan, final_text) final_text = _re_cp_orphan.sub(_clean_cp_orphan, final_text)
# Téléphones fragmentés : "0X XX XX XX\nXX" coupé en fin de ligne (ligne suivante immédiate) # Téléphones fragmentés : "0X XX XX XX\nXX" coupé en fin de ligne (ligne suivante immédiate)
@@ -5115,8 +5435,6 @@ def process_pdf(
anon.audit.append(PiiHit(-1, "TEL", m.group(0).strip(), PLACEHOLDERS["TEL"])) anon.audit.append(PiiHit(-1, "TEL", m.group(0).strip(), PLACEHOLDERS["TEL"]))
return PLACEHOLDERS["TEL"] + "\n" return PLACEHOLDERS["TEL"] + "\n"
return m.group(0) return m.group(0)
final_text = _re_tel_frag.sub(_clean_tel_frag, final_text)
# Téléphones incomplets en fin de ligne (8 ou 9 chiffres au format 0X XX XX XX) : masquer la partie visible # Téléphones incomplets en fin de ligne (8 ou 9 chiffres au format 0X XX XX XX) : masquer la partie visible
_re_tel_partial = re.compile(r"(?<!\d)((?:\+33\s?|0)\d(?:[ .-]?\d){5,7})(?!\d)\s*$", re.MULTILINE) _re_tel_partial = re.compile(r"(?<!\d)((?:\+33\s?|0)\d(?:[ .-]?\d){5,7})(?!\d)\s*$", re.MULTILINE)
def _clean_tel_partial(m): def _clean_tel_partial(m):
@@ -5125,6 +5443,8 @@ def process_pdf(
anon.audit.append(PiiHit(-1, "TEL", m.group(0).strip(), PLACEHOLDERS["TEL"])) anon.audit.append(PiiHit(-1, "TEL", m.group(0).strip(), PLACEHOLDERS["TEL"]))
return PLACEHOLDERS["TEL"] return PLACEHOLDERS["TEL"]
return m.group(0) return m.group(0)
if "TEL" not in _disabled_cats:
final_text = _re_tel_frag.sub(_clean_tel_frag, final_text)
final_text = _re_tel_partial.sub(_clean_tel_partial, final_text) final_text = _re_tel_partial.sub(_clean_tel_partial, final_text)
# 3c) Initiales identifiantes devant [NOM] : "Dr T. [NOM]" → "Dr [NOM] [NOM]" # 3c) Initiales identifiantes devant [NOM] : "Dr T. [NOM]" → "Dr [NOM] [NOM]"
@@ -5134,7 +5454,6 @@ def process_pdf(
def _clean_initial_before_nom(m): def _clean_initial_before_nom(m):
anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(1) + ".", PLACEHOLDERS["NOM"])) anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(1) + ".", PLACEHOLDERS["NOM"]))
return PLACEHOLDERS["NOM"] + " " + m.group(2) return PLACEHOLDERS["NOM"] + " " + m.group(2)
final_text = _RE_INITIAL_BEFORE_NOM.sub(_clean_initial_before_nom, final_text)
# 3d) Références initiales : "Ref : JF/VA", "Réf : AD/EP" → "Ref : [NOM]/[NOM]" # 3d) Références initiales : "Ref : JF/VA", "Réf : AD/EP" → "Ref : [NOM]/[NOM]"
_RE_REF_INITIALS = re.compile( _RE_REF_INITIALS = re.compile(
@@ -5145,6 +5464,8 @@ def process_pdf(
anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(2), PLACEHOLDERS["NOM"])) anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(2), PLACEHOLDERS["NOM"]))
prefix = m.group(0)[:m.group(0).index(m.group(1))] prefix = m.group(0)[:m.group(0).index(m.group(1))]
return prefix + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"] return prefix + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"]
if "NOM" not in _disabled_cats:
final_text = _RE_INITIAL_BEFORE_NOM.sub(_clean_initial_before_nom, final_text)
final_text = _RE_REF_INITIALS.sub(_clean_ref_initials, final_text) final_text = _RE_REF_INITIALS.sub(_clean_ref_initials, final_text)
# 3e) Layout BACTERIO résiduel : le numéro de venue peut survivre s'il est # 3e) Layout BACTERIO résiduel : le numéro de venue peut survivre s'il est
@@ -5297,6 +5618,11 @@ def process_pdf(
continue continue
if h.kind in _GLOBAL_SKIP_KINDS: if h.kind in _GLOBAL_SKIP_KINDS:
continue continue
# Plan 1b (P1-2/F-2/F-4) : ne pas propager une catégorie décochée dans le
# texte (sa valeur, laissée en clair plus haut, serait re-masquée ici).
# Default-deny via _category_of. No-op quand _disabled_cats est vide.
if _disabled_cats and _category_of(h.kind) in _disabled_cats:
continue
token = h.original.strip() token = h.original.strip()
if not token or len(token) < 4: if not token or len(token) < 4:
continue continue
@@ -5450,15 +5776,22 @@ def process_pdf(
# initiales, whitelist). Si PII résiduelles > seuil, on NE LIVRE PAS — quarantaine full. # initiales, whitelist). Si PII résiduelles > seuil, on NE LIVRE PAS — quarantaine full.
# Inconditionnel : toujours exécuté même si quarantine_mgr absent (Codex review). # Inconditionnel : toujours exécuté même si quarantine_mgr absent (Codex review).
if SEUIL_RESCAN_RESIDUEL is not None: if SEUIL_RESCAN_RESIDUEL is not None:
_residual_pii_patterns = [ # Plan 1b (P1-2/F-4) — patterns gatés par catégorie décochée.
(re.compile(RE_NIR.pattern if hasattr(RE_NIR, 'pattern') else r"\b\d{15}\b"), "NIR"), # disabled_kinds contient des noms de CATÉGORIE (les 7 toggles).
(re.compile(r"\b[\w.%+-]+@[\w.-]+\.\w{2,}\b"), "EMAIL"), _rescan_disabled = cfg.get("disabled_kinds") or set()
(re.compile(r"\b(?:FR\d{2})?\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2,3}\b"), "IBAN"), _residual_pii_patterns = _build_residual_patterns(_rescan_disabled)
(re.compile(r"\b(?:\+33|0)[\s.\-]?\d[\s.\-]?(?:\d[\s.\-]?){8}\b"), "TEL"), # Pré-masquage SCOPÉ AU SEUL SCAN TEL : quand NIR est décoché, neutralise
] # les blocs type-NIR laissés EN CLAIR uniquement pour le pattern TEL
# (sinon TEL s'amorce sur le bloc central de chiffres du NIR → quarantaine
# injustifiée). EMAIL/IBAN/NIR scannent le texte ORIGINAL : sinon le
# pré-masquage effacerait les groupes de chiffres d'un IBAN en clair et
# affaiblirait silencieusement le filet IBAN (toujours actif). Identité
# quand NIR n'est pas décoché → comportement byte-for-byte préservé.
_tel_scan_text = _residual_premask_text(final_text, _rescan_disabled)
residual_count = 0 residual_count = 0
for pat, _label in _residual_pii_patterns: for pat, _label in _residual_pii_patterns:
residual_count += len(pat.findall(final_text)) _scan_text = _tel_scan_text if _label == "TEL" else final_text
residual_count += len(pat.findall(_scan_text))
# F4 — filet de rescan élargi aux noms INSEE en MAJUSCULES. # F4 — filet de rescan élargi aux noms INSEE en MAJUSCULES.
# OPT-IN : désactivé par défaut. Sur le corpus audit_30, INSEE contient # OPT-IN : désactivé par défaut. Sur le corpus audit_30, INSEE contient
@@ -5467,9 +5800,13 @@ def process_pdf(
# les documents en quarantaine. À utiliser quand on tolère le sur- # les documents en quarantaine. À utiliser quand on tolère le sur-
# masquage et qu'on veut zéro fuite (ex: profil "paranoid"). # masquage et qu'on veut zéro fuite (ex: profil "paranoid").
# Pour activer : passer cfg["rescan"]["check_insee_names"] = True. # Pour activer : passer cfg["rescan"]["check_insee_names"] = True.
# Plan 1b (P1-2/F-4) : ce filet vise des NOMS → désactivé si la catégorie
# NOM est décochée (sinon un nom laissé en clair déclencherait quarantaine).
_check_insee = False _check_insee = False
if isinstance(cfg, dict): if isinstance(cfg, dict):
_check_insee = bool((cfg.get("rescan", {}) or {}).get("check_insee_names", False)) _check_insee = bool((cfg.get("rescan", {}) or {}).get("check_insee_names", False))
if "NOM" in _rescan_disabled:
_check_insee = False
if _check_insee: if _check_insee:
_placeholder_bare = {p.strip("[]") for p in PLACEHOLDERS.values()} _placeholder_bare = {p.strip("[]") for p in PLACEHOLDERS.values()}
_wl_terms = [] _wl_terms = []
@@ -5489,6 +5826,13 @@ def process_pdf(
residual_count += 1 residual_count += 1
log.warning("Residual INSEE name detected: %s (in %s)", token, pdf_path.name) log.warning("Residual INSEE name detected: %s (in %s)", token, pdf_path.name)
# Plan 1b (P1-2/F-4) — le filet résiduel reste STRICT (seuil 0)
# inconditionnellement : toute fuite EMAIL/IBAN/NIR/TEL met TOUJOURS le
# document en quarantaine. La contamination croisée d'une catégorie
# décochée (ses spans en clair matchant un pattern résiduel actif) sera
# traitée span-précisément en Task 3 (gating texte : pré-masquage des
# spans des catégories décochées AVANT le rescan), pas par un seuil
# relâché qui affaiblirait globalement le filet.
if residual_count > SEUIL_RESCAN_RESIDUEL: if residual_count > SEUIL_RESCAN_RESIDUEL:
if quarantine_mgr is not None: if quarantine_mgr is not None:
quarantine_mgr.flag( quarantine_mgr.flag(
@@ -5518,6 +5862,15 @@ def process_pdf(
return {"status": "quarantined", "reason": "rescan_residual_pii", return {"status": "quarantined", "reason": "rescan_residual_pii",
"residual_count": residual_count, "text": "", "audit": ""} "residual_count": residual_count, "text": "", "audit": ""}
# Plan 1b (P1-2/F-1) — Filtre Tier 1 : retire de l'audit les hits dont la
# catégorie est désactivée, JUSTE AVANT le burn PDF et l'écriture de l'audit.
# Comme le burn PDF (vector + raster) et le .audit.jsonl dérivent tous de
# anon.audit, cette mutation unique en place sécurise le livrable PDF et la
# piste d'audit. No-op si disabled_kinds est vide (non-régression).
_disabled = cfg.get("disabled_kinds") or set()
if _disabled:
anon.audit = _filter_audit_by_disabled(anon.audit, _disabled)
# Sauvegardes # Sauvegardes
base = pdf_path.stem base = pdf_path.stem
txt_path = out_dir / f"{base}.pseudonymise.txt" txt_path = out_dir / f"{base}.pseudonymise.txt"
@@ -5554,7 +5907,7 @@ def process_pdf(
if make_vector_redaction and fitz is not None: if make_vector_redaction and fitz is not None:
vec_path = out_dir / f"{base}.redacted_vector.pdf" vec_path = out_dir / f"{base}.redacted_vector.pdf"
try: try:
redact_pdf_vector(pdf_path, anon.audit, vec_path, ocr_word_map=ocr_word_map) redact_pdf_vector(pdf_path, anon.audit, vec_path, ocr_word_map=ocr_word_map, disabled_kinds=_disabled)
outputs["pdf_vector"] = str(vec_path) outputs["pdf_vector"] = str(vec_path)
_perf_mark("pdf_vector") _perf_mark("pdf_vector")
except Exception as e: except Exception as e:
@@ -5570,6 +5923,7 @@ def process_pdf(
redact_pdf_raster( redact_pdf_raster(
pdf_path, anon.audit, ras_fb_path, pdf_path, anon.audit, ras_fb_path,
ogc_label=ogc_label, ocr_word_map=ocr_word_map, ogc_label=ogc_label, ocr_word_map=ocr_word_map,
disabled_kinds=_disabled,
) )
outputs["pdf_raster"] = str(ras_fb_path) outputs["pdf_raster"] = str(ras_fb_path)
raster_fallback_ok = True raster_fallback_ok = True
@@ -5611,7 +5965,7 @@ def process_pdf(
# S5 : ne pas refaire le raster si le fallback vector→raster l'a déjà produit # S5 : ne pas refaire le raster si le fallback vector→raster l'a déjà produit
if "pdf_raster" not in outputs: if "pdf_raster" not in outputs:
ras_path = out_dir / f"{base}.redacted_raster.pdf" ras_path = out_dir / f"{base}.redacted_raster.pdf"
redact_pdf_raster(pdf_path, anon.audit, ras_path, ogc_label=ogc_label, ocr_word_map=ocr_word_map) redact_pdf_raster(pdf_path, anon.audit, ras_path, ogc_label=ogc_label, ocr_word_map=ocr_word_map, disabled_kinds=_disabled)
outputs["pdf_raster"] = str(ras_path) outputs["pdf_raster"] = str(ras_path)
_perf_mark("pdf_raster") _perf_mark("pdf_raster")
log.info("PERF %s: done total=%.2fs outputs=%s", pdf_path.name, time.perf_counter() - perf_t0, sorted(outputs.keys())) log.info("PERF %s: done total=%.2fs outputs=%s", pdf_path.name, time.perf_counter() - perf_t0, sorted(outputs.keys()))

View File

@@ -0,0 +1,125 @@
---
from: qwen
to: claude
date: 2026-06-27T10:00:00+02:00
topic: verdict-post-impl-tasks-1-4
status: closed
priority: blocker
references:
- demande: docs/coordination/inbox/for-qwen/2026-06-26_11-32_claude_re-revue-impl-1b-tasks-1-4.md
- commits: b15d0da, 2a3aab1, dd392c4, a02bca5, daec1f5, a6bd364
---
# Verdict Qwen — re-revue post-implémentation Tasks 1-4 (cœur sécurité)
## VERDICT : **GO**
4 commits examinés, 66 tests passed, 0 régression. Aucune fuite croisée, aucun type non-toggleable dépendant d'une passe gatée (sauf CP = toggleable par design). L'implémentation est correcte et sécurisée.
---
## §FAX — aucun autre type non-toggleable dépend d'une passe gatée ✅
J'ai vérifié exhaustivement les 16 types non-toggleables (EMAIL, IBAN, ADELI, DOSSIER, NDA, EPISODE, IPP, FINESS, OGC, RPPS, VILLE, AGE, URL, force_term, force_regex, FAX). **Tous ont des chemins d'audit hit inconditionnels** — ils ne dépendent pas d'une passe gatée.
| Type | Chemins audit hit | Gaté ? | Verdict |
|---|---|---|---|
| EMAIL | `_mask_line_by_regex` (incond.) + `_mask_with_hf/eds` (incond.) | ❌ | ✅ Safe |
| IBAN | `_mask_line_by_regex` (incond.) + `_kv_value_only_mask` (incond.) | ❌ | ✅ Safe |
| FAX | `_mask_fax_unconditional` (incond., fix post-review) | ❌ | ✅ Safe |
| ADELI | `_mask_line_by_regex` (incond.) + `_mask_admin_label` (incond.) | ❌ | ✅ Safe |
| DOSSIER/NDA/EPISODE | `_mask_line_by_regex` (incond.) + `_kv_value_only_mask` (incond.) + Trackare (incond.) | ❌ | ✅ Safe |
| IPP/FINESS/OGC/RPPS | `_mask_line_by_regex` (incond.) + `_mask_admin_label` (incond.) + Trackare (incond.) | ❌ | ✅ Safe |
| VILLE/AGE | `_mask_line_by_regex` (incond.) + gazetteers (incond.) | ❌ | ✅ Safe |
| force_term/force_regex | `anonymise_document_regex` (incond.) | ❌ | ✅ Safe |
**CODE_POSTAL** est le seul "problème" — mais c'est **toggleable par design** (décision Dom : CP suit le toggle ADRESSE). Ses hits sont gatés dans 7 chemins, mais c'est intentional : quand ADRESSE est décoché, CP est aussi décoché. `_category_of("CODE_POSTAL") = "ADRESSE"` → cohérent. ✅
---
## §Divergence seuil — **ACCORD : seuil 0 strict + premask scopé** ✅
Claude a raison de garder seuil 0 inconditionnel. Mon suggestion de seuil adaptatif (0→1) était **incorrecte** car :
1. **Seuil 1 affaiblit EMAIL/IBAN backstops** : un vrai email fuité (1 occurrence) ne quarantainerait plus dès qu'une catégorie est décochée. C'est une régression sécurité.
2. **Premask span-précis** est supérieur : `_residual_premask_text` neutralise les spans NIR-like avant le scan TEL → le pattern TEL ne matche pas les chiffres du NIR. **Span-précis, pas aveugle.**
3. **Fail-closed** : si un fragment ambigu (8 chiffres) matche TEL résiduel → quarantaine injustifiée, mais **aucune fuite**. Acceptable pour beta.
Je retire ma suggestion de seuil adaptatif et **confirme l'approche Claude** (seuil 0 strict + premask scopé NIR→TEL).
---
## §Task 3b — **Report acceptable pour beta** ✅
Le premask généralisé (neutraliser les spans de toutes catégories décochées avant le scan résiduel) est **différé**. Sans lui :
- **Pire cas** : ADHERENT décoché laisse un numéro adhérent en clair qui matche NIR/TEL résiduel → sur-quarantaine
- **Impact** : fail-closed (document retenu, **aucune fuite**)
- **Fréquence** : rare — ADHERENT/NIR/TEL overlap est marginal dans les documents réels
- **Verdict** : acceptable pour beta. Le premask généralisé est un v12 enhancement.
---
## Implémentation review — highlights
### `_category_of(kind)` (Task 1, b15d0da)
- **5 branches dérivation** : suffixe `_GLOBAL` → table explicite → placeholder-self → VLM reverse → EDS map → None (default-deny). Correct. ✅
- **Anti-dérive test** : vérifie que toutes les kinds VLM/EDS sont couverts par la dérivation. ✅
- **CODE_POSTAL = ADRESSE** : reflète la décision Dom. ✅
- **`_EXPLICIT_KIND_CATEGORY`** : table manuelle pour kinds regex/inline. 7 entrées. ⚠️ Fragile pour les futurs kinds (nécessite update manuelle), mais documenté avec warning.
### `_filter_audit_by_disabled` (Task 1)
- **Placement** : avant le bloc Sauvegardes → couvre audit.jsonl + redact_pdf_vector + redact_pdf_raster. ✅
- **disabled_kinds = set de CATÉGORIES** (pas de kinds). Utilise `_category_of(kind)` pour mapper. ✅
- **None → ne retire pas** (default-deny kinds restent toujours masqués). ✅
### `_build_residual_patterns` + `_residual_premask_text` (Task 2, 2a3aab1)
- **EMAIL/IBAN toujours inclus**. ✅
- **NIR/TEL conditionnels** (retirés si catégorie décochée). ✅
- **Premask NIR→TEL** : neutralise spans NIR-like avant le scan TEL quand NIR est décoché. ✅
- **Non-régression** : `_build_residual_patterns(set())` = liste historique byte-for-byte. ✅
### Gates texte (Task 3, a02bca5) — ~20 sites
- **NER/VLM per-hit** (intra-boucle), jamais per-function. ✅ — c'est le point critique que j'avais flagué en F-5 S1.
- **Test anti-fuite croisée** : 7 variants (chaque catégorie décochée, les 6 autres restent masquées). ✅
- **`_mask_fax_unconditional`** : FAX non-toggleable, masqué+audité inconditionnellement. ✅
- **`RE_TRACKARE_IAO_MULTILINE_VALUE`** : NOM_FORCE gated sous NOM. ✅ (correction de ma F-2 A)
- **Post-mask cleanups** : NOM orphan, TEL fragment gated. ✅
- **Propagation globale** : gated par catégorie (step 4e). ✅
- **`_apply_trackare_hits_to_text`** : gated par catégorie. ✅
### Address burn guard (Task 4, daec1f5)
- **`_search_pdf_address_lines`** : gardé par `"ADRESSE" not in disabled`. ✅
- **Images/barcodes** : documentés conservateurs. ✅
- **`_VECTOR/_RASTER_SKIP_KINDS`** : composition soustractive, pas de conflit avec gating. ✅
---
## 1 trouble mineur (T1 — MOYEN)
**Override user-defined nommé avec une catégorie toggleable** : si un utilisateur définit un admin_rule override avec `name="NOM"` (au lieu du défaut `name="override"`), l'override kind="NOM" est toggleable → quand NOM est décoché, `_filter_audit_by_disabled` retire le hit du PDF burn, mais le texte reste masqué par l'override (l'override masque le texte directement dans `anonymise_document_regex`). **Incohérence texte/PDF** : texte masqué `[NOM]`, PDF non masqué (rect retiré de l'audit).
**Impact** : faible — le défaut `name="override"` → kind="override" → `_category_of("override") = None` → default-deny → toujours masqué. Le cas `name="NOM"` est un choix utilisateur explicite.
**Correction** : documenter que les overrides user-defined doivent utiliser `name` non-toggleable (ex: "override" ou "custom_nom"), pas un nom de catégorie toggleable. Ou bien : les overrides sont toujours inconditionnels (non-gatés) dans le texte ET l'audit. **Pas bloquant pour beta.**
---
## Verdict final
| Critère | Résultat |
|---|---|
| Fuite croisée (catégorie cochée démasquée) | ❌ Aucune |
| Type non-toggleable fuyant via passe gatée | ❌ Aucun (sauf CP = toggleable par design) |
| Régression chemin par défaut (tout coché) | ❌ Aucune (66 tests passed, 0 xfail) |
| §Divergence seuil | ✅ Accord Claude (seuil 0 strict) |
| §Task 3b | ✅ Report acceptable (fail-closed) |
**GO.** Exécution Tasks 1-4 validée. Dom peut diffuser.
— Qwen (auditeur/reviewer, boucle adversariale 3/3)

View File

@@ -12,9 +12,9 @@
- VLM : `VLM_CATEGORY_MAP` (vlm_manager.py:51) `label→(kind, placeholder)` — source de vérité (Qwen ratait `VLM_CP`). - VLM : `VLM_CATEGORY_MAP` (vlm_manager.py:51) `label→(kind, placeholder)` — source de vérité (Qwen ratait `VLM_CP`).
- `_GLOBAL` : `PiiHit(kind=f"{kind}_GLOBAL")` (core:5286) pour `_CRITICAL_PII_TYPES` (core:5245) — **plusieurs** kinds, pas seulement NIR/ADHERENT. - `_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_NAISSANCE` du PDF. - Burn : `_VECTOR/_RASTER_SKIP_KINDS` (core:4564/4723) excluent déjà `EDS_SECU/EDS_TEL/EDS_DATE_NAISSANCE` du PDF.
- **Décision CP/ZIP** : code postal (`VLM_CP`, `EDS_ZIP`, placeholder `CODE_POSTAL`) = **PAS** dans les 7 toggles → **toujours masqué** (conservateur ; « Adresses » révèle la voie, pas le CP). Qwen d'accord (RGPD : le CP identifie le lieu de soin/résidence). **Caveat UX à documenter** : décocher « Adresses » donne un rendu type « 12 rue X [CODE_POSTAL] Ville » (voie en clair, CP masqué) — assumé. À confirmer Dom. - **Décision CP/ZIP (TRANCHÉE Dom 2026-06-26)** : code postal (`VLM_CP`, `EDS_ZIP`, placeholder `CODE_POSTAL`) **suit le toggle « Adresses »** → catégorie `ADRESSE`. 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** : `VILLE` reste 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/CODE_POSTAL` non toggleables = toujours masqués). **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 »).
--- ---
@@ -45,11 +45,14 @@ def test_category_of_each_source():
assert core._category_of("VLM_ETAB") == "ETAB" assert core._category_of("VLM_ETAB") == "ETAB"
assert core._category_of("EDS_SECU") == "NIR" # dérivé EDS (SECU→NIR) assert core._category_of("EDS_SECU") == "NIR" # dérivé EDS (SECU→NIR)
assert core._category_of("EDS_HOPITAL") == "ETAB" 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(): def test_category_of_default_deny():
# Non toggleables → None (restent TOUJOURS masqués). Sécurité. # Non toggleables → None (restent TOUJOURS masqués). Sécurité.
for k in ("EMAIL", "IBAN", "IPP", "VILLE", "FAX", "VLM_CP", "EDS_ZIP", # 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"): "VLM_VILLE", "EMAIL_GLOBAL", "INCONNU_XYZ"):
assert core._category_of(k) is None, k assert core._category_of(k) is None, k
@@ -85,6 +88,7 @@ def test_filter_audit_drops_only_disabled():
_PLACEHOLDER_TO_CATEGORY = { _PLACEHOLDER_TO_CATEGORY = {
"NOM": "NOM", "DATE_NAISSANCE": "DATE_NAISSANCE", "ETAB": "ETAB", "NOM": "NOM", "DATE_NAISSANCE": "DATE_NAISSANCE", "ETAB": "ETAB",
"ADRESSE": "ADRESSE", "NIR": "NIR", "TEL": "TEL", "ADHERENT": "ADHERENT", "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. # Kinds regex/inline non dérivables d'une map → leur catégorie explicitement.
_EXPLICIT_KIND_CATEGORY = { _EXPLICIT_KIND_CATEGORY = {
@@ -149,7 +153,7 @@ Add `disabled_kinds: set = None` kwarg to `process_pdf` (~l.4973). After `cfg =
- [ ] **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 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 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`. Brancher `cfg.get("disabled_kinds")`. **Seuil adaptatif (suggestion re-revue Qwen)** : `SEUIL_RESCAN_RESIDUEL=0` est 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 si `disabled_kinds` non vide**, 0 sinon (préserve la rigueur quand tout est activé). - [ ] **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, pré-masquer les spans NIR-like (13-15 chiffres groupés) UNIQUEMENT pour le scan TEL** (PAS EMAIL/IBAN/NIR — sinon on efface la queue numérique d'un IBAN et on affaiblit son backstop toujours-actif). Gate la branche INSEE-names (~l.5470) sous `"NOM" not in disabled`. Brancher `cfg.get("disabled_kinds")`. **Seuil résiduel : rester strict à `SEUIL_RESCAN_RESIDUEL` (0), INCONDITIONNEL.** Un seuil relâché (1 quand des catégories décochées, suggestion initiale Qwen) affaiblirait globalement les backstops toujours-actifs EMAIL/IBAN (un vrai email fuité ne quarantainerait plus). La contamination croisée (donnée d'une catégorie décochée matchant un pattern actif) **n'apparaît qu'après Task 3** (avant, le texte masque encore tout) et y est traitée **span-précisément** (cf. Task 3), seuil restant 0. **[Divergence assumée vs suggestion Qwen seuil adaptatif — corrigée en revue qualité Claude, à confirmer en re-revue Qwen.]**
- [ ] **Step 4 — Run, expect PASS.** - [ ] **Step 4 — Run, expect PASS.**
- [ ] **Step 5 — Non-régression :** `.venv/bin/pytest tests/unit/ -q`. - [ ] **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)"` - [ ] **Step 6 — Commit :** `git commit -m "feat(core): coordination quarantaine résiduelle NIR/TEL décochés (P1-2/F-4)"`
@@ -169,6 +173,7 @@ Add `disabled_kinds: set = None` kwarg to `process_pdf` (~l.4973). After `cfg =
- selective_rescan (~4159) : DATE_NAISSANCE(4203), ADRESSE(4205-4207), ETAB(4229-4251), ADHERENT(4200-4201), TEL(4191-4193), NIR(4187-4188). - 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). - 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. - **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.
- **Résiduel post-gating (coordination Task 2, F-4)** : une fois une catégorie laissée EN CLAIR dans le texte, **pré-masquer ses spans avant le scan résiduel de quarantaine** (depuis les hits filtrés de l'audit Task 1 — les capturer au moment du filtre, ou recomposer) pour éviter une quarantaine injustifiée. **Le seuil résiduel reste 0** (backstops EMAIL/IBAN/NIR/TEL stricts — un vrai leak quarantaine toujours). C'est le pendant texte du premask NIR⇄TEL de Task 2, généralisé aux catégories décochées.
- **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)`. - **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). - Post-mask cleanups (F-2 #6) : NOM orphan (~5098/5137/5148), TEL fragment (~5118/5128).
@@ -205,11 +210,11 @@ Add `disabled_kinds: set = None` kwarg to `process_pdf` (~l.4973). After `cfg =
--- ---
## Self-review (couverture spec + revue Qwen + vérif Claude) ## Self-review (couverture spec + revue Qwen + vérif Claude)
- **F-1** : `_category_of` dé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 (VLM_CP→masqué). Default-deny. ✓ - **F-1** : `_category_of` dé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-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-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-4** : Task 2 coordonne résiduel + **exclusion NIR-like du scan TEL UNIQUEMENT** (backstop IBAN préservé) + **seuil résiduel strict 0** (pas de relâchement aveugle des backstops EMAIL/IBAN) + gate selective_rescan/propagation (Task 3). Contamination croisée des catégories décochées traitée span-précisément en Task 3. ✓
- **F-5** : Task 3 impose le gating NER **intra-boucle** (per-hit), jamais per-function. ✓ - **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. - **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 ouverte Dom** : CODE_POSTAL (CP/ZIP) hors des 7 toggles (masqué) — confirmer. - **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). - **Re-revue Qwen post-implémentation obligatoire** (Tasks 1-4).

View File

@@ -9,10 +9,36 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, List, Optional from typing import Callable, FrozenSet, List, Optional
from gui_v6.engine_bridge import EngineSettings from gui_v6.engine_bridge import EngineSettings
# Mapping centralisé champ ConfigState → CATÉGORIE moteur (Plan 1b / P1-2).
#
# Les 7 catégories doivent matcher EXACTEMENT le set accepté par
# ``anonymizer_core_refactored_onnx.process_pdf(disabled_kinds=...)`` :
# {"NOM", "DATE_NAISSANCE", "ETAB", "ADRESSE", "NIR", "TEL", "ADHERENT"}.
#
# Sémantique des booléens ``detect_*`` : True = « détecter cette catégorie »
# (= masquer, comportement par défaut). False = laisser en clair → la catégorie
# entre dans ``disabled_kinds``. Note : CODE_POSTAL suit le toggle ADRESSE côté
# moteur (décision Dom 2026-06-26), aucun toggle dédié n'est exposé.
#
# L'ordre suit les 7 lignes de ``tab_config._DETECTION_OPTIONS`` :
# Noms/prénoms · Dates de naissance · Établissements · Adresses/CP ·
# N° sécurité sociale · Téléphones/e-mails · N° adhérent mutuelle.
CATEGORY_FIELDS = {
"detect_nom": "NOM",
"detect_date_naissance": "DATE_NAISSANCE",
"detect_etab": "ETAB",
"detect_adresse": "ADRESSE",
"detect_nir": "NIR",
"detect_tel": "TEL",
"detect_adherent": "ADHERENT",
}
# Catégories canoniques (ordre = ordre des toggles UI).
DETECTION_CATEGORIES = tuple(CATEGORY_FIELDS.values())
@dataclass @dataclass
class ConfigState: class ConfigState:
@@ -33,6 +59,26 @@ class ConfigState:
mask_margin_y: int = 1 mask_margin_y: int = 1
mask_rounded_corners: bool = False mask_rounded_corners: bool = False
# 7 toggles « Données à détecter » — tous ON par défaut (zéro changement).
detect_nom: bool = True
detect_date_naissance: bool = True
detect_etab: bool = True
detect_adresse: bool = True
detect_nir: bool = True
detect_tel: bool = True
detect_adherent: bool = True
def disabled_kinds(self) -> FrozenSet[str]:
"""Set des CATÉGORIES décochées (laissées en clair).
Défaut (tous les toggles ON) ⇒ ``frozenset()`` (no-op moteur).
"""
return frozenset(
category
for field_name, category in CATEGORY_FIELDS.items()
if not getattr(self, field_name)
)
def to_engine_settings(self, config_path: Optional[Path] = None) -> EngineSettings: def to_engine_settings(self, config_path: Optional[Path] = None) -> EngineSettings:
return EngineSettings( return EngineSettings(
make_vector_redaction=False, make_vector_redaction=False,
@@ -43,6 +89,7 @@ class ConfigState:
enable_gliner=self.enable_gliner, enable_gliner=self.enable_gliner,
ogc_label=self.ogc_label, ogc_label=self.ogc_label,
profile=self.profile, profile=self.profile,
disabled_kinds=self.disabled_kinds(),
) )

View File

@@ -19,10 +19,10 @@ Aucune logique de détection ici : on orchestre uniquement.
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, Optional from typing import Any, Callable, Dict, FrozenSet, Optional
from engine_capabilities import capabilities_map from engine_capabilities import capabilities_map
@@ -61,6 +61,9 @@ class EngineSettings:
enable_gliner: bool = False enable_gliner: bool = False
ogc_label: Optional[str] = None ogc_label: Optional[str] = None
profile: Optional[str] = None profile: Optional[str] = None
# Plan 1b (P1-2) — set des CATÉGORIES laissées en clair (toggles décochés).
# Vide par défaut ⇒ aucun changement de comportement (tout est masqué).
disabled_kinds: FrozenSet[str] = field(default_factory=frozenset)
def _default_factories() -> dict[str, ManagerFactory]: def _default_factories() -> dict[str, ManagerFactory]:
@@ -206,6 +209,8 @@ def build_engine_kwargs(
"also_make_raster_burn": settings.also_make_raster_burn, "also_make_raster_burn": settings.also_make_raster_burn,
"config_path": settings.config_path, "config_path": settings.config_path,
"ogc_label": settings.ogc_label, "ogc_label": settings.ogc_label,
# Plan 1b (P1-2) — catégories décochées laissées en clair (set vide = no-op).
"disabled_kinds": frozenset(settings.disabled_kinds or ()),
} }
if managers is not None and settings.use_local_ner: if managers is not None and settings.use_local_ner:
kwargs.update(managers.as_kwargs()) kwargs.update(managers.as_kwargs())

View File

@@ -26,14 +26,17 @@ _SUBTABS = [
("shr", "🔄 Partage"), ("shr", "🔄 Partage"),
] ]
# Chaque ligne = (libellé, aide, champ ConfigState). Le champ relie le toggle
# à la catégorie moteur (cf. gui_v6.config_state.CATEGORY_FIELDS). ON = détecter
# (masquer) ; OFF = laisser en clair (entre dans disabled_kinds).
_DETECTION_OPTIONS = [ _DETECTION_OPTIONS = [
("Noms et prénoms", "Annuaire + IA"), ("Noms et prénoms", "Annuaire + IA", "detect_nom"),
("Dates de naissance", "Contexte naissance"), ("Dates de naissance", "Contexte naissance", "detect_date_naissance"),
("Établissements", "FINESS + contexte"), ("Établissements", "FINESS + contexte", "detect_etab"),
("Adresses / CP", "Voie, ville, code"), ("Adresses / CP", "Voie, ville, code", "detect_adresse"),
("N° sécurité sociale", "NIR"), ("N° sécurité sociale", "NIR", "detect_nir"),
("Téléphones / e-mails", "Contact"), ("Téléphones / e-mails", "Contact", "detect_tel"),
("N° adhérent mutuelle", "Identifiant local"), ("N° adhérent mutuelle", "Identifiant local", "detect_adherent"),
] ]
_MASK_COLORS = [ _MASK_COLORS = [
@@ -353,8 +356,20 @@ class ConfigTab(ctk.CTkFrame):
help_text=_HELP_DONNEES_DETECTER, help_title="Données à détecter", help_text=_HELP_DONNEES_DETECTER, help_title="Données à détecter",
) )
det.pack(fill="both", expand=True) det.pack(fill="both", expand=True)
for label, hint in _DETECTION_OPTIONS: # Les 7 toggles « Données à détecter » sont câblés sur les booléens
self._mini_toggle(det, label, hint, value=True).pack(fill="x", padx=12, pady=1) # detect_* de ConfigState (lecture initiale + écriture au changement).
# ON = détecter/masquer ; OFF = laisser en clair (→ disabled_kinds).
self._detect_toggles: dict[str, object] = {}
for label, hint, field_name in _DETECTION_OPTIONS:
toggle = self._mini_toggle(
det,
label,
hint,
value=bool(getattr(self._state, field_name)),
command=lambda f=field_name: self._on_detect_toggle(f),
)
toggle.pack(fill="x", padx=12, pady=1)
self._detect_toggles[field_name] = toggle
ner = ui_kit.Card( ner = ui_kit.Card(
cols[1], p, title="🧠 Moteurs et masques", cols[1], p, title="🧠 Moteurs et masques",
@@ -865,6 +880,16 @@ class ConfigTab(ctk.CTkFrame):
def _on_profile(self, value: str) -> None: def _on_profile(self, value: str) -> None:
self._state.profile = value self._state.profile = value
def _on_detect_toggle(self, field_name: str) -> None:
"""Recopie l'état d'un toggle « Données à détecter » dans ConfigState.
ON = détecter (masquer) ; OFF = laisser en clair. ``disabled_kinds()``
de ConfigState dérive ensuite le set des catégories désactivées.
"""
toggle = self._detect_toggles.get(field_name)
if toggle is not None:
setattr(self._state, field_name, bool(toggle.get()))
def _on_ner(self) -> None: def _on_ner(self) -> None:
self._state.use_local_ner = self._tog_ner.get() self._state.use_local_ner = self._tog_ner.get()

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Plan 1b — Task 4 (P1-2/F-3) : garde-fou du burn adresse géométrique.
`_search_pdf_address_lines` est un chemin de caviardage INDÉPENDANT de
l'audit : il noircit directement les lignes d'adresse trouvées
géométriquement sur la page (cf. `test_pdf_redaction_directly_masks_finess_address_range`).
Le filtre d'audit de la Task 1 ne le couvre donc PAS.
Ces tests vérifient que ce chemin est gaté sous la catégorie ADRESSE :
- ADRESSE désactivée → `_search_pdf_address_lines` n'est PAS appliqué ;
- ADRESSE activée (ou disabled vide) → il est appelé comme avant.
"""
import anonymizer_core_refactored_onnx as core
from anonymizer_core_refactored_onnx import (
PiiHit,
fitz,
redact_pdf_raster,
redact_pdf_vector,
)
def _make_address_pdf(tmp_path):
source = tmp_path / "addr.pdf"
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), "15 à 35 rue Claude Boucher Bordeaux Cedex")
page.insert_text((72, 108), "Motif d'hospitalisation : contrôle clinique.")
doc.save(source)
doc.close()
return source
# ---------------------------------------------------------------------------
# VECTOR
# ---------------------------------------------------------------------------
def test_vector_address_search_called_when_adresse_enabled(tmp_path, monkeypatch):
if fitz is None:
return
source = _make_address_pdf(tmp_path)
output = tmp_path / "addr.enabled.pdf"
calls = []
real = core._search_pdf_address_lines
def _spy(page):
calls.append(page.number)
return real(page)
monkeypatch.setattr(core, "_search_pdf_address_lines", _spy)
# disabled vide → comportement par défaut (adresse cherchée)
redact_pdf_vector(source, [], output, disabled_kinds=set())
assert calls, "ADRESSE activée : _search_pdf_address_lines doit être appelé"
redacted = fitz.open(output)
text = redacted[0].get_text()
redacted.close()
# L'adresse a bien été caviardée (le burn géométrique s'applique)
assert "rue Claude Boucher" not in text
# La ligne clinique reste lisible
assert "Motif d'hospitalisation" in text
def test_vector_address_search_not_applied_when_adresse_disabled(tmp_path, monkeypatch):
if fitz is None:
return
source = _make_address_pdf(tmp_path)
output = tmp_path / "addr.disabled.pdf"
calls = []
real = core._search_pdf_address_lines
def _spy(page):
calls.append(page.number)
return real(page)
monkeypatch.setattr(core, "_search_pdf_address_lines", _spy)
redact_pdf_vector(source, [], output, disabled_kinds={"ADRESSE"})
# Le burn géométrique d'adresse ne doit PAS être appliqué.
assert not calls, (
"ADRESSE désactivée : _search_pdf_address_lines ne doit pas être appliqué"
)
redacted = fitz.open(output)
text = redacted[0].get_text()
redacted.close()
# L'adresse reste lisible puisque la catégorie est décochée.
assert "rue Claude Boucher" in text
# ---------------------------------------------------------------------------
# RASTER
# ---------------------------------------------------------------------------
def test_raster_address_search_called_when_adresse_enabled(tmp_path, monkeypatch):
if fitz is None:
return
source = _make_address_pdf(tmp_path)
output = tmp_path / "addr.raster.enabled.pdf"
calls = []
real = core._search_pdf_address_lines
def _spy(page):
calls.append(page.number)
return real(page)
monkeypatch.setattr(core, "_search_pdf_address_lines", _spy)
redact_pdf_raster(source, [], output, disabled_kinds=set())
assert calls, "ADRESSE activée (raster) : _search_pdf_address_lines doit être appelé"
def test_raster_address_search_not_applied_when_adresse_disabled(tmp_path, monkeypatch):
if fitz is None:
return
source = _make_address_pdf(tmp_path)
output = tmp_path / "addr.raster.disabled.pdf"
calls = []
real = core._search_pdf_address_lines
def _spy(page):
calls.append(page.number)
return real(page)
monkeypatch.setattr(core, "_search_pdf_address_lines", _spy)
redact_pdf_raster(source, [], output, disabled_kinds={"ADRESSE"})
assert not calls, (
"ADRESSE désactivée (raster) : _search_pdf_address_lines ne doit pas être appliqué"
)
# ---------------------------------------------------------------------------
# Non-régression : signature positionnelle d'origine + défaut byte-for-byte
# ---------------------------------------------------------------------------
def test_vector_default_signature_still_calls_address_search(tmp_path, monkeypatch):
"""Sans disabled_kinds (appel positionnel d'origine), le burn adresse
reste actif — non-régression stricte."""
if fitz is None:
return
source = _make_address_pdf(tmp_path)
output = tmp_path / "addr.default.pdf"
calls = []
real = core._search_pdf_address_lines
def _spy(page):
calls.append(page.number)
return real(page)
monkeypatch.setattr(core, "_search_pdf_address_lines", _spy)
# Appel d'origine : aucun argument disabled.
redact_pdf_vector(source, [PiiHit(0, "OGC", "14", "[OGC]")], output)
assert calls, "Défaut (pas de disabled) : burn adresse doit rester actif"

View File

@@ -0,0 +1,47 @@
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
# Garde de terminaison de la récursion (_GLOBAL strip) : entrées vides.
assert core._category_of(None) is None
assert core._category_of("") is None
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

View File

@@ -0,0 +1,260 @@
"""Plan 1b — Task 3 (P1-2 / F-2 + F-5) : gating TEXTE par catégorie.
Vérifie que, quand une des 7 catégories toggleables est décochée
(``cfg["disabled_kinds"]``), la valeur de cette catégorie ressort EN CLAIR
dans le texte produit, SANS jamais démasquer une autre catégorie encore
activée (pas de fuite croisée) et SANS régression quand rien n'est désactivé.
Entrées RÉELLES fabriquées à partir des vraies regex du moteur (aucun mock).
NIR valide (clé modulo 97) calculé : body 1850578006084 → clé 91.
"""
import re
import anonymizer_core_refactored_onnx as core
# --- Échantillons clairs par catégorie (1 PII de la catégorie cible) ---------
# Chaque échantillon est validé : masqué quand la catégorie est activée.
_SAMPLES = {
"NOM": ("Nom de famille : DUPONT", "DUPONT", "[NOM]"),
"DATE_NAISSANCE": ("Né le 12/03/1950", "12/03/1950", "[DATE_NAISSANCE]"),
"ETAB": ("Etablissement : EHPAD Solemnis", "Solemnis", "[ETABLISSEMENT]"),
"ADRESSE": ("Domicile : 13 rue des Lilas", "rue des Lilas", "[ADRESSE]"),
"NIR": ("NIR 185057800608491", "185057800608491", "[NIR]"),
"TEL": ("Tel : 0612345678", "0612345678", "[TEL]"),
"ADHERENT": ("N° adhérent : ABC123456", "ABC123456", "[ADHERENT]"),
}
# Une catégorie « témoin » différente, toujours activée, dont le placeholder doit
# rester présent (anti-fuite croisée). On choisit NIR comme témoin sauf pour la
# catégorie cible NIR (témoin = TEL).
_WITNESS = {
"NOM": ("NIR 185057800608491", "[NIR]"),
"DATE_NAISSANCE": ("NIR 185057800608491", "[NIR]"),
"ETAB": ("NIR 185057800608491", "[NIR]"),
"ADRESSE": ("NIR 185057800608491", "[NIR]"),
"NIR": ("Tel : 0612345678", "[TEL]"),
"TEL": ("NIR 185057800608491", "[NIR]"),
"ADHERENT": ("NIR 185057800608491", "[NIR]"),
}
_SEVEN = ["NOM", "DATE_NAISSANCE", "ETAB", "ADRESSE", "NIR", "TEL", "ADHERENT"]
def _run(text, disabled):
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = set(disabled)
return core.anonymise_document_regex([text], [], cfg)
import pytest
@pytest.mark.parametrize("cat", _SEVEN)
def test_disabled_category_left_in_clear_witness_masked(cat):
"""La catégorie décochée ressort en clair ; le témoin reste masqué."""
target_line, clear_value, target_ph = _SAMPLES[cat]
witness_line, witness_ph = _WITNESS[cat]
text = target_line + "\n" + witness_line
res = _run(text, {cat})
out = res.text_out
# 1) la valeur de la catégorie décochée doit être EN CLAIR
assert clear_value in out, (
f"{cat} décochée : '{clear_value}' devrait être en clair.\nout={out!r}")
# 2) son placeholder ne doit PAS apparaître
assert target_ph not in out, (
f"{cat} décochée : '{target_ph}' ne devrait pas apparaître.\nout={out!r}")
# 3) le témoin (autre catégorie activée) doit RESTER masqué
assert witness_ph in out, (
f"{cat} décochée : témoin {witness_ph} devrait rester masqué.\nout={out!r}")
@pytest.mark.parametrize("cat", _SEVEN)
def test_enabled_category_still_masked(cat):
"""Avec rien de désactivé, chaque catégorie reste masquée (non-régression)."""
target_line, clear_value, target_ph = _SAMPLES[cat]
res = _run(target_line, set())
assert target_ph in res.text_out, (
f"{cat} activée devrait être masquée.\nout={res.text_out!r}")
def test_one_disabled_all_others_stay_masked():
"""1 catégorie décochée : TOUTES les autres restent masquées (anti-fuite)."""
text = "\n".join(s[0] for s in _SAMPLES.values())
for off in _SEVEN:
res = _run(text, {off})
out = res.text_out
# la catégorie décochée doit être en clair
clear = _SAMPLES[off][1]
assert clear in out, f"{off} décochée devrait être en clair.\nout={out!r}"
# toutes les AUTRES doivent rester masquées
for other in _SEVEN:
if other == off:
continue
ph = _SAMPLES[other][2]
assert ph in out, (
f"{off} décochée NE doit PAS démasquer {other} ({ph}).\nout={out!r}")
def test_baseline_all_enabled_byte_for_byte():
"""disabled vide ⇒ sortie identique à un run sans la clé disabled_kinds."""
text = "\n".join(s[0] for s in _SAMPLES.values())
cfg_a = core.load_dictionaries(None)
cfg_a["disabled_kinds"] = set()
cfg_b = core.load_dictionaries(None) # pas de clé du tout
out_a = core.anonymise_document_regex([text], [], cfg_a).text_out
out_b = core.anonymise_document_regex([text], [], cfg_b).text_out
assert out_a == out_b
# et tout est bien masqué
for _line, _clear, ph in _SAMPLES.values():
assert ph in out_a
# --- selective_rescan : filet de sécurité, doit aussi gater ------------------
@pytest.mark.parametrize("cat,line,clear,ph", [
("TEL", "Joindre au 0612345678", "0612345678", "[TEL]"),
("NIR", "Secu 185057800608491", "185057800608491", "[NIR]"),
("ADRESSE", "13 rue des Lilas ici", "rue des Lilas", "[ADRESSE]"),
("DATE_NAISSANCE", "Né le 12/03/1950", "12/03/1950", "[DATE_NAISSANCE]"),
("ETAB", "Etablissement EHPAD Solemnis", "Solemnis", "[ETABLISSEMENT]"),
("ADHERENT", "N° adhérent : ABC123456", "ABC123456", "[ADHERENT]"),
])
def test_selective_rescan_gates_disabled(cat, line, clear, ph):
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = {cat}
out = core.selective_rescan(line, cfg=cfg)
assert clear in out, f"rescan {cat} décochée : '{clear}' devrait rester clair.\nout={out!r}"
assert ph not in out, f"rescan {cat} décochée : {ph} ne devrait pas apparaître.\nout={out!r}"
def test_selective_rescan_empty_disabled_byte_for_byte():
"""selective_rescan : disabled vide == aucune clé (non-régression)."""
line = ("Joindre au 0612345678, Secu 185057800608491, "
"13 rue des Lilas, Né le 12/03/1950, EHPAD Solemnis")
cfg_none = core.load_dictionaries(None)
cfg_empty = core.load_dictionaries(None)
cfg_empty["disabled_kinds"] = set()
assert core.selective_rescan(line, cfg=cfg_none) == core.selective_rescan(line, cfg=cfg_empty)
def test_selective_rescan_enabled_still_masks():
"""Non-régression rescan : rien désactivé ⇒ masque tout."""
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = set()
line = "Joindre au 0612345678 et Secu 185057800608491"
out = core.selective_rescan(line, cfg=cfg)
assert "[TEL]" in out and "[NIR]" in out
assert "0612345678" not in out and "185057800608491" not in out
# --- NER per-hit (F-5) : _mask_with_hf -------------------------------------
def test_mask_with_hf_per_hit_gating():
"""NOM décoché : l'entité PER ressort en clair, l'ORG (ETAB) reste masquée."""
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = {"NOM"}
text = "Le patient Martin suivi par Hopital Saint-Louis"
ents = [
{"word": "Martin", "entity_group": "PER"},
{"word": "Hopital Saint-Louis", "entity_group": "ORG"},
]
audit = []
out = core._mask_with_hf(text, ents, cfg, audit)
assert "Martin" in out, f"NOM décoché : Martin devrait rester clair.\nout={out!r}"
assert "[NOM]" not in out
assert "[ETABLISSEMENT]" in out, f"ETAB activé devrait être masqué.\nout={out!r}"
def test_mask_with_hf_no_disabled_masks_all():
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = set()
text = "Le patient Martin"
ents = [{"word": "Martin", "entity_group": "PER"}]
out = core._mask_with_hf(text, ents, cfg, [])
assert "[NOM]" in out and "Martin" not in out
# --- NER per-hit (F-5) : _mask_with_eds_pseudo -----------------------------
def test_mask_with_eds_pseudo_per_hit_gating():
"""NOM décoché : entité EDS NOM en clair, HOPITAL (ETAB) reste masquée."""
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = {"NOM"}
text = "Compte rendu Bernardo signe a Belledonne"
ents = [
{"word": "Bernardo", "entity_group": "NOM", "eds_mapped_key": "NOM", "score": 0.99},
{"word": "Belledonne", "entity_group": "HOPITAL", "eds_mapped_key": "ETAB", "score": 0.99},
]
out = core._mask_with_eds_pseudo(text, ents, cfg, [])
assert "Bernardo" in out, f"NOM décoché : Bernardo devrait rester clair.\nout={out!r}"
assert "[NOM]" not in out
assert "[ETABLISSEMENT]" in out, f"ETAB activé devrait être masqué.\nout={out!r}"
# --- VLM per-hit (F-2) : _apply_vlm gating helper --------------------------
def test_vlm_kind_gating_is_per_hit():
"""Le gating VLM s'évalue par hit via _category_of(kind)."""
import vlm_manager
# NOM décoché : VLM_NOM doit être filtré, VLM_ETAB conservé.
nom_kind, _ = vlm_manager.VLM_CATEGORY_MAP["NOM"]
etab_kind, _ = vlm_manager.VLM_CATEGORY_MAP["ETABLISSEMENT"]
assert core._category_of(nom_kind) == "NOM"
assert core._category_of(etab_kind) == "ETAB"
# === Régression AUDIT-LEVEL (revue qualité : fuite PDF FAX avec TEL décoché) ===
# Le burn PDF (vector+raster) dérive UNIQUEMENT de anon.audit. Un type non
# toggleable dont l'unique site de détection tombait dans (ou en aval d')un bloc
# gaté ne produisait plus de hit audit → numéro VISIBLE dans le PDF livré, même
# si le .txt paraissait propre. Ces tests assertent sur anon.audit, pas le texte.
def _audit_kinds(text, disabled):
"""Lance le constructeur d'audit (anonymise_document_regex) et renvoie les hits."""
cfg = core.load_dictionaries(None)
cfg["disabled_kinds"] = set(disabled)
return core.anonymise_document_regex([text], [], cfg).audit
def _has_hit(audit, kind, placeholder=None):
for h in audit:
if h.kind == kind and (placeholder is None or h.placeholder == placeholder):
return True
return False
@pytest.mark.parametrize("line,fax_value", [
("Fax : 0512345678", "0512345678"),
("Télécopie : 05 12 34 56 78", "05 12 34 56 78"),
("Télécopieur : 0512345678", "0512345678"),
])
def test_fax_audit_hit_survives_tel_disabled(line, fax_value):
"""FAX (non toggleable) DOIT rester dans anon.audit quand TEL est décoché.
C'est le test qui échouait avant le correctif de découplage FAX (fuite PDF)."""
audit = _audit_kinds(line, {"TEL"})
# Un hit FAX doit exister (kind ET placeholder), pour que le burn PDF le masque.
assert _has_hit(audit, "FAX", core.PLACEHOLDERS["FAX"]), (
f"FAX absent de l'audit avec TEL décoché → fuite PDF.\n"
f"line={line!r}\naudit={[(h.kind, h.original) for h in audit]}")
# La valeur ne doit pas survivre déguisée en hit TEL non plus.
assert not _has_hit(audit, "TEL"), "Un fax ne doit pas devenir un hit TEL."
def test_fax_audit_hit_present_when_nothing_disabled():
"""Non-régression : FAX produit bien un hit audit sur le chemin par défaut."""
audit = _audit_kinds("Fax : 0512345678", set())
assert _has_hit(audit, "FAX", core.PLACEHOLDERS["FAX"])
def test_tel_audit_hit_dropped_when_tel_disabled():
"""Cohérence : un vrai TÉLÉPHONE (toggleable) sort bien de l'audit si TEL décoché."""
audit = _audit_kinds("Tel : 0612345678", {"TEL"})
assert not _has_hit(audit, "TEL"), "TEL décoché ⇒ pas de hit TEL (numéro laissé clair)."
@pytest.mark.parametrize("off", ["NOM", "ADRESSE", "NIR", "ADHERENT", "ETAB", "DATE_NAISSANCE"])
def test_fax_audit_survives_any_unrelated_toggle(off):
"""Général : le non toggleable FAX reste dans l'audit quel que soit le toggle décoché."""
audit = _audit_kinds("Fax : 0512345678", {off})
assert _has_hit(audit, "FAX", core.PLACEHOLDERS["FAX"]), (
f"FAX absent de l'audit avec {off} décoché.\n"
f"audit={[(h.kind, h.original) for h in audit]}")

View File

@@ -0,0 +1,76 @@
"""Anti-fuite PDF : un masque FORCÉ (override utilisateur / blacklist force-mask)
ne doit JAMAIS être retiré de l'audit par un toggle de catégorie.
Contexte (Plan 1b, P1-2/T1) :
- `_apply_overrides` masque le TEXTE inline ET ajoute un `PiiHit` dont le `kind`
est contrôlé par l'utilisateur (`name` de l'override). Cet appel est
inconditionnel (pas gaté par `disabled_kinds`).
- `_filter_audit_by_disabled` retire ensuite de l'audit les hits dont la
catégorie est désactivée, AVANT la gravure PDF.
- BUG : si un utilisateur nomme un override avec une catégorie toggleable
(ex. `name="NOM"`) et désactive cette catégorie, le texte reste masqué mais
le hit est retiré de l'audit → la gravure PDF laisse la valeur EN CLAIR.
Correctif attendu : marquer les hits forcés (`forced=True`) et les exempter du
filtre catégorie. Un terme explicitement forcé est TOUJOURS gravé.
"""
import re
import anonymizer_core_refactored_onnx as core
def test_forced_override_hit_survives_category_filter():
"""Un override nommé "NOM" produit un hit FORCÉ qui survit au filtre {"NOM"}."""
cfg = {
"regex_overrides": [
{"pattern": r"\bDupont\b", "placeholder": "[NOM]", "name": "NOM"},
],
}
audit: list = []
line = "Patient Dupont vu ce jour."
masked = core._apply_overrides(line, audit, 0, cfg)
# Le texte est bien masqué (comportement inline inchangé).
assert "Dupont" not in masked
assert "[NOM]" in masked
# Un hit a été produit, de catégorie NOM, et marqué forcé.
assert len(audit) == 1
forced_hit = audit[0]
assert forced_hit.kind == "NOM"
assert core._category_of(forced_hit.kind) == "NOM"
assert getattr(forced_hit, "forced", False) is True
# Cœur du correctif : avec NOM désactivé, le hit FORCÉ reste dans l'audit
# (donc serait gravé dans le PDF) → pas de fuite.
filtered = core._filter_audit_by_disabled(list(audit), {"NOM"})
assert forced_hit in filtered, "le hit forcé a été retiré → fuite PDF"
def test_genuine_nom_hit_still_dropped_by_filter():
"""Le correctif ne sur-exempte pas : un vrai hit NOM (non forcé) est bien retiré."""
genuine = core.PiiHit(0, "NOM", "Martin", "[NOM]")
# Par défaut un PiiHit n'est PAS forcé.
assert getattr(genuine, "forced", False) is False
filtered = core._filter_audit_by_disabled([genuine], {"NOM"})
assert genuine not in filtered, "un hit NOM non forcé doit être retiré quand NOM est désactivé"
def test_forced_blacklist_terms_marked_forced():
"""Les force_mask_terms / force_mask_regex sont aussi marqués forcés."""
cfg = {
"blacklist": {
"force_mask_terms": ["CHUXX"],
"force_mask_regex": [r"SIGLE-\d+"],
},
}
audit: list = []
line = "Etablissement CHUXX, code SIGLE-42."
core._apply_overrides(line, audit, 0, cfg)
assert len(audit) == 2
for h in audit:
assert getattr(h, "forced", False) is True, f"{h.kind} non marqué forcé"

View File

@@ -0,0 +1,121 @@
"""Tests du câblage des 7 toggles « Données à détecter » → moteur (Plan 1b / P1-2).
Sémantique UI : un toggle ON = « détecter cette catégorie » (= masquer).
Un toggle OFF = la catégorie est laissée en clair → elle entre dans
``disabled_kinds`` (set des CATÉGORIES désactivées passé au moteur).
Aucun widget, aucun display : on teste l'état (ConfigState) et le pont
(build_engine_kwargs / make_process_fn) en pur Python.
"""
from __future__ import annotations
from pathlib import Path
from gui_v6.config_state import (
CATEGORY_FIELDS,
DETECTION_CATEGORIES,
ConfigState,
)
from gui_v6.engine_bridge import (
EngineSettings,
NerManagers,
build_engine_kwargs,
make_process_fn,
)
# -- catégories canoniques -------------------------------------------------
def test_seven_categories_match_engine_set():
# Les 7 catégories exposées doivent matcher EXACTEMENT le set moteur.
assert set(DETECTION_CATEGORIES) == {
"NOM",
"DATE_NAISSANCE",
"ETAB",
"ADRESSE",
"NIR",
"TEL",
"ADHERENT",
}
# Un champ booléen par catégorie.
assert set(CATEGORY_FIELDS.values()) == set(DETECTION_CATEGORIES)
# -- disabled_kinds dérivé -------------------------------------------------
def test_disabled_kinds_empty_by_default():
# Défaut : tous les toggles ON ⇒ aucun désactivé (zéro changement vs aujourd'hui).
state = ConfigState()
assert state.disabled_kinds() == frozenset()
def test_disabled_kinds_unchecking_nir_and_etab():
# Décocher « N° sécurité sociale » (NIR) et « Établissements » (ETAB).
state = ConfigState(detect_nir=False, detect_etab=False)
assert state.disabled_kinds() == frozenset({"NIR", "ETAB"})
def test_disabled_kinds_all_off():
state = ConfigState(
detect_nom=False,
detect_date_naissance=False,
detect_etab=False,
detect_adresse=False,
detect_nir=False,
detect_tel=False,
detect_adherent=False,
)
assert state.disabled_kinds() == frozenset(DETECTION_CATEGORIES)
# -- propagation vers EngineSettings --------------------------------------
def test_to_engine_settings_propagates_disabled_kinds():
state = ConfigState(detect_nir=False, detect_tel=False)
settings = state.to_engine_settings()
assert settings.disabled_kinds == frozenset({"NIR", "TEL"})
def test_to_engine_settings_default_empty():
settings = ConfigState().to_engine_settings()
assert settings.disabled_kinds == frozenset()
# -- propagation dans les kwargs moteur -----------------------------------
def test_build_engine_kwargs_includes_disabled_kinds():
settings = EngineSettings(disabled_kinds=frozenset({"NIR", "ETAB"}))
kwargs = build_engine_kwargs(settings, managers=None)
assert kwargs["disabled_kinds"] == frozenset({"NIR", "ETAB"})
def test_build_engine_kwargs_default_empty_disabled_kinds():
# Défaut (set vide) = no-op : la clé est présente mais vide.
kwargs = build_engine_kwargs(EngineSettings(), managers=None)
assert kwargs["disabled_kinds"] == frozenset()
def test_process_fn_threads_disabled_kinds_to_engine(tmp_path):
settings = EngineSettings(
use_local_ner=False, disabled_kinds=frozenset({"ADRESSE"})
)
managers = NerManagers(settings)
captured = {}
def fake_engine(doc_path, out_dir, **kwargs):
captured["kwargs"] = kwargs
return {"status": "ok"}
fn = make_process_fn(settings, managers=managers, engine=fake_engine)
fn(tmp_path / "doc.pdf", tmp_path / "out")
assert captured["kwargs"]["disabled_kinds"] == frozenset({"ADRESSE"})
# -- bout-en-bout : ConfigState → settings → kwargs -----------------------
def test_end_to_end_state_to_kwargs(tmp_path):
state = ConfigState(detect_adherent=False)
settings = state.to_engine_settings(config_path=Path("/tmp/c.yml"))
kwargs = build_engine_kwargs(settings, managers=None)
assert kwargs["disabled_kinds"] == frozenset({"ADHERENT"})

View File

@@ -55,10 +55,26 @@ def test_config_interaction_contract_prebuilds_panels_and_mask_editor():
] ]
def test_detection_options_fields_match_category_fields():
"""Garde-fou anti-dérive : les champs déclarés dans _DETECTION_OPTIONS doivent
rester alignés (mêmes champs ET même ordre) sur CATEGORY_FIELDS, sinon un
toggle pointerait vers un attribut ConfigState inexistant (AttributeError au
lancement de la GUI au lieu d'un échec de test)."""
from gui_v6.config_state import CATEGORY_FIELDS, ConfigState
fields = [field for _l, _h, field in _DETECTION_OPTIONS]
assert fields == list(CATEGORY_FIELDS) # mêmes champs ET même ordre (ordre UI = ordre catégories)
for f in fields: # chacun est bien un booléen réel de ConfigState
assert isinstance(getattr(ConfigState(), f), bool)
def test_detection_rows_are_readable_in_light_theme(): def test_detection_rows_are_readable_in_light_theme():
"""Retour Dom : les sous-labels de la colonne détection doivent rester lisibles.""" """Retour Dom : les sous-labels de la colonne détection doivent rester lisibles."""
assert ("Noms et prénoms", "Annuaire + IA") in _DETECTION_OPTIONS # Chaque ligne est désormais (libellé, aide, champ ConfigState) ; on ne
assert ("Noms et prénoms", "Bases de données + IA") not in _DETECTION_OPTIONS # vérifie ici que le couple (libellé, aide) reste lisible.
label_hint = [(label, hint) for label, hint, _field in _DETECTION_OPTIONS]
assert ("Noms et prénoms", "Annuaire + IA") in label_hint
assert ("Noms et prénoms", "Bases de données + IA") not in label_hint
assert MINI_TOGGLE_HEIGHT >= 44 assert MINI_TOGGLE_HEIGHT >= 44
assert MINI_TOGGLE_LABEL_FONT_SIZE >= 12 assert MINI_TOGGLE_LABEL_FONT_SIZE >= 12
assert MINI_TOGGLE_HINT_FONT_SIZE >= 11 assert MINI_TOGGLE_HINT_FONT_SIZE >= 11

View File

@@ -182,6 +182,138 @@ class TestRescanQuarantine:
assert mgr.has_full_quarantine("doc_leak") assert mgr.has_full_quarantine("doc_leak")
# === Tests F4 : patterns résiduels gated par catégorie désactivée ===
class TestResidualPatternsGating:
"""F-4 (P1-2) — `_build_residual_patterns(disabled)` : une catégorie
décochée ne doit pas déclencher la quarantaine résiduelle, ni directement,
ni via le pattern résiduel d'une autre catégorie (piège NIR ⇄ TEL)."""
def _labels(self, patterns):
return {label for _pat, label in patterns}
@staticmethod
def _residual_count(text, disabled):
"""Reproduit EXACTEMENT le calcul du call-site (process_pdf) :
seul le scan TEL voit le texte pré-masqué ; EMAIL/IBAN/NIR voient
le texte ORIGINAL."""
from anonymizer_core_refactored_onnx import (
_build_residual_patterns,
_residual_premask_text,
)
patterns = _build_residual_patterns(disabled)
tel_text = _residual_premask_text(text, disabled)
total = 0
for pat, label in patterns:
scan = tel_text if label == "TEL" else text
total += len(pat.findall(scan))
return total
def test_default_set_includes_all_labels(self) -> None:
"""Aucune catégorie désactivée → NIR, EMAIL, IBAN, TEL tous présents."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
labels = self._labels(_build_residual_patterns(set()))
assert {"NIR", "EMAIL", "IBAN", "TEL"}.issubset(labels)
def test_nir_disabled_drops_nir_keeps_others(self) -> None:
"""NIR décoché → NIR absent, EMAIL/IBAN toujours présents."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
labels = self._labels(_build_residual_patterns({"NIR"}))
assert "NIR" not in labels
assert "EMAIL" in labels
assert "IBAN" in labels
def test_tel_disabled_drops_tel(self) -> None:
"""TEL décoché → TEL absent."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
labels = self._labels(_build_residual_patterns({"TEL"}))
assert "TEL" not in labels
def test_nir_disabled_tel_does_not_match_nir_in_clear(self) -> None:
"""Piège F-4 : NIR décoché laissé en clair → le pré-masquage SCOPÉ-TEL
empêche le pattern TEL de matcher le bloc central de chiffres du NIR.
Le NIR-pattern est retiré du set et EMAIL/IBAN ne matchent pas des
chiffres nus → décompte résiduel global == 0 pour ce NIR en clair."""
from anonymizer_core_refactored_onnx import (
_build_residual_patterns,
_residual_premask_text,
)
nir_en_clair = "1 85 05 74 123 456 78"
disabled = {"NIR"}
# Le pattern TEL appliqué au texte pré-masqué → 0 match.
patterns = _build_residual_patterns(disabled)
tel_pat = next(pat for pat, label in patterns if label == "TEL")
premasked = _residual_premask_text(nir_en_clair, disabled)
assert tel_pat.findall(premasked) == []
# Décompte résiduel global (logique call-site, TEL-scopé) == 0.
total = self._residual_count(nir_en_clair, disabled)
assert total == 0, (
f"NIR décoché ne doit pas déclencher la quarantaine, "
f"or {total} match(s) résiduel(s) sur {nir_en_clair!r}"
)
def test_nir_disabled_clear_iban_still_matches(self) -> None:
"""Fix 1 (régression) : le pré-masquage NIR est SCOPÉ au seul scan TEL.
Un IBAN en clair, avec NIR décoché, DOIT toujours déclencher le filet
IBAN résiduel — le pré-masquage ne doit PAS effacer ses groupes de
chiffres (sinon le backstop IBAN, toujours actif, serait affaibli)."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
iban_clair = "FR76 3000 1007 9412 3456 7890 185"
disabled = {"NIR"}
# Le pattern IBAN (scanné sur le texte ORIGINAL) matche toujours.
patterns = _build_residual_patterns(disabled)
iban_pat = next(pat for pat, label in patterns if label == "IBAN")
assert iban_pat.findall(iban_clair), "le filet IBAN doit rester actif"
# Décompte résiduel global (logique call-site) ≥ 1.
total = self._residual_count(iban_clair, disabled)
assert total >= 1, (
f"IBAN en clair doit déclencher la quarantaine même NIR décoché, "
f"or {total} match(s)"
)
def test_residual_threshold_is_strict_zero_regardless_of_disabled(self) -> None:
"""Fix 2 (régression) : le seuil résiduel reste STRICT (0)
inconditionnellement. Un EMAIL en clair → 1 résidu, et 1 > 0 ⇒
quarantaine, même avec des catégories décochées (pas de relâchement
à 1 qui laisserait passer une fuite EMAIL/IBAN)."""
from anonymizer_core_refactored_onnx import SEUIL_RESCAN_RESIDUEL
assert SEUIL_RESCAN_RESIDUEL == 0
email_clair = "a@b.fr"
# Une catégorie est décochée mais le seuil effectif reste 0.
for disabled in (set(), {"NIR"}, {"NIR", "TEL"}):
total = self._residual_count(email_clair, disabled)
assert total == 1, (disabled, total)
# 1 résidu > seuil strict (0) ⇒ quarantaine déclenchée.
assert total > SEUIL_RESCAN_RESIDUEL
def test_nir_enabled_tel_behavior_unchanged(self) -> None:
"""Non-régression : NIR activé → le pré-masquage est l'identité et
un vrai téléphone est toujours détecté par le pattern TEL."""
from anonymizer_core_refactored_onnx import (
_build_residual_patterns,
_residual_premask_text,
)
tel = "06 12 34 56 78"
patterns = _build_residual_patterns(set())
text = _residual_premask_text(tel, set())
assert text == tel # identité quand rien n'est désactivé
tel_pat = next(pat for pat, label in patterns if label == "TEL")
assert tel_pat.findall(tel), "un vrai téléphone doit rester détecté"
# === Tests A : INDEX.md et errors.log =========================== # === Tests A : INDEX.md et errors.log ===========================
class TestQuarantineArtifacts: class TestQuarantineArtifacts: