feat(rag): Legal-Metadaten — article_label + deterministische IDs + chunk_hash
Neues pures Modul legal_metadata.py (nur stdlib, lokal+CI testbar): §3-Normalisierung section->article, strikte Header-Extraktion (Datum/Seiten-Rauschen -> kein Falsch-Zitat), citation_style pro Regulierung (EU/CH=article, DE=paragraph), Urteil=Aktenzeichen statt §, camelCase-Klarnamen (ProdHaftG), deterministische uuid5-Point-ID + chunk_hash (sha256). documents.py verdrahtet build_legal_fields in den Payload-Build + document_version. 10 Tests gruen. Vertrag: rag_reingest_spec.md (§2/§3). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""Legal-Metadaten-Normalisierung fuer zitierfaehige Chunks.
|
||||
|
||||
Vertrag: docs-src/development/rag_reingest_spec.md (§2 Payload-Feld-Vertrag, §3 Transform).
|
||||
Transformiert die Roh-Ausgabe des Legal-Chunkers (section="§ 38" / "Artikel 13",
|
||||
section_title, paragraph="(1)") in die consumer-facing Felder
|
||||
(article, citation_style, article_label, paragraph, sub, section_header, is_recital).
|
||||
|
||||
Pure Funktionen (nur stdlib) -> lokal + in CI testbar, haelt documents.py schlank.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import uuid
|
||||
|
||||
# Namespace fuer deterministische Point-IDs (Qdrant braucht UUID/uint, kein roher sha1).
|
||||
_ID_NS = uuid.UUID("6ba7b811-9dad-11d1-80b4-00c04fd430c8")
|
||||
|
||||
# Strikt: nur echte Legal-Header am Anfang des Section-Strings (optional in [..]).
|
||||
# Lehnt Datums-/Seiten-/Fundstellen-Rauschen ab ("4.5.2016", "L 119/9", "Begriffsbestimmungen").
|
||||
_LEGAL_HEADER_RE = re.compile(r"^\s*\[?\s*(§|art\.?|artikel)\s*(\d+)\s*([a-z])?", re.IGNORECASE)
|
||||
_SUB_RE = re.compile(r"(lit\.\s*[a-z]+|Satz\s*\d+|Nr\.\s*\d+)", re.IGNORECASE)
|
||||
|
||||
# Urteile/Beschluesse: KEINE §-/Art.-Fundstelle bilden. Der Entscheidungstext zitiert
|
||||
# §§ fremder Gesetze (Querverweise) -> wuerde ein falsches Label "AZ § 87" erzeugen.
|
||||
# Zitiert wird das Aktenzeichen (display_name / regulation_short).
|
||||
_RULING_TYPES = {"urteil", "ruling", "court_decision", "beschluss"}
|
||||
|
||||
|
||||
def detect_citation_style(section: str) -> str:
|
||||
"""'§ 38' -> 'paragraph' (DE-Gesetze); 'Artikel/Art. 13' -> 'article' (EU-VO)."""
|
||||
s = (section or "").lower()
|
||||
if "§" in s:
|
||||
return "paragraph"
|
||||
if "art" in s:
|
||||
return "article"
|
||||
return "paragraph"
|
||||
|
||||
|
||||
def normalize_article(section: str) -> str:
|
||||
"""Strikt aus echtem Legal-Header: '§ 38'->'38', 'Artikel 13'->'13', '[Artikel 17]'->'17',
|
||||
'Art. 13a'->'13a'. Kein Header (Datum/Seite/Titel) -> '' (keine falsche Fundstelle)."""
|
||||
m = _LEGAL_HEADER_RE.match(section or "")
|
||||
return (m.group(2) + (m.group(3) or "")) if m else ""
|
||||
|
||||
|
||||
def normalize_paragraph(paragraph: str) -> str:
|
||||
"""'(1)' -> '1', 'Abs. 2' -> '2', '' -> ''."""
|
||||
m = re.search(r"\d+", paragraph or "")
|
||||
return m.group(0) if m else ""
|
||||
|
||||
|
||||
def extract_sub(text: str) -> str:
|
||||
"""Best-effort feinste Granularitaet: 'lit. c' / 'Satz 2' / 'Nr. 3' (sonst '')."""
|
||||
m = _SUB_RE.search(text or "")
|
||||
return re.sub(r"\s+", " ", m.group(1)).strip() if m else ""
|
||||
|
||||
|
||||
def is_recital(section: str, section_title: str) -> bool:
|
||||
blob = f"{section} {section_title}".lower()
|
||||
return any(k in blob for k in ("erwaegungsgrund", "erwägungsgrund", "recital"))
|
||||
|
||||
|
||||
def format_article_label(
|
||||
regulation_label: str, citation_style: str, article: str, paragraph: str, sub: str
|
||||
) -> str:
|
||||
"""Druckbare Fundstelle: 'ProdHaftG § 1' bzw. 'Art. 13 Abs. 1 lit. c DSGVO'.
|
||||
|
||||
`regulation_label` ist der Klarname (regulation_short, Originalschreibweise) — NICHT
|
||||
uppercasen, damit 'ProdHaftG'/'GeschGehG'/'MuSchG' korrekt erscheinen. Akronyme
|
||||
(BDSG/DSGVO/HGB) sind ohnehin schon gross."""
|
||||
code = (regulation_label or "").strip()
|
||||
if not article:
|
||||
return code
|
||||
abs_part = f" Abs. {paragraph}" if paragraph else ""
|
||||
sub_part = f" {sub}" if sub else ""
|
||||
if citation_style == "article":
|
||||
return f"Art. {article}{abs_part}{sub_part} {code}".strip()
|
||||
return f"{code} § {article}{abs_part}{sub_part}".strip()
|
||||
|
||||
|
||||
def compute_chunk_hash(text: str) -> str:
|
||||
"""sha256 des whitespace-normalisierten Chunk-Texts (Re-Link-/Ledger-Anker)."""
|
||||
norm = re.sub(r"\s+", " ", (text or "").strip())
|
||||
return hashlib.sha256(norm.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def deterministic_point_id(
|
||||
regulation_code: str, article: str, paragraph: str, chunk_index: int, document_version: str
|
||||
) -> str:
|
||||
"""Deterministische Qdrant-Point-ID (uuid5) fuer stabilen Re-Link + Alt/Neu-Koexistenz."""
|
||||
raw = f"{regulation_code}|{article}|{paragraph}|{chunk_index}|{document_version}"
|
||||
return str(uuid.uuid5(_ID_NS, raw))
|
||||
|
||||
|
||||
def build_legal_fields(
|
||||
struct_meta: dict,
|
||||
regulation_code: str,
|
||||
chunk_text: str = "",
|
||||
citation_style: str = None,
|
||||
display_name: str = None,
|
||||
source_type: str = None,
|
||||
) -> dict:
|
||||
"""Roh-Chunker-Metadaten -> consumer-facing Spec-Felder (§2/§3).
|
||||
|
||||
citation_style ist PRO REGULIERUNG (EU-VO/CH -> 'article', DE-§-Gesetz -> 'paragraph')
|
||||
und sollte explizit ueber die Ingest-Metadaten gesetzt werden; ohne Angabe wird er
|
||||
aus dem Section-String geraten (unzuverlaessig bei gemischten §/Art.-Referenzen).
|
||||
|
||||
`display_name` (= regulation_short) ist der druckbare Klarname fuers Label; `regulation_code`
|
||||
bleibt der GROSS-Feldwert (Filter/Gruppierung). `source_type` steuert Urteils-Sonderfall."""
|
||||
sm = struct_meta or {}
|
||||
section = sm.get("section", "") or ""
|
||||
section_title = sm.get("section_title", "") or ""
|
||||
printable = (display_name or regulation_code or "").strip()
|
||||
if (source_type or "").strip().lower() in _RULING_TYPES:
|
||||
# Urteil: kein §/Art. (Querverweise verfaelschen). Label = Aktenzeichen.
|
||||
style = citation_style if citation_style in ("article", "paragraph") else ""
|
||||
article = ""
|
||||
label = printable
|
||||
else:
|
||||
style = (
|
||||
citation_style
|
||||
if citation_style in ("article", "paragraph")
|
||||
else detect_citation_style(section)
|
||||
)
|
||||
article = normalize_article(section)
|
||||
# Absatz/sub bewusst NICHT aus dem Chunk-Text raten: Querverweise ("Artikel 8 Absatz 1")
|
||||
# erzeugen FALSCHE Fundstellen. Zitat bleibt artikel-genau (zuverlaessig); feinere
|
||||
# Granularitaet ist ein spaeteres Refinement mit eigener, sicherer Extraktion.
|
||||
label = format_article_label(printable, style, article, "", "")
|
||||
return {
|
||||
"regulation_code": (regulation_code or "").upper(),
|
||||
"citation_style": style,
|
||||
"article": article,
|
||||
"paragraph": "",
|
||||
"sub": "",
|
||||
"is_recital": is_recital(section, section_title),
|
||||
"section_header": section_title,
|
||||
"article_label": label,
|
||||
}
|
||||
Reference in New Issue
Block a user