feat(pipeline): structural metadata end-to-end (Blocks D2-D4)

D2: RAG service stores section/section_title/paragraph/paragraph_num/page
from embedding service chunks_with_metadata into Qdrant payloads.

D3: Control generator prefers section > article > section_title from
Qdrant, adds page to source_citation and generation_metadata.

D4: Validated with real BGB §§ 312-312k text. Found and fixed critical
bug where Phase 3 overlap destroyed the [§ ...] section prefix, causing
only the first chunk per document to have metadata. All subsequent
chunks lost section info.

Also fixes pre-existing lint issues (unused imports, ambiguous variable
names, duplicate dict key, bare except).

456 tests passing (58 embedding + 387 pipeline + 11 rag-service).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-01 20:34:00 +02:00
parent da21339e76
commit 93099b2770
15 changed files with 1086 additions and 25 deletions
+12 -9
View File
@@ -10,8 +10,8 @@ Provides REST endpoints for:
This service handles all ML-heavy operations, keeping the main klausur-service lightweight.
"""
import os
import logging
import re
from typing import List, Optional
from contextlib import asynccontextmanager
@@ -282,8 +282,6 @@ ENGLISH_ABBREVIATIONS = {
ALL_ABBREVIATIONS = GERMAN_ABBREVIATIONS | ENGLISH_ABBREVIATIONS
# Regex pattern for legal section headers (§, Art., Article, Section, etc.)
import re
_LEGAL_SECTION_RE = re.compile(
r'^(?:'
r'§\s*\d+' # § 25, § 5a
@@ -411,8 +409,6 @@ def _parse_section_metadata(header: str) -> dict:
# Find which group matched
for i, g in enumerate(m.groups(), 1):
if g:
# Reconstruct the section reference
prefix = header[:m.start()].strip()
section = header[m.start():m.end()].strip()
break
@@ -577,7 +573,14 @@ def chunk_text_legal(text: str, chunk_size: int, overlap: int) -> List[str]:
if space_idx > 0:
overlap_text = overlap_text[space_idx + 1:]
if overlap_text:
chunk = overlap_text + ' ' + chunk
# Insert overlap AFTER the [§ ...] prefix to preserve it
# for structured metadata extraction
prefix_match = re.match(r'\[.+?\]\s*', chunk)
if prefix_match:
pos = prefix_match.end()
chunk = chunk[:pos] + overlap_text + ' ' + chunk[pos:]
else:
chunk = overlap_text + ' ' + chunk
final_chunks.append(chunk.strip())
return [c for c in final_chunks if c]
@@ -742,13 +745,13 @@ def detect_pdf_backends() -> List[str]:
available = []
try:
from unstructured.partition.pdf import partition_pdf
from unstructured.partition.pdf import partition_pdf # noqa: F401
available.append("unstructured")
except ImportError:
pass
try:
from pypdf import PdfReader
from pypdf import PdfReader # noqa: F401
available.append("pypdf")
except ImportError:
pass
@@ -808,7 +811,7 @@ def extract_pdf_unstructured(pdf_content: bytes) -> ExtractPDFResponse:
import os as os_module
try:
os_module.unlink(tmp_path)
except:
except OSError:
pass
-1
View File
@@ -11,7 +11,6 @@ Covers:
- Long sentence force-splitting
"""
import pytest
from main import (
chunk_text_legal,
chunk_text_recursive,
+217
View File
@@ -0,0 +1,217 @@
"""
D4 Validation: BGB § 312k structural chunking test.
Tests that real German legal text is correctly chunked with structural
metadata (section, section_title, paragraph, paragraph_num).
This is the gate test before re-ingesting all 297 legal sources.
"""
import os
import pytest
from main import chunk_text_legal, chunk_text_legal_structured
FIXTURE_PATH = os.path.join(
os.path.dirname(__file__), "tests", "fixtures", "bgb_312_excerpt.txt"
)
# Reasonable defaults for legal text
CHUNK_SIZE = 1500
OVERLAP = 100
@pytest.fixture
def bgb_text():
with open(FIXTURE_PATH, encoding="utf-8") as f:
return f.read()
@pytest.fixture
def plain_chunks(bgb_text):
return chunk_text_legal(bgb_text, CHUNK_SIZE, OVERLAP)
@pytest.fixture
def structured_chunks(bgb_text):
return chunk_text_legal_structured(bgb_text, CHUNK_SIZE, OVERLAP)
# =========================================================================
# Basic sanity
# =========================================================================
class TestChunkingSanity:
def test_fixture_loads(self, bgb_text):
assert len(bgb_text) > 2000, "BGB excerpt should be substantial"
assert "§ 312k" in bgb_text
assert "§ 312 " in bgb_text
def test_chunk_count_reasonable(self, plain_chunks):
assert 4 <= len(plain_chunks) <= 30, (
f"Expected 4-30 chunks, got {len(plain_chunks)}"
)
def test_structured_same_count(self, plain_chunks, structured_chunks):
assert len(plain_chunks) == len(structured_chunks)
def test_no_empty_chunks(self, plain_chunks):
for i, chunk in enumerate(plain_chunks):
assert chunk.strip(), f"Chunk {i} is empty"
def test_chunk_sizes_reasonable(self, plain_chunks):
for i, chunk in enumerate(plain_chunks):
assert len(chunk) < 3000, f"Chunk {i} too large: {len(chunk)} chars"
assert len(chunk) > 30, f"Chunk {i} too small: {len(chunk)} chars"
# =========================================================================
# Section detection
# =========================================================================
class TestSectionDetection:
def test_all_four_sections_detected(self, structured_chunks):
"""All 4 BGB sections should appear as section metadata."""
found_sections = set()
for meta in structured_chunks:
if meta["section"]:
found_sections.add(meta["section"])
assert "§ 312" in found_sections or any(
s.startswith("§ 312") and s != "§ 312a" and s != "§ 312g" and s != "§ 312k"
for s in found_sections
), f"§ 312 not found. Sections: {found_sections}"
assert "§ 312a" in found_sections, f"§ 312a not found. Sections: {found_sections}"
assert "§ 312g" in found_sections, f"§ 312g not found. Sections: {found_sections}"
assert "§ 312k" in found_sections, f"§ 312k not found. Sections: {found_sections}"
def test_section_prefix_in_chunks(self, plain_chunks):
"""Most chunks should have [§ ...] prefix."""
prefixed = sum(1 for c in plain_chunks if c.startswith(""))
ratio = prefixed / len(plain_chunks)
assert ratio >= 0.8, (
f"Only {ratio:.0%} chunks have section prefix (expected >= 80%)"
)
def test_312k_has_own_chunk(self, plain_chunks):
"""§ 312k must appear as a chunk section header, not merged into another §."""
chunks_with_312k = [c for c in plain_chunks if "[§ 312k" in c]
assert len(chunks_with_312k) >= 1, (
"§ 312k should have at least 1 dedicated chunk"
)
# =========================================================================
# § 312k specific metadata
# =========================================================================
class TestSection312k:
def _312k_chunks(self, structured_chunks):
return [m for m in structured_chunks if m["section"] == "§ 312k"]
def test_312k_section_metadata(self, structured_chunks):
"""§ 312k chunks should have section='§ 312k' with a title."""
chunks = self._312k_chunks(structured_chunks)
assert len(chunks) >= 1, "No chunks with section='§ 312k'"
for meta in chunks:
assert meta["section"] == "§ 312k"
# Title should contain key words
title = meta["section_title"].lower()
assert "kuendigung" in title or "verbrauchervertrae" in title, (
f"Unexpected section_title: {meta['section_title']}"
)
def test_312k_paragraph_extraction(self, structured_chunks):
"""At least some § 312k chunks should have paragraph references."""
chunks = self._312k_chunks(structured_chunks)
paragraphs_found = [m["paragraph"] for m in chunks if m["paragraph"]]
# § 312k has (1) through (6), at least some should be detected
assert len(paragraphs_found) >= 1, (
"No paragraph references found in § 312k chunks"
)
def test_312k_content_present(self, structured_chunks):
"""§ 312k chunk text should contain key legal terms."""
chunks = self._312k_chunks(structured_chunks)
all_text = " ".join(m["text"] for m in chunks)
assert "Kuendigungsschaltflaeche" in all_text or "kuendigen" in all_text.lower()
assert "Webseite" in all_text or "elektronischen" in all_text
def test_312k_not_merged_with_312g(self, structured_chunks):
"""§ 312k and § 312g should be separate sections, not merged."""
sections_312g = [m for m in structured_chunks if m["section"] == "§ 312g"]
sections_312k = self._312k_chunks(structured_chunks)
assert len(sections_312g) >= 1, "§ 312g missing"
assert len(sections_312k) >= 1, "§ 312k missing"
# Verify they are different chunks (no overlap in indices)
g_indices = {m["index"] for m in sections_312g}
k_indices = {m["index"] for m in sections_312k}
assert g_indices.isdisjoint(k_indices), (
f"§ 312g and § 312k share chunk indices: {g_indices & k_indices}"
)
# =========================================================================
# Metadata quality across all sections
# =========================================================================
class TestMetadataQuality:
def test_most_chunks_have_section(self, structured_chunks):
"""At least 90% of chunks should have a section reference."""
with_section = sum(1 for m in structured_chunks if m["section"])
ratio = with_section / len(structured_chunks)
assert ratio >= 0.9, (
f"Only {ratio:.0%} chunks have section metadata (expected >= 90%)"
)
def test_section_titles_not_empty(self, structured_chunks):
"""Chunks with a section should also have a section_title."""
for meta in structured_chunks:
if meta["section"]:
assert meta["section_title"], (
f"Chunk {meta['index']} has section={meta['section']} but no title"
)
def test_paragraph_nums_are_integers(self, structured_chunks):
"""paragraph_num should be int or None, never str."""
for meta in structured_chunks:
pn = meta["paragraph_num"]
assert pn is None or isinstance(pn, int), (
f"Chunk {meta['index']}: paragraph_num={pn!r} (type={type(pn).__name__})"
)
def test_indices_sequential(self, structured_chunks):
"""Chunk indices should be 0, 1, 2, ... in order."""
for i, meta in enumerate(structured_chunks):
assert meta["index"] == i, (
f"Expected index {i}, got {meta['index']}"
)
# =========================================================================
# Edge cases
# =========================================================================
class TestEdgeCases:
def test_numbered_list_not_false_section(self, structured_chunks):
"""Numbered items (1., 2., 3.) inside a § should NOT create new sections."""
for meta in structured_chunks:
section = meta["section"]
# Section should always start with § or be empty
if section:
assert section.startswith("§"), (
f"Unexpected section format: {section!r}"
)
def test_subsection_letters_preserved(self, plain_chunks):
"""Lettered subsections (a, b, c, d, e) in § 312k(2) should be in the text."""
all_text = " ".join(plain_chunks)
# § 312k Abs 2 Nr 1 has a) through e)
for letter in ["a)", "b)", "c)", "d)", "e)"]:
assert letter in all_text, (
f"Subsection letter {letter} from § 312k(2) missing"
)
+62
View File
@@ -0,0 +1,62 @@
§ 312 Anwendungsbereich
(1) Die Vorschriften der Kapitel 1 und 2 dieses Untertitels sind auf Verbrauchervertraege anzuwenden, bei denen sich der Verbraucher zu der Zahlung eines Preises verpflichtet.
(1a) Die Vorschriften der Kapitel 1 und 2 dieses Untertitels sind auch auf Verbrauchervertraege anzuwenden, bei denen der Verbraucher dem Unternehmer personenbezogene Daten bereitstellt oder sich hierzu verpflichtet. Dies gilt nicht, wenn der Unternehmer die vom Verbraucher bereitgestellten personenbezogenen Daten ausschliesslich verarbeitet, um seine Leistungspflicht oder an ihn gestellte rechtliche Anforderungen zu erfuellen, und sie zu keinem anderen Zweck verarbeitet.
(2) Von den Vorschriften der Kapitel 1 und 2 dieses Untertitels ist nur § 312a Absatz 1, 3, 4 und 6 auf folgende Vertraege anzuwenden:
1. notariell beurkundete Vertraege
2. Vertraege ueber die Begruendung, den Erwerb oder die Uebertragung von Eigentum oder anderen Rechten an Grundstuecken
3. Vertraege ueber den Bau von neuen Gebaeuden oder erhebliche Umbaumassnahmen an bestehenden Gebaeuden
4. Vertraege ueber Reiseleistungen nach § 651a
5. Vertraege ueber die Befoerderung von Personen
6. Vertraege, die unter Einsatz von Warenautomaten oder automatisierten Geschaeftsraeumen geschlossen werden
§ 312a Allgemeine Pflichten und Grundsaetze bei Verbrauchervertraegen
(1) Ruft der Unternehmer oder eine Person, die in seinem Namen oder Auftrag handelt, den Verbraucher an, um mit diesem einen Vertrag zu schliessen, hat der Anrufer zu Beginn des Gespraechs seine Identitaet und gegebenenfalls die Identitaet der Person, fuer die er anruft, sowie den geschaeftlichen Zweck des Anrufs offenzulegen.
(2) Der Unternehmer ist verpflichtet, den Verbraucher nach Massgabe des Artikels 246 des Einfuehrungsgesetzes zum Buergerlichen Gesetzbuche zu informieren. Der Unternehmer kann von dem Verbraucher Fracht-, Liefer- oder Versandkosten und sonstige Kosten nur verlangen, soweit er den Verbraucher ueber diese Kosten entsprechend den Anforderungen aus Artikel 246 Absatz 1 Nummer 3 des Einfuehrungsgesetzes zum Buergerlichen Gesetzbuche informiert hat. Die Saetze 1 und 2 sind weder auf ausserhalb von Geschaeftsraeumen geschlossene Vertraege noch auf Fernabsatzvertraege noch auf Vertraege ueber Finanzdienstleistungen anzuwenden.
(3) Eine Vereinbarung, die auf eine ueber das vereinbarte Entgelt fuer die Hauptleistung hinausgehende Zahlung des Verbrauchers gerichtet ist, kann ein Unternehmer mit einem Verbraucher nur ausdruecklich treffen. Schliesst der Unternehmer und der Verbraucher einen Vertrag im elektronischen Geschaeftsverkehr, wird eine solche Vereinbarung nur Vertragsbestandteil, wenn der Unternehmer die Vereinbarung nicht durch eine Voreinstellung herbeifuehrt.
(4) Eine Vereinbarung, durch die ein Verbraucher verpflichtet wird, ein Entgelt dafuer zu zahlen, dass der Verbraucher fuer die Erfuellung seiner vertraglichen Pflichten ein bestimmtes Zahlungsmittel nutzt, ist unwirksam, wenn fuer den Verbraucher keine zumutbare und gaengige unentgeltliche Zahlungsmoeglichkeit besteht oder das vereinbarte Entgelt ueber die Kosten hinausgeht, die dem Unternehmer durch die Nutzung des Zahlungsmittels entstehen.
(5) Eine Vereinbarung, durch die ein Verbraucher verpflichtet wird, ein Entgelt dafuer zu zahlen, dass der Verbraucher den Unternehmer wegen Fragen oder Erklaerungen zu einem zwischen ihnen geschlossenen Vertrag ueber eine Rufnummer anruft, die der Unternehmer fuer solche Zwecke bereithaelt, ist unwirksam, wenn das vereinbarte Entgelt das Entgelt fuer die blosse Nutzung des Telekommunikationsdienstes uebersteigt.
(6) Ist eine Vereinbarung nach den Absaetzen 3 bis 5 nicht Vertragsbestandteil geworden oder ist sie unwirksam, bleibt der Vertrag im Uebrigen wirksam.
§ 312g Widerrufsrecht
(1) Dem Verbraucher steht bei ausserhalb von Geschaeftsraeumen geschlossenen Vertraegen und bei Fernabsatzvertraegen ein Widerrufsrecht gemaess § 355 zu.
(2) Das Widerrufsrecht besteht, soweit die Parteien nichts anderes vereinbart haben, nicht bei folgenden Vertraegen:
1. Vertraege zur Lieferung von Waren, die nicht vorgefertigt sind und fuer deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Verbraucher massgeblich ist oder die eindeutig auf die persoenlichen Beduerfnisse des Verbrauchers zugeschnitten sind,
2. Vertraege zur Lieferung von Waren, die schnell verderben koennen oder deren Verfallsdatum schnell ueberschritten wuerde,
3. Vertraege zur Lieferung versiegelter Waren, die aus Gruenden des Gesundheitsschutzes oder der Hygiene nicht zur Rueckgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde.
(3) Das Widerrufsrecht besteht ferner nicht bei Vertraegen, bei denen dem Verbraucher bereits auf Grund der §§ 495, 506 bis 513 ein Widerrufsrecht zusteht.
§ 312k Kuendigung von Verbrauchervertraegen im elektronischen Geschaeftsverkehr
(1) Wird Verbrauchern ueber eine Webseite ermoeglicht, einen Vertrag im elektronischen Geschaeftsverkehr zu schliessen, der auf die Begruendung eines Dauerschuldverhaeltnisses gerichtet ist, das einen Unternehmer zu einer entgeltlichen Leistung verpflichtet, so treffen den Unternehmer die Pflichten nach dieser Vorschrift. Dies gilt nicht
1. fuer Vertraege, fuer deren Kuendigung gesetzlich ausschliesslich eine strengere Form als die Textform vorgesehen ist, und
2. in Bezug auf Webseiten, die Finanzdienstleistungen betreffen, oder fuer Vertraege ueber Finanzdienstleistungen.
(2) Der Unternehmer hat sicherzustellen, dass der Verbraucher auf der Webseite eine Erklaerung zur ordentlichen oder ausserordentlichen Kuendigung eines auf der Webseite abschliessbaren Vertrags nach Absatz 1 Satz 1 ueber eine Kuendigungsschaltflaeche abgeben kann. Die Kuendigungsschaltflaeche muss gut lesbar mit nichts anderem als den Woertern "Vertraege hier kuendigen" oder mit einer entsprechenden eindeutigen Formulierung beschriftet sein. Sie muss den Verbraucher unmittelbar zu einer Bestaetigungsseite fuehren, die
1. den Verbraucher auffordert und ihm ermoeglicht Angaben zu machen
a) zur Art der Kuendigung sowie im Falle der ausserordentlichen Kuendigung zum Kuendigungsgrund,
b) zu seiner eindeutigen Identifizierbarkeit,
c) zur eindeutigen Bezeichnung des Vertrags,
d) zum Zeitpunkt, zu dem die Kuendigung das Vertragsverhaeltnis beenden soll,
e) zur schnellen elektronischen Uebermittlung der Kuendigungsbestaetigung an ihn und
2. eine Bestaetigungsschaltflaeche enthaelt, ueber deren Betaetigung der Verbraucher die Kuendigungserklaerung abgeben kann und die gut lesbar mit nichts anderem als den Woertern "jetzt kuendigen" oder mit einer entsprechenden eindeutigen Formulierung beschriftet ist.
Die Schaltflaechen und die Bestaetigungsseite muessen staendig verfuegbar sowie unmittelbar und leicht zugaenglich sein.
(3) Der Verbraucher muss seine durch das Betaetigen der Bestaetigungsschaltflaeche abgegebene Kuendigungserklaerung mit dem Datum und der Uhrzeit der Abgabe auf einem dauerhaften Datentraeger so speichern koennen, dass erkennbar ist, dass die Kuendigungserklaerung durch das Betaetigen der Bestaetigungsschaltflaeche abgegeben wurde.
(4) Der Unternehmer hat dem Verbraucher den Inhalt sowie Datum und Uhrzeit des Zugangs der Kuendigungserklaerung sowie den Zeitpunkt, zu dem das Vertragsverhaeltnis durch die Kuendigung beendet werden soll, sofort auf elektronischem Wege in Textform zu bestaetigen. Es wird vermutet, dass eine durch das Betaetigen der Bestaetigungsschaltflaeche abgegebene Kuendigungserklaerung dem Unternehmer unmittelbar nach ihrer Abgabe zugegangen ist.
(5) Wenn der Verbraucher bei der Abgabe der Kuendigungserklaerung keinen Zeitpunkt angibt, zu dem die Kuendigung das Vertragsverhaeltnis beenden soll, wirkt die Kuendigung im Zweifel zum fruehestmoeglichen Zeitpunkt.
(6) Werden die Schaltflaechen und die Bestaetigungsseite nicht entsprechend den Absaetzen 1 und 2 zur Verfuegung gestellt, kann ein Verbraucher einen Vertrag, fuer dessen Kuendigung die Schaltflaechen und die Bestaetigungsseite zur Verfuegung zu stellen sind, jederzeit und ohne Einhaltung einer Kuendigungsfrist kuendigen. Die Moeglichkeit des Verbrauchers zur ausserordentlichen Kuendigung bleibt hiervon unberuehrt.