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
85 changes: 60 additions & 25 deletions generative/agents/cross_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ def _clean_wikilink(s: str) -> str:
return (s or "").strip().strip("[]").strip()


def _clean_dup_targets(raw: str | None) -> list[str]:
"""Zerlegt ein `duplicate_path`-Feld in saubere Einzelziel-Stems.

`duplicate_path` ist als EIN Duplikat-Ziel modelliert; das LLM liefert aber
gelegentlich mehrere kommaseparierte Titel in einem Feld. Würde das ungeprüft
zu `[[Titel]]` gewrappt, entstünde ein kaputter Sammel-Wikilink
`[[A, B, C, D]]` (Knowles-Run 2026-06-25). Splittet daher an Kommas, entfernt
Wikilink-Klammern/Alias-Pipe und `.md`/Pfad, droppt Leersegmente.
"""
cleaned = _clean_wikilink(raw or "")
targets: list[str] = []
for seg in cleaned.split(","):
seg = seg.split("|", 1)[0].strip()
stem = Path(seg).stem if seg else ""
if stem:
targets.append(stem)
return targets


def _resolve_vault_path(dup_path: str, existing_concepts: dict[str, str] | None) -> Path | None:
"""Löst einen vom LLM gelieferten duplicate_path auf eine reale Vault-Datei auf.

Expand Down Expand Up @@ -408,34 +427,50 @@ def run(draft: AtomicNoteDraft, existing_concepts: dict[str, str],
draft.quality_flags.append(f"⚠️ Widerspruch: {contradiction}")

dup_risk = data.get("duplicate_risk", "none")
# LLM liefert duplicate_path gelegentlich als "[[Titel]]" oder "Titel|alias" statt
# reinem Pfad/Titel — normalisieren, sonst entsteht unten "[[[[Titel]]]]" und ein
# verklammertes Duplikat-Flag (beobachtet im Ebner-Run 2026-06-22).
dup_path = _clean_wikilink(data.get("duplicate_path") or "").split("|", 1)[0].strip()
# Typ-bewusstes Blocking: ein Dup-Treffer gegen eine koexistierende Lit-/MoC-/Stub-Note
# ist KEIN echtes Duplikat (Schema-Lit ≠ Schema-Konzept) — sonst würde eine Konzept-Note
# fälschlich in ihre eigene Lit-Note gemergt (Ebner-Run 2026-06-23). Dann: kein
# action=extend, aber den Bezug als related-Link erhalten (Konzept SOLL auf Quelle linken).
if dup_risk == "high" and not _dup_target_eligible(dup_path, existing_concepts):
draft.quality_flags.append(f"ℹ️ Verwandte Quelle/Note (kein Konzept-Duplikat): {dup_path}")
if dup_path:
dup_link = f"[[{Path(dup_path).stem}]]"
if dup_link not in data["related"]:
data["related"].insert(0, dup_link)
elif dup_risk == "high":
# Duplikat ist der stärkste mögliche Vault-Beleg — confidence NICHT runter,
# stattdessen action='extend' setzen + Duplikat als related-Link aufnehmen,
# damit Confidence-Agent has_vault_corroboration=True erkennt
draft.quality_flags.append(f"⚠️ Duplikat-Risiko hoch — prüfe: {dup_path}")
draft.action = "extend"
if dup_path:
draft.extend_path = dup_path
# Wikilink-Form aus dem Pfad bauen (Stem ohne .md)
from pathlib import Path as _P
stem = _P(dup_path).stem
# duplicate_path ist als EIN Ziel modelliert. Das LLM liefert gelegentlich
# mehrere kommaseparierte Titel in einem Feld → ungeprüft entstünde ein kaputter
# Sammel-Wikilink "[[A, B, C, D]]" (Knowles-Run 2026-06-25). Mehrfachziele werden
# daher als Review-Hinweis + saubere Einzel-Links behandelt, OHNE action=extend
# (man kann nicht in mehrere Notes mergen).
dup_targets = _clean_dup_targets(data.get("duplicate_path"))
if dup_risk == "high" and len(dup_targets) > 1:
# Mehrere Dup-Kandidaten in einem Feld → kein einzelnes Merge-Ziel ableitbar.
# Review-Hinweis + jeden Kandidaten als SAUBEREN Einzel-Link aufnehmen, OHNE
# action=extend (man kann nicht in mehrere Notes mergen). Mutually exclusive
# zum Einzel-Target-Pfad unten (elif), damit der Roh-String nie als ein
# Merge-Ziel verwendet wird.
draft.quality_flags.append(
"⚠️ Mehrere Duplikat-Kandidaten — prüfe: " + ", ".join(dup_targets))
for stem in dup_targets:
dup_link = f"[[{stem}]]"
if dup_link not in data["related"]:
data["related"].insert(0, dup_link)
elif dup_risk == "high":
# Einzel-Target. LLM liefert duplicate_path gelegentlich als "[[Titel]]" oder
# "Titel|alias" statt reinem Pfad/Titel — normalisieren, sonst entsteht unten
# "[[[[Titel]]]]" und ein verklammertes Duplikat-Flag (Ebner-Run 2026-06-22).
dup_path = _clean_wikilink(data.get("duplicate_path") or "").split("|", 1)[0].strip()
# Typ-bewusstes Blocking: ein Dup-Treffer gegen eine koexistierende Lit-/MoC-/
# Stub-Note ist KEIN echtes Duplikat (Schema-Lit ≠ Schema-Konzept) — sonst würde
# eine Konzept-Note fälschlich in ihre eigene Lit-Note gemergt (Ebner 2026-06-23).
# Dann: kein action=extend, aber den Bezug als related-Link erhalten.
if not _dup_target_eligible(dup_path, existing_concepts):
draft.quality_flags.append(f"ℹ️ Verwandte Quelle/Note (kein Konzept-Duplikat): {dup_path}")
if dup_path:
dup_link = f"[[{Path(dup_path).stem}]]"
if dup_link not in data["related"]:
data["related"].insert(0, dup_link)
else:
# Duplikat ist der stärkste mögliche Vault-Beleg — confidence NICHT runter,
# stattdessen action='extend' setzen + Duplikat als related-Link aufnehmen,
# damit Confidence-Agent has_vault_corroboration=True erkennt
draft.quality_flags.append(f"⚠️ Duplikat-Risiko hoch — prüfe: {dup_path}")
draft.action = "extend"
if dup_path:
draft.extend_path = dup_path
dup_link = f"[[{Path(dup_path).stem}]]"
if dup_link not in data["related"]:
data["related"].insert(0, dup_link)

# related-Links setzen (nicht nur ergänzen — Cross-Reference ist die Autorität dafür).
# Defense-in-depth: überschüssige Klammern normalisieren, damit nachträglich (nach
Expand Down
6 changes: 4 additions & 2 deletions generative/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1353,8 +1353,10 @@ def _run_extraction_stages(args, source_path: Path, runtime_config=None): # mai
q_title = pdf_meta.get("Title")
if not q_title or vault_writer._TITLE_LOOKS_BAD.match(q_title or ""):
q_title = fb.get("Title") or q_title
if fb.get("Year"):
pdf_meta["Year"] = fb["Year"]
# Zitier-Autor/-Jahr aus dem Dateiname befüllen (Info-Dict liefert keinen
# zitierfähigen Autor/CreationDate-Jahr mehr — pdf_metadata). Muss vor dem
# Extractor laufen, sonst stünde der Platzhalter "Autor" im Body.
vault_writer.apply_filename_citation_metadata(pdf_meta, fb)
quality_report = quality.check_quality(
doi=args.doi,
title=q_title,
Expand Down
58 changes: 43 additions & 15 deletions generative/pipeline/pdf_chunker.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,31 +320,59 @@ def concept_text_window(full_text: str, search_terms: list[str],
return "\n\n[...]\n\n".join(snippets)


def pdf_metadata(pdf_path: Path) -> dict[str, str]:
"""Liest pdfinfo-Metadaten als dict (Title, Author, Subject, Pages, Year aus CreationDate)."""
result = subprocess.run(
["pdfinfo", str(pdf_path)],
capture_output=True, text=True, encoding="utf-8", errors="replace"
)
if result.returncode != 0:
return {}
keep = {"Title", "Author", "Subject", "Pages", "CreationDate"}
def _parse_pdfinfo_output(stdout: str) -> dict[str, str]:
"""Parst pdfinfo-stdout zu Metadaten-dict (pure, testbar).

Quellen-Treue (universell, nicht quellen-spezifisch): pdfinfo-`Author`
(= Datei-Ersteller) und das Jahr aus `CreationDate` (= Speicher-/Abtipp-
Zeitpunkt) sind NICHT zitierfähig — sie identifizieren weder Werk-Autor noch
Publikationsjahr und führen bei abgetippten/gescannten/neu-gespeicherten PDFs
zu systematischer Fehlattribution. Deshalb werden sie NICHT als `Author`/`Year`
exportiert, sondern nur diagnostisch als `InfoDictAuthor`/`InfoDictCreationYear`
(für Logging, nie für Zitate). Zitier-Autor/-Jahr kommen ausschließlich aus
Dateiname, CrossRef/DOI oder validierter Titelseiten-Extraktion (Orchestrator).
`Title`/`Pages`/`Subject` bleiben zitierfähig.
"""
keep = {"Title", "Subject", "Pages"}
meta: dict[str, str] = {}
for line in result.stdout.splitlines():
info_author = ""
info_creationdate = ""
for line in stdout.splitlines():
if ":" not in line:
continue
key, val = line.split(":", 1)
key, val = key.strip(), val.strip()
if key in keep and val:
if not val:
continue
if key in keep:
meta[key] = val
# Year aus CreationDate extrahieren (Format z.B. "Mon Mar 15 14:23:01 2019 CET")
if "CreationDate" in meta:
m = re.search(r"\b(19|20)\d{2}\b", meta["CreationDate"])
elif key == "Author":
info_author = val
elif key == "CreationDate":
info_creationdate = val
# Info-Dict-Autor + Year aus CreationDate nur diagnostisch ablegen (Format
# z.B. "Mon Mar 15 14:23:01 2019 CET") — nie als zitierfähige Quelle.
if info_author:
meta["InfoDictAuthor"] = info_author
if info_creationdate:
m = re.search(r"\b(19|20)\d{2}\b", info_creationdate)
if m:
meta["Year"] = m.group(0)
meta["InfoDictCreationYear"] = m.group(0)
return meta


def pdf_metadata(pdf_path: Path) -> dict[str, str]:
"""Liest pdfinfo-Metadaten als dict (Title, Subject, Pages zitierfähig;
Info-Dict-Autor/-CreationDate nur diagnostisch — siehe _parse_pdfinfo_output)."""
result = subprocess.run(
["pdfinfo", str(pdf_path)],
capture_output=True, text=True, encoding="utf-8", errors="replace"
)
if result.returncode != 0:
return {}
return _parse_pdfinfo_output(result.stdout)


# Kapitel-Heading-Pattern. Erkennt:
# - arabisch: "1 Titel", "Kapitel 2", "Chapter 3", "2.1 Untertitel"
# - römisch: "I. Einleitung", "Part II", "Kapitel III"
Expand Down
23 changes: 23 additions & 0 deletions generative/pipeline/vault_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,29 @@ def _parse_filename_fallback(source_file: str) -> dict[str, str]:
return {}


def apply_filename_citation_metadata(pdf_meta: dict, fb: dict) -> None:
"""Befüllt zitierfähige `Author`/`Year` in ``pdf_meta`` aus dem Dateiname-
Fallback ``fb`` — mutiert ``pdf_meta`` in place.

Hintergrund: ``pdf_metadata`` liefert keinen (unzuverlässigen) Info-Dict-Autor
bzw. kein CreationDate-Jahr mehr als Zitier-Quelle. Damit Extractor-Prompt,
Planner und Quellen-Block einen korrekten Autor/Jahr sehen (statt Platzhalter
„Autor"), wird der Dateiname-Autor hier vor der Extraktion gemergt.

Präzedenz:
- **Author**: fill-if-missing — ein bereits vorhandener (stärkerer) Autor aus
CrossRef/DOI-Enrichment wird NICHT vom Dateiname überschrieben.
- **Year**: Filename-Year ist autoritativ für die vorliegende Edition und
überschreibt ein abweichendes meta-Year (dokumentierte v28/Hiatt-Regel:
CrossRef gibt bei Mehrfachauflagen oft das Jahr der jüngsten Auflage).
- Fehlt der Autor überall, bleibt er leer — ehrlich unresolved statt geraten.
"""
if fb.get("Author") and not pdf_meta.get("Author"):
pdf_meta["Author"] = fb["Author"]
if fb.get("Year"):
pdf_meta["Year"] = fb["Year"]


_PAGE_PREFIX_RE = re.compile(r"^\s*S\.\s*", re.IGNORECASE)


Expand Down
26 changes: 26 additions & 0 deletions generative/tests/test_cross_reference_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,29 @@ def test_rewrapped_link_is_single_pair():
dup_link = f"[[{cr._clean_wikilink(raw)}]]"
assert dup_link == "[[Kirkpatrick Level 1→2 Zusammenhang]]"
assert "[[[[" not in dup_link


# Knowles-Run 2026-06-25: das LLM lieferte duplicate_path als EINE kommaseparierte
# Liste von 4 Titeln → related-Eintrag wurde zu einem kaputten Sammel-Wikilink
# "[[A, B, C, D]]". duplicate_path ist als EIN Ziel modelliert; Mehrfachziele
# müssen in saubere Einzelziele zerlegt werden, nie als ein Link gerendert.
def test_clean_dup_targets_splits_comma_list():
raw = ("Readiness to Learn (Andragogy), Self-directed Learning, "
"Experience as Learning Resource, Problem-centered Learning Orientation")
assert cr._clean_dup_targets(raw) == [
"Readiness to Learn (Andragogy)",
"Self-directed Learning",
"Experience as Learning Resource",
"Problem-centered Learning Orientation",
]


def test_clean_dup_targets_single_target():
assert cr._clean_dup_targets("[[Foo]]") == ["Foo"]
assert cr._clean_dup_targets("Foo|alias") == ["Foo"]
assert cr._clean_dup_targets("path/to/Foo.md") == ["Foo"]


def test_clean_dup_targets_empty():
assert cr._clean_dup_targets("") == []
assert cr._clean_dup_targets(None) == []
37 changes: 37 additions & 0 deletions generative/tests/test_pdf_chunker.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,40 @@ def test_inline_page_refs_not_counted_as_pages():
q = assess_text_quality(text)
assert q.pages == 1 # nur der eine echte Pipeline-Marker
assert q.is_thin is False # ~100 Wörter auf 1 Seite ist nicht dünn


# ---- pdf_metadata: Info-Dict-Autor/Jahr sind NICHT zitierfähig -----------
# Universelle Regel (nicht quellen-spezifisch): pdfinfo-`Author` (= Datei-
# Ersteller) und das Jahr aus `CreationDate` (= Speicher-/Abtipp-Zeitpunkt)
# identifizieren NICHT Werk-Autor bzw. Publikationsjahr. Sie dürfen nie als
# Zitier-Autor/-Jahr durchgereicht werden — sonst systematische Fehlattribution
# bei abgetippten/gescannten/neu-gespeicherten PDFs (realer Fall: ein in Word
# abgetipptes Knowles-Kapitel trug `Author: Pierre Landry` / CreationDate 2019
# → alle Notes zitierten "Landry 2019" statt Knowles).
from generative.pipeline.pdf_chunker import _parse_pdfinfo_output

_PDFINFO_RETYPED = (
"Title: What Is Andragogy?\n"
"Author: Pierre Landry\n"
"Creator: Microsoft Word 2016\n"
"CreationDate: Wed Mar 20 18:27:09 2019 CET\n"
"Pages: 25\n"
)


def test_pdfinfo_author_not_exposed_as_citation_author():
meta = _parse_pdfinfo_output(_PDFINFO_RETYPED)
assert "Author" not in meta
assert meta.get("InfoDictAuthor") == "Pierre Landry"


def test_pdfinfo_creationdate_not_exposed_as_citation_year():
meta = _parse_pdfinfo_output(_PDFINFO_RETYPED)
assert "Year" not in meta
assert meta.get("InfoDictCreationYear") == "2019"


def test_pdfinfo_keeps_title_and_pages():
meta = _parse_pdfinfo_output(_PDFINFO_RETYPED)
assert meta.get("Title") == "What Is Andragogy?"
assert meta.get("Pages") == "25"
47 changes: 45 additions & 2 deletions generative/tests/test_pdf_enrich.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,13 +380,16 @@ def test_pubmed_lookup_returns_none_on_missing_result(monkeypatch):


def test_enrich_uses_embedded_metadata_first(tmp_path, monkeypatch):
"""enrich() nutzt eingebettete PDF-Metadaten wenn Author + Title vorhanden."""
"""enrich() nutzt eingebettete PDF-Metadaten wenn Author + Title vorhanden
UND der Dateiname den eingebetteten Autor bestaetigt (Info-Dict-Autor ist sonst
der Datei-Ersteller, nicht der Werk-Autor — Codex-Review 2026-06-25)."""
from generative.tools.pdf_enrich import enrich
from unittest.mock import MagicMock
mock_reader = MagicMock()
mock_reader.metadata = {"/Title": "Information Behavior", "/Author": "Marcia Bates", "/Subject": "2017"}
monkeypatch.setattr("generative.tools.pdf_enrich.PdfReader", lambda *a, **kw: mock_reader)
pdf = tmp_path / "paper.pdf"
# Dateiname bestaetigt den eingebetteten Autor (Bates) -> embedded akzeptiert.
pdf = tmp_path / "Bates - Information Behavior.pdf"
pdf.write_bytes(b"%PDF")
meta = enrich(pdf, dry_run=True)
assert meta is not None
Expand Down Expand Up @@ -804,3 +807,43 @@ def spy(doi):
meta = pdf_enrich.enrich(pdf, dry_run=True)
assert meta is not None
assert called["doi"] == "10.1002/asi.23681" # Kopf-DOI aus OCR-Quelle, nicht 10.9999/cited


# Zweiter Info-Dict-Autor-Kanal (Codex-Review 2026-06-25): enrich() Stage-0 zieht
# `/Author` aus dem Info-Dict und akzeptiert ihn als zitierfähig, sofern der Guard
# _zotero_author_matches_embedded True liefert. Bisher gab der Guard bei nicht-
# parsbarem Dateiname True ("kein Widerspruch") → ein Info-Dict-Autor (= Datei-
# Ersteller) konnte ungeprüft als Zitierautor durchrutschen. Korrekt: nur positive
# Bestätigung durch den Dateiname akzeptiert den eingebetteten Autor.
from pathlib import Path as _Path
from generative.tools.pdf_enrich import _zotero_author_matches_embedded


def test_filename_author_confirms_embedded():
assert _zotero_author_matches_embedded(
_Path("Knowles - From Pedagogy to Andragogy.pdf"), "Knowles") is True


def test_filename_author_contradicts_embedded():
assert _zotero_author_matches_embedded(
_Path("Knowles - From Pedagogy to Andragogy.pdf"), "Landry") is False


def test_unparseable_filename_does_not_confirm_embedded_author():
# Kein Autor im Dateiname → Info-Dict-Autor ist nicht positiv bestätigt →
# darf NICHT als zitierfähig akzeptiert werden.
assert _zotero_author_matches_embedded(_Path("scan001.pdf"), "Landry") is False


def test_short_filename_surname_does_not_falsely_confirm_unrelated_embedded():
# Substring-Falle (Codex-Pass-2 2026-06-25): Dateiname-Nachname "Li" ist Substring
# von embedded "Williams" → darf NICHT als Bestätigung gelten. Zotero-Format mit
# Jahr, damit der Dateiname PARSBAR ist (sonst greift der not-parsed→False-Pfad).
assert _zotero_author_matches_embedded(
_Path("Li - 2020 - Some Title.pdf"), "Williams") is False


def test_exact_surname_confirms_embedded():
# Gegenprobe: exakter Nachname-Match bestätigt weiterhin korrekt.
assert _zotero_author_matches_embedded(
_Path("Bates - 2017 - Information Behavior.pdf"), "Bates") is True
36 changes: 36 additions & 0 deletions generative/tests/test_vault_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,39 @@ def test_no_source_status_line_when_unset(self):

if __name__ == "__main__":
unittest.main()


# ---- apply_filename_citation_metadata: Dateiname/CrossRef als Zitier-Quelle ----
# Nach dem pdf_metadata-Fix trägt pdf_meta keinen (unzuverlässigen) Info-Dict-
# Autor/Jahr mehr. Zitier-Autor/-Jahr müssen vor dem Extractor aus dem Dateiname
# (bzw. davor schon CrossRef) befüllt werden — sonst stünde "Autor 2019, S. N"
# im Body. Präzedenz: CrossRef (stärker) > Dateiname; Filename-Year autoritativ.
from generative.pipeline.vault_writer import apply_filename_citation_metadata


def test_filename_author_fills_missing_citation_author():
meta = {"Title": "From Pedagogy to Andragogy"}
fb = {"Author": "Knowles", "Title": "From Pedagogy to Andragogy"}
apply_filename_citation_metadata(meta, fb)
assert meta["Author"] == "Knowles"


def test_crossref_author_not_overridden_by_filename():
meta = {"Author": "Knowles, Malcolm S."}
fb = {"Author": "Knowles"}
apply_filename_citation_metadata(meta, fb)
assert meta["Author"] == "Knowles, Malcolm S."


def test_filename_year_overrides_meta_year():
meta = {"Year": "2023"}
fb = {"Year": "2006"}
apply_filename_citation_metadata(meta, fb)
assert meta["Year"] == "2006"


def test_no_author_anywhere_stays_unresolved():
meta = {"Title": "Some Title"}
fb = {}
apply_filename_citation_metadata(meta, fb)
assert "Author" not in meta
Loading
Loading