Skip to content
Open
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
11 changes: 9 additions & 2 deletions generative/agents/cross_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
1 change: 1 addition & 0 deletions generative/agents/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
15 changes: 13 additions & 2 deletions generative/eval_figure_feasibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 14 additions & 4 deletions generative/pipeline/figure_alt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -203,16 +205,24 @@ 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
Seite tragen -> ``None`` (nicht exakt bindbar, precision-first: skip).
"""
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)
88 changes: 84 additions & 4 deletions generative/pipeline/pdf_chunker.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,80 @@ 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]
# 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
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.)
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(
Expand All @@ -49,9 +121,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:
Expand Down
11 changes: 9 additions & 2 deletions generative/pipeline/vault_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions generative/tests/test_cross_reference_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]"
Expand Down
18 changes: 18 additions & 0 deletions generative/tests/test_figure_alt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
64 changes: 64 additions & 0 deletions generative/tests/test_pdf_chunker.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,70 @@ 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
# 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


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

def test_empty_text_is_empty_not_thin():
Expand Down
11 changes: 11 additions & 0 deletions generative/tests/test_vault_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down
Loading