From e51bbbcded96fedd6d56540fdbe48cd3344505d0 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Thu, 25 Jun 2026 20:13:13 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(meta):=20Info-Dict-Autor/CreationDate-J?= =?UTF-8?q?ahr=20nicht=20mehr=20zitierf=C3=A4hig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realer Lauf (Knowles "From Pedagogy to Andragogy") deckte systematische Quellen-Fehlattribution auf: das PDF trug im Info-Dict Author "Pierre Landry" (wer das Kapitel abtippte) + CreationDate 2019 → alle erzeugten Notes zitierten "Landry 2019" statt Knowles. Universell (nicht quellen-spezifisch): Info-Dict- Author = Datei-Ersteller, CreationDate-Jahr = Speicher-/Abtipp-Zeitpunkt — beide identifizieren weder Werk-Autor noch Publikationsjahr. - pdf_chunker._parse_pdfinfo_output() (neu, pure): Info-Dict-Author/CreationDate nur diagnostisch (InfoDictAuthor/InfoDictCreationYear), nicht als Author/Year. - vault_writer.apply_filename_citation_metadata() (neu): zitierfähigen Author/Year aus dem Dateiname befüllen (CrossRef behält Vorrang, Filename-Year autoritativ), vor dem Extractor — sonst Platzhalter "Autor" im Body. - orchestrator: Helper vor dem Extractor aufrufen (ersetzt inline Year-Merge); Enrichment-Gate öffnet jetzt (kein Info-Dict-Author/Year) → CrossRef läuft. - pdf_enrich._zotero_author_matches_embedded (2. Info-Dict-Kanal, Codex-Review): embedded Author nur bei positiver Dateiname-Bestätigung — nicht-parsbarer Dateiname → False (vorher fälschlich True); Nachname-Gleichheit statt Substring (keine "Li" bestätigt "Williams"-Falle). TDD: 13 neue Tests. Cross-Model-reviewt (Codex Pass 1+2, Qwen). Suite grün. --- generative/orchestrator.py | 6 ++- generative/pipeline/pdf_chunker.py | 58 ++++++++++++++++++++------- generative/pipeline/vault_writer.py | 23 +++++++++++ generative/tests/test_pdf_chunker.py | 37 +++++++++++++++++ generative/tests/test_pdf_enrich.py | 47 +++++++++++++++++++++- generative/tests/test_vault_writer.py | 36 +++++++++++++++++ generative/tools/pdf_enrich.py | 21 ++++++++-- 7 files changed, 205 insertions(+), 23 deletions(-) diff --git a/generative/orchestrator.py b/generative/orchestrator.py index 5b33ce5..1e0aa8e 100644 --- a/generative/orchestrator.py +++ b/generative/orchestrator.py @@ -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, diff --git a/generative/pipeline/pdf_chunker.py b/generative/pipeline/pdf_chunker.py index 1f6d7eb..f6b5b80 100644 --- a/generative/pipeline/pdf_chunker.py +++ b/generative/pipeline/pdf_chunker.py @@ -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" diff --git a/generative/pipeline/vault_writer.py b/generative/pipeline/vault_writer.py index 96cae37..0e86c6b 100644 --- a/generative/pipeline/vault_writer.py +++ b/generative/pipeline/vault_writer.py @@ -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) diff --git a/generative/tests/test_pdf_chunker.py b/generative/tests/test_pdf_chunker.py index 4cd878a..f44b5f0 100644 --- a/generative/tests/test_pdf_chunker.py +++ b/generative/tests/test_pdf_chunker.py @@ -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" diff --git a/generative/tests/test_pdf_enrich.py b/generative/tests/test_pdf_enrich.py index 507c099..fb23b1e 100644 --- a/generative/tests/test_pdf_enrich.py +++ b/generative/tests/test_pdf_enrich.py @@ -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 @@ -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 diff --git a/generative/tests/test_vault_writer.py b/generative/tests/test_vault_writer.py index 20e437c..88fb797 100644 --- a/generative/tests/test_vault_writer.py +++ b/generative/tests/test_vault_writer.py @@ -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 diff --git a/generative/tools/pdf_enrich.py b/generative/tools/pdf_enrich.py index 21ff986..e5cb2d8 100644 --- a/generative/tools/pdf_enrich.py +++ b/generative/tools/pdf_enrich.py @@ -561,13 +561,26 @@ def _parse_filename_dynamic(pdf_path: Path) -> dict | None: def _zotero_author_matches_embedded(pdf_path: Path, embedded_author: str) -> bool: - """Prueft ob embedded Author mit dem Autor aus dem Dateinamen uebereinstimmt.""" + """Prueft ob der eingebettete Info-Dict-Autor durch den Dateinamen POSITIV + bestaetigt wird (Gate: darf der embedded Author als zitierfaehig dienen?). + + Der Info-Dict-`/Author` ist der Datei-Ersteller, nicht zwingend der Werk-Autor + (abgetippte/gescannte PDFs). Er wird daher nur akzeptiert, wenn der Dateiname + ihn bestaetigt. Ein nicht parsbarer Dateiname kann den embedded Author NICHT + bestaetigen -> False (zuvor faelschlich True/"kein Widerspruch", wodurch der + Datei-Ersteller als Zitierautor durchrutschte; Codex-Review 2026-06-25).""" parsed = _parse_filename_dynamic(pdf_path) if not parsed: - return True # Kein erkennbares Format -> kein Widerspruch + return False # Kein bestaetigendes Signal -> embedded Author nicht zitierfaehig fn_author = parsed["author"].split()[-1].lower() - emb_author = embedded_author.strip().lower() - return fn_author in emb_author or emb_author in fn_author + emb_tokens = embedded_author.strip().split() + if not emb_tokens: + return False + emb_author = emb_tokens[-1].lower() + # Nachname-Gleichheit, KEIN Substring: ein kurzer Dateiname-Nachname ("Li") darf + # nicht zufaellig einen unverwandten embedded Autor ("Williams") bestaetigen + # (Substring-Falle, Codex-Pass-2 2026-06-25). + return fn_author == emb_author def _meta_complete(meta: dict) -> bool: From de44e3ace6001e870e5c6f82372f97a0bbd5b1dc Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Thu, 25 Jun 2026 20:13:25 +0200 Subject: [PATCH 2/2] fix(cross-ref): kommaseparierte duplicate_path-Mehrfachziele nicht als ein Wikilink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Knowles-Lauf: das LLM lieferte duplicate_path als EINE kommaseparierte Liste von 4 Titeln → related-Eintrag wurde der kaputte Sammel-Wikilink "[[A, B, C, D]]". duplicate_path ist als EIN Merge-Ziel modelliert. - _clean_dup_targets() (neu, pure): zerlegt in saubere Einzelziel-Stems (Komma-Split, Klammern/Alias/Pfad entfernt). - run(): bei >1 Kandidat Review-Flag + saubere Einzel-Links, KEIN action=extend (man kann nicht in mehrere Notes mergen). Einzel-Target-Pfad unverändert (elif statt dup_risk-Reassignment — Klarheit, Qwen-Review). TDD: 3 neue Helper-Tests. Suite grün. --- generative/agents/cross_reference.py | 85 +++++++++++++------ .../tests/test_cross_reference_links.py | 26 ++++++ 2 files changed, 86 insertions(+), 25 deletions(-) diff --git a/generative/agents/cross_reference.py b/generative/agents/cross_reference.py index 76ab098..e790739 100644 --- a/generative/agents/cross_reference.py +++ b/generative/agents/cross_reference.py @@ -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. @@ -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 diff --git a/generative/tests/test_cross_reference_links.py b/generative/tests/test_cross_reference_links.py index 59c91c8..e277296 100644 --- a/generative/tests/test_cross_reference_links.py +++ b/generative/tests/test_cross_reference_links.py @@ -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) == []