From 04d721101f3e03fc95e0bde8196640d51230363f Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Sat, 18 Jul 2026 16:48:21 +0200 Subject: [PATCH] fix(chunker): Outline-first Kapitel-Split statt Volltext-Matching (#345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit split_by_chapters() bekommt mit pdf_path einen Outline-first-Pfad: Kapitelgrenze = fitz-aufgeloeste PDF-Lesezeichen-Zielseite, ueber eine exakte physisch->Marker- Seiten-Map (zwei Zweige: PageLabels-Label-Map bzw. Zaehl-Map nicht-leerer Seiten) auf den [S. N]-Marker gemappt und per Titel-Wort-Overlap validiert (>=50 %, sonst HiPS-Kaskade Substring/Fuzzy-80). Fehlgeschlagene Grenzen werden verworfen und ins Vorsegment gemergt; >50 % Fehlschlaege oder Degenerations-Guards (Median-Segment < 400 Woerter, Riesensegment > 70 %) -> Outline verwerfen -> heuristischer Normalpfad. Front-/Backmatter-Filter, Root-Descend (Single-Root -> Ebene tiefer), Duplikat-Merge (Seitenabstand <=1), Offset-Voting-Kreuzcheck (Confidence-Flag), Diagnose-Zeile. Ohne nutzbare Outline exakt bisheriges Verhalten; ohne pdf_path (Alt-Aufrufer/eval) unveraendert. - _TOC_TRAIL_RE nur erweitert: gespacte Dot-Leader (`. . . 9`) + roemische Seitenzahlen. _CHAPTER_RE/_is_real_chapter_match unangetastet, test_chapter_regex bleibt gruen. (Alte Regex liess reale Klingenberg-TOC-Zeilen als Kapitel durch.) - Chunk.source ("outline"/"heuristic"/"words"). - extract_overview(text, chapters=chunks): ein Split pro Lauf, outline-basiert. - orchestrator: split_by_chapters(text, pdf_path) + extract_overview(chapters), Degenerations-Guard mit hartem by-chapter-Abbruch statt Warnung (der 15,5-h-/ 32-Mio-Token-/0-Notes-Lauf). Empirie: Klingenberg 9, DAMA 17, Kuhlen 6 (39-Seiten-Drift), Gantert, Hobohm 12 — alle Quelle outline, 100 % Validierung. Heuristik-Pfad auf Klingenberg: 117 Chunks. --- generative/config.py | 9 + generative/orchestrator.py | 21 +- generative/pipeline/pdf_chunker.py | 413 +++++++++++++++++- .../test_by_chapter_degeneration_abort.py | 72 +++ .../tests/test_outline_chapter_split.py | 334 ++++++++++++++ 5 files changed, 841 insertions(+), 8 deletions(-) create mode 100644 generative/tests/test_by_chapter_degeneration_abort.py create mode 100644 generative/tests/test_outline_chapter_split.py diff --git a/generative/config.py b/generative/config.py index 7b394a0..68fe80f 100644 --- a/generative/config.py +++ b/generative/config.py @@ -143,6 +143,15 @@ MAX_CHUNKS_SHORT_DOC = 10 MAX_PAGES_SHORT_DOC = 50 +# Outline-first Kapitel-Split (#345). Kalibriert an 5 Bestands-Büchern (Plan v4, +# empirisch 24/24) — NICHT gegen ein Gold-Set validiert, ENV nur als Escape-Hatch. +# Sanity-Obergrenze Outline-Hauptkapitel: darüber ist es wahrscheinlich ein +# flach-gegliederter Sammelband (>60 L1-Einträge im Stichproben-Bestand) → Normalpfad. +MAX_SANE_OUTLINE_CHAPTERS = int(os.getenv("ATOMIC_AGENT_MAX_SANE_OUTLINE_CHAPTERS", "60")) +# Median-Segmentgröße-Guard: liegt der Median der Kapiteltexte darunter, ist der +# Outline-Split zu Slivern degeneriert (Titel-Marker-Fehlmap) → Normalpfad. +MIN_CHAPTER_SEGMENT_WORDS = int(os.getenv("ATOMIC_AGENT_MIN_CHAPTER_SEGMENT_WORDS", "400")) + # Backlog: nicht verdrahtet — Einlösung = Kosten-Cap-Feature, Maintainer-Entscheid MAX_TOKENS_PER_RUN = 500_000 diff --git a/generative/orchestrator.py b/generative/orchestrator.py index c6c072b..281b9e5 100644 --- a/generative/orchestrator.py +++ b/generative/orchestrator.py @@ -2228,7 +2228,9 @@ def _run_extraction_stages( words_per_page=text_quality.words_per_page if text_quality.is_thin else None, ) ) - chunks = pdf_chunker.split_by_chapters(text) + # #345: Outline-first Kapitel-Split — Kapitelgrenze = validierte PDF-Lesezeichen- + # Zielseite. Ohne nutzbare Outline exakt das bisherige (heuristische) Verhalten. + chunks = pdf_chunker.split_by_chapters(text, pdf_path=source_path) pdf_meta_early = pdf_chunker.pdf_metadata(source_path) or {} try: source_pages = int(pdf_meta_early.get("Pages") or 0) @@ -2245,6 +2247,19 @@ def _run_extraction_stages( ) chunks = pdf_chunker._split_by_words(text) print(f" {len(chunks)} Chunks") + # #345: --by-chapter auf einem Dokument OHNE nutzbare Outline degeneriert zu + # Hunderten Mikro-„Kapiteln" (jedes ein Planner-Call) — das war der 15,5-h-/ + # 32-Mio-Token-/0-Notes-Lauf. Statt zu warnen und loszulaufen: hart abbrechen, + # sobald der Split KEIN Outline-Split ist und die Chunk-Zahl über die Schwelle + # geht. Ein echter Outline-Split (source="outline") passiert diesen Guard. + _split_source = getattr(chunks[0], "source", None) if chunks else None + if getattr(args, "by_chapter", False) and len(chunks) > LARGE_DOC_THRESHOLD and _split_source != "outline": + sys.exit( + f"[FEHLER] --by-chapter: {len(chunks)} Segmente ohne nutzbare PDF-Outline " + f"(Split-Quelle: {_split_source}) — das ist ein degenerierter Split, kein " + f"Kapitel-Split. Abbruch statt {len(chunks)} Planner-Aufrufe (vgl. #345). " + f"Ohne eingebettete Lesezeichen ist --by-chapter für dieses PDF ungeeignet." + ) if len(chunks) > LARGE_DOC_THRESHOLD and not getattr(args, "by_chapter", False): print(f" [WARN] {len(chunks)} Chunks - großes Dokument. Erwäge --by-chapter für Bücher.") acronym_dict = acronym_fix.extract_acronym_pairs(text) @@ -2254,7 +2269,9 @@ def _run_extraction_stages( f"{', '.join(list(acronym_dict.keys())[:8])}" f"{'...' if len(acronym_dict) > 8 else ''}" ) - overview = pdf_chunker.extract_overview(text) + # #345/M1: ein Split pro Lauf — die bereits berechneten Chunks in die Overview + # injizieren (outline-basiert, kein zweiter split_by_chapters-Aufruf). + overview = pdf_chunker.extract_overview(text, chapters=chunks) pdf_meta = pdf_meta_early # #234: Selbst-Vergiftungs-Schutz — eingebetteten /Title verwerfen, wenn der # Info-Dict-Autor dem Dateinamen widerspricht. Vor dem Enrichment, damit der diff --git a/generative/pipeline/pdf_chunker.py b/generative/pipeline/pdf_chunker.py index 705f005..13c8548 100644 --- a/generative/pipeline/pdf_chunker.py +++ b/generative/pipeline/pdf_chunker.py @@ -9,10 +9,16 @@ import re import subprocess import sys +import unicodedata from dataclasses import dataclass from pathlib import Path -from generative.config import CHUNK_WORDS, MIN_WORDS_PER_PAGE +from generative.config import ( + CHUNK_WORDS, + MAX_SANE_OUTLINE_CHAPTERS, + MIN_CHAPTER_SEGMENT_WORDS, + MIN_WORDS_PER_PAGE, +) # S5 (#150): Obergrenze fuer poppler-Subprozesse (pdftotext/pdfinfo). Ein # defektes/boesartiges PDF darf die Pipeline nicht unbegrenzt haengen lassen — @@ -27,6 +33,9 @@ class Chunk: index: int page_start: int | None = None page_end: int | None = None + # Herkunft des Splits (#345): "outline" = PDF-Lesezeichen, "heuristic" = + # Text-Heading-Regex, "words" = Wort-Count-Fallback. None bei Alt-Konstruktion. + source: str | None = None # Marker den Extractor/Verifier sehen: leere Zeile + [S. N] + leere Zeile @@ -716,7 +725,12 @@ def pdf_metadata(pdf_path: Path) -> dict[str, str]: # TOC-Trail: Inhaltsverzeichnis-Zeilen wie "I. Einleitung .......... 12" oder # mehrfaches Spacing + Seitenzahl. Solche Zeilen sind keine echten Kapitel-Headings. -_TOC_TRAIL_RE = re.compile(r"(?:\.{2,}|\s{3,}|\t)\s*\d{1,4}\s*$") +# Erweiterung (#345): gespacte Dot-Leader (`. . . .` — PDF-Extraktionsartefakt) und +# römische Seitenzahlen (`... xii`, Frontmatter) fängt der Alt-Ausdruck nicht. +# NUR additive Alternativen — die bestehenden Fälle bleiben unverändert (test_chapter_regex). +# Römisch bewusst lowercase-only (Frontmatter-Konvention) und ohne IGNORECASE, damit +# ein Titel-Wort wie "civil" (∈ {i,v,x,l,c}) nach `\s{3,}` nicht als Seitenzahl gilt. +_TOC_TRAIL_RE = re.compile(r"(?:\.{2,}|(?:\.\s){2,}|\s{3,}|\t)\s*(?:\d{1,4}|[ivxl]{1,7})\s*$") def _is_real_chapter_match(match: re.Match) -> bool: @@ -728,8 +742,389 @@ def _is_real_chapter_match(match: re.Match) -> bool: return True -def split_by_chapters(text: str) -> list[Chunk]: - """Teilt Text (mit `[S. N]`-Markern) an Kapitel-Headings. Fallback: Word-Count.""" +# --- Outline-first Kapitel-Split (#345) ----------------------------------- +# Kapitelgrenze = fitz-aufgelöste Outline-Zielseite, auf den `[S. N]`-Marker +# gemappt und per Titel-Wort-Overlap validiert — NICHT Titel-Matching im Volltext +# (P4-Befund: Heading-Regex liefert auf realen Büchern 124–1798 „Kapitel" statt +# 7–32). Ohne nutzbare Outline fällt `split_by_chapters` transparent auf den +# heuristischen Normalpfad zurück (ehrliche Grenze: Scans ohne Bookmarks). + +_OUTLINE_VALIDATION_WINDOW_CHARS = 400 # Fenster ab gemapptem Marker (24/24-Empirie) +_OUTLINE_TITLE_OVERLAP_MIN = 0.5 # ≥50 % der Titel-Wörter im Fenster (R3/V4-5) +_OUTLINE_FUZZY_THRESHOLD = 80 # HiPS-Kaskade Zweitcheck (partial_ratio %) +_OUTLINE_GIANT_SEGMENT_RATIO = 0.70 # ein Segment >70 % Gesamtwörter → degeneriert +_OUTLINE_OFFSET_VOTING_SAMPLE = 20 # Stichprobe für den Map-Kreuzcheck +_MARKER_LINE_RE = re.compile(r"(?m)^[ \t]*\[S\.\s*(\d+)\][ \t]*$") + +# Front-/Backmatter-Outline-Titel (kurze Bookmark-Titel, nicht Seiten-Body). +# `_FRONTMATTER_RE` bleibt SSoT der Body-Phrasen (drop_frontmatter_pages); hier +# NUR additiv für den Outline-Kanal (#345), Termliste exakt aus Plan §1: +# Substring `verzeichnis` (fängt Literatur-/Abbildungs-/Abkürzungsverzeichnis…) +# + Ganzwort Inhalt/Vorwort/Glossar/Register/Index/Anhang/Geleitwort/Impressum/… +# + Präfix Danksag(ung)/Autor(en). +# Bewusst KEIN bare „literatur"/„abbildung": „Zweiter Teil. … Literatur, Bücher, +# Medien" (Gantert) ist ein echtes Kapitel — bare Substrings droppen reale Titel. +_OUTLINE_SKIP_EXACT = frozenset( + { + "inhalt", + "vorwort", + "glossar", + "geleitwort", + "grusswort", + "register", + "index", + "anhang", + "appendix", + "widmung", + "impressum", + "stichwort", + } +) +_OUTLINE_SKIP_PREFIXES = ("danksag", "autor") # Danksagung; Autoren, Autorenverzeichnis + + +def _norm_text(s: str) -> str: + """NFKD-entdiakritisiert, lowercase, nur alnum+space — für Titel-Overlap-Vergleich.""" + s = unicodedata.normalize("NFKD", s) + s = "".join(c for c in s if not unicodedata.combining(c)) + return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip() + + +def _clean_outline_title(title: str) -> str: + """NUL-Padding (bei Gantert beobachtet) + Whitespace entfernen.""" + if not title: + return "" + return title.replace("\x00", "").strip() + + +def _is_outline_skip_title(title: str) -> bool: + """Front-/Backmatter-Outline-Eintrag? `_FRONTMATTER_RE` (SSoT) + `verzeichnis`- + Substring + Ganzwort-/Präfix-Termliste (Plan §1).""" + if _FRONTMATTER_RE.search(title): + return True + if "verzeichnis" in title.lower(): + return True + for w in _norm_text(title).split(): + if w in _OUTLINE_SKIP_EXACT or any(w.startswith(p) for p in _OUTLINE_SKIP_PREFIXES): + return True + return False + + +def _outline_raw_entries(pdf_path: Path) -> list[tuple[int, str, int]]: + """[(level, title, phys0)] aus `get_toc(simple=False)`. + + Zielseite bevorzugt aus dem fitz-aufgelösten Tupel-Element (1-basiert, Index 2) + — bei 2/5 Stichproben-Büchern ist `dest["page"]` eine unaufgelöste named- + destination-String; das Tupel-Element ist dann der korrekte Wert (#345).""" + import fitz + + doc = fitz.open(str(Path(pdf_path).resolve())) + try: + toc = doc.get_toc(simple=False) + finally: + doc.close() + + entries: list[tuple[int, str, int]] = [] + for item in toc: + level = item[0] + title = _clean_outline_title(item[1]) + page_1based = item[2] + dest = item[3] if len(item) > 3 else None + phys0: int | None = None + if isinstance(page_1based, int) and page_1based > 0: + phys0 = page_1based - 1 + elif isinstance(dest, dict): + dp = dest.get("page", -1) + if isinstance(dp, int) and dp >= 0: + phys0 = dp + if phys0 is not None and title: + entries.append((level, title, phys0)) + return entries + + +def _select_main_level_entries(entries: list[tuple[int, str, int]]) -> list[tuple[int, str, int]]: + """Hauptkapitel-Ebene nach Front-/Backmatter-Filter. Root-Descend (R3/V4-3): + bleiben <2 Einträge UND existiert genau 1 Wurzel → eine Ebene tiefer (Moser- + Muster: 55 Einträge unter einer Wurzel).""" + if not entries: + return [] + min_level = min(e[0] for e in entries) + top = [e for e in entries if e[0] == min_level] + kept = [e for e in top if not _is_outline_skip_title(e[1])] + if len(kept) >= 2: + return kept + if len(top) == 1: + deeper = [e for e in entries if e[0] == min_level + 1] + kept_deeper = [e for e in deeper if not _is_outline_skip_title(e[1])] + if len(kept_deeper) >= 2: + return kept_deeper + return kept + + +def _merge_duplicate_boundaries(entries: list[tuple[int, str, int]]) -> list[tuple[int, str, int]]: + """Duplikate mit Seitenabstand ≤1 zusammenfassen — längerer Titel gewinnt, früheste + Seite bleibt (DAMA-Doppel-Bookmarks). Voraussetzung: nach `phys0` sortiert.""" + merged: list[tuple[int, str, int]] = [] + for lvl, title, phys0 in sorted(entries, key=lambda e: e[2]): + if merged and phys0 - merged[-1][2] <= 1: + plvl, ptitle, pphys0 = merged[-1] + if len(title) > len(ptitle): + merged[-1] = (plvl, title, pphys0) + else: + merged.append((lvl, title, phys0)) + return merged + + +def _pdftotext_raw_pages(pdf_path: Path) -> list[str] | None: + """Roh-Seiten (pdftotext, `\\f`-Split, OHNE Leerseiten-Filter) — physischer Index. + Fail-open: jeder Fehler → None (der Outline-Pfad fällt dann auf den Normalpfad).""" + try: + result = subprocess.run( + ["pdftotext", str(Path(pdf_path).resolve()), "-"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_PDF_SUBPROCESS_TIMEOUT_S, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + return result.stdout.split("\f") + + +def _physical_to_marker_map(pdf_path: Path) -> dict[int, int] | None: + """{physischer 0-basierter Seitenindex: `[S. N]`-Markernummer}, passend zu den + Markern aus `pdf_to_pages`/`pages_to_marked_text`. ZWEI Zweige (R3/V4-1): + + - Labels-nutzbar → Marker = numerisches Druckseiten-Label je physischer Seite + (Issue-#95-Klasse „PDF-Seite 179 → Druckseite 159" ist real). + - Labels-None → Marker = komprimierte Zählung nicht-leerer Seiten (Drift real + 3 [Klingenberg] bis 39 Seiten [Kuhlen]). + + `physical_pages_by_anchor` bleibt VERBOTEN (Identität ohne Labels, #342-Erbe).""" + raw = _pdftotext_raw_pages(pdf_path) + if raw is None: + return None + labels = _pdf_page_labels(pdf_path) + mapping: dict[int, int] = {} + if labels is not None: + numbered = _resolve_page_numbers(raw, labels) + for i, (num, page_text) in enumerate(numbered): + if page_text.strip(): + mapping[i] = num + else: + n = 0 + for i, page_text in enumerate(raw): + if page_text.strip(): + n += 1 + mapping[i] = n + return mapping or None + + +def _nearest_marker(pmap: dict[int, int], phys0: int, max_ahead: int = 3) -> int | None: + """Marker der Zielseite; ist sie leer/ungemappt, die nächste nicht-leere Seite.""" + for d in range(max_ahead + 1): + if phys0 + d in pmap: + return pmap[phys0 + d] + return None + + +def _title_overlap(title: str, window_text: str) -> float: + """Anteil der (inhaltlichen) Titel-Wörter, die im Fenster vorkommen (0..1).""" + words = [w for w in _norm_text(title).split() if len(w) > 2] + if not words: + words = _norm_text(title).split() + if not words: + return 0.0 + haystack = set(_norm_text(window_text).split()) + hit = sum(1 for w in words if w in haystack) + return hit / len(words) + + +def _validate_boundary(title: str, window_text: str) -> bool: + """Trägt das Fenster ab gemapptem Marker den Kapitel-Opener? Primär Wort-Overlap + (≥50 %); bei Fehlschlag HiPS-Kaskade (normalisiert Substring → Fuzzy 80 %).""" + if _title_overlap(title, window_text) >= _OUTLINE_TITLE_OVERLAP_MIN: + return True + nt = _norm_text(title) + nw = _norm_text(window_text) + if nt and nt in nw: + return True + try: + from rapidfuzz import fuzz + + if nt and fuzz.partial_ratio(nt, nw) >= _OUTLINE_FUZZY_THRESHOLD: + return True + except Exception: + pass + return False + + +def _find_marker_pos(text: str, marker: int) -> int | None: + """Position der `[S. marker]`-Marker-Zeile im Text (Marker sind eindeutig).""" + m = re.search(rf"(?m)^[ \t]*\[S\.\s*{marker}\][ \t]*$", text) + return m.start() if m else None + + +def _outline_chapters(text: str, pdf_path: Path) -> list[Chunk] | None: + """Outline-first Kapitel-Chunks oder None (→ heuristischer Normalpfad). + + Pipeline: Outline lesen → Hauptebene (+ Root-Descend) → Front-/Backmatter-Filter + → Duplikat-Merge → Sanity (≥2, ≤MAX) → physisch→Marker-Map → je Grenze Titel- + Overlap-Validierung (Fehlschlag: Grenze verwerfen/mergen; >50 % Fehlschläge: + Outline verwerfen) → Degenerations-Guards (Median, Riesensegment). + """ + try: + entries = _outline_raw_entries(pdf_path) + except Exception: + return None + if not entries: + return None + selected = _select_main_level_entries(entries) + if len(selected) < 2: + return None + merged = _merge_duplicate_boundaries(selected) + if not 2 <= len(merged) <= MAX_SANE_OUTLINE_CHAPTERS: + return None + + pmap = _physical_to_marker_map(pdf_path) + if not pmap: + return None + + # Grenze = (Titel, Markernummer). Zielseite leer → nächste nicht-leere Seite. + boundaries: list[tuple[str, int]] = [] + for _lvl, title, phys0 in merged: + marker = _nearest_marker(pmap, phys0) + if marker is not None: + boundaries.append((title, marker)) + if len(boundaries) < 2: + return None + + # Je Grenze am gemappten Marker validieren; Fehlschlag → Grenze verwerfen + # (Segment mergt implizit in den Vorgänger, da kein Split dort entsteht). + validated: list[tuple[str, int]] = [] + n_total = len(boundaries) + for title, marker in boundaries: + pos = _find_marker_pos(text, marker) + if pos is None: + continue + window = text[pos + 1 : pos + 1 + _OUTLINE_VALIDATION_WINDOW_CHARS + 12] + if _validate_boundary(title, window): + validated.append((title, marker)) + n_dropped = n_total - len(validated) + if len(validated) < 2 or n_dropped > n_total // 2: + return None + + # Nach Marker-Position sortieren + Duplikat-Marker (längerer Titel gewinnt). + positions: list[tuple[int, str, int]] = [] + seen_markers: set[int] = set() + for title, marker in validated: + if marker in seen_markers: + continue + seen_markers.add(marker) + pos = _find_marker_pos(text, marker) + if pos is not None: + positions.append((pos, title, marker)) + positions.sort() + if len(positions) < 2: + return None + + chunks: list[Chunk] = [] + for i, (start, title, _marker) in enumerate(positions): + end = positions[i + 1][0] if i + 1 < len(positions) else len(text) + chunk_text = text[start:end].strip() + prefix_pages = [int(mm.group(1)) for mm in _PAGE_MARKER_RE.finditer(text[:start])] + chunk_pages = [int(mm.group(1)) for mm in _PAGE_MARKER_RE.finditer(chunk_text)] + all_pages = ([prefix_pages[-1]] if prefix_pages else []) + chunk_pages + chunks.append( + Chunk( + title=title, + text=chunk_text, + index=i, + page_start=min(all_pages) if all_pages else None, + page_end=max(all_pages) if all_pages else None, + source="outline", + ) + ) + + # Degenerations-Guards (Sicherheitsnetz gegen fehlerhafte Outlines): ein + # Segment >70 % der Wörter ODER Median-Segment < MIN_CHAPTER_SEGMENT_WORDS. + seg_words = [len(c.text.split()) for c in chunks] + total_words = sum(seg_words) or 1 + if len(chunks) >= 3 and max(seg_words) > _OUTLINE_GIANT_SEGMENT_RATIO * total_words: + return None + if _median(seg_words) < MIN_CHAPTER_SEGMENT_WORDS: + return None + + # Diagnose + Offset-Voting-Kreuzcheck (Confidence-Flag, verändert den Split nicht). + agree, voted = _offset_vote(text, merged, pmap) + conf = "" if voted == 0 or agree * 2 >= voted else f" [map-confidence niedrig: {agree}/{voted}]" + print( + f" [chapter-split] {len(chunks)} Kapitel erkannt (Quelle: outline, " + f"Validierung {len(validated)}/{n_total}, {n_dropped} Grenzen verworfen){conf}", + file=sys.stderr, + ) + return chunks + + +def _offset_vote(text: str, selected: list[tuple[int, str, int]], pmap: dict[int, int]) -> tuple[int, int]: + """Kreuzvalidierung der Seiten-Map (Confidence-Flag, verändert den Split nicht). + + Für eine Stichprobe unabhängig ALLE Marker sammeln, deren Fenster den Titel trägt + (Kolumnentitel/Running-Heads erzeugen ein Plateau mehrerer Marker), und prüfen, ob + der Map-Marker DARIN liegt — Tie-Break Richtung Map/Shift 0 (Plan §2). Liegt er + außerhalb, ist die Map systematisch verschoben → niedrige Confidence. (agree, total). + """ + if len(selected) > _OUTLINE_OFFSET_VOTING_SAMPLE: + step = len(selected) / _OUTLINE_OFFSET_VOTING_SAMPLE + sample = [selected[int(i * step)] for i in range(_OUTLINE_OFFSET_VOTING_SAMPLE)] + else: + sample = selected + # Marker-Fenster einmalig als normalisierte Wort-Mengen vorberechnen. + marker_windows: list[tuple[int, set[str]]] = [] + for m in _MARKER_LINE_RE.finditer(text): + window = text[m.end() : m.end() + _OUTLINE_VALIDATION_WINDOW_CHARS] + marker_windows.append((int(m.group(1)), set(_norm_text(window).split()))) + agree = total = 0 + for _lvl, title, phys0 in sample: + map_marker = _nearest_marker(pmap, phys0) + if map_marker is None: + continue + words = [w for w in _norm_text(title).split() if len(w) > 2] or _norm_text(title).split() + if not words: + continue + thresh = _OUTLINE_TITLE_OVERLAP_MIN * len(words) + matches = {mk for mk, ws in marker_windows if sum(1 for w in words if w in ws) >= thresh} + if not matches: + continue + total += 1 + if map_marker in matches: + agree += 1 + return agree, total + + +def _median(values: list[int]) -> float: + if not values: + return 0.0 + s = sorted(values) + n = len(s) + return float(s[n // 2]) if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2 + + +def split_by_chapters(text: str, pdf_path: Path | None = None) -> list[Chunk]: + """Teilt Text (mit `[S. N]`-Markern) an Kapitelgrenzen. + + Mit `pdf_path` wird zuerst der Outline-first-Pfad (#345) versucht — Kapitelgrenze + = validierte PDF-Lesezeichen-Zielseite. Ohne nutzbare Outline (oder ohne + `pdf_path`) exakt das bisherige Verhalten: Heading-Heuristik, sonst Word-Count. + """ + if pdf_path is not None: + outline = _outline_chapters(text, pdf_path) + if outline is not None: + return outline + matches = [m for m in _CHAPTER_RE.finditer(text) if _is_real_chapter_match(m)] if len(matches) < 2: return _split_by_words(text) @@ -754,6 +1149,7 @@ def split_by_chapters(text: str) -> list[Chunk]: index=i, page_start=page_start, page_end=page_end, + source="heuristic", ) ) return chunks @@ -774,12 +1170,13 @@ def _split_by_words(text: str) -> list[Chunk]: index=i // CHUNK_WORDS, page_start=page_start, page_end=page_end, + source="words", ) ) return chunks -def extract_overview(text: str, max_words: int = 1500) -> str: +def extract_overview(text: str, max_words: int = 1500, chapters: list[Chunk] | None = None) -> str: """Repräsentativer Planner-Input über ALLE Kapitel, strikt innerhalb max_words. Alt: erste N + letzte K Wörter → mittlere Kapitel systematisch blind. @@ -789,6 +1186,10 @@ def extract_overview(text: str, max_words: int = 1500) -> str: Neu: Intro (min(600, max_words//3)) + Kapitel-Snippets (Budget-basiert, ohne Kapitel-1-Überlappung) + Fazit (min(300, max_words//5)). Alle Teile zusammen ≤ max_words. Fallback ohne Kapitel: Stichproben. + + `chapters` (#345, M1): bereits berechnete Chunks injizieren — vermeidet einen + zweiten Split pro Lauf und macht die Overview outline-basiert. Ohne `chapters` + (externe Aufrufer ohne pdf_path) exakt unverändert (heuristischer Split). """ words = text.split() n = len(words) @@ -799,7 +1200,7 @@ def extract_overview(text: str, max_words: int = 1500) -> str: parts = [" ".join(words[:intro_budget])] - chapters = split_by_chapters(text) + chapters = chapters if chapters is not None else split_by_chapters(text) # Kapitel-1-Überlappung vermeiden: erstes Kapitel hat oft denselben Inhalt # wie der Intro-Block → ab Index 1 beginnen (Gemini-Finding 2026-05-13). later_chapters = chapters[1:] if len(chapters) > 1 else [] diff --git a/generative/tests/test_by_chapter_degeneration_abort.py b/generative/tests/test_by_chapter_degeneration_abort.py new file mode 100644 index 0000000..929fdcf --- /dev/null +++ b/generative/tests/test_by_chapter_degeneration_abort.py @@ -0,0 +1,72 @@ +"""#345: --by-chapter bricht bei einem degenerierten Split (keine nutzbare Outline) +HART ab, statt mit Hunderten Mikro-„Kapiteln" loszulaufen (der 15,5-h-/32-Mio-Token-/ +0-Notes-Lauf). Ein echter Outline-Split (source="outline") passiert den Guard.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from generative import orchestrator +from generative.schemas.atomic_note import ConceptPlan, QualityReport + + +def _stub(monkeypatch, chunks): + pc = orchestrator.pdf_chunker + monkeypatch.setattr(pc, "pdf_to_text", lambda *_a, **_k: "Quelltext mit genug Woertern fuer den Test.") + monkeypatch.setattr(pc, "split_by_chapters", lambda *_a, **_k: chunks) + monkeypatch.setattr(pc, "extract_overview", lambda *_a, **_k: "Überblick") + monkeypatch.setattr( + pc, "pdf_metadata", lambda *_a, **_k: {"Author": "A", "Year": "2020", "Title": "T", "Pages": "500"} + ) + monkeypatch.setattr(orchestrator.acronym_fix, "extract_acronym_pairs", lambda *_a, **_k: {}) + monkeypatch.setattr( + orchestrator.context_builder, + "build_relevance_profile", + lambda *_a, **_k: {"existing_concepts": [], "tag_whitelist": []}, + ) + monkeypatch.setattr(orchestrator.context_builder, "build_concept_links", lambda *_a, **_k: {}) + monkeypatch.setattr( + orchestrator.quality, + "check_quality", + lambda **_kw: QualityReport(peer_reviewed=None, citation_count=None, retracted=False, flags=[]), + ) + monkeypatch.setattr(orchestrator.planner, "run", lambda *_a, **_k: ConceptPlan("T", "S", [])) + monkeypatch.setattr(orchestrator.planner, "filter_hallucinated", lambda plan, _text: (plan, [])) + + async def _no_concepts(*_a, **_k): + return ([], {}, 0, []) + + monkeypatch.setattr(orchestrator, "run_extractors_per_concept", _no_concepts) + + +def _chunks(n, source): + return [SimpleNamespace(title=f"Seg {i}", text=f"Text {i} " * 20, source=source) for i in range(n)] + + +def test_by_chapter_aborts_on_degenerate_non_outline_split(monkeypatch): + """Viele Chunks OHNE Outline (source="words") unter --by-chapter → SystemExit.""" + _stub(monkeypatch, _chunks(120, "words")) + args = SimpleNamespace(by_chapter=True, dry_run=True, doi=None, llm_fallback=False) + with pytest.raises(SystemExit) as exc: + orchestrator._run_extraction_stages(args, Path("fake.pdf"), None) + assert "degenerierter Split" in str(exc.value) + + +def test_by_chapter_proceeds_on_outline_split(monkeypatch): + """Viele Chunks MIT Outline (source="outline") → kein Abbruch (echter Buch-Split).""" + _stub(monkeypatch, _chunks(30, "outline")) + args = SimpleNamespace(by_chapter=True, dry_run=True, doi=None, llm_fallback=False) + # Läuft durch (kein SystemExit); Rückgabe ist die RunContext-Dataclass. + result = orchestrator._run_extraction_stages(args, Path("fake.pdf"), None) + assert result is not None + + +def test_normal_path_unaffected_by_guard(monkeypatch): + """Ohne --by-chapter bleibt selbst ein großer Nicht-Outline-Split erlaubt (Warnung, kein Abbruch).""" + _stub(monkeypatch, _chunks(120, "words")) + args = SimpleNamespace(by_chapter=False, dry_run=True, doi=None, llm_fallback=False) + result = orchestrator._run_extraction_stages(args, Path("fake.pdf"), None) + assert result is not None diff --git a/generative/tests/test_outline_chapter_split.py b/generative/tests/test_outline_chapter_split.py new file mode 100644 index 0000000..062e0cb --- /dev/null +++ b/generative/tests/test_outline_chapter_split.py @@ -0,0 +1,334 @@ +"""Outline-first Kapitel-Split (#345). + +Kapitelgrenze = validierte PDF-Lesezeichen-Zielseite statt Titel-Matching im +Volltext. Deckt die Akzeptanzkriterien v4 PR 1 (a)–(h) ab: + +- (a) 5-Bücher-Fixture: echte PDFs (skip wenn nicht vorhanden — Tills Privatbestand, + nicht in CI). Kuhlen als härtester Drift-Fall (39 Leerseiten), alle Grenzen + tragen den Kapitel-Opener (100 % Validierung). +- (b) Klingenberg 8–10 Kapitel, Quelle outline. +- (c) book-mode ohne Outline → Normalpfad-Fallback (synthetisch, läuft überall). +- (d) TOC-Trail-Negativtests aus echten Klingenberg-Zeilen (gespacte Dot-Leader). +- (e) Overview-Budget ≤ ~1500 Wörter. +- (g) synthetisches PageLabels-Fixture (Labels-Zweig der Seiten-Map). +- (h) Moser-artiges Single-Root-Fixture (Root-Descend). + +Die synthetischen Fixtures (c/e/g/h) + TOC-Trail (d) laufen ohne Bestandsdateien +und sind die dauerhafte Abdeckung; die echten Bücher (a/b) grounden die Empirie. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +import pytest + +from generative import config +from generative.pipeline import pdf_chunker as pc +from generative.pipeline.pdf_chunker import ( + _CHAPTER_RE, + _is_real_chapter_match, + _title_overlap, + _validate_boundary, + extract_overview, + split_by_chapters, +) + +fitz = pytest.importorskip("fitz") + + +# --------------------------------------------------------------------------- # +# Fixture-Bau: PDF mit Outline (+ optional PageLabels) und extrahierbarem Text +# --------------------------------------------------------------------------- # + +_FILLER_LINE = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed eiusmod" + + +def _make_pdf( + tmp_path: Path, + name: str, + n_pages: int, + openers: dict[int, str], + toc: list | None, + labels: list | None = None, + filler_lines: int = 44, +) -> Path: + """Baut ein PDF mit `n_pages` textreichen Seiten (mehrzeilig → pdftotext-lesbar), + optionaler Outline (`set_toc`) und optionalen `/PageLabels`. `openers[i]` setzt + den Seitenkopf physischer Seite i (Kapitel-Opener).""" + doc = fitz.open() + for i in range(n_pages): + page = doc.new_page(width=500, height=760) + head = openers.get(i, f"Fuellseite {i} mit Inhalt") + body = head + "\n" + "\n".join([_FILLER_LINE] * filler_lines) + page.insert_text((36, 30), body, fontsize=7) + if toc: + doc.set_toc(toc) + if labels: + doc.set_page_labels(labels) + out = tmp_path / name + doc.save(str(out)) + doc.close() + return out + + +def _opening_marker(chunk) -> int | None: + m = re.search(r"\[S\.\s*(\d+)\]", chunk.text) + return int(m.group(1)) if m else None + + +# --------------------------------------------------------------------------- # +# (g) Labels-Zweig der physisch→Marker-Seiten-Map +# --------------------------------------------------------------------------- # + + +def test_labels_branch_maps_to_printed_label(tmp_path): + """PageLabels-Fixture: Druckseite = physischer Index + 100. Die Kapitelgrenze + muss auf den LABEL-Marker ([S. 103]) mappen, nicht auf den physischen Index — + beweist den Labels-Zweig (Issue-#95-Klasse „PDF-Seite 179 → Druckseite 159").""" + pdf = _make_pdf( + tmp_path, + "labels.pdf", + n_pages=14, + openers={3: "Kapitel Eins Grundlagen der Theorie", 8: "Kapitel Zwei Methoden und Verfahren"}, + toc=[[1, "Kapitel Eins Grundlagen der Theorie", 4], [1, "Kapitel Zwei Methoden und Verfahren", 9]], + labels=[{"startpage": 0, "prefix": "", "style": "D", "firstpagenum": 100}], + ) + text = pc.pdf_to_text(pdf) + chunks = split_by_chapters(text, pdf_path=pdf) + + assert [c.source for c in chunks] == ["outline", "outline"] + # Physische Seite 3 → Label 103 (nicht der leerseiten-gezählte Kleinindex ~4). + assert _opening_marker(chunks[0]) == 103 + assert _opening_marker(chunks[1]) == 108 + # Jede Grenze trägt ihren Opener (Validierung; synthetisch sauber → voller Overlap). + assert _title_overlap(chunks[0].title, chunks[0].text[:500]) >= 0.5 + + +# --------------------------------------------------------------------------- # +# (h) Root-Descend: eine Wurzel, Kapitel eine Ebene tiefer (Moser-Muster) +# --------------------------------------------------------------------------- # + + +def test_root_descend_single_root(tmp_path): + """Genau 1 L1-Wurzel, echte Kapitel auf L2 → Root-Descend liefert die L2-Kinder + (Moser-Muster: 55 Einträge unter einer Wurzel).""" + pdf = _make_pdf( + tmp_path, + "moser.pdf", + n_pages=14, + openers={ + 3: "Erstes Kapitel Bibliothek und Wissen", + 7: "Zweites Kapitel Erschliessung der Bestaende", + 10: "Drittes Kapitel Digitale Angebote", + }, + toc=[ + [1, "Gesammelte Werke", 1], + [2, "Erstes Kapitel Bibliothek und Wissen", 4], + [2, "Zweites Kapitel Erschliessung der Bestaende", 8], + [2, "Drittes Kapitel Digitale Angebote", 11], + ], + ) + text = pc.pdf_to_text(pdf) + chunks = split_by_chapters(text, pdf_path=pdf) + + assert len(chunks) == 3 + assert all(c.source == "outline" for c in chunks) + titles = [c.title for c in chunks] + assert any("Erstes Kapitel" in t for t in titles) + assert any("Drittes Kapitel" in t for t in titles) + + +# --------------------------------------------------------------------------- # +# (c) Kein Outline → transparenter Fallback auf den Normalpfad +# --------------------------------------------------------------------------- # + + +def test_no_outline_falls_back_to_normal_path(tmp_path): + """PDF ohne Lesezeichen: `split_by_chapters(text, pdf_path)` fällt auf den + heuristischen Normalpfad zurück (source != outline) — ehrliche Grenze.""" + pdf = _make_pdf( + tmp_path, + "no_outline.pdf", + n_pages=10, + openers={}, # nur Füllseiten, keine Kapitel-Opener + toc=None, # keine Outline + ) + text = pc.pdf_to_text(pdf) + chunks = split_by_chapters(text, pdf_path=pdf) + + assert chunks[0].source != "outline" + assert chunks[0].source in ("words", "heuristic") + + +def test_no_pdf_path_is_backward_compatible(tmp_path): + """Ohne `pdf_path` exakt das bisherige Verhalten: Heading-Heuristik greift, + Outline wird nie konsultiert (Rückwärtskompatibilität aller Alt-Aufrufer).""" + text = ( + "[S. 1]\n1 Einleitung\n" + "Fliesstext " * 50 + "\n" + "[S. 5]\n2 Methoden\n" + "Mehr Text " * 50 + "\n" + "[S. 9]\n3 Ergebnisse\n" + "Noch mehr " * 50 + ) + chunks = split_by_chapters(text) # kein pdf_path + assert len(chunks) == 3 + assert all(c.source == "heuristic" for c in chunks) + + +# --------------------------------------------------------------------------- # +# (d) TOC-Trail-Negativtests aus echten Klingenberg-Zeilen (gespacte Dot-Leader) +# --------------------------------------------------------------------------- # + + +def _real_chapter_titles(text: str) -> list[str]: + return [m.group(0).strip() for m in _CHAPTER_RE.finditer(text) if _is_real_chapter_match(m)] + + +@pytest.mark.parametrize( + "toc_line", + [ + "1 Einleitung . . . . . . . . . . . . . . . . . . . . . . 1", + "2 Data, Governance und Co. . . . . . . . . . . . . . . 9", + "5 Rollen und Gremien fuer Data Governance . . . . . . . 101", + "6 Datenqualitaet . . . . . . . . . . . . . . . . . . . . 115", + "9 Zusammenfassung und Ausblick . . . . . . . . . . . . 257", + ], +) +def test_spaced_dot_leader_toc_line_not_a_chapter(toc_line): + """Echte Klingenberg-Inhaltsverzeichnis-Zeilen mit gespacten Dot-Leadern + (`. . . . 9`) sind KEINE Kapitel-Headings — die alte `_TOC_TRAIL_RE` (nur + `\\.{2,}`/`\\s{3,}`) ließ sie durch (#345-Ursache), die Erweiterung fängt sie.""" + assert _real_chapter_titles(toc_line) == [] + + +def test_roman_page_number_toc_line_filtered(): + """Frontmatter-TOC-Zeile mit römischer Seitenzahl am Ende.""" + assert _real_chapter_titles("1 Vorbemerkung . . . . . . . . . . xii") == [] + + +def test_extension_leaves_real_headings_intact(): + """Regressions-Schutz: echte Headings OHNE Trailer matchen weiter (keine + Über-Filterung durch die Erweiterung).""" + assert len(_real_chapter_titles("1 Einleitung\n2 Methoden\n3 Ergebnisse")) == 3 + + +# --------------------------------------------------------------------------- # +# (e) Overview-Budget +# --------------------------------------------------------------------------- # + + +def test_overview_budget_from_chapters(tmp_path): + """`extract_overview(text, chapters=chunks)` bleibt im Wort-Budget (~1500) und + weit unter dem Volltext (N2-Baseline lief auf ~21k).""" + openers = {i: f"Kapitel {i} Thema {i} Ueberschrift" for i in (3, 6, 9, 12)} + pdf = _make_pdf( + tmp_path, + "overview.pdf", + n_pages=15, + openers=openers, + toc=[[1, f"Kapitel {i} Thema {i} Ueberschrift", i + 1] for i in (3, 6, 9, 12)], + ) + text = pc.pdf_to_text(pdf) + chunks = split_by_chapters(text, pdf_path=pdf) + overview = extract_overview(text, chapters=chunks) + + n_overview = len(overview.split()) + assert n_overview <= 1700 # ~1500-Budget + Sektions-Dekoration + assert n_overview < len(text.split()) + + +# --------------------------------------------------------------------------- # +# (a)/(b) Echte 5 Bücher — skip wenn Bestandsdateien fehlen (nicht in CI) +# --------------------------------------------------------------------------- # + +_BOOK_DIR = Path(os.environ.get("ATOMIC_AGENT_TEST_BOOK_DIR", str(config.LITERATURE_DIR))) + +_BOOKS = { + "Klingenberg": ( + "Klingenberg und Weber - 2025 - Data Governance der Leitfaden für die Praxis.pdf", + (8, 10), + ), + "DAMA-DMBOK": ( + "DAMA International - 2024 - DAMA-DMBOK Data management body of knowledge.pdf", + (14, 20), + ), + "Kuhlen": ( + "Kuhlen et al. - 2022 - Grundlagen der Informationswissenschaft.pdf", + (6, 6), + ), + "Gantert": ( + "Gantert - 2016 - Bibliothekarisches Grundwissen.pdf", + (4, 9), + ), + "Hobohm": ( + "Hobohm - 2024 - Age of Access Grundfragen der Informationsgesellschaft.pdf", + (10, 14), + ), +} + + +def _resolve_book(name: str) -> Path: + filename, _band = _BOOKS[name] + path = _BOOK_DIR / filename + if not path.exists(): + pytest.skip(f"Bestands-PDF nicht vorhanden ({name}); setze ATOMIC_AGENT_TEST_BOOK_DIR") + return path + + +@pytest.mark.parametrize("name", list(_BOOKS)) +def test_real_book_outline_split(name): + """(a) Jedes Buch: Quelle outline, Kapitelzahl im erwarteten Band, und JEDE + Grenze trägt den Kapitel-Opener im gemappten Fenster (100 % Validierung — + kein stiller Falsch-Split).""" + path = _resolve_book(name) + lo, hi = _BOOKS[name][1] + text = pc.pdf_to_text(path) + chunks = split_by_chapters(text, pdf_path=path) + + assert chunks[0].source == "outline", f"{name}: erwartete outline-Quelle" + assert lo <= len(chunks) <= hi, f"{name}: {len(chunks)} Kapitel außerhalb [{lo},{hi}]" + # 100 % der emittierten Grenzen passieren das Validierungs-Gate (Wort-Overlap + # ODER HiPS-Kaskade Substring/Fuzzy — dieselbe Funktion wie im Split, fängt + # Encoding-Mojibake „Datenqualit�t" und Teil-Divider-Abweichungen ab). + for c in chunks: + assert _validate_boundary(c.title, c.text[:600]), f"{name}: Opener fehlt bei {c.title!r}" + + +def test_klingenberg_chapter_count_and_source(): + """(b) Klingenberg: 8–10 Kapitel, Quelle outline; und der Heuristik-Pfad + (ohne pdf_path) degeneriert nachweislich (viele Mikro-Chunks) — der Grund für #345.""" + path = _resolve_book("Klingenberg") + text = pc.pdf_to_text(path) + outline_chunks = split_by_chapters(text, pdf_path=path) + heuristic_chunks = split_by_chapters(text) # Alt-Pfad = degeneriert + + assert outline_chunks[0].source == "outline" + assert 8 <= len(outline_chunks) <= 10 + # Der Alt-Pfad produziert ein Vielfaches (TOC-Einträge als Mikro-Kapitel). + assert len(heuristic_chunks) > 3 * len(outline_chunks) + + +def test_kuhlen_hardest_drift_case(): + """(a) Kuhlen ist der härteste Drift-Fall (39 Leerseiten, kein PageLabels): + 6 Teile A–F, alle über die Zähl-Map korrekt getroffen.""" + path = _resolve_book("Kuhlen") + text = pc.pdf_to_text(path) + chunks = split_by_chapters(text, pdf_path=path) + + assert chunks[0].source == "outline" + assert len(chunks) == 6 + joined = " ".join(c.title for c in chunks) + assert "Information Retrieval" in joined + + +def test_gantert_keeps_literatur_content_chapter(): + """Regressions-Schutz: „Zweiter Teil. … Literatur, Bücher, Medien" ist ein + echtes Kapitel — der Backmatter-Filter darf es nicht wegen des Wortes + „Literatur" droppen (bare-Substring-Falle).""" + path = _resolve_book("Gantert") + text = pc.pdf_to_text(path) + chunks = split_by_chapters(text, pdf_path=path) + + assert chunks[0].source == "outline" + assert any("Zweiter Teil" in c.title for c in chunks)