feat(control-pipeline): GuidanceIngester engine for supervisory guidance (Parser 3)
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 28s
CI / test-python-voice (push) Successful in 42s
CI / test-bqas (push) Successful in 39s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 28s
CI / test-python-voice (push) Successful in 42s
CI / test-bqas (push) Successful in 39s
Add services/guidance_ingester.py — extracts guidance documents (pdfplumber for PDF, an HTML stripper otherwise; pdfplumber is imported lazily so the module and its tests load without it) and tags them as a SEPARATE interpretative source: source_class=supervisory_guidance / authority_weight=70 / bindingness= interpretative / use_for_primary=false, with references_out to the binding norms they interpret (Art. N DSGVO / § N BDSG). Guidance therefore ranks below binding law for obligation questions yet stays retrievable as interpretation context. supervisory_guidance is reused deliberately: the live re-ranker already weights it 70 and 8k+ chunks use it (no classifier change, no schema drift). EDPB is the first target; technical standards (weight 80) are a later separate class. Tested: 6 unit tests on the text + metadata path (PDF extraction is exercised in the container), ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""GuidanceIngester (Parser 3): ingests supervisory guidance (EDPB / DSK / ENISA
|
||||
/ BSI / CNIL) as a SEPARATE interpretative source — never a primary obligation.
|
||||
|
||||
Guidance documents are heterogeneous PDFs / HTML, unlike the uniform eur-lex
|
||||
XHTML of Parsers 1-2. This module extracts the text (pdfplumber for PDF, a small
|
||||
HTML stripper otherwise), normalises it, and tags it
|
||||
source_class=supervisory_guidance / authority_weight=70 /
|
||||
bindingness=interpretative / use_for_primary=false, with references_out to the
|
||||
binding norms it interprets (Art. N DSGVO / § N BDSG). So guidance ranks BELOW
|
||||
binding law for obligation questions, yet stays fully retrievable as
|
||||
interpretation context (and is the right Top-1 for "what does the EDPB say?").
|
||||
|
||||
Chunking is left to the RAG service (chunk_strategy='legal'); each resulting
|
||||
chunk inherits the guidance metadata. pdfplumber is imported lazily so the
|
||||
module (and its tests) load without it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_lib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from services.legal_act_ingester import UploadUnit
|
||||
|
||||
GUIDANCE_WEIGHT = 70
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_WS_RE = re.compile(r"[ \t]+")
|
||||
_BLANK_RE = re.compile(r"\n{3,}")
|
||||
# "Artikel 37", "Art. 38", "Article 9" → the interpreted article number
|
||||
_ART_REF_RE = re.compile(r"\bArt(?:ikel|icle|\.)?\s*(\d+[a-z]?)", re.IGNORECASE)
|
||||
_PARA_REF_RE = re.compile(r"§\s*(\d+[a-z]?)")
|
||||
_MIN_GUIDANCE_CHARS = 200
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuidanceSpec:
|
||||
"""One guidance document + the binding norm it interprets."""
|
||||
|
||||
source_id: str # stable handle, e.g. "edpb_dpo"
|
||||
short: str # display handle used as regulation_short, e.g. "EDPB DPO"
|
||||
title: str # full title
|
||||
publisher: str # EDPB / DSK / ENISA / BSI / CNIL
|
||||
url: str
|
||||
interpreted_reg: str # binding norm it interprets, e.g. "DSGVO" (for references_out)
|
||||
collection: str = "bp_compliance_datenschutz"
|
||||
version_date: str = ""
|
||||
jurisdiction: str = "EU"
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Collapse intra-line whitespace and runs of blank lines."""
|
||||
text = html_lib.unescape(text)
|
||||
text = _WS_RE.sub(" ", text)
|
||||
text = "\n".join(line.strip() for line in text.split("\n"))
|
||||
return _BLANK_RE.sub("\n\n", text).strip()
|
||||
|
||||
|
||||
def extract_pdf(path: str) -> str:
|
||||
"""Extract text from a PDF. pdfplumber is imported lazily (container only)."""
|
||||
import pdfplumber # noqa: PLC0415 — heavy, optional dep; only needed at ingest time
|
||||
|
||||
parts: list[str] = []
|
||||
with pdfplumber.open(path) as pdf:
|
||||
for page in pdf.pages:
|
||||
page_text = page.extract_text(x_tolerance=3, y_tolerance=4)
|
||||
if page_text:
|
||||
parts.append(page_text)
|
||||
return normalize_text("\n".join(parts))
|
||||
|
||||
|
||||
def extract_html(raw: str) -> str:
|
||||
"""Strip tags to plain text (for guidance served as HTML)."""
|
||||
return normalize_text(_TAG_RE.sub(" ", raw))
|
||||
|
||||
|
||||
def guidance_refs_out(interpreted_reg: str, text: str) -> list[str]:
|
||||
"""Forward edges from the guidance to the binding norms it cites."""
|
||||
out = {f"Art. {m} {interpreted_reg}" for m in _ART_REF_RE.findall(text)}
|
||||
out |= {f"§ {m} BDSG" for m in _PARA_REF_RE.findall(text)}
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def guidance_meta(spec: GuidanceSpec, text: str) -> dict[str, Any]:
|
||||
return {
|
||||
"regulation_code": spec.short,
|
||||
"regulation_short": spec.short,
|
||||
"regulation_name_de": spec.title,
|
||||
"citation_style": "guidance",
|
||||
"document_type": "guidance",
|
||||
"source_class": "supervisory_guidance",
|
||||
"bindingness": "interpretative",
|
||||
"authority_level": GUIDANCE_WEIGHT,
|
||||
"authority_weight": GUIDANCE_WEIGHT,
|
||||
"source_type": "guidance",
|
||||
"issuer": spec.publisher,
|
||||
"jurisdiction": spec.jurisdiction,
|
||||
"version_date": spec.version_date,
|
||||
"source": spec.url,
|
||||
"license": "public_eu",
|
||||
"category": "guidance",
|
||||
"use_for_primary": False, # interpretative — never a primary obligation source
|
||||
"is_citable": True,
|
||||
"citation_unit": spec.title,
|
||||
"article_label": spec.short,
|
||||
"chunk_scope": "guidance",
|
||||
"interprets": spec.interpreted_reg,
|
||||
"references_out": guidance_refs_out(spec.interpreted_reg, text),
|
||||
"norm_id": f"GUIDANCE-{spec.source_id}",
|
||||
}
|
||||
|
||||
|
||||
def self_test(text: str) -> tuple[bool, list[str]]:
|
||||
"""Gate before upload — guard against an empty/failed extraction."""
|
||||
problems: list[str] = []
|
||||
if len(text.strip()) < _MIN_GUIDANCE_CHARS:
|
||||
problems.append(f"extracted text too short ({len(text.strip())} chars)")
|
||||
return (not problems, problems)
|
||||
|
||||
|
||||
def build_upload_unit(spec: GuidanceSpec, text: str, run_tag: str) -> UploadUnit:
|
||||
"""One UploadUnit for the whole document; the RAG service chunks it and each
|
||||
chunk inherits the guidance metadata."""
|
||||
return UploadUnit(
|
||||
filename=f"{spec.source_id}.txt",
|
||||
text=text,
|
||||
meta=guidance_meta(spec, text),
|
||||
document_version=f"{run_tag}-{spec.source_id}",
|
||||
collection=spec.collection,
|
||||
)
|
||||
Reference in New Issue
Block a user