diff --git a/generative/orchestrator.py b/generative/orchestrator.py index 1e0aa8e..f1d76a2 100644 --- a/generative/orchestrator.py +++ b/generative/orchestrator.py @@ -1795,6 +1795,34 @@ def main(argv: list[str] | None = None): if _framing: print(_framing) + # Edition-Verifikation (Layer 1): Auszug aus einem größeren Werk OHNE DOI-Beleg + # → Auflage/Jahr/Seiten sind nur dateiname-geraten (Impressum fehlt im Extrakt). + # first_print_page = erste numerische Druckseite aus /PageLabels (>1 ⟺ Auszug); + # doi_verified, wenn eine harte DOI via CrossRef auflöste (--doi ODER hartes + # Enrichment, kein Title-Match-Raten). Nur create-Notes, unresolved bleibt stärker. + _ed_labels = pdf_chunker._pdf_page_labels(source_path) + _first_print_page = int(str(_ed_labels[0]).strip()) if _ed_labels else None + # doi_verified NUR wenn CrossRef die DOI tatsächlich auflöste (crossref_year + # gesetzt) und sie nicht per Title-Match geraten wurde. Ein gepinntes --doi, das + # nicht auflöst (falsch/CrossRef down), zählt NICHT als verifiziert → fail-closed, + # die Note wird geflaggt statt still vertraut. (Codex-Review, fail-open-Lücke.) + _doi_verified = bool(quality_report.crossref_year) and not quality_report.doi_from_title_match + if routing_report.is_edition_unverified(_doi_verified, _first_print_page): + _ed_marked = 0 + for draft in drafts: + if draft.action == "create" and draft.source_status != "unresolved": + draft.source_status = "edition-unverified" + draft.quality_flags.append( + f"⚠️ Edition unverifiziert — Auszug ab Druckseite {_first_print_page} " + f"ohne DOI; Jahr+Seiten nur aus Dateiname. Auflage manuell prüfen " + f"oder via --doi pinnen.") + _ed_marked += 1 + if _ed_marked: + _ed_framing = routing_report.source_status_framing( + "edition-unverified", source_path.name) + if _ed_framing: + print(_ed_framing) + # v23: Tag-Hint via --target-tag wird allen Drafts angehängt → Auto-Note-Mover # routet beim Öffnen aus 00-inbox/ in den Zielordner (siehe CLAUDE.md-Mapping). if args.target_tag: diff --git a/generative/pipeline/routing_report.py b/generative/pipeline/routing_report.py index 2ee3ef3..feb4839 100644 --- a/generative/pipeline/routing_report.py +++ b/generative/pipeline/routing_report.py @@ -95,6 +95,26 @@ def is_source_unresolved(enriched_meta: dict, fb: dict, return bool(block_crossref_override) or not (author and year) +def is_edition_unverified(doi_verified: bool, first_print_page: int | None) -> bool: + """True wenn die Edition/Auflage NICHT gegen eine DOI belegt ist UND das + Dokument ein Auszug aus einem größeren Werk ist. + + Hintergrund: Ein Kapitel-Extrakt trägt keine Impressum-/Titelei-Seite (ISBN, + „N. Auflage", Copyright-Jahr). Die Pipeline leitet Jahr/Edition dann allein aus + dem Dateinamen ab und kann nicht wissen, welche Auflage vorliegt — etwa KSS-6 + (2013, S. 172 ff.) vs. KSS-7 (2022, S. 147 ff.) desselben Kapitels. Ohne DOI als + harten Anker ist die Zitation deshalb unverifiziert. + + Auszug-Signal: erste **numerische Druckseite > 1** (aus `/PageLabels`). Ein + Standalone-Dokument beginnt bei 1; ein mid-book-Extrakt bei der Druckseite, an + der das Kapitel im Gesamtwerk startet. `first_print_page=None` (keine + numerischen Labels, z.B. normales Paper) → kein Auszug-Signal → kein Flag. + """ + if doi_verified: + return False + return first_print_page is not None and first_print_page > 1 + + def source_status_framing(source_status: str | None, source_name: str) -> str | None: """First-person-NL-Zeile bei unsicherer Quelle — sonst None. @@ -102,6 +122,12 @@ def source_status_framing(source_status: str | None, source_name: str) -> str | NL-Unsicherheit senkt Over-Reliance, FAccT 2024). Nur auf dem fail-closed-Pfad aktiv — High-Confidence/aufgelöste Quellen bleiben frictionless. """ + if source_status == "edition-unverified": + return (f" [Quelle] '{source_name}' ist ein Auszug ohne DOI — ich kann die " + f"Auflage/Edition nicht belegen (Jahr+Seiten stammen nur aus dem " + f"Dateinamen). Bei mehrfach aufgelegten Werken weicht die Seitenzählung " + f"je Auflage ab; ich habe die Notes mit `source-status: edition-unverified` " + f"markiert und nicht für den Vault empfohlen. Mit `--doi` pinnen behebt es.") if source_status != "unresolved": return None # Wahrheitsgemäß: source-status ist ein Sichtbarkeits-Flag, kein Routing-Gate. diff --git a/generative/pipeline/vault_writer.py b/generative/pipeline/vault_writer.py index 10e0fd4..cf8abdc 100644 --- a/generative/pipeline/vault_writer.py +++ b/generative/pipeline/vault_writer.py @@ -525,6 +525,13 @@ def auto_write_decision(note: AtomicNoteDraft) -> tuple[bool, str]: and note.critic_score >= 4 and len(note.hub_subconcepts) >= 2) + # Edition unverifiziert (Auszug ohne DOI): Auflage/Jahr/Seiten sind nur + # dateiname-geraten, nicht belegt → nie automatisch in den Vault, immer in die + # Inbox zur manuellen Bestätigung (oder via --doi pinnen). Vor allen anderen + # Pfaden, damit auch ein Score-5-Auszug geblockt wird. + if note.source_status == "edition-unverified": + return False, "edition unverifiziert (Auszug ohne DOI)" + if not note.hard_gates_pass and not is_strong_hub: return False, "hard-gate fail (Glance/Future-Self/Quellen)" # Pfad C: Hub-Note mit Score ≥ 4 und ≥2 Sub-Konzepten → Vault auch ohne HG-pass diff --git a/generative/tests/test_edition_verification.py b/generative/tests/test_edition_verification.py new file mode 100644 index 0000000..cdf1613 --- /dev/null +++ b/generative/tests/test_edition_verification.py @@ -0,0 +1,134 @@ +"""Tests für Edition-Verifikation (Layer 1 + Layer 3). + +Wurzel-Fix gegen stille Edition-Verwechslung: ein Kapitel-Extrakt trägt keine +Impressum-Seite, die Pipeline leitet Jahr/Edition allein aus dem Dateinamen ab. +Ohne DOI-Verifikation kann sie nicht wissen, welche Auflage vorliegt (z.B. KSS-6 +2013 S.172 vs. KSS-7 2022 S.147). Layer 1 macht das laut + blockt auto-vault; +Layer 3 löst die Kapitel-DOI über den Seitenbereich auf (der die Edition beweist). +""" +from __future__ import annotations + + +# ---- Layer 1: is_edition_unverified ------------------------------------- + +def test_edition_unverified_when_excerpt_and_no_doi(): + from generative.pipeline.routing_report import is_edition_unverified + # Auszug (erste Druckseite 172 > 1) + keine DOI-Verifikation → unverifiziert + assert is_edition_unverified(doi_verified=False, first_print_page=172) is True + + +def test_edition_verified_when_doi_present(): + from generative.pipeline.routing_report import is_edition_unverified + # DOI verifiziert (CrossRef-Auflösung) → Edition belegt, kein Flag + assert is_edition_unverified(doi_verified=True, first_print_page=172) is False + + +def test_not_excerpt_when_starts_at_page_one(): + from generative.pipeline.routing_report import is_edition_unverified + # Standalone-Dokument ab Seite 1 → kein Auszug, Edition-Risiko entfällt + assert is_edition_unverified(doi_verified=False, first_print_page=1) is False + + +def test_no_print_page_info_is_not_flagged(): + from generative.pipeline.routing_report import is_edition_unverified + # Keine numerischen PageLabels (normales Paper) → kein Auszug-Signal + assert is_edition_unverified(doi_verified=False, first_print_page=None) is False + + +# ---- Layer 3: pick_chapter_doi (Seitenbereich beweist Edition) ---------- + +# Reale CrossRef-Form (gekürzt): zwei Auflagen desselben Kapitels von Reimer. +_KSS6 = {"DOI": "10.1515/9783110258264.172", "page": "172-182", + "title": ["B 3 Wissensorganisation"], + "issued": {"date-parts": [[2013, 3, 14]]}} +_KSS7 = {"DOI": "10.1515/9783110769043-013", "page": "147-160", + "title": ["B 1 Einführung in die Wissensorganisation"], + "issued": {"date-parts": [[2023]]}} + + +def test_pick_chapter_doi_disambiguates_by_start_page(): + from generative.tools.resolve_chapter_doi import pick_chapter_doi + # Startseite 172 → muss die 2013er (KSS-6) wählen, NICHT die 2023er + hit = pick_chapter_doi([_KSS7, _KSS6], start_page=172, year="2013") + assert hit is not None + assert hit["DOI"] == "10.1515/9783110258264.172" + + +def test_pick_chapter_doi_other_edition_by_start_page(): + from generative.tools.resolve_chapter_doi import pick_chapter_doi + # Startseite 147 → 2022/2023er Ausgabe + hit = pick_chapter_doi([_KSS6, _KSS7], start_page=147, year="2023") + assert hit is not None + assert hit["DOI"] == "10.1515/9783110769043-013" + + +def test_pick_chapter_doi_none_when_no_page_match(): + from generative.tools.resolve_chapter_doi import pick_chapter_doi + # Startseite 999 passt zu keinem Treffer → ehrlich None statt Raten + assert pick_chapter_doi([_KSS6, _KSS7], start_page=999, year="2013") is None + + +def test_pick_chapter_doi_year_must_match_when_given(): + from generative.tools.resolve_chapter_doi import pick_chapter_doi + # Seitenbereich passt, aber Jahr widerspricht → kein Match (fail-closed) + assert pick_chapter_doi([_KSS6], start_page=172, year="2022") is None + + +def test_pick_chapter_doi_ignores_year_when_not_given(): + from generative.tools.resolve_chapter_doi import pick_chapter_doi + hit = pick_chapter_doi([_KSS6], start_page=172, year=None) + assert hit is not None and hit["DOI"] == "10.1515/9783110258264.172" + + +def test_pick_chapter_doi_ambiguous_returns_none(): + from generative.tools.resolve_chapter_doi import pick_chapter_doi + # Zwei DOI-Treffer mit gleicher Startseite+Jahr → mehrdeutig → fail-closed None + # (CrossRef-Dublette o.ä.; lieber kein DOI als willkürlich raten). (Codex-Review.) + dup = {"DOI": "10.1515/other", "page": "172-180", + "issued": {"date-parts": [[2013]]}} + assert pick_chapter_doi([_KSS6, dup], start_page=172, year="2013") is None + + +def test_pick_chapter_doi_skips_items_without_doi(): + from generative.tools.resolve_chapter_doi import pick_chapter_doi + # Treffer mit passender Seite/Jahr aber ohne DOI-Key → ignorieren (nicht pinbar, + # kein KeyError); der valide DOI-Treffer gewinnt. (Codex-Review.) + no_doi = {"page": "172-182", "issued": {"date-parts": [[2013]]}} + hit = pick_chapter_doi([no_doi, _KSS6], start_page=172, year="2013") + assert hit is not None and hit["DOI"] == "10.1515/9783110258264.172" + + +# ---- Layer 1: auto-vault-Block + Framing -------------------------------- + +def _draft(**kw): + from generative.schemas.atomic_note import AtomicNoteDraft + base = dict(title="T", body="b", source_anchors=[], related=[], tags=[], + synthesis_confidence="low", action="create", + critic_score=5, hard_gates_pass=True) + base.update(kw) + return AtomicNoteDraft(**base) + + +def test_auto_write_blocks_edition_unverified_even_with_score5(): + from generative.pipeline.vault_writer import auto_write_decision + note = _draft(source_status="edition-unverified") # sonst Vault-tauglich + auto, reason = auto_write_decision(note) + assert auto is False + assert "edition" in reason.lower() + + +def test_auto_write_allows_when_edition_resolved(): + from generative.pipeline.vault_writer import auto_write_decision + note = _draft(source_status=None) + assert auto_write_decision(note) == (True, "ok") + + +def test_framing_for_edition_unverified_mentions_doi_pin(): + from generative.pipeline.routing_report import source_status_framing + line = source_status_framing("edition-unverified", "Reimer - 2013 - X.pdf") + assert line is not None and "--doi" in line + + +def test_framing_none_for_resolved_source(): + from generative.pipeline.routing_report import source_status_framing + assert source_status_framing(None, "x.pdf") is None diff --git a/generative/tools/resolve_chapter_doi.py b/generative/tools/resolve_chapter_doi.py new file mode 100644 index 0000000..0cfa8e6 --- /dev/null +++ b/generative/tools/resolve_chapter_doi.py @@ -0,0 +1,107 @@ +"""Kapitel-DOI über den Seitenbereich auflösen (Layer 3 der Edition-Verifikation). + +Wurzel-Fix gegen Edition-Verwechslung: Ein Buchkapitel-Titel ist oft generisch +(„Wissensorganisation") und CrossRef liefert mehrere Auflagen als Treffer — der +naive Top-Treffer ist nicht zwingend die richtige Edition (KSS-6 2013 vs. KSS-7 +2022 desselben Reimer-Kapitels). Der **Seitenbereich** beweist die Edition: das +2013er Kapitel beginnt auf Druckseite 172, das 2022er auf 147. + +Der Extraktions-Schritt ruft `resolve_chapter_doi(...)` mit Titel + Autor + +**bekannter Startseite** + Jahr auf und pinnt die zurückgegebene DOI via +`--doi` an die Pipeline. Dann ist die Zitation CrossRef-belegt statt +dateiname-geraten, und eine falsche Auflage fällt sofort auf (kein Match → None). +""" +from __future__ import annotations + +import json +import re +import sys +import urllib.parse +import urllib.request +from typing import Optional + +_CROSSREF = "https://api.crossref.org/works" + + +def _start_page(item: dict) -> Optional[int]: + """Erste Zahl aus dem CrossRef-`page`-Feld (z.B. '172-182' → 172).""" + page = item.get("page") + if not page: + return None + m = re.match(r"\s*(\d+)", str(page)) + return int(m.group(1)) if m else None + + +def _year(item: dict) -> Optional[int]: + pub = item.get("issued") or item.get("published-print") or item.get("published-online") or {} + parts = (pub.get("date-parts") or [[None]])[0] + return parts[0] if parts and parts[0] else None + + +def pick_chapter_doi(items: list[dict], start_page: int, + year: Optional[str] = None) -> Optional[dict]: + """Wählt aus CrossRef-Treffern das Kapitel, dessen Seitenbereich auf + ``start_page`` beginnt — und, falls ``year`` gegeben, dessen Jahr passt. + + Fail-closed: kein passender Seitenbereich → ``None`` (lieber kein DOI als die + falsche Auflage). Der Seitenbereich ist der eigentliche Edition-Beweis; der + Titel-Score von CrossRef wird bewusst NICHT als Tie-Breaker genutzt, weil er + bei generischen Kapiteltiteln die falsche Auflage nach oben sortiert. + """ + want_year = int(year) if year and str(year).isdigit() else None + matches = [] + for item in items: + if not item.get("DOI"): + continue # ohne DOI nicht pinbar — überspringen statt KeyError + if _start_page(item) != start_page: + continue + if want_year is not None and _year(item) != want_year: + continue + matches.append(item) + # Genau EIN Treffer — sonst fail-closed None (0 = keiner; >1 = mehrdeutig, z.B. + # CrossRef-Dublette oder zwei Werke mit gleicher Startseite). (Codex-Review.) + return matches[0] if len(matches) == 1 else None + + +def resolve_chapter_doi(title: str, author: Optional[str], start_page: int, + year: Optional[str] = None, rows: int = 10) -> Optional[dict]: + """Fragt CrossRef nach ``title``(+``author``) und disambiguiert per Startseite. + + Gibt das matchende CrossRef-Item (inkl. ``DOI``) zurück oder ``None``. I/O — + die reine Auswahllogik steckt in :func:`pick_chapter_doi` (testbar ohne Netz). + """ + params = {"query.bibliographic": title, "rows": str(rows)} + if author: + params["query.author"] = author + url = f"{_CROSSREF}?{urllib.parse.urlencode(params)}" + req = urllib.request.Request(url, headers={"User-Agent": "atomic-notes (mailto:noreply@example.com)"}) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read()) + except Exception: + return None + items = (data.get("message") or {}).get("items", []) + return pick_chapter_doi(items, start_page, year) + + +def _main(argv: list[str]) -> int: + import argparse + p = argparse.ArgumentParser( + description="Kapitel-DOI über den Seitenbereich auflösen (Edition-Beweis).") + p.add_argument("--title", required=True, help="Kapitel- oder Werk-Titel") + p.add_argument("--author", default=None, help="Autor (Nachname genügt)") + p.add_argument("--start-page", type=int, required=True, + help="Bekannte Druck-Startseite des Kapitels (beweist die Auflage)") + p.add_argument("--year", default=None, help="Erwartetes Jahr (fail-closed-Filter)") + a = p.parse_args(argv) + hit = resolve_chapter_doi(a.title, a.author, a.start_page, a.year) + if not hit: + print("kein eindeutiger Treffer (Seitenbereich passt zu keiner Auflage) — kein DOI gepinnt", + file=sys.stderr) + return 1 + print(hit["DOI"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(_main(sys.argv[1:]))