From 71f26d85a148ea8ddfb9ac051b5850025dba9c65 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Sat, 27 Jun 2026 19:09:28 +0200 Subject: [PATCH 1/3] fix: Seitenanker aus PDF-Druckseiten (/PageLabels) statt Form-Feed-Index pdf_to_pages nummerierte Seiten stumpf ab 1 (Form-Feed-Position) und ignorierte die echten Druckseiten-Labels -> Buch-Kapitel zitierten falsche Seiten (S. 1 statt S. 159). pdf_to_pages liest jetzt /PageLabels via _pdf_page_labels + _resolve_page_numbers (Whitespace/non-str gehaertet). Eindeutigkeits-Gate _usable_page_labels: Label-Modus nur bei vollstaendig numerischen, eindeutigen UND monoton steigenden Labels -- sonst einheitlicher i+1-Fallback. Verhindert Namespace-Kollision roemisch<->arabisch (False-Bind in figure_alt) und falsche min/max-Chunk-Ranges. figure_alt.pdf_index_to_anchor_page + eval_figure_feasibility._page_visual_signals an die Druckseiten-Labels angepasst (sonst Seite==Index-Regression). Cross-Model-reviewt (Codex fing den HIGH False-Bind + 2 Re-Review-Runden), 668 Tests gruen. --- generative/eval_figure_feasibility.py | 15 ++++- generative/pipeline/figure_alt.py | 18 ++++-- generative/pipeline/pdf_chunker.py | 81 +++++++++++++++++++++++++-- generative/tests/test_figure_alt.py | 18 ++++++ generative/tests/test_pdf_chunker.py | 57 +++++++++++++++++++ 5 files changed, 179 insertions(+), 10 deletions(-) diff --git a/generative/eval_figure_feasibility.py b/generative/eval_figure_feasibility.py index 4b78d13..6990db6 100644 --- a/generative/eval_figure_feasibility.py +++ b/generative/eval_figure_feasibility.py @@ -151,13 +151,24 @@ def _count_raster_image_placements(page: Any) -> int: def _page_visual_signals(pdf_path: Path, pages: list[tuple[int, str]]) -> list[PageVisualSignals]: """Duenne PyMuPDF-Abstraktion fuer echte PDF-Signale.""" import fitz # PyMuPDF + from generative.pipeline.pdf_chunker import _pdf_page_labels + + # `page_number` aus pdf_to_pages ist das Druckseiten-Label (z.B. 159), NICHT + # der physische PyMuPDF-Index. Mapping Label→physischer Index aus /PageLabels; + # ohne Labels ist `page_number` die 1-basierte Position → Index = page_number-1. + labels = _pdf_page_labels(pdf_path) + label_to_index: dict[str, int] = {} + if labels: + for idx, lbl in enumerate(labels): + label_to_index.setdefault(str(lbl).strip(), idx) signals: list[PageVisualSignals] = [] with fitz.open(str(pdf_path)) as doc: for page_number, page_text in pages: - if page_number < 1 or page_number > len(doc): + pymupdf_index = label_to_index.get(str(page_number), page_number - 1) + if pymupdf_index < 0 or pymupdf_index >= len(doc): continue - page = doc[page_number - 1] + page = doc[pymupdf_index] raster_images = _count_raster_image_placements(page) vector_drawings = len(page.get_drawings()) signals.append(PageVisualSignals( diff --git a/generative/pipeline/figure_alt.py b/generative/pipeline/figure_alt.py index dc23180..8d4a359 100644 --- a/generative/pipeline/figure_alt.py +++ b/generative/pipeline/figure_alt.py @@ -90,11 +90,13 @@ def embed_alt_figures(pdf_path: Path, drafts: list[AtomicNoteDraft]) -> BindRepo with fitz.open(str(pdf_path)) as doc: page_count = doc.page_count has_text = _page_text_flags(pdf_path, page_count) + from generative.pipeline.pdf_chunker import _pdf_page_labels + page_labels = _pdf_page_labels(pdf_path) figures: list[TaggedFigure] = [] report = BindReport() for page_index, alt in raw: - anchor_page = pdf_index_to_anchor_page(has_text, page_index) + anchor_page = pdf_index_to_anchor_page(has_text, page_index, page_labels) if anchor_page is None: # Figur auf textloser Seite -> kein source_anchor kann sie tragen. # anchor_page-Sentinel, da es keine gueltige pdftotext-Seite gibt. @@ -203,11 +205,14 @@ def extract_tagged_figures(pdf_path: Path) -> list[tuple[int, str]]: return figures -def pdf_index_to_anchor_page(has_text: list[bool], pymupdf_index: int) -> int | None: +def pdf_index_to_anchor_page(has_text: list[bool], pymupdf_index: int, + page_labels: list | None = None) -> int | None: """Bildet einen 0-basierten PyMuPDF-Seitenindex auf die source_anchor-Seitennummer ab. - ``source_anchors`` nutzen die pdftotext-Nummerierung (``pdf_chunker.pdf_to_pages``): - 1-basiert, ueber NUR die textfuehrenden Seiten (textlose Seiten werden verworfen). + ``source_anchors`` tragen dieselbe Seitenzahl wie ``pdf_chunker.pdf_to_pages``: + das numerische Druckseiten-Label aus ``/PageLabels`` (Buch: PyMuPDF-Seite 178 → + „159"), falls vorhanden — sonst die 1-basierte Zählung ueber NUR die + textfuehrenden Seiten (= altes pdftotext-Verhalten, PDFs ohne Labels unveraendert). ``has_text[i]`` sagt, ob PyMuPDF-Seite i pdftotext-Text liefert. Liegt die Figur selbst auf einer textlosen Seite, kann kein source_anchor diese @@ -215,4 +220,9 @@ def pdf_index_to_anchor_page(has_text: list[bool], pymupdf_index: int) -> int | """ if not has_text[pymupdf_index]: return None + # Mit numerischem Druckseiten-Label: konsistent zu pdf_to_pages/source_anchors. + if page_labels and pymupdf_index < len(page_labels): + label = str(page_labels[pymupdf_index]).strip() + if label.isdigit(): + return int(label) return sum(1 for flag in has_text[: pymupdf_index + 1] if flag) diff --git a/generative/pipeline/pdf_chunker.py b/generative/pipeline/pdf_chunker.py index f6b5b80..e7f9888 100644 --- a/generative/pipeline/pdf_chunker.py +++ b/generative/pipeline/pdf_chunker.py @@ -34,8 +34,73 @@ class Chunk: _PAGE_MARKER_LINE_RE = re.compile(r"^\s*\[S\.\s*\d+\]\s*$", re.MULTILINE) +def _pdf_page_labels(pdf_path: Path) -> list[str] | None: + """Druckseiten-Bezeichner (`/PageLabels`) je PDF-Seite, oder None wenn das PDF + keine führt. Fail-open: jeder pypdf-Fehler → None. **Nur** wenn `/PageLabels` + real vorhanden ist, weicht das Ergebnis vom alten i+1-Verhalten ab — PDFs ohne + Labels (die meisten Paper/Test-Fixtures) bleiben damit bit-identisch.""" + try: + from pypdf import PdfReader + from pypdf.generic import DictionaryObject + reader = PdfReader(str(pdf_path)) + root = reader.trailer["/Root"].get_object() + if not isinstance(root, DictionaryObject) or "/PageLabels" not in root: + return None + return _usable_page_labels(list(reader.page_labels)) + except Exception: + return None + + +def _usable_page_labels(labels: list | None) -> list | None: + """Gibt ``labels`` nur zurück, wenn ALLE numerisch UND eindeutig sind — sonst None. + + Verhindert, dass nicht-numerische (römische) Labels auf den i+1-Fallback fallen + und mit echten numerischen Druckseiten im selben ``S. N``-Namespace kollidieren + (→ False-Binds in figure_alt) bzw. dass doppelte Labels die Label→Index-Abbildung + mehrdeutig machen. Gemischt/doppelt → einheitlicher i+1-Pfad für ALLE Konsumenten. + (Codex-Review, 2. Durchgang.)""" + if not labels: + return None + stripped = [str(label).strip() for label in labels] + if not all(s.isdigit() for s in stripped): + return None + if len(set(stripped)) != len(stripped): + return None + # Auch strikt monoton steigend verlangen: nicht-monotone (aber eindeutige) + # Labels wie 100,1,2 würden in min/max-Chunk-Ranges (page_range_of_text, + # split_by_chapters) falsche breite Spannen erzeugen. (Codex-Re-Review.) + nums = [int(s) for s in stripped] + if nums != sorted(nums): + return None + return labels + + +def _resolve_page_numbers( + pages_raw: list[str], labels: list | None +) -> list[tuple[int, str]]: + """Ordnet jeder Seite ihre zitierfähige Seitenzahl zu: das numerische + Druckseiten-Label, sonst die 1-basierte Form-Feed-Position. + + Nicht-numerische Labels (römisches Frontmatter) fallen bewusst auf den Index + zurück — die Anker-Kette (`PAGE_MARKER_RE`, `_extract_page_span`) erwartet + `\\d+`. Längen-Mismatch (pdftotext-Extraseite via finalem \\f) ist sicher.""" + out: list[tuple[int, str]] = [] + for i, page_text in enumerate(pages_raw): + raw = labels[i] if labels and i < len(labels) else None + # robust: pypdf-Labels können Whitespace (" 159 ") oder selten non-str + # tragen → strippen/coercen statt aufs Form-Feed zurückzufallen/zu crashen. + label = str(raw).strip() if raw is not None else "" + num = int(label) if label.isdigit() else i + 1 + out.append((num, page_text)) + return out + + def pdf_to_pages(pdf_path: Path) -> list[tuple[int, str]]: - """Liefert [(page_num, page_text), ...] via pdftotext + \\f-Split.""" + """Liefert [(page_num, page_text), ...] via pdftotext + \\f-Split. + + `page_num` ist die zitierfähige Druckseite aus den PDF-`/PageLabels`, falls das + PDF welche führt (Buch: PDF-Seite 179 → Druckseite „159"); sonst die 1-basierte + pdftotext-Position (Paper ohne Labels — unverändertes Verhalten).""" from generative.pipeline.error_hints import pdftotext_error_hint try: result = subprocess.run( @@ -49,9 +114,17 @@ def pdf_to_pages(pdf_path: Path) -> list[tuple[int, str]]: if result.returncode != 0: sys.exit(pdftotext_error_hint(result.stderr)) pages_raw = result.stdout.split("\f") - # letzte page kann leer sein (pdftotext hängt oft \f am Ende an) - pages_raw = [p for p in pages_raw if p.strip()] - return [(i + 1, p) for i, p in enumerate(pages_raw)] + labels = _pdf_page_labels(pdf_path) + if labels is None: + # Unverändertes Verhalten: leere Seiten verwerfen, lückenlos ab 1 zählen + # (pdftotext hängt oft ein finales \\f → leere letzte Seite). + pages_raw = [p for p in pages_raw if p.strip()] + return [(i + 1, p) for i, p in enumerate(pages_raw)] + # Mit Druckseiten-Labels: Label-Index = PDF-Seite, daher VOR dem Leerseiten- + # Filter zuordnen (eine leere Seite mittendrin darf die Folgeseiten nicht + # verschieben), dann leere Seiten verwerfen. + numbered = _resolve_page_numbers(pages_raw, labels) + return [(n, p) for n, p in numbered if p.strip()] def pages_to_marked_text(pages: list[tuple[int, str]]) -> str: diff --git a/generative/tests/test_figure_alt.py b/generative/tests/test_figure_alt.py index 838b927..1bd2be2 100644 --- a/generative/tests/test_figure_alt.py +++ b/generative/tests/test_figure_alt.py @@ -70,6 +70,24 @@ def test_anchor_page_on_textless_page_is_unbindable(): assert pdf_index_to_anchor_page(flags, 1) is None +def test_anchor_page_uses_numeric_print_labels_when_present(): + # Mit /PageLabels (Buch) ist die Anker-Seite das Druckseiten-Label, nicht die + # positionale Zählung — sonst matcht sie die source_anchors (jetzt Druckseiten) + # nicht mehr (Regression durch den page-label-Fix). + flags = [True, True, True] + labels = ["159", "160", "161"] + assert pdf_index_to_anchor_page(flags, 0, labels) == 159 + assert pdf_index_to_anchor_page(flags, 2, labels) == 161 + + +def test_anchor_page_nonnumeric_label_falls_back_to_positional(): + # Römisches Frontmatter-Label (nicht-numerisch) → positionaler Fallback, + # konsistent mit _resolve_page_numbers. + flags = [True, True] + labels = ["xi", "xii"] + assert pdf_index_to_anchor_page(flags, 1, labels) == 2 + + def test_bind_unique_create_match_mutates_body(): fig = TaggedFigure(anchor_page=3, alt_text="Ein Balkendiagramm zur Suche.", label="Abbildung 2") draft = _draft("Suche", ["S. 3"]) diff --git a/generative/tests/test_pdf_chunker.py b/generative/tests/test_pdf_chunker.py index f44b5f0..6e5a752 100644 --- a/generative/tests/test_pdf_chunker.py +++ b/generative/tests/test_pdf_chunker.py @@ -292,6 +292,63 @@ def test_inline_page_ref_not_treated_as_page_start(): ) +# ---- _resolve_page_numbers (Druckseiten-Labels statt Form-Feed-Index) ----- + +def test_resolve_page_numbers_uses_numeric_labels(): + """Echte (arabische) Druckseiten-Labels werden als Seitenzahl genutzt — nicht + die Form-Feed-Position. Buch-Kapitel/-Extrakt: PDF-Seite 1 trägt Druckseite 159.""" + from generative.pipeline.pdf_chunker import _resolve_page_numbers + assert _resolve_page_numbers(["a", "b", "c"], ["159", "160", "161"]) == [ + (159, "a"), (160, "b"), (161, "c") + ] + + +def test_resolve_page_numbers_roman_falls_back_to_index(): + """Nicht-numerische Labels (römisches Frontmatter) dürfen die \\d+-Anker-Kette + nicht brechen → Fallback auf 1-basierten Form-Feed-Index.""" + from generative.pipeline.pdf_chunker import _resolve_page_numbers + assert _resolve_page_numbers(["a", "b", "c"], ["xi", "xii", "1"]) == [ + (1, "a"), (2, "b"), (1, "c") + ] + + +def test_resolve_page_numbers_no_labels_is_index(): + """Kein PageLabels-Eintrag (labels=None) → exakt das alte Verhalten (i+1).""" + from generative.pipeline.pdf_chunker import _resolve_page_numbers + assert _resolve_page_numbers(["a", "b"], None) == [(1, "a"), (2, "b")] + + +def test_resolve_page_numbers_length_mismatch_safe(): + """pdftotext kann eine Extraseite liefern (finaler \\f) → überzählige Seiten + fallen sauber auf den Index zurück, kein IndexError.""" + from generative.pipeline.pdf_chunker import _resolve_page_numbers + assert _resolve_page_numbers(["a", "b", "c"], ["159", "160"]) == [ + (159, "a"), (160, "b"), (3, "c") + ] + + +def test_resolve_page_numbers_strips_whitespace_and_coerces_nonstr(): + """pypdf-Labels können Whitespace (' 159 ') oder (selten) non-str tragen → + robust strippen/coercen statt aufs Form-Feed zurückzufallen oder zu crashen + (Qwen-Review HIGH/MED, 2. Durchgang).""" + from generative.pipeline.pdf_chunker import _resolve_page_numbers + assert _resolve_page_numbers(["a", "b"], [" 159 ", 160]) == [(159, "a"), (160, "b")] + + +def test_usable_page_labels_gate_requires_numeric_and_unique(): + """Label-Modus nur bei vollständig numerischen UND eindeutigen Labels — sonst + None. Verhindert Namespace-Kollision römisch↔arabisch (False-Bind in figure_alt) + und mehrdeutige Index-Abbildung bei Duplikaten (Codex-Review, 2. Durchgang).""" + from generative.pipeline.pdf_chunker import _usable_page_labels + assert _usable_page_labels(["159", "160", "161"]) == ["159", "160", "161"] + assert _usable_page_labels([" 159 ", "160"]) == [" 159 ", "160"] # numerisch m. Whitespace ok + assert _usable_page_labels(["xi", "xii", "1"]) is None # gemischt römisch/arabisch + assert _usable_page_labels(["1", "2", "2"]) is None # doppelt → mehrdeutig + assert _usable_page_labels(["100", "1", "2"]) is None # nicht monoton → falsche Ranges + assert _usable_page_labels(None) is None + assert _usable_page_labels([]) is None + + # ---- assess_text_quality (G6/#27 — Textqualitäts-Gate) ------------------- def test_empty_text_is_empty_not_thin(): From 01462f822a5953918208ed18051a7954b9c3f08d Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Sat, 27 Jun 2026 19:09:29 +0200 Subject: [PATCH 2/3] fix: drei kleine Pipeline-Bugs (Multi-Agent-Review-Durchgang) - cross_reference._clean_wikilink: strip('[]') verstuemmelte Titel mit Klammern an einem Ende ('[2024] Projekt' -> '2024] Projekt'); jetzt nur paarweise von aussen strippen, wenn beide Enden Klammern tragen. - extractor: Trunkierungs-Retry-_PROMPT.format() fehlte related_mentions_block -> KeyError -> Note ging verloren statt Fallback aufs Original. - vault_writer.build_quellen_block: Seiten lexikografisch sortiert (S. 159, 160, 9); jetzt numerisch + range-aware ('159-160') + Leerstring-Filter. 668 Tests gruen. --- generative/agents/cross_reference.py | 11 +++++++++-- generative/agents/extractor.py | 1 + generative/pipeline/vault_writer.py | 11 +++++++++-- generative/tests/test_cross_reference_links.py | 8 ++++++++ generative/tests/test_vault_writer.py | 11 +++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/generative/agents/cross_reference.py b/generative/agents/cross_reference.py index e790739..78819be 100644 --- a/generative/agents/cross_reference.py +++ b/generative/agents/cross_reference.py @@ -19,8 +19,15 @@ def _clean_wikilink(s: str) -> str: eckige Klammern: '[[[[A]]]]' -> 'A', '[[A]]' -> 'A', 'A' -> 'A', '[[A|x]]' -> 'A|x'. Härtet gegen vom LLM gelieferte oder doppelt gewrappte Strings, die sonst zu - '[[[[..]]]]' führen (Muster wie vault_writer.rewrite_merged_related_links).""" - return (s or "").strip().strip("[]").strip() + '[[[[..]]]]' führen (Muster wie vault_writer.rewrite_merged_related_links). + + Strippt Klammern NUR paarweise von außen, wenn der String an beiden Enden eine + trägt — sonst zerstörte `strip('[]')` Titel wie '[2024] Projekt' → '2024] Projekt' + oder 'Array [1]' → 'Array [1' (Qwen-Review HIGH, 2. Durchgang).""" + s = (s or "").strip() + while s.startswith("[") and s.endswith("]"): + s = s[1:-1].strip() + return s def _clean_dup_targets(raw: str | None) -> list[str]: diff --git a/generative/agents/extractor.py b/generative/agents/extractor.py index 615e705..cf83bf6 100644 --- a/generative/agents/extractor.py +++ b/generative/agents/extractor.py @@ -409,6 +409,7 @@ async def run_per_concept(concept, concept_text: str, concepts=concepts_str, existing=existing_str or "(noch keine)", background_block=_format_background_block(background_context), + related_mentions_block=_format_related_mentions(related_mentions), tag_whitelist=_format_tag_whitelist(tag_whitelist, source_text=concept_text), chunk_title=concept.title, chunk_text=concept_text[:8000], diff --git a/generative/pipeline/vault_writer.py b/generative/pipeline/vault_writer.py index 0e86c6b..10e0fd4 100644 --- a/generative/pipeline/vault_writer.py +++ b/generative/pipeline/vault_writer.py @@ -133,11 +133,18 @@ def build_quellen_block(note: AtomicNoteDraft, source_file: str, # (rapidfuzz-Fallback) — beide sind valide Seitenbelege für den Quellen-Block. # Issue #20: Anker-Werte enthalten bereits den `S. `-Prefix (Verifier setzt # `page_str = f"S. {n}"`). Hier strippen, da Z. 119 ihn erneut voranstellt. - pages = sorted({ + _seen_pages = { _strip_page_prefix((a.page or a.fuzzy_page).strip()) for a in note.source_anchors if (a.page or a.fuzzy_page) and (a.page or a.fuzzy_page).strip().lower() not in ("none", "null", "") - }) + } + # Leere Reste (z.B. "S. " ohne Zahl → "") raus; numerisch statt lexikografisch + # sortieren und range-aware (Anker tragen auch "159–160" → int() auf die erste + # Zahl, sonst mis-sortiert/crasht ein Range). (Qwen-Review HIGH, 2. Durchgang.) + pages = sorted( + (p for p in _seen_pages if p), + key=lambda p: (int(m.group()) if (m := re.match(r"\d+", p)) else 10**9, p), + ) pages_str = ", ".join(pages) if pages else "" # Quellen-Block: Wikilink zeigt direkt auf die PDF im Vault (Junction diff --git a/generative/tests/test_cross_reference_links.py b/generative/tests/test_cross_reference_links.py index e277296..1a688a0 100644 --- a/generative/tests/test_cross_reference_links.py +++ b/generative/tests/test_cross_reference_links.py @@ -25,6 +25,14 @@ def test_clean_wikilink_handles_empty(): assert cr._clean_wikilink(None) == "" +def test_clean_wikilink_does_not_mangle_inner_brackets(): + # strip("[]") zerstörte Titel mit Klammern an einem Ende; jetzt nur paarweise + # von außen strippen, wenn beide Enden Klammern tragen (Qwen-Review HIGH). + assert cr._clean_wikilink("[2024] Projekt X") == "[2024] Projekt X" + assert cr._clean_wikilink("Array [1]") == "Array [1]" + assert cr._clean_wikilink("[Titel](path.md)") == "[Titel](path.md)" + + def test_rewrapped_link_is_single_pair(): # Der Code baut dup_link als f"[[{_clean_wikilink(x)}]]" — nie doppelt geklammert. raw = "[[Kirkpatrick Level 1→2 Zusammenhang]]" diff --git a/generative/tests/test_vault_writer.py b/generative/tests/test_vault_writer.py index 88fb797..93c5f87 100644 --- a/generative/tests/test_vault_writer.py +++ b/generative/tests/test_vault_writer.py @@ -171,6 +171,17 @@ def test_multiple_pages_each_stripped(self): self.assertIn(", S. 1, 5*", out) self.assertNotIn("S. S.", out) + def test_pages_sorted_numerically_not_lexicographically(self): + # Gemischt-stellige Seiten (durch den page-label-Fix: Druckseiten 9, 159…) + # müssen numerisch sortiert werden, nicht lexikografisch (Qwen-Review HIGH). + draft = _draft_with_anchors([ + TextAnchor(quote="a", page="S. 159"), + TextAnchor(quote="b", page="S. 9"), + TextAnchor(quote="c", page="S. 159–160"), + ]) + out = build_quellen_block(draft, self.SRC, self.META) + self.assertIn(", S. 9, 159, 159–160*", out) + class TestRewriteMergedRelatedLinks(unittest.TestCase): """Issue #21: Drafts, die beim Schreiben zu Merge-Stubs werden (Title-/Alias- From a27289ac8282a390e4e5c27442c496cfb1ec4807 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Sat, 27 Jun 2026 20:34:28 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=5Fusable=5Fpage=5Flabels=20h=C3=A4r?= =?UTF-8?q?ten=20=E2=80=94=20Zero-Pad-Duplikate=20+=20Unicode-Ziffern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Lücken im PageLabel-Gate (PR #79), gefunden im Cross-Model-Bug-Hunt (Qwen + Codex) am echten KSS-Handbuch-Kapitel (Reimer 2013, B 3): 1. Eindeutigkeit wurde auf Strings geprüft (len(set(stripped))), die Monotonie ließ aber numerische Duplikate durch: ["01","1","2"] passierte das Gate (Strings unique, sorted([1,1,2])==[1,1,2]) → zwei Seiten "S. 1" → False-Bind. Jetzt numerische Eindeutigkeit (len(set(nums))). 2. str.isdigit() ist True für Unicode-Superscripts (²), die int() nicht parsen kann → ValueError, bisher nur durch das except im Aufrufer maskiert. isdecimal() lehnt sie sauber ab (Gate selbst-korrekt). Beide via TDD, 34 pdf_chunker-Tests + volle generative-Suite (621) grün. --- generative/pipeline/pdf_chunker.py | 13 ++++++++++--- generative/tests/test_pdf_chunker.py | 7 +++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/generative/pipeline/pdf_chunker.py b/generative/pipeline/pdf_chunker.py index e7f9888..bc619d2 100644 --- a/generative/pipeline/pdf_chunker.py +++ b/generative/pipeline/pdf_chunker.py @@ -62,14 +62,21 @@ def _usable_page_labels(labels: list | None) -> list | None: if not labels: return None stripped = [str(label).strip() for label in labels] - if not all(s.isdigit() for s in stripped): + # isdecimal() statt isdigit(): isdigit() ist True für Unicode-Superscripts (²), + # die int() dann nicht parsen kann (ValueError). isdecimal() == genau die von + # int() akzeptierten Ziffern → kein Crash, sauberer Fallback. (Codex-Review.) + if not all(s.isdecimal() for s in stripped): return None - if len(set(stripped)) != len(stripped): + nums = [int(s) for s in stripped] + # Eindeutigkeit auf der ZAHL prüfen, nicht dem String: "01" und "1" sind als + # String verschieden, als Druckseite identisch → zwei Seiten "S. 1" (False-Bind). + # Numerische Eindeutigkeit erzwingt zusammen mit der Monotonie echte strikte + # Monotonie. (Qwen-Review, 2026-06-27.) + if len(set(nums)) != len(nums): return None # Auch strikt monoton steigend verlangen: nicht-monotone (aber eindeutige) # Labels wie 100,1,2 würden in min/max-Chunk-Ranges (page_range_of_text, # split_by_chapters) falsche breite Spannen erzeugen. (Codex-Re-Review.) - nums = [int(s) for s in stripped] if nums != sorted(nums): return None return labels diff --git a/generative/tests/test_pdf_chunker.py b/generative/tests/test_pdf_chunker.py index 6e5a752..4ec8e01 100644 --- a/generative/tests/test_pdf_chunker.py +++ b/generative/tests/test_pdf_chunker.py @@ -345,6 +345,13 @@ def test_usable_page_labels_gate_requires_numeric_and_unique(): assert _usable_page_labels(["xi", "xii", "1"]) is None # gemischt römisch/arabisch assert _usable_page_labels(["1", "2", "2"]) is None # doppelt → mehrdeutig assert _usable_page_labels(["100", "1", "2"]) is None # nicht monoton → falsche Ranges + # Zero-Padding-Duplikat: als Strings verschieden ("01"≠"1"), als Zahl gleich (1==1). + # Muss abgelehnt werden, sonst zwei Seiten mit [S. 1] → False-Bind (Qwen-Review, 2026-06-27). + assert _usable_page_labels(["01", "1", "2"]) is None + # Unicode-Ziffern: str.isdigit() ist True für Superscripts (²), aber int("²") crasht. + # isdecimal()-Gate lehnt sie ab statt sich auf das except im Aufrufer zu verlassen + # (Codex-Review, 2026-06-27). + assert _usable_page_labels(["1", "²"]) is None assert _usable_page_labels(None) is None assert _usable_page_labels([]) is None