Compare commits
9 Commits
c77082409d
...
4357a58d7d
| Author | SHA1 | Date | |
|---|---|---|---|
| 4357a58d7d | |||
| 5663966938 | |||
| bf832e12f0 | |||
| daec1f53bd | |||
| a02bca516d | |||
| dd392c4a50 | |||
| 2a3aab117d | |||
| b15d0da141 | |||
| c93dc34a70 |
@@ -36,7 +36,7 @@ for _env in ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS",
|
||||
log = logging.getLogger(__name__)
|
||||
from dataclasses import dataclass, field
|
||||
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:
|
||||
@@ -609,6 +609,89 @@ PLACEHOLDERS = {
|
||||
|
||||
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
|
||||
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)
|
||||
@@ -786,6 +869,88 @@ def validate_nir(nir_raw: str) -> bool:
|
||||
return False
|
||||
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.
|
||||
# Source de vérité externalisée dans data/stopwords_manuels.txt + BDPM/edsnlp.
|
||||
_MEDICAL_STOP_WORDS_FALLBACK = {
|
||||
@@ -1170,6 +1335,10 @@ class PiiHit:
|
||||
original: str
|
||||
placeholder: str
|
||||
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
|
||||
class AnonResult:
|
||||
@@ -1608,7 +1777,7 @@ def _apply_overrides(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict[st
|
||||
except Exception:
|
||||
continue
|
||||
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
|
||||
line = rx.sub(_rep, line)
|
||||
# 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
|
||||
word_rx = re.compile(rf"\b{re.escape(term)}\b", re.IGNORECASE)
|
||||
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)
|
||||
# force-mask regex
|
||||
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:
|
||||
continue
|
||||
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"]
|
||||
line = rx.sub(_repl_force_regex, 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:
|
||||
# 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
|
||||
def _repl_email(m: re.Match) -> str:
|
||||
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
|
||||
audit.append(PiiHit(page_idx, "NIR", raw, 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
|
||||
# possible sans la clé ; l'ancre label suffit à éviter les faux positifs).
|
||||
def _repl_nir_no_key(m: re.Match) -> str:
|
||||
val = m.group(1)
|
||||
audit.append(PiiHit(page_idx, "NIR", 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)
|
||||
|
||||
# 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:
|
||||
audit.append(PiiHit(page_idx, "TEL", m.group(0), 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.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:
|
||||
audit.append(PiiHit(page_idx, "DATE_NAISSANCE", m.group(0), 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]
|
||||
def _repl_date_naissance_annee(m: re.Match) -> str:
|
||||
val = m.group(1)
|
||||
audit.append(PiiHit(page_idx, "DATE_NAISSANCE", 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)
|
||||
|
||||
# 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"]
|
||||
# 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:
|
||||
audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"]))
|
||||
return PLACEHOLDERS["ADRESSE"]
|
||||
line = RE_ADRESSE.sub(_repl_adresse, line)
|
||||
|
||||
# BOITE POSTALE (BP)
|
||||
def _repl_bp(m: re.Match) -> str:
|
||||
audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"]))
|
||||
return PLACEHOLDERS["ADRESSE"]
|
||||
line = RE_BP.sub(_repl_bp, line)
|
||||
|
||||
# CODE_POSTAL
|
||||
def _repl_code_postal(m: re.Match) -> str:
|
||||
audit.append(PiiHit(page_idx, "CODE_POSTAL", m.group(0), 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)
|
||||
|
||||
# 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:
|
||||
audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"]))
|
||||
return PLACEHOLDERS["ADRESSE"]
|
||||
if "ADRESSE" not in disabled:
|
||||
line = RE_ADRESSE_LIEU_DIT.sub(_repl_lieu_dit, line)
|
||||
|
||||
# Lieux-dits courants seuls sur une ligne (ex: "Le BOURG", "Le Village")
|
||||
line = RE_LIEU_DIT_SEUL.sub(
|
||||
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"]))
|
||||
full = m.group(0)
|
||||
return full[:full.find(val)] + PLACEHOLDERS["ADHERENT"]
|
||||
if "ADHERENT" not in disabled:
|
||||
line = RE_NUM_ADHERENT.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:
|
||||
audit.append(PiiHit(page_idx, "ETAB", m.group(0), PLACEHOLDERS["ETAB"]))
|
||||
return PLACEHOLDERS["ETAB"]
|
||||
if "ETAB" not in disabled:
|
||||
line = RE_ETABLISSEMENT.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"]))
|
||||
|
||||
# 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)
|
||||
for matched_addr in addr_matched:
|
||||
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",
|
||||
"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:
|
||||
# Vérifier si au moins un segment contient un mot-clé d'établissement
|
||||
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
|
||||
audit.append(PiiHit(page_idx, "ETAB", full_match, PLACEHOLDERS["MASK"]))
|
||||
return PLACEHOLDERS["MASK"]
|
||||
if "ETAB" not in disabled:
|
||||
line = RE_SERVICE.sub(_repl_service, line)
|
||||
|
||||
# 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"]))
|
||||
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
|
||||
def _repl_civilite_init(m: re.Match) -> str:
|
||||
prefix = m.group(1)
|
||||
lettre = m.group(2)
|
||||
audit.append(PiiHit(page_idx, "NOM", lettre, 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)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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.
|
||||
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:
|
||||
audit.append(PiiHit(page_idx, "TEL", m.group(0), PLACEHOLDERS["TEL"]))
|
||||
return PLACEHOLDERS["TEL"]
|
||||
if "TEL" not in disabled:
|
||||
key = RE_TEL_SLASH.sub(_repl_tel, key)
|
||||
key = RE_TEL.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:
|
||||
audit.append(PiiHit(page_idx, "ADRESSE", m.group(0), PLACEHOLDERS["ADRESSE"]))
|
||||
return PLACEHOLDERS["ADRESSE"]
|
||||
key = RE_ADRESSE.sub(_repl_adresse, key)
|
||||
# CODE_POSTAL (inclut la ville)
|
||||
def _repl_cp(m: re.Match) -> str:
|
||||
audit.append(PiiHit(page_idx, "CODE_POSTAL", m.group(0), 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)
|
||||
# FINESS adresses Aho-Corasick
|
||||
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:]
|
||||
|
||||
|
||||
def _mask_structured_line(line: str, audit: List[PiiHit], page_idx: int) -> str:
|
||||
"""Masque les champs structurés dont la détection dépend du libellé de la ligne."""
|
||||
def _mask_structured_line(line: str, audit: List[PiiHit], page_idx: int,
|
||||
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:
|
||||
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"]))
|
||||
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_NUMERO_DOSSIER.sub(_repl_dossier, 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_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_PRENOM.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_MODIFIED_BY_NOM.sub(_repl_label_with_placeholder("NOM_FORCE", "NOM"), 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)
|
||||
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:
|
||||
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)
|
||||
structured_line = _mask_structured_line(line, audit, page_idx)
|
||||
structured_line = _mask_structured_line(line, audit, page_idx, disabled)
|
||||
if structured_line != line:
|
||||
return structured_line
|
||||
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`.
|
||||
if len(parts) == 2 and parts[1].strip() and len(parts[0].split()) <= 5:
|
||||
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)
|
||||
return f"{masked_key.strip()} : {masked_val.strip()}"
|
||||
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
|
||||
|
||||
|
||||
def _apply_extracted_names(text: str, names: set, audit: List[PiiHit], force_names: set = None) -> str:
|
||||
"""Remplace globalement chaque nom extrait dans le texte."""
|
||||
def _apply_extracted_names(text: str, names: set, audit: List[PiiHit], force_names: set = None,
|
||||
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"]
|
||||
_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")
|
||||
if 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
|
||||
replacements: Dict[str, str] = {} # original → placeholder
|
||||
for h in audit:
|
||||
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
|
||||
# Remplacer les plus longs d'abord (éviter les remplacements partiels)
|
||||
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):
|
||||
cfg = dict(cfg)
|
||||
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)
|
||||
|
||||
# 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})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if "DATE_NAISSANCE" not in disabled:
|
||||
for m in _RE_DATE_NAISSANCE_MULTILINE.finditer(full_raw):
|
||||
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",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if "NIR" not in disabled:
|
||||
for m in _RE_NIR_NO_KEY_MULTILINE.finditer(full_raw):
|
||||
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:
|
||||
audit.append(PiiHit(_page, "ETAB", m.group(0), PLACEHOLDERS["ETAB"]))
|
||||
return PLACEHOLDERS["ETAB"]
|
||||
if "ETAB" not in disabled:
|
||||
page_txt = RE_ETAB_LINEBREAK.sub(_repl_etab_linebreak, page_txt)
|
||||
|
||||
def _repl_iao_multiline(m: re.Match, _page=i) -> str:
|
||||
value = m.group(2).strip()
|
||||
audit.append(PiiHit(_page, "NOM_FORCE", value, 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)
|
||||
|
||||
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)
|
||||
# Utilise all_names (validé par NER-first si disponible, sinon extracted_names original)
|
||||
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)
|
||||
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:
|
||||
# remplace via regex sur les 'word' détectés (approche pragmatique)
|
||||
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:
|
||||
return re.sub(rf"\b{re.escape(old)}\b", new, s)
|
||||
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
|
||||
continue
|
||||
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"]))
|
||||
out = repl_once(out, w, PLACEHOLDERS["NOM"])
|
||||
elif grp in {"ORG"}:
|
||||
if keep_org_gpe:
|
||||
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"]))
|
||||
out = repl_once(out, w, PLACEHOLDERS["ETAB"])
|
||||
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:
|
||||
"""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:
|
||||
return re.sub(rf"\b{re.escape(old)}\b", new, s)
|
||||
out = text
|
||||
@@ -3278,6 +3530,9 @@ def _mask_with_eds_pseudo(text: str, ents: List[Dict[str, Any]], cfg: Dict[str,
|
||||
continue
|
||||
if w.upper() in _STRUCTURAL_WORDS:
|
||||
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"])
|
||||
audit.append(PiiHit(-1, f"EDS_{label}", 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:
|
||||
"""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
|
||||
def strip_tables(s: str):
|
||||
kept = []
|
||||
@@ -4184,10 +4444,12 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
|
||||
# espacé soit consommé par RE_TEL.
|
||||
def _rescan_nir(m: re.Match) -> str:
|
||||
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_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].
|
||||
protected = RE_FAX.sub(PLACEHOLDERS["FAX"], protected)
|
||||
if "TEL" not in disabled:
|
||||
protected = RE_TEL_SLASH.sub(PLACEHOLDERS["TEL"], protected)
|
||||
protected = RE_TEL.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_ADELI.sub(PLACEHOLDERS["ADELI"], protected)
|
||||
protected = RE_OGC.sub(PLACEHOLDERS["OGC"], protected)
|
||||
if "ADHERENT" not in disabled:
|
||||
protected = RE_NUM_ADHERENT.sub(PLACEHOLDERS["ADHERENT"], protected)
|
||||
protected = RE_NUM_MUTUELLE.sub(PLACEHOLDERS["ADHERENT"], protected)
|
||||
# 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.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:
|
||||
if m.group(1):
|
||||
return _replace_captured_value(m.group(0), m.group(1), 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)
|
||||
# N° Episode
|
||||
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:
|
||||
return PLACEHOLDERS["FINESS"] if m.group(1).upper() in _FINESS_NUMBERS else m.group(0)
|
||||
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_HOPITAL_VILLE.sub(PLACEHOLDERS["ETAB"], protected)
|
||||
# Établissements (gazetteer Aho-Corasick FINESS — 116K noms distinctifs)
|
||||
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]
|
||||
_re_spaced = re.compile(r'(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ]\s){4,}[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇÑ]')
|
||||
_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()
|
||||
if any(kw in collapsed for kw in _spaced_kw):
|
||||
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)
|
||||
if _VILLE_AC is None:
|
||||
_build_ville_ac()
|
||||
if _VILLE_AC is not None:
|
||||
protected, _ = _mask_ville_gazetteers(protected)
|
||||
# Services hospitaliers
|
||||
# Services hospitaliers → catégorie ETAB.
|
||||
if "ETAB" not in disabled:
|
||||
protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected)
|
||||
# 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.+)")
|
||||
@@ -4272,6 +4540,7 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str:
|
||||
if not clean:
|
||||
return raw
|
||||
return raw.replace(span, PLACEHOLDERS["NOM"])
|
||||
if "NOM" not in disabled:
|
||||
protected = RE_PERSON_CONTEXT.sub(_rescan_person, protected)
|
||||
# Mr/Mme + initiale isolée : "Mme Z", "Mr R" → masquer
|
||||
protected = RE_CIVILITE_INITIALE.sub(
|
||||
@@ -4551,9 +4820,14 @@ def _apply_pseudo_xmp_metadata(doc) -> None:
|
||||
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:
|
||||
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))
|
||||
# index hits par page; page==-1 → rechercher sur toutes pages
|
||||
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)
|
||||
# Kinds à ne pas chercher dans le PDF (dates masquées uniquement dans le texte,
|
||||
# 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"}
|
||||
# Kinds sensibles au substring matching : utiliser _search_whole_word
|
||||
_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)):
|
||||
page = doc[pno]
|
||||
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:
|
||||
continue
|
||||
# Dédupliquer les tokens : (token, kind) → rechercher une seule fois par page
|
||||
@@ -4668,6 +4953,13 @@ def _rasterize_page(args):
|
||||
if rx1 > rx0:
|
||||
draw.rectangle([rx0, ry0, rx1, ry1], fill=(0, 0, 0))
|
||||
# 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:
|
||||
rx0 = x0 * zoom
|
||||
ry0 = y0 * zoom
|
||||
@@ -4715,11 +5007,18 @@ def _rasterize_page(args):
|
||||
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:
|
||||
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))
|
||||
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"}
|
||||
# Kinds sensibles au substring matching : utiliser _search_whole_word
|
||||
_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))
|
||||
all_rects[pno] = rects
|
||||
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:
|
||||
token = h.original.strip()
|
||||
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 -----------------
|
||||
|
||||
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é.
|
||||
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
|
||||
doc = fitz.open(str(pdf_path))
|
||||
# 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:
|
||||
continue
|
||||
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"])
|
||||
|
||||
if cat in _SPLIT_CATS:
|
||||
@@ -4984,6 +5293,7 @@ def process_pdf(
|
||||
gliner_manager=None,
|
||||
camembert_manager=None,
|
||||
quarantine_mgr: Optional["QuarantineManager"] = None,
|
||||
disabled_kinds: Optional[Set[str]] = None,
|
||||
) -> Dict[str, str]:
|
||||
perf_t0 = time.perf_counter()
|
||||
last_mark = perf_t0
|
||||
@@ -5000,6 +5310,10 @@ def process_pdf(
|
||||
_log_env_banner()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
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")
|
||||
pages_text, tables_lines, ocr_used, ocr_word_map = extract_text_with_fallback_ocr(pdf_path)
|
||||
_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:
|
||||
try:
|
||||
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")
|
||||
except Exception:
|
||||
pass # dégradation gracieuse
|
||||
|
||||
# 2) NER (optionnel) — sur le narratif
|
||||
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] = []
|
||||
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
|
||||
@@ -5097,6 +5415,7 @@ def process_pdf(
|
||||
return m.group(0)
|
||||
anon.audit.append(PiiHit(-1, "NOM_GLOBAL", tok, 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)
|
||||
|
||||
# 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):
|
||||
anon.audit.append(PiiHit(-1, "CODE_POSTAL", m.group(2), 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)
|
||||
|
||||
# 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"]))
|
||||
return PLACEHOLDERS["TEL"] + "\n"
|
||||
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
|
||||
_re_tel_partial = re.compile(r"(?<!\d)((?:\+33\s?|0)\d(?:[ .-]?\d){5,7})(?!\d)\s*$", re.MULTILINE)
|
||||
def _clean_tel_partial(m):
|
||||
@@ -5125,6 +5443,8 @@ def process_pdf(
|
||||
anon.audit.append(PiiHit(-1, "TEL", m.group(0).strip(), PLACEHOLDERS["TEL"]))
|
||||
return PLACEHOLDERS["TEL"]
|
||||
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)
|
||||
|
||||
# 3c) Initiales identifiantes devant [NOM] : "Dr T. [NOM]" → "Dr [NOM] [NOM]"
|
||||
@@ -5134,7 +5454,6 @@ def process_pdf(
|
||||
def _clean_initial_before_nom(m):
|
||||
anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(1) + ".", PLACEHOLDERS["NOM"]))
|
||||
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]"
|
||||
_RE_REF_INITIALS = re.compile(
|
||||
@@ -5145,6 +5464,8 @@ def process_pdf(
|
||||
anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(2), PLACEHOLDERS["NOM"]))
|
||||
prefix = m.group(0)[:m.group(0).index(m.group(1))]
|
||||
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)
|
||||
|
||||
# 3e) Layout BACTERIO résiduel : le numéro de venue peut survivre s'il est
|
||||
@@ -5297,6 +5618,11 @@ def process_pdf(
|
||||
continue
|
||||
if h.kind in _GLOBAL_SKIP_KINDS:
|
||||
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()
|
||||
if not token or len(token) < 4:
|
||||
continue
|
||||
@@ -5450,15 +5776,22 @@ def process_pdf(
|
||||
# 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).
|
||||
if SEUIL_RESCAN_RESIDUEL is not None:
|
||||
_residual_pii_patterns = [
|
||||
(re.compile(RE_NIR.pattern if hasattr(RE_NIR, 'pattern') else r"\b\d{15}\b"), "NIR"),
|
||||
(re.compile(r"\b[\w.%+-]+@[\w.-]+\.\w{2,}\b"), "EMAIL"),
|
||||
(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"),
|
||||
(re.compile(r"\b(?:\+33|0)[\s.\-]?\d[\s.\-]?(?:\d[\s.\-]?){8}\b"), "TEL"),
|
||||
]
|
||||
# Plan 1b (P1-2/F-4) — patterns gatés par catégorie décochée.
|
||||
# disabled_kinds contient des noms de CATÉGORIE (les 7 toggles).
|
||||
_rescan_disabled = cfg.get("disabled_kinds") or set()
|
||||
_residual_pii_patterns = _build_residual_patterns(_rescan_disabled)
|
||||
# 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
|
||||
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.
|
||||
# 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-
|
||||
# masquage et qu'on veut zéro fuite (ex: profil "paranoid").
|
||||
# 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
|
||||
if isinstance(cfg, dict):
|
||||
_check_insee = bool((cfg.get("rescan", {}) or {}).get("check_insee_names", False))
|
||||
if "NOM" in _rescan_disabled:
|
||||
_check_insee = False
|
||||
if _check_insee:
|
||||
_placeholder_bare = {p.strip("[]") for p in PLACEHOLDERS.values()}
|
||||
_wl_terms = []
|
||||
@@ -5489,6 +5826,13 @@ def process_pdf(
|
||||
residual_count += 1
|
||||
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 quarantine_mgr is not None:
|
||||
quarantine_mgr.flag(
|
||||
@@ -5518,6 +5862,15 @@ def process_pdf(
|
||||
return {"status": "quarantined", "reason": "rescan_residual_pii",
|
||||
"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
|
||||
base = pdf_path.stem
|
||||
txt_path = out_dir / f"{base}.pseudonymise.txt"
|
||||
@@ -5554,7 +5907,7 @@ def process_pdf(
|
||||
if make_vector_redaction and fitz is not None:
|
||||
vec_path = out_dir / f"{base}.redacted_vector.pdf"
|
||||
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)
|
||||
_perf_mark("pdf_vector")
|
||||
except Exception as e:
|
||||
@@ -5570,6 +5923,7 @@ def process_pdf(
|
||||
redact_pdf_raster(
|
||||
pdf_path, anon.audit, ras_fb_path,
|
||||
ogc_label=ogc_label, ocr_word_map=ocr_word_map,
|
||||
disabled_kinds=_disabled,
|
||||
)
|
||||
outputs["pdf_raster"] = str(ras_fb_path)
|
||||
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
|
||||
if "pdf_raster" not in outputs:
|
||||
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)
|
||||
_perf_mark("pdf_raster")
|
||||
log.info("PERF %s: done total=%.2fs outputs=%s", pdf_path.name, time.perf_counter() - perf_t0, sorted(outputs.keys()))
|
||||
|
||||
@@ -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)
|
||||
@@ -12,9 +12,9 @@
|
||||
- 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.
|
||||
- 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("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é.
|
||||
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"):
|
||||
assert core._category_of(k) is None, k
|
||||
|
||||
@@ -85,6 +88,7 @@ def test_filter_audit_drops_only_disabled():
|
||||
_PLACEHOLDER_TO_CATEGORY = {
|
||||
"NOM": "NOM", "DATE_NAISSANCE": "DATE_NAISSANCE", "ETAB": "ETAB",
|
||||
"ADRESSE": "ADRESSE", "NIR": "NIR", "TEL": "TEL", "ADHERENT": "ADHERENT",
|
||||
"CODE_POSTAL": "ADRESSE", # décision Dom 2026-06-26 : CP suit le toggle « Adresses »
|
||||
}
|
||||
# Kinds regex/inline non dérivables d'une map → leur catégorie explicitement.
|
||||
_EXPLICIT_KIND_CATEGORY = {
|
||||
@@ -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 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 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)"`
|
||||
@@ -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).
|
||||
- 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.
|
||||
- **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)`.
|
||||
- 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)
|
||||
- **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-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. ✓
|
||||
- **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).
|
||||
|
||||
@@ -9,10 +9,36 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
from typing import Callable, FrozenSet, List, Optional
|
||||
|
||||
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
|
||||
class ConfigState:
|
||||
@@ -33,6 +59,26 @@ class ConfigState:
|
||||
mask_margin_y: int = 1
|
||||
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:
|
||||
return EngineSettings(
|
||||
make_vector_redaction=False,
|
||||
@@ -43,6 +89,7 @@ class ConfigState:
|
||||
enable_gliner=self.enable_gliner,
|
||||
ogc_label=self.ogc_label,
|
||||
profile=self.profile,
|
||||
disabled_kinds=self.disabled_kinds(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ Aucune logique de détection ici : on orchestre uniquement.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
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
|
||||
|
||||
@@ -61,6 +61,9 @@ class EngineSettings:
|
||||
enable_gliner: bool = False
|
||||
ogc_label: 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]:
|
||||
@@ -206,6 +209,8 @@ def build_engine_kwargs(
|
||||
"also_make_raster_burn": settings.also_make_raster_burn,
|
||||
"config_path": settings.config_path,
|
||||
"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:
|
||||
kwargs.update(managers.as_kwargs())
|
||||
|
||||
@@ -26,14 +26,17 @@ _SUBTABS = [
|
||||
("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 = [
|
||||
("Noms et prénoms", "Annuaire + IA"),
|
||||
("Dates de naissance", "Contexte naissance"),
|
||||
("Établissements", "FINESS + contexte"),
|
||||
("Adresses / CP", "Voie, ville, code"),
|
||||
("N° sécurité sociale", "NIR"),
|
||||
("Téléphones / e-mails", "Contact"),
|
||||
("N° adhérent mutuelle", "Identifiant local"),
|
||||
("Noms et prénoms", "Annuaire + IA", "detect_nom"),
|
||||
("Dates de naissance", "Contexte naissance", "detect_date_naissance"),
|
||||
("Établissements", "FINESS + contexte", "detect_etab"),
|
||||
("Adresses / CP", "Voie, ville, code", "detect_adresse"),
|
||||
("N° sécurité sociale", "NIR", "detect_nir"),
|
||||
("Téléphones / e-mails", "Contact", "detect_tel"),
|
||||
("N° adhérent mutuelle", "Identifiant local", "detect_adherent"),
|
||||
]
|
||||
|
||||
_MASK_COLORS = [
|
||||
@@ -353,8 +356,20 @@ class ConfigTab(ctk.CTkFrame):
|
||||
help_text=_HELP_DONNEES_DETECTER, help_title="Données à détecter",
|
||||
)
|
||||
det.pack(fill="both", expand=True)
|
||||
for label, hint in _DETECTION_OPTIONS:
|
||||
self._mini_toggle(det, label, hint, value=True).pack(fill="x", padx=12, pady=1)
|
||||
# Les 7 toggles « Données à détecter » sont câblés sur les booléens
|
||||
# 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(
|
||||
cols[1], p, title="🧠 Moteurs et masques",
|
||||
@@ -865,6 +880,16 @@ class ConfigTab(ctk.CTkFrame):
|
||||
def _on_profile(self, value: str) -> None:
|
||||
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:
|
||||
self._state.use_local_ner = self._tog_ner.get()
|
||||
|
||||
|
||||
160
tests/unit/test_core_address_burn_guard.py
Normal file
160
tests/unit/test_core_address_burn_guard.py
Normal 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"
|
||||
47
tests/unit/test_core_category_gating.py
Normal file
47
tests/unit/test_core_category_gating.py
Normal 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
|
||||
260
tests/unit/test_core_category_gating_behavior.py
Normal file
260
tests/unit/test_core_category_gating_behavior.py
Normal 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]}")
|
||||
76
tests/unit/test_core_forced_hits_exempt.py
Normal file
76
tests/unit/test_core_forced_hits_exempt.py
Normal 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é"
|
||||
121
tests/unit/test_gui_v6_category_toggles.py
Normal file
121
tests/unit/test_gui_v6_category_toggles.py
Normal 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"})
|
||||
@@ -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():
|
||||
"""Retour Dom : les sous-labels de la colonne détection doivent rester lisibles."""
|
||||
assert ("Noms et prénoms", "Annuaire + IA") in _DETECTION_OPTIONS
|
||||
assert ("Noms et prénoms", "Bases de données + IA") not in _DETECTION_OPTIONS
|
||||
# Chaque ligne est désormais (libellé, aide, champ ConfigState) ; on ne
|
||||
# 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_LABEL_FONT_SIZE >= 12
|
||||
assert MINI_TOGGLE_HINT_FONT_SIZE >= 11
|
||||
|
||||
@@ -182,6 +182,138 @@ class TestRescanQuarantine:
|
||||
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 ===========================
|
||||
|
||||
class TestQuarantineArtifacts:
|
||||
|
||||
Reference in New Issue
Block a user