Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion generative/pipeline/pdf_chunker.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,28 @@ def concept_text_window(full_text: str, search_terms: list[str],
if not words:
return ""

# Seite pro Wort-Index tracken: damit ein selektiertes Fenster, das mitten auf
# einer Seite beginnt (der `[S. N]`-Marker stand am Seitenanfang, vor dem
# Fenster), seinen korrekten Marker vorangestellt bekommt. Sonst erbt die
# Downstream-Seitenableitung ("letzter [S. N]-Marker vor der Fundstelle":
# Extractor-LLM, Verifier, Renderer) die Seite eines früheren Snippets →
# falsche Fußnoten-Seite (#4 Anker-Clustering, Merrill-Run 2026-06-24).
# NUR line-isolierte Pipeline-Marker (`\n\n[S. N]\n\n` aus pages_to_marked_text)
# zählen als Seitenanfang — Inline-Quellenverweise wie „vgl. [S. 12]" im
# Fließtext NICHT (sonst erbt Folgetext die zitierte statt der echten Seite;
# Codex-Review 2026-06-24). re.finditer(r"\S+") liefert dieselbe Token-Folge
# wie full_text.split() oben, plus Positionen fürs Marker-Mapping.
_real_markers = [(m.start(), m.group(1)) for m in
re.finditer(r"(?m)^[ \t]*\[S\.\s*(\d+)\][ \t]*$", full_text)]
page_at_word: list[str | None] = []
_cur_page: str | None = None
_mi = 0
for _tok in re.finditer(r"\S+", full_text):
while _mi < len(_real_markers) and _real_markers[_mi][0] <= _tok.start():
_cur_page = _real_markers[_mi][1]
_mi += 1
page_at_word.append(_cur_page)

# Title normalisieren auf gleiche Whitespace-Form wie `chunk` (single-space-join)
# — sonst matcht z.B. "Multi-Agent\n\nSystem" nicht im normalisierten Chunk.
title = " ".join((search_terms[0] or "").split())
Expand Down Expand Up @@ -284,7 +306,17 @@ def concept_text_window(full_text: str, search_terms: list[str],
else:
merged.append((s, e))

snippets = [" ".join(words[s:e]) for s, e in merged]
# Snippet-Bau: jedem markerlosen Snippet seinen gültigen Seitenmarker
# voranstellen (s.o.). Das addiert ~"[S. N] " (≤ ~10 Zeichen) je injiziertem
# Snippet — die max_chars-Aussage oben ist damit nicht mehr strikt, der
# Overhead ist aber vernachlässigbar gegen das ohnehin unterausgenutzte Budget.
snippets: list[str] = []
for s, e in merged:
snip = " ".join(words[s:e])
page = page_at_word[s]
if page is not None and not snip.lstrip().startswith("[S."):
snip = f"[S. {page}] {snip}"
snippets.append(snip)
return "\n\n[...]\n\n".join(snippets)


Expand Down
71 changes: 71 additions & 0 deletions generative/tests/test_pdf_chunker.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
[[Atomic-Agent-Pipeline]] v24.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path

Expand Down Expand Up @@ -221,6 +222,76 @@ def test_single_chunk_oversize_still_returned():
assert "TARGET" in out


# ---- concept_text_window: Seiten-Marker-Reinjektion (#4 Anker-Clustering) ----
# Bug: concept_text_window klebt Top-Fenster aus dem ganzen Dokument zusammen.
# Beginnt ein Fenster mitten auf einer Seite, fehlt ihm der [S. N]-Marker (der
# stand am Seitenanfang, vor dem Fenster). Downstream (Extractor-LLM, Verifier,
# Renderer) leitet die Seite über "letzter [S. N]-Marker vor der Fundstelle" ab
# und erbt dann die Seite eines früheren Snippets → falsche Fußnoten-Seite.
# Merrill-Run 2026-06-24: Integration-Detail (echt S.8) bekam pauschal "S.3".

def _page_before(text: str, needle: str) -> str | None:
"""Letzter [S. N]-Marker vor `needle` — exakt wie Downstream die Seite ableitet."""
pos = text.find(needle)
if pos < 0:
return None
last = None
for m in re.finditer(r"\[S\.\s*(\d+)\]", text):
if m.start() > pos:
break
last = m.group(1)
return last


def test_snippet_retains_correct_page_marker_when_window_starts_mid_page():
"""Ein Fenster, das mitten auf einer Seite beginnt, muss seinen korrekten
[S. N]-Marker tragen — sonst erbt die Seitenableitung den Marker eines
früheren Snippets (der reproduzierte #4-Bug). Marker line-isoliert wie aus
pages_to_marked_text (`\\n\\n[S. N]\\n\\n`)."""
pad = " ".join(["filler"] * 500)
# S.1: Overview-Fenster mit Titel+Tokens (rankt hoch, trägt eigenen Marker)
overview = "\n\n[S. 1]\n\nTARGET alpha beta gamma " + pad
# S.2–S.4: Marker vorhanden, aber kein Token → nicht selektiert
mid = "\n\n[S. 2]\n\n" + pad + "\n\n[S. 3]\n\n" + pad + "\n\n[S. 4]\n\n" + pad
# S.5: Marker, dann >window_words filler, dann der Detail-Cluster → das
# selektierte Detail-Fenster beginnt NACH dem [S. 5]-Marker.
detail = "\n\n[S. 5]\n\n" + " ".join(["filler"] * 550) + " TARGET alpha beta delta DETAILNEEDLE"
text = f"{overview}{mid}{detail}"

out = concept_text_window(
text, ["TARGET", "alpha", "beta"], window_words=400, max_chars=8000
)

assert "DETAILNEEDLE" in out, "Detail-Fenster muss selektiert sein"
assert _page_before(out, "DETAILNEEDLE") == "5", (
"Detail-Snippet muss seinen eigenen Seitenmarker S.5 tragen, "
"nicht den S.1 des früheren Snippets erben"
)


def test_inline_page_ref_not_treated_as_page_start():
"""Inline-Quellenverweis „vgl. [S. 12]" im Fließtext darf NICHT als
Seitenanfang gelten — ein folgendes markerloses Snippet erbt die echte
Seite (S.7), nicht die inline zitierte (S.12). (Codex-Review #4 MED.)"""
# Echter Seitenanfang S.7 (line-isoliert), dann ein Inline-Ref im Fließtext,
# dann >window_words filler, dann der Detail-Cluster.
text = (
"\n\n[S. 7]\n\nvgl. dazu [S. 12] in der Literatur "
+ " ".join(["filler"] * 550)
+ " TARGET alpha beta DETAILNEEDLE"
)

out = concept_text_window(
text, ["TARGET", "alpha", "beta"], window_words=400, max_chars=8000
)

assert "DETAILNEEDLE" in out, "Detail-Fenster muss selektiert sein"
assert _page_before(out, "DETAILNEEDLE") == "7", (
"markerloses Detail-Snippet muss die echte Seite S.7 erben, "
"nicht die inline zitierte S.12"
)


# ---- assess_text_quality (G6/#27 — Textqualitäts-Gate) -------------------

def test_empty_text_is_empty_not_thin():
Expand Down
Loading