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
28 changes: 28 additions & 0 deletions generative/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions generative/pipeline/routing_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,39 @@ 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.

Bewusst in natürlicher Sprache, ehrlich, erste Person (zu testende Hypothese:
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.
Expand Down
7 changes: 7 additions & 0 deletions generative/pipeline/vault_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions generative/tests/test_edition_verification.py
Original file line number Diff line number Diff line change
@@ -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
107 changes: 107 additions & 0 deletions generative/tools/resolve_chapter_doi.py
Original file line number Diff line number Diff line change
@@ -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:]))
Loading