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:
2026-06-26 10:10:07 +02:00
parent b15d0da141
commit 2a3aab117d
2 changed files with 239 additions and 7 deletions

View File

@@ -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(

View File

@@ -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: