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(