feat(core): coordination quarantaine résiduelle NIR/TEL décochés (P1-2/F-4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -865,6 +865,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 = {
|
||||
@@ -5534,15 +5616,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
|
||||
@@ -5551,9 +5640,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 = []
|
||||
@@ -5573,6 +5666,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(
|
||||
|
||||
@@ -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