From 3e5c58ed1f9c25d1c377c6d729944194e6dac1e0 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Fri, 17 Jul 2026 13:15:23 +0200 Subject: [PATCH] feat(db): 0-Notes-Laeufe in pipeline_runs erfassen (#330) Laeufe, die 0 Notes erzeugen (Konzeptmangel oder stiller Totalverlust, #281), kehrten in orchestrator.main() an zwei Stellen vor dem Erfolgspfad-Insert zurueck und hinterliessen dadurch keine pipeline_runs-Zeile. Solche Laeufe waren in DB/Dashboard unsichtbar -- Run-Zaehlung, Token-/Kosten-Tracking und die Frage "wie oft produziert die Pipeline nichts?" waren systematisch untererfasst. Beide Fruehausstiege (direkt nach der Extraktion, und nach _drop_artifacts()) schreiben jetzt per neuem _insert_zero_notes_run()- Helper eine Zeile mit n_generated=0 und echten Token-/Dauer-Werten. Additive nullable Spalte abort_reason unterscheidet den Grund (no_concepts, all_secondary_mentions, extraction_total_loss, all_artifacts) -- Migration ueber das etablierte _add_column-Muster, Erfolgspfad-Zeilen bleiben NULL. --- generative/db.py | 17 +- generative/orchestrator.py | 95 ++++++++++++ generative/tests/test_db.py | 100 +++++++++++- .../tests/test_zero_notes_run_persisted.py | 146 ++++++++++++++++++ shared/db_schema.py | 8 +- 5 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 generative/tests/test_zero_notes_run_persisted.py diff --git a/generative/db.py b/generative/db.py index 388416f..74ed83b 100644 --- a/generative/db.py +++ b/generative/db.py @@ -71,6 +71,10 @@ def init_db(path: Path = DB_PATH) -> None: # in shared/db_schema.py fuer den Zwei-Phasen-Schreibpfad (insert_run VOR, # update_wall_clock_s NACH Stage-8). _add_column(conn, "pipeline_runs", "wall_clock_s REAL DEFAULT 0") + # #330: 0-Notes-Laeufe (Konzeptmangel/Totalverlust) bekommen jetzt eine + # pipeline_runs-Zeile mit Abbruchgrund statt komplett zu fehlen — + # additive nullable Spalte, Bestandszeilen bleiben NULL (Erfolgspfad). + _add_column(conn, "pipeline_runs", "abort_reason TEXT") _add_column(conn, "note_evals", "anchor_rate REAL") # Anker-Roh-Counts: für die gepoolte Halluzinationsrate (Σ halluziniert / # Σ gesamt) im Dashboard — die Pipeline berechnet sie ohnehin, persistiert @@ -109,7 +113,7 @@ def insert_run(conn: sqlite3.Connection, data: dict) -> None: run_id, timestamp, pipeline_version, pdf_source, pdf_key, pdf_label, n_generated, n_extracted, n_vault, n_inbox, n_merge, n_dropped, n_words, model, tokens_total, tokens_input, tokens_output, tokens_cache_read, - duration_s, eval_version, profile, wall_clock_s + duration_s, eval_version, profile, wall_clock_s, abort_reason n_generated = geschriebene Notes (historische Semantik, unangetastet). n_extracted = "nach Planner/Extractor generiert" (Funnel-Top, #197). Fehlt @@ -119,6 +123,12 @@ def insert_run(conn: sqlite3.Connection, data: dict) -> None: der Aufrufer (orchestrator.main()) uebergibt hier bewusst denselben Wert. Nach Stage-8 korrigiert update_wall_clock_s() die Zeile auf die echte Gesamtzeit inkl. Eval-Phase. + + abort_reason (#330): Grund fuer einen 0-Notes-Lauf (z.B. "no_concepts", + "all_secondary_mentions", "all_artifacts", "extraction_total_loss"). + Fehlt der Key (Erfolgspfad-Aufrufer), bleibt die Spalte NULL — anders als + die uebrigen Feldern hier KEIN 0/''-Default, NULL ist die korrekte + "kein Abbruch"-Semantik. """ data.setdefault("timestamp", datetime.utcnow().isoformat()) conn.execute( @@ -127,12 +137,12 @@ def insert_run(conn: sqlite3.Connection, data: dict) -> None: (run_id, timestamp, pipeline_version, pdf_source, pdf_key, pdf_label, n_generated, n_extracted, n_vault, n_inbox, n_merge, n_dropped, n_words, model, cost_usd, tokens_total, tokens_input, tokens_output, tokens_cache_read, - duration_s, eval_version, fully_cached, profile, wall_clock_s) + duration_s, eval_version, fully_cached, profile, wall_clock_s, abort_reason) VALUES (:run_id, :timestamp, :pipeline_version, :pdf_source, :pdf_key, :pdf_label, :n_generated, :n_extracted, :n_vault, :n_inbox, :n_merge, :n_dropped, :n_words, :model, :cost_usd, :tokens_total, :tokens_input, :tokens_output, :tokens_cache_read, - :duration_s, :eval_version, :fully_cached, :profile, :wall_clock_s) + :duration_s, :eval_version, :fully_cached, :profile, :wall_clock_s, :abort_reason) """, { "run_id": data.get("run_id"), @@ -159,6 +169,7 @@ def insert_run(conn: sqlite3.Connection, data: dict) -> None: "fully_cached": 1 if (data.get("tokens_total", 0) == 0 and data.get("duration_s", 0) > 0) else 0, "profile": data.get("profile", ""), "wall_clock_s": data.get("wall_clock_s", 0.0), + "abort_reason": data.get("abort_reason"), }, ) diff --git a/generative/orchestrator.py b/generative/orchestrator.py index 95ed8cc..d6381c4 100644 --- a/generative/orchestrator.py +++ b/generative/orchestrator.py @@ -422,6 +422,69 @@ def total_loss_warning_line(n_attempted: int) -> str: ) +# --- #330: 0-Notes-Läufe bekommen eine pipeline_runs-Zeile ------------------ +# Läufe, die 0 Notes erzeugen (legitimer Konzeptmangel ODER stiller +# Totalverlust, #281), kehrten bisher VOR dem Erfolgspfad-Insert (weiter unten +# in main(), #198 P1) zurück — keine DB-Zeile, also unsichtbar für Run- +# Zählung, Token-/Kosten-Tracking und die Frage „wie oft produziert die +# Pipeline nichts?". Diese Funktion schließt die Lücke additiv an beiden +# betroffenen Rückgabepunkten (s. main()): n_generated/n_vault/n_inbox/ +# n_merge bleiben 0 (keine Note geschrieben), abort_reason macht den Grund +# sichtbar. Eigenes try/except — ein DB-Fehler darf den Lauf-Exit nie +# verhindern (gleiches Muster wie der Erfolgspfad-Insert weiter unten). +def _insert_zero_notes_run( + *, + run_start: float, + source_path: Path, + runtime_config, + dropped_total: int, + word_count: int, + n_extracted: int, + abort_reason: str, +) -> None: + import time as _time + + from generative import config as _db_cfg + from generative import db as _db + from generative import eval_agent_stats as _eas + from generative.agents.base import _RUN_DIR, _RUN_ID + + try: + trace_path = _RUN_DIR / f"{_RUN_ID}.jsonl" + wall_s = round(_time.time() - run_start, 1) + pre = _eas.run_totals(trace_path) # tolerant bei fehlendem/kaputtem Trace → Nullen + with _db.get_db(_db.DB_PATH) as conn: + _db.insert_run( + conn, + { + "run_id": _RUN_ID, + "pipeline_version": AGENT_VERSION, + "pdf_source": source_path.name, + "pdf_key": source_path.stem.split(" - ")[0].strip().lower(), + "pdf_label": source_path.stem.split(" - ")[0].strip(), + "n_generated": 0, + "n_extracted": n_extracted, + "n_vault": 0, + "n_inbox": 0, + "n_merge": 0, + "n_dropped": dropped_total, + "n_words": word_count, + "model": getattr(_db_cfg, "MODEL_PLANNER", ""), + "cost_usd": pre["cost_usd"], + "tokens_total": pre["total"], + "tokens_input": pre["input"], + "tokens_output": pre["output"], + "tokens_cache_read": pre["cache_read"], + "duration_s": wall_s, + "profile": runtime_config.profile, + "wall_clock_s": wall_s, + "abort_reason": abort_reason, + }, + ) + except Exception as _db_err: + print(f" [warn] DB-Write (0-Notes-Lauf) fehlgeschlagen: {_db_err}") + + def _normalize(title: str) -> str: """Normalisiert Titel für Dedup-Vergleich: Kleinbuchstaben, Satzzeichen entfernen.""" import re @@ -2625,6 +2688,26 @@ def main(argv: list[str] | None = None): print(total_loss_warning_line(n_extract_attempted)) for _line in format_extractor_failure_report(ctx.extractor_failures, n_extract_attempted): print(_line, file=sys.stderr) + # #330: 0-Notes-Lauf trotzdem in pipeline_runs erfassen. Drei + # unterscheidbare Ursachen: stiller Totalverlust (>=1 Konzept + # versucht, 0 überlebt — exit_code bereits _EXIT_TOTAL_LOSS), sonst + # legitimer Konzeptmangel — mit Sekundär-Erwähnungen (Planner fand + # nur secondary_mention-Konzepte) oder ganz ohne Konzepte/nur Skips. + if exit_code == _EXIT_TOTAL_LOSS: + _abort_reason = "extraction_total_loss" + elif ctx.related_mentions: + _abort_reason = "all_secondary_mentions" + else: + _abort_reason = "no_concepts" + _insert_zero_notes_run( + run_start=_run_start, + source_path=source_path, + runtime_config=runtime_config, + dropped_total=ctx.dropped_total, + word_count=ctx.word_count, + n_extracted=0, + abort_reason=_abort_reason, + ) return exit_code # #197 Schritt 2: Funnel-Top "nach Planner/Extractor generiert" festhalten, @@ -2647,6 +2730,18 @@ def main(argv: list[str] | None = None): print(total_loss_warning_line(n_extract_attempted)) for _line in format_extractor_failure_report(ctx.extractor_failures, n_extract_attempted): print(_line, file=sys.stderr) + # #330: derselbe 0-Notes-Insert wie oben — hier waren Konzepte bereits + # extrahiert (n_extracted >= 1), landeten aber komplett als + # Abwesenheits-Artefakte statt echter Notes. + _insert_zero_notes_run( + run_start=_run_start, + source_path=source_path, + runtime_config=runtime_config, + dropped_total=ctx.dropped_total, + word_count=ctx.word_count, + n_extracted=n_extracted, + abort_reason="all_artifacts", + ) return exit_code # Qualitäts-Flags aus QualityReport auf alle Notes übertragen diff --git a/generative/tests/test_db.py b/generative/tests/test_db.py index 3e7fc45..35dece5 100644 --- a/generative/tests/test_db.py +++ b/generative/tests/test_db.py @@ -232,7 +232,12 @@ def test_orchestrator_migrates_existing_db_missing_wall_clock_s_column(tmp_path) # Regex statt starrem String-Replace: robust gegen Kommentar-Textaenderungen # an der wall_clock_s-Spaltendefinition — matcht Komma + optionale # Kommentarzeilen + die Spaltenzeile selbst, unabhaengig vom genauen Wortlaut. - old_schema = re.sub(r",\n(\s*--[^\n]*\n)*\s*wall_clock_s\s+REAL DEFAULT 0\n", "\n", SCHEMA_SQL) + # #330: wall_clock_s ist seither NICHT mehr die letzte Spalte (abort_reason + # folgt) — hat also selbst ein trailing Komma (","?" faengt beide Faelle), + # und die Ersetzung muss der VORHERIGEN Spalte ("profile") ihr eigenes + # trailing Komma zurueckgeben (",\n" statt nur "\n"), sonst fehlt das + # Trennzeichen zu abort_reason und das simulierte Alt-Schema ist ungueltiges SQL. + old_schema = re.sub(r",\n(\s*--[^\n]*\n)*\s*wall_clock_s\s+REAL DEFAULT 0,?\n", ",\n", SCHEMA_SQL) assert "wall_clock_s" not in old_schema, "Test-Fixture-Bug: Alt-Schema hat wall_clock_s schon" path = tmp_path / "old-schema.db" @@ -250,3 +255,96 @@ def test_orchestrator_migrates_existing_db_missing_wall_clock_s_column(tmp_path) finally: conn2.close() assert "wall_clock_s" in cols + + +# --- #330: Abbruchgrund fuer 0-Notes-Laeufe additiv persistieren ----------- +# +# 0-Notes-Laeufe (Konzeptmangel oder Totalverlust) hinterliessen bisher KEINE +# pipeline_runs-Zeile. abort_reason ist die additive, nullable Spalte, die den +# Grund festhaelt, wenn orchestrator.main() so einen Lauf jetzt trotzdem +# persistiert (n_generated=0). Erfolgspfad-Zeilen bleiben NULL. + + +def test_pipeline_runs_has_abort_reason_column(tmp_path): + from generative import db + + path = tmp_path / "test.db" + db.init_db(path) + with db.get_db(path) as conn: + cols = [r[1] for r in conn.execute("PRAGMA table_info(pipeline_runs)").fetchall()] + assert "abort_reason" in cols + + +def test_insert_run_stores_abort_reason(tmp_path): + from generative import db + + path = tmp_path / "test.db" + db.init_db(path) + with db.get_db(path) as conn: + db.insert_run( + conn, + { + "run_id": "test-run-zero-notes", + "pipeline_version": "v0.0.1", + "n_generated": 0, + "abort_reason": "no_concepts", + }, + ) + conn2 = sqlite3.connect(str(path)) + try: + row = conn2.execute("SELECT abort_reason FROM pipeline_runs WHERE run_id='test-run-zero-notes'").fetchone() + finally: + conn2.close() + assert row is not None + assert row[0] == "no_concepts" + + +def test_insert_run_abort_reason_defaults_to_null(tmp_path): + """Erfolgspfad-Aufrufer uebergeben keinen abort_reason-Key -> muss NULL + bleiben, nicht leerer String (unterscheidbar von einem echten Abbruchgrund).""" + from generative import db + + path = tmp_path / "test.db" + db.init_db(path) + with db.get_db(path) as conn: + db.insert_run( + conn, + { + "run_id": "test-run-success", + "pipeline_version": "v0.0.1", + "n_generated": 3, + }, + ) + conn2 = sqlite3.connect(str(path)) + try: + row = conn2.execute("SELECT abort_reason FROM pipeline_runs WHERE run_id='test-run-success'").fetchone() + finally: + conn2.close() + assert row is not None + assert row[0] is None + + +def test_orchestrator_migrates_existing_db_missing_abort_reason_column(tmp_path): + """Migration wie #235/#239: bestehende DB ohne abort_reason bekommt die + Spalte per init_db()-Aufruf nachgezogen (additiv, Bestandszeilen bleiben NULL).""" + from generative import db + from shared.db_schema import SCHEMA_SQL + + old_schema = re.sub(r",\n(\s*--[^\n]*\n)*\s*abort_reason\s+TEXT\n", "\n", SCHEMA_SQL) + assert "abort_reason" not in old_schema, "Test-Fixture-Bug: Alt-Schema hat abort_reason schon" + + path = tmp_path / "old-schema.db" + conn = sqlite3.connect(str(path)) + try: + conn.executescript(old_schema) + conn.commit() + finally: + conn.close() + + db.init_db(path) + conn2 = sqlite3.connect(str(path)) + try: + cols = [r[1] for r in conn2.execute("PRAGMA table_info(pipeline_runs)").fetchall()] + finally: + conn2.close() + assert "abort_reason" in cols diff --git a/generative/tests/test_zero_notes_run_persisted.py b/generative/tests/test_zero_notes_run_persisted.py new file mode 100644 index 0000000..d24eec7 --- /dev/null +++ b/generative/tests/test_zero_notes_run_persisted.py @@ -0,0 +1,146 @@ +"""Issue #330: 0-Notes-Laeufe (Konzeptmangel/Totalverlust) hinterliessen bisher +KEINE pipeline_runs-Zeile -- Run-Zaehlung, Token-/Kosten-Tracking und die Frage +"wie oft produziert die Pipeline nichts?" waren dadurch systematisch +untererfasst (2 Belege aus der Coverage-Serie 2: Lauf 1 dbv-Framework 198s/ +15.325 Tokens, Lauf 4 Witt 268s/51.272 Tokens, beide Exit 0 ohne DB-Zeile). + +Deckt beide Fruehausstiege in orchestrator.main() ab, die VOR dem +Erfolgspfad-Insert (#198 P1) zurueckkehren: + + 1. `if not drafts:` direkt nach der Extraktion -- legitimer Konzeptmangel + (0 Konzepte / alle secondary_mention / alle action=skip) ODER stiller + Totalverlust (#281, >=1 Konzept versucht, 0 ueberlebt). + 2. `if not drafts:` nach `_drop_artifacts()` -- alle verbliebenen Drafts als + Abwesenheits-Artefakte verworfen. + +Harness: `--load-drafts` + monkeypatch(_load_draft_state) -- identisch zu +test_orchestrator_total_loss_exit_code.py -- kombiniert mit der autouse- +Fixture `isolate_pipeline_side_effects` (DB in tmp). Die pipeline_runs-Zeile +wird direkt per sqlite3 gegen `isolate_pipeline_side_effects.db_path` geprueft. + +RED auf master-Stand: kein insert_run()-Aufruf in beiden Fruehausstiegen -> +0 pipeline_runs-Zeilen nach jedem dieser Laeufe. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from generative import orchestrator +from generative.config import AGENT_VERSION +from generative.schemas.atomic_note import AtomicNoteDraft, QualityReport +from generative.schemas.citation import CitationMeta +from generative.schemas.run_context import RunContext + + +def _fake_ctx( + *, + dropped_total: int = 0, + related_mentions: list[str] | None = None, + drafts: list | None = None, +) -> RunContext: + return RunContext( + drafts=drafts or [], + concept_map={}, + existing_concepts={}, + concept_links={}, + text="Etwas Text.", + chunks=[], + acronym_dict={}, + quality_report=QualityReport(peer_reviewed=None, citation_count=None, retracted=False, flags=[]), + pdf_meta={}, + source_path=Path("fake.pdf"), + tag_whitelist=[], + background_map={}, + fb_year=None, + dropped_total=dropped_total, + word_count=42, + related_mentions=related_mentions or [], + q_title=None, + citation=CitationMeta(author=None, year=None, title=None, doi=None, source_file="fake.pdf"), + extractor_failures=[], # KEIN Exception -- stille Drops (#280) wie in #281-Tests + ) + + +def _artifact_draft(title: str) -> AtomicNoteDraft: + """Draft, den `_drop_artifacts()` als Abwesenheits-Artefakt verwirft (#1223ff.).""" + return AtomicNoteDraft( + title=title, + body="Dieses Konzept wird im Quelltext nicht behandelt.", + source_anchors=[], + related=[], + tags=[], + synthesis_confidence="low", + ) + + +def _run_main_load_drafts(monkeypatch, ctx: RunContext) -> int: + monkeypatch.setattr(orchestrator, "_load_draft_state", lambda _path: ctx) + monkeypatch.setenv("ATOMIC_AGENT_GUI", "1") + return orchestrator.main(["--load-drafts", "irrelevant.json"]) + + +def _pipeline_run_rows(db_path) -> list[tuple]: + conn = sqlite3.connect(str(db_path)) + try: + return conn.execute( + "SELECT run_id, pipeline_version, n_generated, n_extracted, n_dropped, " + "duration_s, tokens_total, abort_reason FROM pipeline_runs" + ).fetchall() + finally: + conn.close() + + +def test_no_concepts_run_persists_with_abort_reason(monkeypatch, isolate_pipeline_side_effects): + """0 versucht, 0 final, keine Sekundaer-Erwaehnungen -> abort_reason='no_concepts'.""" + rc = _run_main_load_drafts(monkeypatch, _fake_ctx(dropped_total=0, related_mentions=[])) + + assert rc == 0 + rows = _pipeline_run_rows(isolate_pipeline_side_effects.db_path) + assert len(rows) == 1, f"Erwartet genau 1 pipeline_runs-Zeile, gefunden: {rows}" + run_id, pipeline_version, n_generated, n_extracted, n_dropped, duration_s, tokens_total, abort_reason = rows[0] + assert pipeline_version == AGENT_VERSION + assert n_generated == 0 + assert n_extracted == 0 + assert n_dropped == 0 + assert duration_s is not None and duration_s >= 0 + assert abort_reason == "no_concepts" + + +def test_all_secondary_mention_run_persists_with_distinct_abort_reason(monkeypatch, isolate_pipeline_side_effects): + """0 versucht, aber Sekundaer-Erwaehnungen vorhanden -> eigener, unterscheidbarer + Abbruchgrund (Issue-Beispiel: 'all_secondary_mentions').""" + rc = _run_main_load_drafts(monkeypatch, _fake_ctx(dropped_total=0, related_mentions=["Nebenkonzept"])) + + assert rc == 0 + rows = _pipeline_run_rows(isolate_pipeline_side_effects.db_path) + assert len(rows) == 1 + assert rows[0][-1] == "all_secondary_mentions" + + +def test_extraction_total_loss_run_persists_with_abort_reason(monkeypatch, isolate_pipeline_side_effects): + """#281-Fall (stiller Drop, dropped_total=1, Exit=_EXIT_TOTAL_LOSS) bekommt + jetzt ZUSAETZLICH eine DB-Zeile -- der bestehende Exit-Code-Vertrag aus + test_orchestrator_total_loss_exit_code.py bleibt dabei unveraendert.""" + rc = _run_main_load_drafts(monkeypatch, _fake_ctx(dropped_total=1, related_mentions=[])) + + assert rc == orchestrator._EXIT_TOTAL_LOSS + rows = _pipeline_run_rows(isolate_pipeline_side_effects.db_path) + assert len(rows) == 1 + assert rows[0][4] == 1 # n_dropped + assert rows[0][-1] == "extraction_total_loss" + + +def test_all_artifacts_dropped_run_persists_with_abort_reason(monkeypatch, isolate_pipeline_side_effects): + """Konzept wurde extrahiert (n_extracted=1), landete aber komplett als + Abwesenheits-Artefakt -> zweiter Fruehausstieg, eigener Abbruchgrund.""" + ctx = _fake_ctx(drafts=[_artifact_draft("Nur-Artefakt-Konzept")]) + rc = _run_main_load_drafts(monkeypatch, ctx) + + assert rc == orchestrator._EXIT_TOTAL_LOSS + rows = _pipeline_run_rows(isolate_pipeline_side_effects.db_path) + assert len(rows) == 1 + assert rows[0][3] == 1 # n_extracted zaehlt den verworfenen Artefakt-Draft mit + assert rows[0][2] == 0 # n_generated bleibt 0 -- keine Note geschrieben + assert rows[0][-1] == "all_artifacts" diff --git a/shared/db_schema.py b/shared/db_schema.py index e9612d0..b4477b4 100644 --- a/shared/db_schema.py +++ b/shared/db_schema.py @@ -40,7 +40,13 @@ -- Gesamtzeit korrigiert. Bei deaktiviertem Inline-Eval (Profil fast/ -- balanced) bleibt wall_clock_s == duration_s (kein Stage-8 gelaufen, -- beide Werte sind dann korrekt identisch). - wall_clock_s REAL DEFAULT 0 + wall_clock_s REAL DEFAULT 0, + -- #330: Abbruchgrund fuer 0-Notes-Laeufe (Konzeptmangel/Totalverlust), + -- z.B. "no_concepts", "all_secondary_mentions", "all_artifacts", + -- "extraction_total_loss". NULL bei Erfolgspfad-Zeilen (>=1 Note + -- geschrieben) -- bewusst kein DEFAULT '', damit NULL echtes "kein + -- Abbruch" bedeutet statt eines leeren aber gesetzten Strings. + abort_reason TEXT ); CREATE TABLE IF NOT EXISTS note_evals (