diff --git a/generative/orchestrator.py b/generative/orchestrator.py index d55ebec..a9f1d1f 100644 --- a/generative/orchestrator.py +++ b/generative/orchestrator.py @@ -91,6 +91,7 @@ def _reconfigure_streams_utf8() -> None: from generative.pipeline.page_index import build_page_index from generative.schemas.atomic_note import AtomicNoteDraft, ConceptPlan from generative.schemas.citation import CitationMeta, build_citation_meta, crossref_override_blocked +from generative.schemas.run_context import RunContext from shared.path_safety import resolve_source_path from generative.config import ( AGENT_VERSION, @@ -114,7 +115,6 @@ def _reconfigure_streams_utf8() -> None: from generative.runtime_config import ( load_runtime_config, cap_actionable_concepts, - count_actionable, RunBudget, refine_accepted, should_attempt_refine, @@ -1752,18 +1752,166 @@ def _build_citation( return citation +@dataclasses.dataclass(frozen=True) +class _PlanExtractResult: + """Ergebnis EINER `_plan_and_extract`-Invokation (ein Text-Scope). + + Benannte Felder statt Positions-Tupel — dieselbe Anti-Wiring-Bug-Motivation + wie RunContext, nur für die Planner→Extractor-Kette. `kept_actionable` speist + die kumulative Budget-Dekrementierung des by-chapter-Pfads; `related` sammelt + der by-chapter-Pfad über Kapitel dedupliziert, der Normalpfad nutzt es direkt. + """ + + drafts: list + concept_map: dict + dropped: int + failures: list + related: list + kept_actionable: int + background_map: dict + + +def _plan_and_extract( + *, + plan_text: str, + hall_text: str, + extract_text: str, + relevance_profile: dict, + existing_concepts: dict, + citation: CitationMeta | None, + tag_whitelist: list, + runtime_config, + cap_budget: int | None, + cap_label: str, + hall_ellipsis: str, + by_chapter: bool, + source_name: str, + n_chunks: int, +) -> _PlanExtractResult: + """Gemeinsame Planner→(Cap)→Background→Extractor-Kette für Normal- und + by-chapter-Pfad (#152). Die bug-anfällige Verdrahtung (welcher Text in + planner.run/filter_hallucinated/run_extractors_per_concept, welche + background_map) lebt hier an EINER Stelle; nur die pfad-spezifischen + Ausgabetexte und die Cap-Semantik werden parametrisiert — Verhalten pro Pfad + bleibt exakt erhalten (siehe Divergenz-Liste im PR): + + - Textbasis (Div. 3): `plan_text` (Planner), `hall_text` (Halluzinations- + Filter), `extract_text` (Extractor) getrennt übergeben. + - Cap (Div. 2): `cap_budget`/`cap_label` — by-chapter reicht das pro Kapitel + dekrementierte `remaining_concepts` herein und dekrementiert selbst via + `kept_actionable`; der Normalpfad cappt einmalig gegen `max_concepts`. + - Background (Div. 1): by-chapter fährt IMMER `background_map={}` (bewusst, + #102); der Normalpfad gated `background_extractor.run(plan)` über + `ENABLE_BACKGROUND_EXTRACTOR` inkl. Stage-4.5-Log. + - Spans (Div. 7): Planner- und Extractor-`_span` liegen jetzt hier, der + by-chapter-Pfad ERBT sie dadurch (Bookkeeping ohne model-Feld, brechen keine + Aggregation); der BackgroundExtractor-Span bleibt normalpfad-only, weil + by-chapter den Background-Extractor gar nicht fährt. + - Ellipsis (Div. 6): `hall_ellipsis` bewahrt die pfad-eigene Schreibweise der + Halluzinations-Zeile ("..." by-chapter / "…" Normalpfad). + """ + primary_authors = _extract_primary_authors(citation) + with _span("Planner", pdf=source_name, n_chunks=n_chunks): + plan = planner.run(plan_text, relevance_profile, primary_authors=primary_authors) + plan, hallucinated = planner.filter_hallucinated(plan, hall_text) + if hallucinated: + print( + f" {len(hallucinated)} halluzinierte Konzepte verworfen: " + f"{', '.join(hallucinated[:3])}{hall_ellipsis if len(hallucinated) > 3 else ''}" + ) + if runtime_config is not None: + plan.concepts, _capped = cap_actionable_concepts(plan.concepts, cap_budget) + if _capped: + print( + f" [runtime-config] {cap_label}={cap_budget} " + f"-> {len(_capped)} Konzept(e) übersprungen: " + f"{', '.join(c.title for c in _capped[:3])}" + f"{'…' if len(_capped) > 3 else ''}" + ) + + related = [c.title for c in plan.concepts if c.origin == "secondary_mention"] + actionable = [c for c in plan.concepts if c.action != "skip" and c.origin != "secondary_mention"] + + if by_chapter: + if not actionable: + print(" Keine Konzepte fuer dieses Kapitel") + # Wie der frühere `continue`: kein Extractor-Call, und `related` dieses + # Kapitels wird NICHT akkumuliert (related=[]), kept=0 dekrementiert nicht. + return _PlanExtractResult( + drafts=[], + concept_map={}, + dropped=0, + failures=[], + related=[], + kept_actionable=0, + background_map={}, + ) + print( + f" {len(actionable)} Konzepte: " + f"{', '.join(c.title for c in actionable[:4])}{'...' if len(actionable) > 4 else ''}" + ) + # #102: hart leer statt background_extractor.run() pro Kapitel — bewusst + # (Kosten-Multiplikation), Sichtbarkeit via Skip-Zeile im by-chapter-Zweigkopf. + background_map = {} + else: + if related: + print( + f" {len(related)} Sekundär-Erwähnungen → Related Mentions: " + f"{', '.join(related[:3])}{'…' if len(related) > 3 else ''}" + ) + print(f" {len(actionable)} Konzepte geplant ({len(plan.concepts)} total)") + for c in actionable: + print(f" [{c.priority:6s}] {c.action:6s} — {c.title}") + + # --- Schritt 4.5: Background-Extractor (nur Normalpfad) --- + if ENABLE_BACKGROUND_EXTRACTOR: + print("[4.5/7] Background-Extractor: Trainingswissen pro Konzept…") + with _span("BackgroundExtractor", pdf=source_name): + background_map = background_extractor.run(plan) + else: + print("[4.5/7] Background-Extractor: deaktiviert (ENABLE_BACKGROUND_EXTRACTOR=0)") + background_map = {} + + # --- Schritt 5: Extractor --- + print(f"\n[5/7] Extractor: {len(actionable)} Konzepte parallel verarbeiten…") + + with _span("Extractor", pdf=source_name, n_concepts=len(actionable)): + drafts, concept_map, dropped, failures = asyncio.run( + run_extractors_per_concept( + extract_text, + plan, + existing_concepts, + citation=citation, + tag_whitelist=tag_whitelist, + background_map=background_map, + related_mentions=related, + max_concurrent_calls=(runtime_config.max_concurrent_calls if runtime_config is not None else None), + ) + ) + + if not by_chapter: + print(f" {len(drafts)} Draft-Notes extrahiert") + + return _PlanExtractResult( + drafts=drafts, + concept_map=concept_map, + dropped=dropped, + failures=failures, + related=related, + kept_actionable=len(actionable), + background_map=background_map, + ) + + def _run_extraction_stages( args, source_path: Path, runtime_config=None ): # main() übergibt immer einen RuntimeConfig; None = kein Runtime-Config / Capping deaktiviert """Stages 0–5: PDF extract → planning → extraction. Returns: - (drafts, concept_map, existing_concepts, concept_links, - text, chunks, acronym_dict, quality_report, pdf_meta, - source_path, tag_whitelist, background_map, fb_year, - dropped_total, word_count, related_mentions, q_title, citation, - extractor_failures) - extractor_failures (#210): [(concept_title, error)] für Konzepte, deren + RunContext — benannte Felder statt 19er-Positions-Tupel (#152). Der + `--load-drafts`-Pfad (`_load_draft_state`) liefert dieselbe Struktur. + `extractor_failures` (#210): [(concept_title, error)] für Konzepte, deren Extractor-Call mit Exception (Timeout/CLI-Fehler nach Retries) starb. """ from generative.agents.base import trace_run_start as _trace_run_start @@ -1919,150 +2067,87 @@ def _run_extraction_stages( print(" Leerer Chunk, uebersprungen") continue - primary_authors = _extract_primary_authors(citation) - chapter_plan = planner.run(chunk.text, relevance_profile, primary_authors=primary_authors) - chapter_plan, hallucinated = planner.filter_hallucinated(chapter_plan, chunk.text) - if hallucinated: - print( - f" {len(hallucinated)} halluzinierte Konzepte verworfen: " - f"{', '.join(hallucinated[:3])}{'...' if len(hallucinated) > 3 else ''}" - ) - if runtime_config is not None: - chapter_plan.concepts, _capped = cap_actionable_concepts( - chapter_plan.concepts, - remaining_concepts, - ) - if _capped: - print( - f" [runtime-config] remaining_concepts={remaining_concepts} " - f"-> {len(_capped)} Konzept(e) übersprungen: " - f"{', '.join(c.title for c in _capped[:3])}" - f"{'…' if len(_capped) > 3 else ''}" - ) - kept_actionable = count_actionable(chapter_plan.concepts) - if remaining_concepts is not None: - remaining_concepts = max(0, remaining_concepts - kept_actionable) - ch_related = [c.title for c in chapter_plan.concepts if c.origin == "secondary_mention"] - actionable = [c for c in chapter_plan.concepts if c.action != "skip" and c.origin != "secondary_mention"] - if not actionable: - print(" Keine Konzepte fuer dieses Kapitel") - continue - print( - f" {len(actionable)} Konzepte: " - f"{', '.join(c.title for c in actionable[:4])}{'...' if len(actionable) > 4 else ''}" - ) - - ch_drafts, ch_map, ch_dropped, ch_failures = asyncio.run( - run_extractors_per_concept( - chunk.text, - chapter_plan, - existing_concepts, - citation=citation, - tag_whitelist=tag_whitelist, - # #102: hart leer statt background_extractor.run() pro Kapitel — - # bewusst (Kosten-Multiplikation), Sichtbarkeit via Skip-Zeile - # oben im by-chapter-Zweigkopf, nicht hier pro Kapitel. - background_map={}, - related_mentions=ch_related, - max_concurrent_calls=(runtime_config.max_concurrent_calls if runtime_config is not None else None), - ) + # #152: gemeinsame Planner→Extractor-Kette. by-chapter reicht dreimal + # chunk.text herein (plan/hall/extract), cappt gegen das kumulative + # remaining_concepts und fährt background_map={} (#102, Skip-Zeile oben). + _pe = _plan_and_extract( + plan_text=chunk.text, + hall_text=chunk.text, + extract_text=chunk.text, + relevance_profile=relevance_profile, + existing_concepts=existing_concepts, + citation=citation, + tag_whitelist=tag_whitelist, + runtime_config=runtime_config, + cap_budget=remaining_concepts, + cap_label="remaining_concepts", + hall_ellipsis="...", + by_chapter=True, + source_name=source_path.name, + n_chunks=len(chunks), ) - for t in ch_related: + if runtime_config is not None and remaining_concepts is not None: + remaining_concepts = max(0, remaining_concepts - _pe.kept_actionable) + for t in _pe.related: if t not in related_mentions: related_mentions.append(t) - dropped_total += ch_dropped - extractor_failures.extend(ch_failures) # #210 - all_drafts.extend(ch_drafts) - for draft_title, concept_context in ch_map.items(): + dropped_total += _pe.dropped + extractor_failures.extend(_pe.failures) # #210 + all_drafts.extend(_pe.drafts) + for draft_title, concept_context in _pe.concept_map.items(): all_concept_map.setdefault(draft_title, concept_context) drafts, concept_map = all_drafts, all_concept_map print(f"\n {len(drafts)} Draft-Notes aus {len(chunks)} Kapiteln extrahiert") else: - # --- Schritt 4: Planner + Halluzinations-Filter --- + # --- Schritt 4+4.5+5: Planner + Background + Extractor (Einzeldokument) --- print("[4/7] Planner: Konzept-Plan erstellen…") - primary_authors = _extract_primary_authors(citation) - with _span("Planner", pdf=source_path.name, n_chunks=len(chunks)): - concept_plan = planner.run(overview, relevance_profile, primary_authors=primary_authors) - concept_plan, hallucinated = planner.filter_hallucinated(concept_plan, text) - if hallucinated: - print( - f" {len(hallucinated)} halluzinierte Konzepte verworfen: " - f"{', '.join(hallucinated[:3])}{'…' if len(hallucinated) > 3 else ''}" - ) - if runtime_config is not None: - concept_plan.concepts, _capped = cap_actionable_concepts( - concept_plan.concepts, - runtime_config.max_concepts, - ) - if _capped: - print( - f" [runtime-config] max_concepts={runtime_config.max_concepts} " - f"-> {len(_capped)} Konzept(e) übersprungen: " - f"{', '.join(c.title for c in _capped[:3])}" - f"{'…' if len(_capped) > 3 else ''}" - ) - - related_mentions = [c.title for c in concept_plan.concepts if c.origin == "secondary_mention"] - if related_mentions: - print( - f" {len(related_mentions)} Sekundär-Erwähnungen → Related Mentions: " - f"{', '.join(related_mentions[:3])}{'…' if len(related_mentions) > 3 else ''}" - ) - - actionable = [c for c in concept_plan.concepts if c.action != "skip" and c.origin != "secondary_mention"] - print(f" {len(actionable)} Konzepte geplant ({len(concept_plan.concepts)} total)") - for c in actionable: - print(f" [{c.priority:6s}] {c.action:6s} — {c.title}") - - # --- Schritt 4.5: Background-Extractor --- - if ENABLE_BACKGROUND_EXTRACTOR: - print("[4.5/7] Background-Extractor: Trainingswissen pro Konzept…") - with _span("BackgroundExtractor", pdf=source_path.name): - background_map = background_extractor.run(concept_plan) - else: - print("[4.5/7] Background-Extractor: deaktiviert (ENABLE_BACKGROUND_EXTRACTOR=0)") - - # --- Schritt 5: Extractor --- - actionable_count = sum( - 1 for c in concept_plan.concepts if c.action != "skip" and c.origin != "secondary_mention" + # #152: dieselbe gemeinsame Kette; Normalpfad plant auf `overview`, filtert + # gegen den Volltext `text`, cappt einmalig gegen max_concepts und fährt den + # (gated) Background-Extractor. + _pe = _plan_and_extract( + plan_text=overview, + hall_text=text, + extract_text=text, + relevance_profile=relevance_profile, + existing_concepts=existing_concepts, + citation=citation, + tag_whitelist=tag_whitelist, + runtime_config=runtime_config, + cap_budget=(runtime_config.max_concepts if runtime_config is not None else None), + cap_label="max_concepts", + hall_ellipsis="…", + by_chapter=False, + source_name=source_path.name, + n_chunks=len(chunks), ) - print(f"\n[5/7] Extractor: {actionable_count} Konzepte parallel verarbeiten…") - with _span("Extractor", pdf=source_path.name, n_concepts=actionable_count): - drafts, concept_map, dropped_total, extractor_failures = asyncio.run( - run_extractors_per_concept( - text, - concept_plan, - existing_concepts, - citation=citation, - tag_whitelist=tag_whitelist, - background_map=background_map, - related_mentions=related_mentions, - max_concurrent_calls=(runtime_config.max_concurrent_calls if runtime_config is not None else None), - ) - ) - print(f" {len(drafts)} Draft-Notes extrahiert") - - return ( - drafts, - concept_map, - existing_concepts, - concept_links, - text, - chunks, - acronym_dict, - quality_report, - pdf_meta, - source_path, - tag_whitelist, - background_map, - fb.get("Year"), - dropped_total, - word_count, - related_mentions, - q_title, - citation, - extractor_failures, + drafts = _pe.drafts + concept_map = _pe.concept_map + dropped_total = _pe.dropped + extractor_failures = _pe.failures + related_mentions = _pe.related + background_map = _pe.background_map + + return RunContext( + drafts=drafts, + concept_map=concept_map, + existing_concepts=existing_concepts, + concept_links=concept_links, + text=text, + chunks=chunks, + acronym_dict=acronym_dict, + quality_report=quality_report, + pdf_meta=pdf_meta, + source_path=source_path, + tag_whitelist=tag_whitelist, + background_map=background_map, + fb_year=fb.get("Year"), + dropped_total=dropped_total, + word_count=word_count, + related_mentions=related_mentions, + q_title=q_title, + citation=citation, + extractor_failures=extractor_failures, ) @@ -2104,7 +2189,7 @@ def _save_draft_state( print(f" [save-drafts] {len(drafts)} Drafts → {path}") -def _load_draft_state(path: str): +def _load_draft_state(path: str) -> RunContext: from generative.schemas.atomic_note import AtomicNoteDraft, TextAnchor, QualityReport, ConceptItem from generative.pipeline.pdf_chunker import Chunk @@ -2119,21 +2204,52 @@ def _to_draft(d: dict) -> AtomicNoteDraft: concept_links = {k: set(v) for k, v in state["concept_links"].items()} quality_report = QualityReport(**state["quality_report"]) chunks = [Chunk(**c) for c in state["chunks"]] - return ( - drafts, - concept_map, - state["existing_concepts"], - concept_links, - state["text"], - chunks, - state["acronym_dict"], + pdf_meta = state["pdf_meta"] + source_path = Path(state["source_name"]) + text = state["text"] + + # --- Rekonstruktion der in _run_extraction_stages berechneten, aber NICHT + # persistierten Felder (Stage 1–5 sind hier übersprungen), damit der + # --load-drafts-Pfad dieselbe RunContext-Struktur liefert wie der Normalpfad + # (#152). Früher lag diese Rekonstruktion inline in main(). --- + # q_title wird im Normalpfad von _run_extraction_stages durchgereicht; hier aus + # dem geladenen pdf_meta abgeleitet. + q_title = (pdf_meta or {}).get("Title") + # citation (CitationMeta, #96 E3a): Stage 1–5 übersprungen, daher aus dem + # geladenen pdf_meta/quality_report neu konstruiert — dieselbe deterministische + # Factory wie im Normalpfad (_build_citation). + # #95: physical_pages hier per Zweit-Check auf dieselbe source_path neu ermittelt + # (analog zur Edition-Verifikation in main(), die _pdf_page_labels(source_path) + # ebenfalls unabhängig neu aufruft) statt über den State persistiert — + # deterministisch, solange die PDF-Datei am gespeicherten Pfad noch existiert. + citation = _build_citation( + pdf_meta, quality_report, - state["pdf_meta"], - state["source_name"], - state["tag_whitelist"], - state.get("background_map") or {}, - state.get("filename_year"), - state.get("related_mentions") or [], + q_title, + source_path.name, + physical_pages=pdf_chunker.pdf_uses_physical_pages(source_path), + ) + + return RunContext( + drafts=drafts, + concept_map=concept_map, + existing_concepts=state["existing_concepts"], + concept_links=concept_links, + text=text, + chunks=chunks, + acronym_dict=state["acronym_dict"], + quality_report=quality_report, + pdf_meta=pdf_meta, + source_path=source_path, + tag_whitelist=state["tag_whitelist"], + background_map=state.get("background_map") or {}, + fb_year=state.get("filename_year"), + dropped_total=0, + word_count=len(text.split()), + related_mentions=state.get("related_mentions") or [], + q_title=q_title, + citation=citation, + extractor_failures=[], # #210: Stage 1-5 übersprungen → keine Extractor-Calls ) @@ -2232,46 +2348,13 @@ def main(argv: list[str] | None = None): from generative.agents.base import trace_event as _trace_event if args.load_drafts: - ( - drafts, - concept_map, - existing_concepts, - concept_links, - text, - chunks, - acronym_dict, - quality_report, - pdf_meta, - _src_name, - tag_whitelist, - background_map, - fb_year, - related_mentions, - ) = _load_draft_state(args.load_drafts) - source_path = Path(_src_name) - # q_title wird im Normalpfad von _run_extraction_stages durchgereicht; - # der load-drafts-Pfad überspringt Stage 1–5, daher hier aus pdf_meta ableiten. - q_title = (pdf_meta or {}).get("Title") - # citation (CitationMeta, #96 E3a) ebenso: Stage 1–5 übersprungen, daher - # hier aus dem geladenen pdf_meta/quality_report neu konstruiert — dieselbe - # deterministische Factory wie im Normalpfad (_run_extraction_stages). - # #95: physical_pages hier per Zweit-Check auf dieselbe source_path neu - # ermittelt (analog zur bestehenden Edition-Verifikation weiter unten, die - # _pdf_page_labels(source_path) ebenfalls unabhängig vom load-drafts-Pfad - # neu aufruft) statt über den State persistiert — deterministisch, solange - # die PDF-Datei am gespeicherten Pfad noch existiert. - citation = _build_citation( - pdf_meta, - quality_report, - q_title, - source_path.name, - physical_pages=pdf_chunker.pdf_uses_physical_pages(source_path), - ) - word_count = len(text.split()) - dropped_total = 0 - extractor_failures: list[tuple[str, str]] = [] # #210: Stage 1-5 übersprungen → keine Extractor-Calls + # #152: _load_draft_state liefert dieselbe RunContext-Struktur wie + # _run_extraction_stages (Rekonstruktion von q_title/citation/word_count etc. + # liegt jetzt dort, inkl. #95/#96/#210-Begründungen). + ctx = _load_draft_state(args.load_drafts) + source_path = ctx.source_path print(f"\n=== Atomic Agent (load-drafts): {source_path.name} ===\n") - print(f" [load-drafts] {len(drafts)} Drafts geladen · Stage 1–5 übersprungen") + print(f" [load-drafts] {len(ctx.drafts)} Drafts geladen · Stage 1–5 übersprungen") else: # #186-Nachbesserung: derselbe Apostroph-/Anfuehrungszeichen-Glob-Fallback # wie extractive/orchestrator.py und eval_chunk_recall.py -- vorher brach @@ -2282,56 +2365,40 @@ def main(argv: list[str] | None = None): except FileNotFoundError as exc: sys.exit(f"Datei nicht gefunden: {exc}") print(f"\n=== Atomic Agent: {source_path.name} ===\n") - ( - drafts, - concept_map, - existing_concepts, - concept_links, - text, - chunks, - acronym_dict, - quality_report, - pdf_meta, - source_path, - tag_whitelist, - background_map, - fb_year, - dropped_total, - word_count, - related_mentions, - q_title, - citation, - extractor_failures, - ) = _run_extraction_stages(args, source_path, runtime_config) + ctx = _run_extraction_stages(args, source_path, runtime_config) if args.save_drafts: _save_draft_state( args.save_drafts, - drafts=drafts, - concept_map=concept_map, - existing_concepts=existing_concepts, - concept_links=concept_links, - text=text, - chunks=chunks, - acronym_dict=acronym_dict, - quality_report=quality_report, - pdf_meta=pdf_meta, + drafts=ctx.drafts, + concept_map=ctx.concept_map, + existing_concepts=ctx.existing_concepts, + concept_links=ctx.concept_links, + text=ctx.text, + chunks=ctx.chunks, + acronym_dict=ctx.acronym_dict, + quality_report=ctx.quality_report, + pdf_meta=ctx.pdf_meta, source_name=str(source_path), - tag_whitelist=tag_whitelist, - background_map=background_map, - filename_year=fb_year, - related_mentions=related_mentions, + tag_whitelist=ctx.tag_whitelist, + background_map=ctx.background_map, + filename_year=ctx.fb_year, + related_mentions=ctx.related_mentions, ) + # `drafts` wird ab hier durch Dedup-/Stage-6-Stufen ersetzt → lokale (mutierbare) + # Bindung; alle übrigen Stage-Ergebnisse werden per Attribut aus `ctx` gelesen. + drafts = ctx.drafts + # #210: Extractor-Ausfälle (Timeout/CLI-Fehler nach Retries) sichtbar machen. # n_attempted = erfolgreiche Extraktionen (drafts vor Dedup) + dropped (Fehler+Leer); # exit_code wird an ALLEN Rückgabepunkten zurückgegeben, damit ein Teilverlust den # Prozess mit 3 beendet (unterscheidbar von hartem Abbruch=1) statt still mit 0. - n_extract_attempted = len(drafts) + dropped_total - exit_code = extractor_failure_exit_code(extractor_failures) + n_extract_attempted = len(drafts) + ctx.dropped_total + exit_code = extractor_failure_exit_code(ctx.extractor_failures) if not drafts: print("\nKeine Konzepte extrahiert. Fertig.") - for _line in format_extractor_failure_report(extractor_failures, n_extract_attempted): + for _line in format_extractor_failure_report(ctx.extractor_failures, n_extract_attempted): print(_line, file=sys.stderr) return exit_code @@ -2345,16 +2412,16 @@ def main(argv: list[str] | None = None): drafts = _drop_artifacts(drafts) if not drafts: print("\nAlle Drafts als Artefakte verworfen. Fertig.") - for _line in format_extractor_failure_report(extractor_failures, n_extract_attempted): + for _line in format_extractor_failure_report(ctx.extractor_failures, n_extract_attempted): print(_line, file=sys.stderr) return exit_code # Qualitäts-Flags aus QualityReport auf alle Notes übertragen for d in drafts: - d.quality_flags.extend(quality_report.flags) + d.quality_flags.extend(ctx.quality_report.flags) # --- Dedup Stage A: Exact-Match (deterministisch, keine LLM-Calls) --- - drafts = dedup_exact(drafts, existing_concepts) + drafts = dedup_exact(drafts, ctx.existing_concepts) print(f" {len(drafts)} nach Exact-Dedup") # --- Dedup Stage B: Entity-Resolution (Embedding-Cluster + LLM-Merge) --- @@ -2393,24 +2460,24 @@ def main(argv: list[str] | None = None): # --- Schritte 6a-c: Verifier + Cross-Reference + Critic pro Note (parallel) --- print(f"\n[6/7] Verifier + Cross-Reference + Critic für {len(drafts)} Notes…") - chunk_map = {c.title: c.text for c in chunks} + chunk_map = {c.title: c.text for c in ctx.chunks} with _span("Stage6-Verifier-CrossRef-Critic", pdf=source_path.name, n_drafts=len(drafts)): drafts = asyncio.run( process_all_notes_async( drafts, - existing_concepts, - concept_links, + ctx.existing_concepts, + ctx.concept_links, chunk_map, - full_text=text, - acronym_dict=acronym_dict, - concept_map=concept_map, - quality_report=quality_report, - citation=citation, + full_text=ctx.text, + acronym_dict=ctx.acronym_dict, + concept_map=ctx.concept_map, + quality_report=ctx.quality_report, + citation=ctx.citation, source_path=source_path, - tag_whitelist=tag_whitelist, - background_map=background_map, - related_mentions=related_mentions, + tag_whitelist=ctx.tag_whitelist, + background_map=ctx.background_map, + related_mentions=ctx.related_mentions, runtime_config=runtime_config, refine_budget=refine_budget, ) @@ -2422,7 +2489,7 @@ def main(argv: list[str] | None = None): # Writer und beide Notes würden geschrieben. Hier auf das vorhandene Signal reagieren # und Geschwister eines Laufs deterministisch zu EINER Note kollabieren — nach den # per-Draft-Calls (Signal steht erst jetzt fest), vor boilerplate_dedup und Writer. - drafts, n_sib = resolve_sibling_dups(drafts, existing_concepts) + drafts, n_sib = resolve_sibling_dups(drafts, ctx.existing_concepts) if n_sib: print(f" [sibling-dedup] {n_sib} Intra-Run-Near-Dup(s) in Geschwister-Note(s) gemergt") @@ -2446,7 +2513,7 @@ def main(argv: list[str] | None = None): # LLM-generierte Autor-/Jahr-Attribution im Body von der kanonischen # CitationMeta abweicht (Regressionsfall: "Landry 2019" statt Knowles). # Kein Body-Edit, kein Routing-Eingriff — nur ein Review-Hinweis. - n_citation_flags = citation_check.apply_citation_check(drafts, citation) + n_citation_flags = citation_check.apply_citation_check(drafts, ctx.citation) if n_citation_flags: print(f"[citation-check] {n_citation_flags} Attribution(s) ohne Quellendeckung geflaggt") @@ -2454,7 +2521,7 @@ def main(argv: list[str] | None = None): # Seiteneffekt-freier Review-Hinweis (analog #8/E3b): render_note/render_moc # kennzeichnen Seitenangaben bereits als "PDF-S." (citation.physical_pages), # dieses Flag macht die Einschränkung zusätzlich im Frontmatter sichtbar. - n_physical_flags = citation_check.apply_physical_pages_flag(drafts, citation) + n_physical_flags = citation_check.apply_physical_pages_flag(drafts, ctx.citation) if n_physical_flags: print(f"[physical-pages] {n_physical_flags} Note(s) ohne /PageLabels — Seiten als PDF-Position geflaggt") @@ -2473,7 +2540,7 @@ def main(argv: list[str] | None = None): # Extraction-Stage). Nur create-Notes werden markiert (extend/hub out-of-scope). _fb = vault_writer.parse_filename_fallback(source_path.name) _source_unresolved = routing_report.is_source_unresolved( - citation.as_meta_dict(), _fb, crossref_override_blocked(quality_report, q_title) + ctx.citation.as_meta_dict(), _fb, crossref_override_blocked(ctx.quality_report, ctx.q_title) ) if _source_unresolved: _marked = 0 @@ -2497,7 +2564,7 @@ def main(argv: list[str] | None = None): # 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 + _doi_verified = bool(ctx.quality_report.crossref_year) and not ctx.quality_report.doi_from_title_match if routing_report.is_edition_unverified(_doi_verified, _first_print_page): _ed_marked = 0 for draft in drafts: @@ -2529,7 +2596,7 @@ def main(argv: list[str] | None = None): # Issue #21: Sibling-related-Links auf Merge-Targets umschreiben, bevor # geschrieben wird — sonst zeigen sie auf nie-erzeugte Draft-Titel-Dateien. - n_rewritten = vault_writer.rewrite_merged_related_links(drafts, existing_concepts) + n_rewritten = vault_writer.rewrite_merged_related_links(drafts, ctx.existing_concepts) if n_rewritten: print(f"[merge-links] {n_rewritten} related-Link(s) auf Merge-Target umgeschrieben") @@ -2558,8 +2625,8 @@ def main(argv: list[str] | None = None): draft, source_file=source_path.name, dry_run=args.dry_run, - citation=citation, - existing_concepts=existing_concepts, + citation=ctx.citation, + existing_concepts=ctx.existing_concepts, inbox_dir=_inbox_dir, ) will_vault, _ = vault_writer.auto_write_decision(draft) @@ -2579,7 +2646,7 @@ def main(argv: list[str] | None = None): print(f"\n=== Fertig: {written} Notes {'(dry-run)' if args.dry_run else 'geschrieben'} ===") # #210: verlorene Konzepte (Timeout/CLI-Fehler) direkt im Summary ausweisen — # kein stilles Exit 0. Der Prozess endet unten mit exit_code (3), wenn befüllt. - for _line in format_extractor_failure_report(extractor_failures, n_extract_attempted): + for _line in format_extractor_failure_report(ctx.extractor_failures, n_extract_attempted): print(_line, file=sys.stderr) # #45: Final-Report um Gründe-Aggregat erweitern (Routing-Verteilung + # "0 PDFs verändert"-Zusicherung sichtbar machen). @@ -2626,7 +2693,7 @@ def main(argv: list[str] | None = None): ) exported_files, export_messages = export_runner.run_export( drafts, - citation, + ctx.citation, export_formats, export_root, written_files=[_t for _t, _ in written_targets], @@ -2708,8 +2775,8 @@ def main(argv: list[str] | None = None): "n_vault": vault_count, "n_inbox": inbox_count, "n_merge": sum(1 for d in drafts if getattr(d, "action", "") == "extend"), - "n_dropped": dropped_total, - "n_words": word_count, + "n_dropped": ctx.dropped_total, + "n_words": ctx.word_count, "model": getattr(_db_cfg, "MODEL_PLANNER", ""), "cost_usd": _cost_usd, "tokens_total": _tok_total, diff --git a/generative/schemas/run_context.py b/generative/schemas/run_context.py new file mode 100644 index 0000000..5696f7f --- /dev/null +++ b/generative/schemas/run_context.py @@ -0,0 +1,52 @@ +"""RunContext: gebündelter Rückgabe-Zustand der Extraction-Stages (0–5). + +Vorher gab `_run_extraction_stages` ein 19-stelliges Positions-Tupel zurück, das +`main()` per Reihenfolge auspackte. Ein Positions-Versehen an dieser Grenze war +die Quelle mehrerer Wiring-Bugs (q_title-NameError, quality-Modul-Shadowing) — +ein falsch platzierter Wert fiel erst zur Laufzeit im nächsten Stage auf. Diese +frozen-Dataclass ersetzt Positionen durch benannte Felder: einmal gefüllt, alle +lesen per Attribut (analog zu `CitationMeta`/`RuntimeConfig` — „einmal bauen, +alle lesen"). Sowohl der Normalpfad (`_run_extraction_stages`) als auch der +`--load-drafts`-Pfad (`_load_draft_state`) liefern dieselbe Struktur. Siehe #152. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from generative.schemas.atomic_note import AtomicNoteDraft, QualityReport + from generative.schemas.citation import CitationMeta + + +@dataclass(frozen=True) +class RunContext: + """Kanonischer Zustand nach den Extraction-Stages — nach Konstruktion unveränderlich. + + Feldreihenfolge = die Reihenfolge des früheren 19er-Tupels (Nachvollziehbarkeit + beim Umbau); für den Zugriff irrelevant, weil ausschließlich per Attribut gelesen. + `drafts` wird im weiteren `main()`-Verlauf durch Dedup-/Stage-6-Stufen ersetzt — + dort in eine lokale Variable gebunden, nicht in-place auf dem RunContext mutiert. + """ + + drafts: list[AtomicNoteDraft] + concept_map: dict + existing_concepts: dict + concept_links: dict + text: str + chunks: list + acronym_dict: dict + quality_report: QualityReport + pdf_meta: dict + source_path: Path + tag_whitelist: list + background_map: dict + fb_year: Optional[str] + dropped_total: int + word_count: int + related_mentions: list[str] + q_title: Optional[str] + citation: CitationMeta + extractor_failures: list[tuple[str, str]] diff --git a/generative/tests/test_load_draft_state_roundtrip.py b/generative/tests/test_load_draft_state_roundtrip.py new file mode 100644 index 0000000..48bef7b --- /dev/null +++ b/generative/tests/test_load_draft_state_roundtrip.py @@ -0,0 +1,199 @@ +"""Regressionstest für den `--load-drafts`-Checkpoint-Roundtrip (#152). + +`_save_draft_state` → `_load_draft_state` ist die intrikateste neue Logik des +RunContext-Refactors: Stage 1–5 werden beim Resume übersprungen, daher müssen +`citation` (via `_build_citation`, inkl. `physical_pages`-Zweit-Check #95/#96), +`q_title`, `word_count`, `dropped_total` und `extractor_failures` aus dem +persistierten State REKONSTRUIERT statt durchgereicht werden. Bis zu diesem +Test war das nur manuell verifiziert (siehe PR #227 Kontroll-Review) — dieser +Test fixiert das Verhalten Feld für Feld gegen die volle `RunContext`- +Dataclass (19 Felder, siehe `generative/schemas/run_context.py`). + +Keine LLM-Calls, kein Netz-/Vault-Zugriff — reiner JSON-Roundtrip über +`tmp_path`. `pdf_chunker.pdf_uses_physical_pages` wird deterministisch +gestubbt (echtes PDF-Parsing ist nicht Gegenstand dieses Tests, siehe +`test_physical_pages.py`). +""" + +from __future__ import annotations + +import dataclasses +import json +from pathlib import Path + +from generative import orchestrator +from generative.pipeline.pdf_chunker import Chunk +from generative.schemas.atomic_note import AtomicNoteDraft, ConceptItem, QualityReport, TextAnchor +from generative.schemas.citation import CitationMeta +from generative.schemas.run_context import RunContext + +SOURCE_NAME = "Wilson - 2020 - Informationsverhalten und Kontext.pdf" +TEXT = "Menschen suchen und nutzen Information in einem sozialen Kontext ständig." + + +def _drafts() -> list[AtomicNoteDraft]: + return [ + AtomicNoteDraft( + title="Informationsverhalten: Kerncharakteristik", + body="# Informationsverhalten: Kerncharakteristik\n\nErste Aussage (S. 3).", + source_anchors=[TextAnchor(quote="Originalzitat A", page="S. 3", fuzzy_page=None)], + related=["[[Anderes Konzept]]"], + tags=["uni/ibi/konzept"], + synthesis_confidence="high", + aliases=["Info-Verhalten"], + quality_flags=["⚠️ Testflag"], + action="create", + critic_score=4, + hard_gates_pass=True, + ), + AtomicNoteDraft( + title="Kontextsensitivität", + body="# Kontextsensitivität\n\nZweite Aussage (S. 5).", + source_anchors=[TextAnchor(quote="Zweites Zitat", page="S. 5")], + related=[], + tags=["uni/ibi/konzept"], + synthesis_confidence="medium", + ), + ] + + +def _concept_map() -> dict: + return { + "Informationsverhalten: Kerncharakteristik": ( + ConceptItem( + title="Informationsverhalten: Kerncharakteristik", + priority="high", + chapter="Kap. 2", + action="create", + ), + "Kontext-Textauszug für die Extraktion.", + ), + } + + +def _quality_report() -> QualityReport: + # Bewusst OHNE CrossRef-Override-Daten (crossref_title=None): die Override- + # Blocklogik selbst ist bereits in test_citation_meta.py abgedeckt. Hier + # geht es um die Rekonstruktion der Factory-Kette nach dem Reload, nicht um + # die Override-Fallunterscheidung — pdf_meta bleibt daher unverfälscht die + # Erwartung für author/year/title. + return QualityReport( + peer_reviewed=True, + citation_count=12, + retracted=False, + flags=["⚠️ Testflag"], + ) + + +def _save_state(path: str) -> None: + orchestrator._save_draft_state( + path, + drafts=_drafts(), + concept_map=_concept_map(), + existing_concepts={"Bestehendes Konzept": 1}, + concept_links={"Informationsverhalten: Kerncharakteristik": ["Kontextsensitivität", "Anderes Konzept"]}, + text=TEXT, + chunks=[Chunk(title="Kapitel 2", text="[S. 3]\n\nText ...", index=0, page_start=3, page_end=5)], + acronym_dict={"HIB": "Human Information Behavior"}, + quality_report=_quality_report(), + pdf_meta={"Title": "Informationsverhalten und Kontext", "Author": "Wilson", "Year": "2020"}, + source_name=SOURCE_NAME, + tag_whitelist=["uni/ibi/konzept", "uni/ibi/methode"], + background_map={"Informationsverhalten: Kerncharakteristik": "Hintergrundtext"}, + filename_year="2020", + related_mentions=["Kontextsensitivität", "Erwähntes Konzept"], + ) + + +def test_save_then_load_reconstructs_full_run_context(monkeypatch, tmp_path): + path = str(tmp_path / "draft_state.json") + + # physical_pages (#95/#96 Zweit-Check) deterministisch stubben + Aufruf + # protokollieren — kein echtes PDF nötig, aber der Aufruf-Pfad (source_path + # aus dem geladenen State) muss stimmen. + calls: list[Path] = [] + + def _spy_physical_pages(source_path): + calls.append(source_path) + return True # bewusst != CitationMeta-Default (False) — reine Passthrough-Prüfung + + monkeypatch.setattr(orchestrator.pdf_chunker, "pdf_uses_physical_pages", _spy_physical_pages) + + _save_state(path) + ctx = orchestrator._load_draft_state(path) + + # -- Struktur: alle 19 RunContext-Felder vorhanden ----------------------- + assert isinstance(ctx, RunContext) + field_names = {f.name for f in dataclasses.fields(RunContext)} + assert len(field_names) == 19 + for name in field_names: + assert hasattr(ctx, name), f"RunContext.{name} fehlt nach Reload" + + # -- Kern-Daten überleben den Roundtrip ----------------------------------- + assert [d.title for d in ctx.drafts] == [ + "Informationsverhalten: Kerncharakteristik", + "Kontextsensitivität", + ] + assert all(isinstance(d, AtomicNoteDraft) for d in ctx.drafts) + assert isinstance(ctx.drafts[0].source_anchors[0], TextAnchor) + assert ctx.drafts[0].source_anchors[0].quote == "Originalzitat A" + assert ctx.drafts[0].source_anchors[0].page == "S. 3" + + assert set(ctx.concept_map.keys()) == {"Informationsverhalten: Kerncharakteristik"} + concept_item, ctext = ctx.concept_map["Informationsverhalten: Kerncharakteristik"] + assert isinstance(concept_item, ConceptItem) + assert concept_item.title == "Informationsverhalten: Kerncharakteristik" + assert concept_item.priority == "high" + assert ctext == "Kontext-Textauszug für die Extraktion." + + assert ctx.text == TEXT + assert ctx.tag_whitelist == ["uni/ibi/konzept", "uni/ibi/methode"] + + # -- Typen/übrige direkt persistierte Felder ------------------------------ + assert ctx.existing_concepts == {"Bestehendes Konzept": 1} + # concept_links: Liste -> set beim Reload (siehe _load_draft_state) + assert ctx.concept_links == { + "Informationsverhalten: Kerncharakteristik": {"Kontextsensitivität", "Anderes Konzept"} + } + assert isinstance(ctx.concept_links["Informationsverhalten: Kerncharakteristik"], set) + assert len(ctx.chunks) == 1 and isinstance(ctx.chunks[0], Chunk) + assert ctx.chunks[0].page_start == 3 + assert ctx.acronym_dict == {"HIB": "Human Information Behavior"} + assert isinstance(ctx.quality_report, QualityReport) + assert ctx.quality_report.flags == ["⚠️ Testflag"] + assert ctx.pdf_meta == {"Title": "Informationsverhalten und Kontext", "Author": "Wilson", "Year": "2020"} + assert ctx.source_path == Path(SOURCE_NAME) + assert isinstance(ctx.source_path, Path) + assert ctx.background_map == {"Informationsverhalten: Kerncharakteristik": "Hintergrundtext"} + assert ctx.related_mentions == ["Kontextsensitivität", "Erwähntes Konzept"] + + # -- Rekonstruierte (NICHT persistierte) Felder — das eigentliche Ziel --- + # fb_year: direkt aus dem gespeicherten filename_year (State-Feld), NICHT + # zu verwechseln mit dem parse_filename_fallback-fb_year innerhalb der + # citation-Factory (unten) — zwei unabhängige Ableitungen desselben Namens. + assert ctx.fb_year == "2020" + assert ctx.dropped_total == 0 + assert ctx.word_count == len(TEXT.split()) == 10 + assert ctx.q_title == "Informationsverhalten und Kontext" # aus pdf_meta["Title"] + assert ctx.extractor_failures == [] + + # citation: über dieselbe Factory wie der Normalpfad (_build_citation) neu + # gebaut. Ohne CrossRef-Override-Daten entspricht sie 1:1 pdf_meta. + assert isinstance(ctx.citation, CitationMeta) + assert ctx.citation.author == "Wilson" + assert ctx.citation.year == "2020" + assert ctx.citation.title == "Informationsverhalten und Kontext" + assert ctx.citation.source_file == SOURCE_NAME + # physical_pages (#95/#96): via pdf_chunker.pdf_uses_physical_pages(source_path) + # neu ermittelt (Zweit-Check, nicht persistiert) und durchgereicht. + assert ctx.citation.physical_pages is True + assert calls == [Path(SOURCE_NAME)] + + # -- Save-Datei selbst trägt die Kern-Daten (unabhängig von der Reload-Seite) -- + raw = json.loads(Path(path).read_text(encoding="utf-8")) + assert [d["title"] for d in raw["drafts"]] == [ + "Informationsverhalten: Kerncharakteristik", + "Kontextsensitivität", + ] + assert raw["tag_whitelist"] == ["uni/ibi/konzept", "uni/ibi/methode"] + assert raw["text"] == TEXT diff --git a/generative/tests/test_orchestrator_quality_wiring.py b/generative/tests/test_orchestrator_quality_wiring.py index 351905f..2b08f5d 100644 --- a/generative/tests/test_orchestrator_quality_wiring.py +++ b/generative/tests/test_orchestrator_quality_wiring.py @@ -59,20 +59,21 @@ def _spy_check_quality(**_kw): # Der Quality-Agent (Modul) muss genau einmal erreicht worden sein. assert calls["n"] == 1 - # quality_report wandert an Tupel-Position 7. - assert isinstance(result[7], QualityReport) - # #210: extractor_failures ist jetzt der letzte Wert; q_title/citation rücken je - # eine Position nach vorne. - assert result[-1] == [] # extractor_failures (kein Ausfall im Stub) + # #152: _run_extraction_stages liefert eine RunContext-Dataclass statt eines + # 19er-Positions-Tupels — Zugriff per Attributname (kein Positions-Wiring mehr, + # das war die Quelle der q_title-/quality-Shadowing-Bugs). + assert isinstance(result.quality_report, QualityReport) + # #210: extractor_failures — kein Ausfall im Stub. + assert result.extractor_failures == [] # q_title (erwarteter Quell-Titel) — sonst crasht main() beim CrossRef-Override- # Check mit NameError (Ebner-Run-Regression). - assert result[-3] == "Titel" - # citation (CitationMeta, #96 E3a) muss als vorletzter Wert mitkommen — konstruiert - # in _run_extraction_stages VOR dem Planner (Stage 3→4-Grenze), damit Extractor/ - # Planner dieselben (CrossRef-korrigierten) Werte sehen wie der Vault-Writer. + assert result.q_title == "Titel" + # citation (CitationMeta, #96 E3a) — konstruiert in _run_extraction_stages VOR dem + # Planner (Stage 3→4-Grenze), damit Extractor/Planner dieselben (CrossRef- + # korrigierten) Werte sehen wie der Vault-Writer. from generative.schemas.citation import CitationMeta - citation = result[-2] + citation = result.citation assert isinstance(citation, CitationMeta) assert citation.author == "Autor" assert citation.year == "2020" @@ -118,5 +119,6 @@ async def _no_concepts(*_a, **_k): args = SimpleNamespace(by_chapter=False, dry_run=True, doi=None, llm_fallback=False) result = orchestrator._run_extraction_stages(args, Path("fake.pdf"), None) - citation = result[-2] # #210: extractor_failures ist jetzt der letzte Wert + # #152: Attributzugriff auf die RunContext-Dataclass statt Tupel-Position. + citation = result.citation assert citation.physical_pages is True