feat(core): garde-fou adresse burn + doc chemins conservateurs (P1-2/F-3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:13:42 +02:00
parent a02bca516d
commit daec1f53bd
2 changed files with 199 additions and 6 deletions

View File

@@ -4812,9 +4812,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]] = {}
@@ -4822,6 +4827,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",
@@ -4830,7 +4843,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
@@ -4929,6 +4945,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
@@ -4976,11 +4999,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",
@@ -5002,7 +5032,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:
@@ -5867,7 +5899,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:
@@ -5883,6 +5915,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
@@ -5924,7 +5957,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()))