diff --git a/generative/agents/verifier.py b/generative/agents/verifier.py index 4128654..6ed4dd4 100644 --- a/generative/agents/verifier.py +++ b/generative/agents/verifier.py @@ -123,6 +123,16 @@ def sync_anchors_from_body(draft: AtomicNoteDraft) -> AtomicNoteDraft: def _log_anchor_stats(title: str, total_in: int, final_anchors: list) -> None: + # #331: confirmation_rate konnte > 100 % werden, weil `confirmed` (gezaehlt aus + # final_anchors, NACH dem Lauf) durch `total_in` (Momentaufnahme VOR dem Lauf) + # geteilt wurde. `_run_inner()` haengt an JEDEM Ausgang ueber + # sync_anchors_from_body() neue, bereits bestaetigte Anker an -- Zaehler und + # Nenner bezogen sich dadurch auf unterschiedliche Mengen, auch ohne Refine- + # Zyklus (empirisch belegt: Coverage-Serie 2, Lauf 5/Kok, confirmation_rate=3.0 + # OHNE Refine). Fix: Nenner = dieselbe Menge wie der Zaehler (len(final_anchors)), + # `total_in` bleibt als separates Feld fuer Transparenz erhalten. Rate ist damit + # mathematisch garantiert <= 1.0. + total_final = len(final_anchors) confirmed = sum(1 for a in final_anchors if a.page or a.fuzzy_page) trace_event( "verifier", @@ -131,7 +141,7 @@ def _log_anchor_stats(title: str, total_in: int, final_anchors: list) -> None: "title": title, "total_in": total_in, "confirmed": confirmed, - "confirmation_rate": round(confirmed / total_in, 3) if total_in > 0 else 0.0, + "confirmation_rate": round(confirmed / total_final, 3) if total_final > 0 else 0.0, }, ) diff --git a/generative/db.py b/generative/db.py index 74ed83b..d6cbf1a 100644 --- a/generative/db.py +++ b/generative/db.py @@ -294,7 +294,10 @@ def query_kpi_trend(path: Path = DB_PATH, eval_version: str | None = None) -> di pipeline_version, COUNT(*) AS n, AVG(hallucination_rate) AS avg_hall, - AVG(coverage_factual) AS avg_cov, + -- #316: coverage_factual ist seit eval_version 4.x strukturell immer + -- NULL (abgeschaffte v1.3-Metrik, #233) -- COALESCE faellt auf + -- coverage_rate zurueck, greift NUR bei SQL-NULL (nicht bei 0.0). + AVG(COALESCE(coverage_factual, coverage_rate)) AS avg_cov, SUM(CASE WHEN acceptance_status='vault' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS accept_rate, SUM(tokens_total) / 1e6 AS tokens_m, diff --git a/generative/eval_common.py b/generative/eval_common.py index 475998a..8ad8448 100644 --- a/generative/eval_common.py +++ b/generative/eval_common.py @@ -77,6 +77,33 @@ def _normalize(text: str) -> str: return text.strip() +# --------------------------------------------------------------------------- +# Coverage-Fallback (#316) -- geteilter Helper fuer Bestands-Stellen AUSSERHALB +# des Dashboards (eval_progress.py, orchestrator.py, db.py::query_kpi_trend). +# Dashboard-eigene Stellen fixt bereits eval_dashboard.py::_row_coverage +# (separates, aelteres Ticket) -- dieselbe Logik, kein Import zwischen beiden +# Modulen (eval_common ist bewusst dependency-frei von eval_dashboard). +# --------------------------------------------------------------------------- + + +def coverage_value(row) -> float | None: + """Coverage-Wert einer Eval-Zeile: `coverage_factual`, wenn NICHT `None` + (auch bei echter 0.0!), NUR bei `None` Fallback auf `coverage_rate`. + + Anti-Pattern-Fix: `row.get("coverage_factual", row.get("coverage_rate", 0))` + faellt bei einem explizit gespeicherten `None`-Wert NICHT zurueck -- + `dict.get`s Default greift nur bei fehlendem Key, nicht bei vorhandenem Key + mit `None`-Wert (#316). Mapping-kompatibel: akzeptiert sowohl `dict` als + auch `sqlite3.Row` (KEIN `.get()` -- Row unterstuetzt das nicht; `in + row.keys()` funktioniert fuer beide). + """ + keys = row.keys() + v = row["coverage_factual"] if "coverage_factual" in keys else None + if v is not None: + return v + return row["coverage_rate"] if "coverage_rate" in keys else None + + # --------------------------------------------------------------------------- # Wilson-Konfidenzintervall (aus eval_quality.py / v1) # --------------------------------------------------------------------------- diff --git a/generative/eval_dashboard.py b/generative/eval_dashboard.py index c2c3da0..b8680ea 100644 --- a/generative/eval_dashboard.py +++ b/generative/eval_dashboard.py @@ -1846,8 +1846,11 @@ def _build_quality_chart_data(quality_rows: list[dict]) -> dict: "pdf": pdf, "pdf_short": _pdf_short_name(pdf), "version": ver, - "hall": round(float(hall) * 100, 1) if hall is not None else None, - "cov": round(float(cov) * 100, 1) if cov is not None else None, + # #315: derselbe `>= 0`-Guard wie in `_chart_scatter`/`_chart_scatter_versioned` + # -- der -1.0-Sentinel (ungueltiger Lauf) darf nicht als -100.0 in die + # Slope-Mediane einsickern. + "hall": round(float(hall) * 100, 1) if hall is not None and float(hall) >= 0 else None, + "cov": round(float(cov) * 100, 1) if cov is not None and float(cov) >= 0 else None, "anchors_confirmed": anch_conf, "anchors_total": anch_total, "tokens_input": r.get("tokens_input", 0) or 0, diff --git a/generative/eval_progress.py b/generative/eval_progress.py index b8bdab8..5ee41f2 100644 --- a/generative/eval_progress.py +++ b/generative/eval_progress.py @@ -12,6 +12,7 @@ import json from generative.config import CACHE_DIR +from generative.eval_common import coverage_value _HISTORY = CACHE_DIR / "quality_history.jsonl" @@ -60,7 +61,11 @@ def print_table(records: list[dict]) -> None: unc = r.get("anchors_uncertain", 0) hall = r.get("anchors_hallucinated", 0) h_rate = r.get("hallucination_rate", 0) - cov_f = r.get("coverage_factual", r.get("coverage_rate", 0)) + # #316: coverage_value() faellt bei explizitem coverage_factual=None korrekt + # auf coverage_rate zurueck (dict.get(..., default) tut das NICHT). `or 0` + # hier bewusst NICHT verwendet (verschluckt echte 0.0) -- expliziter None-Check. + cov_f = coverage_value(r) + cov_f = 0 if cov_f is None else cov_f src_cov = r.get("source_coverage", 0) tok = r.get("tokens_total", 0) zeit = r.get("wall_time_s", 0) diff --git a/generative/eval_quality_v4.py b/generative/eval_quality_v4.py index bf183df..d3b7099 100644 --- a/generative/eval_quality_v4.py +++ b/generative/eval_quality_v4.py @@ -38,7 +38,7 @@ from generative.config import AGENT_VERSION, MODEL_JUDGE, MODEL_CONFIG, QUALITY_HISTORY from decision_engine import ClaimDecision, ClaimInput, DEFAULT_CONFIG, Label, determine_decision from decision_engine.aggregation import aggregate as aggregate_decisions -from decision_engine.models import QualityFlag +from decision_engine.models import QualityFlag, normalize_decision_source from generative.eval_common import ( TOP_K, Chunk, @@ -682,7 +682,8 @@ def _repair_json_with_claude(raw_text: str, expected_indices: list[int], *, use_ repaired = base.call_llm_full( prompt, model=MODEL_JUDGE, - agent="eval_quality_v3_json_repair", + # #319: v4-Judge, nicht v3 -- Label war Altlast aus dem v3-Vorgaenger. + agent="eval_quality_v4_json_repair", use_cache=use_cache, cache_namespace=EVAL_CACHE_NAMESPACE, ) @@ -757,7 +758,8 @@ def _call_judge( result = base.call_llm_full( prompt, model=MODEL_JUDGE, - agent=f"eval_quality_v3_{variant}", + # #319: v4-Judge, nicht v3 -- Label war Altlast aus dem v3-Vorgaenger. + agent=f"eval_quality_v4_{variant}", use_cache=use_cache, cache_namespace=EVAL_CACHE_NAMESPACE, ) @@ -989,7 +991,10 @@ def _aggregate( ClaimDecision( Label(score["label"]), frozenset(QualityFlag(flag) for flag in score["quality_flags"] if flag in _ENGINE_FLAG_VALUES), - score.get("decision_source", "primary"), + # #318: normalisiert den historischen Legacy-Wert "audit" auf das aktuelle + # Vokabular ("audit_override") -- betrifft nur re-aggregierte/gelesene + # Bestandsdaten, keine Mutation der JSONL/DB. + normalize_decision_source(score.get("decision_source", "primary")), ) for score in claim_scores ] diff --git a/generative/orchestrator.py b/generative/orchestrator.py index 48cb30b..c6c072b 100644 --- a/generative/orchestrator.py +++ b/generative/orchestrator.py @@ -1652,6 +1652,30 @@ def dry_run_eval_targets(written: list[tuple[Path, bool]], cache_note_dir: Path) return files +def _stage8_report_averages(eval_results: list[dict]) -> tuple[float | None, float | None]: + """Mittlere Halluzinationsrate + Coverage aus Stage-8-Eval-Resultaten fuer den + Run-Ende-Print. `None, None` wenn keine gueltige Halluzinationsrate vorliegt + (Aufrufer druckt dann nichts, wie zuvor). + + #316: `r.get("coverage_factual", r.get("coverage_rate", -1.0)) >= 0` crasht mit + TypeError, sobald `coverage_factual` explizit als `None` gespeichert ist (der + Normalfall seit eval_version 4.x, #233) -- `dict.get`s Default greift nur bei + fehlendem Key. `coverage_value()` (eval_common.py) behandelt beide Faelle + gleich und faellt korrekt auf `coverage_rate` zurueck. + """ + from generative.eval_common import coverage_value + + hall_rates = [ + r["hallucination_rate"] for r in eval_results if "hallucination_rate" in r and r["hallucination_rate"] >= 0 + ] + cov_rates = [v for r in eval_results if (v := coverage_value(r)) is not None and v >= 0] + if not hall_rates: + return None, None + avg_hall = sum(hall_rates) / len(hall_rates) + avg_cov = sum(cov_rates) / len(cov_rates) if cov_rates else 0.0 + return avg_hall, avg_cov + + def run_stage8_eval( note_files: list[Path], source_path: Path, @@ -3199,19 +3223,8 @@ def main(argv: list[str] | None = None): ) if eval_results: - hall_rates = [ - r["hallucination_rate"] - for r in eval_results - if "hallucination_rate" in r and r["hallucination_rate"] >= 0 - ] - cov_rates = [ - r.get("coverage_factual", r.get("coverage_rate", 0.0)) - for r in eval_results - if r.get("coverage_factual", r.get("coverage_rate", -1.0)) >= 0 - ] - if hall_rates: - avg_hall = sum(hall_rates) / len(hall_rates) - avg_cov = sum(cov_rates) / len(cov_rates) if cov_rates else 0.0 + avg_hall, avg_cov = _stage8_report_averages(eval_results) + if avg_hall is not None: print(f" Ø Halluzinationsrate: {avg_hall:.1%} | Ø Coverage (faktisch): {avg_cov:.1%}") reused_note = f" (+{_reused_count} wiederverwendet, Hash-Guard)" if _reused_count else "" print(f" {_evaluated_count} Notes → .cache/quality_history.jsonl{reused_note}") diff --git a/generative/pipeline/figure_alt.py b/generative/pipeline/figure_alt.py index 52abe90..9b8ba62 100644 --- a/generative/pipeline/figure_alt.py +++ b/generative/pipeline/figure_alt.py @@ -146,9 +146,13 @@ def bind_figures_to_drafts(figures: list[TaggedFigure], drafts: list[AtomicNoteD # ambiguous-Skips HATTEN Matches, dann waere der Namespace-Verdacht irrefuehrend. # Kein Verhaltens-Change, nur Sichtbarkeit. if figures and not report.bound and all(s.reason == "no_match" for s in report.skipped): + # #332: neutrale Formulierung -- diese Funktion kennt den Aufrufkontext + # (--load-drafts vs. --fresh-run) nicht und darf ihn daher nicht als + # Ursache unterstellen (Kok-Beleg, Coverage-Serie 2: 0 Anker-Matches trat + # auch mit --fresh-run auf). print( - f"figure_alt: {len(figures)} Kandidaten, 0 Anker-Matches — bei --load-drafts aus " - "älterer Pipeline-Version können Anker- und Label-Namespace divergieren", + f"figure_alt: {len(figures)} Kandidaten, 0 Anker-Matches — " + "Bilder ohne zuordenbare Anker werden übersprungen", file=sys.stderr, ) diff --git a/generative/tests/test_dashboard_slope_sentinel_filter.py b/generative/tests/test_dashboard_slope_sentinel_filter.py new file mode 100644 index 0000000..57d66a0 --- /dev/null +++ b/generative/tests/test_dashboard_slope_sentinel_filter.py @@ -0,0 +1,68 @@ +"""#315: `_build_quality_chart_data` filterte hallucination_rate/coverage_rate beim +Aufbau der Slope-Datasets nur auf `is not None`, nicht auf den `-1.0`-Sentinel, den +`eval_quality_v4.py` bei ungueltigen Laeufen schreibt (z.B. `valid_claims == 0`). +Ein Sentinel `-1.0` wurde nach der `*100`-Rundung zu `-100.0` und rutschte als +valider Wert in die Slope-Mediane des Legacy-Einmal-Render-Pfads (`main()`/ +`_build_html`). Fix: derselbe `>= 0`-Guard wie in `_chart_scatter`/ +`_chart_scatter_versioned`. + +RED vor dem Fix: `rows_clean[0]["hall"]`/`["cov"]` == -100.0 (Sentinel eingesickert). +GREEN: beide `None` (wie bei einer fehlenden Rate) -- der Slope-Median ignoriert +die Zeile bereits ueber den bestehenden `is not None`-Filter (Zeilen 1878/1880). +""" + +from __future__ import annotations + +from generative.eval_dashboard import _build_quality_chart_data + + +def test_build_quality_chart_data_hall_sentinel_becomes_none(): + rows = [ + { + "hallucination_rate": -1.0, + "coverage_factual": 0.5, + "note": "n1", + "pdf": "a.pdf", + "version": "v1", + } + ] + out = _build_quality_chart_data(rows) + assert out["rows"][0]["hall"] is None # nicht -100.0 + + +def test_build_quality_chart_data_cov_sentinel_becomes_none(): + rows = [ + { + "hallucination_rate": 0.1, + "coverage_factual": -1.0, + "coverage_rate": -1.0, + "note": "n1", + "pdf": "a.pdf", + "version": "v1", + } + ] + out = _build_quality_chart_data(rows) + assert out["rows"][0]["cov"] is None # nicht -100.0 + + +def test_build_quality_chart_data_sentinel_excluded_from_slope_median(): + rows = [ + { + "hallucination_rate": -1.0, + "coverage_factual": 0.5, + "note": "n1", + "pdf": "a.pdf", + "version": "v1", + }, + { + "hallucination_rate": 0.2, + "coverage_factual": 0.5, + "note": "n2", + "pdf": "a.pdf", + "version": "v1", + }, + ] + out = _build_quality_chart_data(rows) + slope = out["slope_datasets"][0] + # Median nur ueber die gueltige Zeile (20.0), nicht (-100.0 + 20.0)/2 = -40.0 + assert slope["hall_data"] == [20.0] diff --git a/generative/tests/test_db.py b/generative/tests/test_db.py index 35dece5..a1fb9e7 100644 --- a/generative/tests/test_db.py +++ b/generative/tests/test_db.py @@ -348,3 +348,71 @@ def test_orchestrator_migrates_existing_db_missing_abort_reason_column(tmp_path) finally: conn2.close() assert "abort_reason" in cols + + +def test_query_kpi_trend_avg_cov_falls_back_to_coverage_rate_when_factual_null(tmp_path): + """#316: `AVG(coverage_factual)` ohne Fallback liefert NULL fuer avg_cov, sobald + `coverage_factual` NULL ist -- strukturell der Fall fuer JEDE eval_version=4.x- + Zeile (#233, coverage_factual seit v4 abgeschafft). Fix: `COALESCE(coverage_factual, + coverage_rate)`. + + RED vor dem Fix: avg_cov ist None. GREEN: avg_cov == 0.7 (coverage_rate).""" + 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": "r1", "pipeline_version": "v1"}) + db.insert_eval( + conn, + { + "eval_id": "e1", + "run_id": "r1", + "note_path": "n1", + "acceptance_status": "vault", + "hallucination_rate": 0.1, + "coverage_factual": None, + "coverage_rate": 0.7, + "tokens_total": 100, + "wall_time_s": 1.0, + "pipeline_version": "v1", + "pdf": "a.pdf", + "eval_version": "4.1", + }, + ) + + trend = db.query_kpi_trend(path) + assert len(trend) == 1 + assert trend[0]["avg_cov"] == 0.7 + + +def test_query_kpi_trend_avg_cov_real_zero_coverage_factual_not_swallowed(tmp_path): + """COALESCE darf eine echte 0.0 in coverage_factual nicht durch coverage_rate + ersetzen -- COALESCE greift NUR bei SQL-NULL, nicht bei 0.0 (anders als ein + Python `or`-Fallback), also bereits korrekt -- Regressionsschutz.""" + 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": "r1", "pipeline_version": "v1"}) + db.insert_eval( + conn, + { + "eval_id": "e1", + "run_id": "r1", + "note_path": "n1", + "acceptance_status": "vault", + "hallucination_rate": 0.1, + "coverage_factual": 0.0, + "coverage_rate": 0.9, + "tokens_total": 100, + "wall_time_s": 1.0, + "pipeline_version": "v1", + "pdf": "a.pdf", + "eval_version": "1.3", + }, + ) + + trend = db.query_kpi_trend(path) + assert trend[0]["avg_cov"] == 0.0 diff --git a/generative/tests/test_eval_agent_label_v4.py b/generative/tests/test_eval_agent_label_v4.py new file mode 100644 index 0000000..5ec74a2 --- /dev/null +++ b/generative/tests/test_eval_agent_label_v4.py @@ -0,0 +1,57 @@ +"""#319: eval_quality_v4.py nutzte intern weiterhin `agent="eval_quality_v3_..."` +fuer Cache-/Kostenzuordnung -- obwohl der Judge laengst v4 ist. Verwirrend bei +Auswertungen nach Agent-Label (Kosten-/Cache-Aufschluesselung zeigt "v3", obwohl +v4 laeuft). + +Cache-Invalidierungs-Nebenwirkung (dokumentiert im PR, Fix-Richtung des Issues): +`agent` ist Teil des LLM-Call-Cache-Keys (`agents/base.py::_cache_key`) -- die +Umbenennung invalidiert den bestehenden Disk-Cache fuer Judge-/Repair-Calls +innerhalb der aktuellen eval_version (die naechste inhaltlich unveraenderte +Note trifft dort einmalig einen Cache-Miss statt -Hit). Der davon unabhaengige +Re-Eval-Hash-Guard (content_hash+eval_version+pipeline_version in +quality_history.jsonl) ist NICHT betroffen. + +RED vor dem Fix: agent-Label traegt noch "eval_quality_v3_...". +""" + +from __future__ import annotations + +from generative.agents import base +import generative.eval_quality_v4 as eq + + +def test_call_judge_agent_label_uses_v4(monkeypatch): + captured: dict = {} + + def fake_call(prompt, *, model, agent, use_cache, cache_namespace=None): + captured["agent"] = agent + return base.CallResult(text="[]") + + monkeypatch.setattr(eq.base, "call_llm_full", fake_call) + + item = eq.RetrievedContext( + claim_idx=1, + claim="Eine Testbehauptung.", + contexts=[{"chunk_idx": 0, "pages": [1], "text": "Kontext."}], + top_cosine=0.5, + best_chunk_idx=0, + best_page=1, + ) + + eq._call_judge("Titel", [item], variant="primary", use_cache=True) + + assert captured["agent"] == "eval_quality_v4_primary" + + +def test_repair_json_with_claude_agent_label_uses_v4(monkeypatch): + captured: dict = {} + + def fake_call(prompt, *, model, agent, use_cache, cache_namespace=None): + captured["agent"] = agent + return base.CallResult(text="[]") + + monkeypatch.setattr(eq.base, "call_llm_full", fake_call) + + eq._repair_json_with_claude("kein valides JSON", [], use_cache=True) + + assert captured["agent"] == "eval_quality_v4_json_repair" diff --git a/generative/tests/test_eval_common_coverage_value.py b/generative/tests/test_eval_common_coverage_value.py new file mode 100644 index 0000000..a8e079b --- /dev/null +++ b/generative/tests/test_eval_common_coverage_value.py @@ -0,0 +1,42 @@ +"""#316: `.get("coverage_factual", r.get("coverage_rate", 0))` faellt bei einem +explizit gespeicherten `None`-Wert NICHT auf `coverage_rate` zurueck -- +`dict.get`s Default greift nur bei fehlendem Key, nicht bei vorhandenem Key mit +`None`-Wert. `eval_dashboard.py::_row_coverage` fixt genau das bereits fuer die +Dashboard-Stellen (separates Ticket) -- dieser Test deckt den geteilten Helper +fuer die Bestands-Stellen AUSSERHALB des Dashboards ab (eval_progress.py, +orchestrator.py, db.py::query_kpi_trend). + +RED vor dem Fix: `coverage_value` existiert nicht in eval_common.py. +""" + +from __future__ import annotations + +import sqlite3 + +from generative.eval_common import coverage_value + + +def test_coverage_value_real_zero_not_swallowed(): + assert coverage_value({"coverage_factual": 0.0, "coverage_rate": 0.9}) == 0.0 + + +def test_coverage_value_none_falls_back_to_coverage_rate(): + assert coverage_value({"coverage_factual": None, "coverage_rate": 0.7}) == 0.7 + + +def test_coverage_value_missing_key_falls_back_to_coverage_rate(): + assert coverage_value({"coverage_rate": 0.7}) == 0.7 + + +def test_coverage_value_both_missing_returns_none(): + assert coverage_value({}) is None + + +def test_coverage_value_accepts_sqlite_row(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute("CREATE TABLE t (coverage_factual REAL, coverage_rate REAL)") + conn.execute("INSERT INTO t VALUES (NULL, 0.8)") + row = conn.execute("SELECT * FROM t").fetchone() + assert coverage_value(row) == 0.8 + conn.close() diff --git a/generative/tests/test_eval_progress_coverage_fallback.py b/generative/tests/test_eval_progress_coverage_fallback.py new file mode 100644 index 0000000..bf8aaa7 --- /dev/null +++ b/generative/tests/test_eval_progress_coverage_fallback.py @@ -0,0 +1,49 @@ +"""#316: `eval_progress.py::print_table` berechnete `cov_f` bisher ueber +`r.get("coverage_factual", r.get("coverage_rate", 0))` -- bei einem explizit +gespeicherten `coverage_factual=None` (strukturell der Fall fuer JEDE +`eval_version=4.x`-Zeile, #233) faellt `dict.get` NICHT auf `coverage_rate` +zurueck (Default greift nur bei fehlendem Key), sondern liefert `None` -- +`{None:>5.1%}` crasht mit TypeError. + +RED vor dem Fix: TypeError beim Formatieren. GREEN: `coverage_value()` +(#316-Helper, `eval_common.py`) faellt korrekt auf `coverage_rate` zurueck. +""" + +from __future__ import annotations + +from generative.eval_progress import print_table + + +def _record(**overrides): + base = { + "version": "v1", + "eval_version": "4.1", + "language": "DE", + "note": "n1", + "timestamp": "2026-01-01T00:00", + "anchors_confirmed": 1, + "anchors_uncertain": 0, + "anchors_hallucinated": 0, + "hallucination_rate": 0.1, + "coverage_factual": None, + "coverage_rate": 0.7, + "source_coverage": 0.0, + "tokens_total": 100, + "wall_time_s": 1.0, + "pdf": "a.pdf", + } + base.update(overrides) + return base + + +def test_print_table_coverage_factual_none_falls_back_to_coverage_rate(capsys): + print_table([_record()]) + out = capsys.readouterr().out + assert "70.0%" in out # coverage_rate, nicht 0.0% (verschluckter Fallback) + + +def test_print_table_coverage_factual_real_zero_not_swallowed(capsys): + print_table([_record(coverage_factual=0.0, coverage_rate=0.9)]) + out = capsys.readouterr().out + assert "0.0%" in out + assert "90.0%" not in out diff --git a/generative/tests/test_figure_alt_neutral_no_match_message.py b/generative/tests/test_figure_alt_neutral_no_match_message.py new file mode 100644 index 0000000..56d4015 --- /dev/null +++ b/generative/tests/test_figure_alt_neutral_no_match_message.py @@ -0,0 +1,33 @@ +"""#332: die figure_alt-Diagnosemeldung ("N Kandidaten, 0 Anker-Matches") nannte +unbedingt einen `--load-drafts`-Kontext als Erklaerung -- auch wenn der Lauf +tatsaechlich mit `--fresh-run` lief (Coverage-Serie 2, Lauf 5/Kok, wortwoertlich +im Log belegt). `bind_figures_to_drafts` kennt den Aufrufkontext (--load-drafts +vs. --fresh-run) gar nicht -- die Meldung unterstellte also eine Ursache, die im +aktuellen Aufrufkontext unmoeglich war. Gleiche Fehlklasse wie das bereits +gefixte #288 ("keine Metadaten im Dateinamen erkannt"). + +Fix: neutrale Formulierung ohne unbelegte Kontext-Annahme -- reine Textaenderung, +keine Logikaenderung (Fix-Richtung des Issues). + +RED vor dem Fix: Meldung nennt "--load-drafts" bedingungslos. +""" + +from __future__ import annotations + +from generative.pipeline.figure_alt import TaggedFigure, bind_figures_to_drafts +from generative.tests.test_figure_alt import _draft + + +def test_zero_match_diagnostic_does_not_claim_load_drafts_context(capsys): + fig = TaggedFigure(anchor_page=5, alt_text="x", label=None) + draft = _draft("Suche", ["S. 3"]) + + bind_figures_to_drafts([fig], [draft]) + + err = capsys.readouterr().err + assert "figure_alt:" in err + assert "0 Anker-Matches" in err + # Die Meldung darf keinen --load-drafts-Kontext unterstellen -- der Aufrufer + # kann ebenso gut --fresh-run gelaufen sein (Kok-Beleg, Coverage-Serie 2). + assert "--load-drafts" not in err + assert "übersprungen" in err diff --git a/generative/tests/test_orchestrator_stage8_report_averages.py b/generative/tests/test_orchestrator_stage8_report_averages.py new file mode 100644 index 0000000..52124f6 --- /dev/null +++ b/generative/tests/test_orchestrator_stage8_report_averages.py @@ -0,0 +1,43 @@ +"""#316: der Stage-8-Run-Ende-Print in orchestrator.main() baute `cov_rates` ueber +`r.get("coverage_factual", r.get("coverage_rate", -1.0)) >= 0` -- crasht mit +TypeError, sobald `coverage_factual` explizit als `None` gespeichert ist (der +Normalfall seit eval_version 4.x, #233): `dict.get`s Default greift nur bei +fehlendem Key, `r.get("coverage_factual", ...)` liefert dann `None`, und +`None >= 0` ist in Python 3 ein TypeError. + +Fix: `_stage8_report_averages()` (extrahierte, pure Funktion) nutzt +`coverage_value()` (eval_common.py, #316-Helper), der `None` und fehlenden Key +gleich behandelt. + +RED vor dem Fix: TypeError beim Aufruf mit einer Zeile, deren `coverage_factual` +explizit `None` ist (Bestandsmuster jeder aktuellen eval_version=4.x-Zeile). +""" + +from __future__ import annotations + +from generative.orchestrator import _stage8_report_averages + + +def test_stage8_report_averages_none_coverage_factual_does_not_crash(): + eval_results = [ + {"hallucination_rate": 0.1, "coverage_factual": None, "coverage_rate": 0.7}, + {"hallucination_rate": 0.3, "coverage_factual": None, "coverage_rate": 0.9}, + ] + avg_hall, avg_cov = _stage8_report_averages(eval_results) + assert avg_hall == 0.2 + assert avg_cov == 0.8 # (0.7 + 0.9) / 2, ueber coverage_rate-Fallback + + +def test_stage8_report_averages_real_zero_coverage_factual_not_swallowed(): + eval_results = [{"hallucination_rate": 0.1, "coverage_factual": 0.0, "coverage_rate": 0.9}] + avg_hall, avg_cov = _stage8_report_averages(eval_results) + assert avg_cov == 0.0 # nicht 0.9 + + +def test_stage8_report_averages_empty_results_returns_none(): + assert _stage8_report_averages([]) == (None, None) + + +def test_stage8_report_averages_no_valid_hall_returns_none(): + eval_results = [{"hallucination_rate": -1.0, "coverage_factual": 0.5}] + assert _stage8_report_averages(eval_results) == (None, None) diff --git a/generative/tests/test_verifier_confirmation_rate_bound.py b/generative/tests/test_verifier_confirmation_rate_bound.py new file mode 100644 index 0000000..24ea5d2 --- /dev/null +++ b/generative/tests/test_verifier_confirmation_rate_bound.py @@ -0,0 +1,68 @@ +"""#331: `confirmation_rate` im Verifier-Trace konnte > 1.0 (100 %) werden -- +Coverage-Serie 2: Lauf 3 (Poege) 2.0 nach einem Refine-Zyklus, Lauf 5 (Kok) 3.0 +OHNE Refine. Der Kok-Beleg widerlegt die urspruengliche Refine-Hypothese: die +Ursache liegt in EINEM einzelnen `verifier.run()`-Aufruf. + +Root Cause: `_log_anchor_stats(title, total_in, final_anchors)` teilte +`confirmed` (gezaehlt aus `final_anchors`, NACH dem Lauf) durch `total_in` +(gezaehlt VOR dem Lauf, in `run()`). `_run_inner()` ruft an JEDEM Ausgang +`sync_anchors_from_body()` auf, das aus `„..." (S. N)`-Zitaten im Note-Body +NEUE, bereits bestaetigte Anker an `draft.source_anchors` anhaengt -- Zaehler +(post-sync) und Nenner (pre-sync) beziehen sich dadurch auf unterschiedliche +Mengen, ohne dass ein Refine-Zyklus noetig ist. + +Fix: Nenner = `len(final_anchors)` (dieselbe Menge wie der Zaehler) statt der +pre-run `total_in`-Momentaufnahme. Rate ist damit mathematisch garantiert <= 1.0. + +RED vor dem Fix: confirmation_rate == 3.0 (1 pre-pass-bestaetigter Original- +Anker + 2 body-synced Anker, geteilt durch total_in=1). +""" + +from __future__ import annotations + +import json + +import generative.agents.tracing as tracing +import generative.agents.verifier as verifier +from generative.schemas.atomic_note import AtomicNoteDraft, TextAnchor + + +def test_confirmation_rate_never_exceeds_one_when_body_sync_adds_anchors(tmp_path, monkeypatch): + backend = tracing.JsonlBackend(run_dir=tmp_path, run_id="test-run") + monkeypatch.setattr(tracing, "_backend", backend) + + draft = AtomicNoteDraft( + title="Test Note", + body=( + "Diese Formulierung erscheint identisch im Originaltext. " + "„Erstes zusaetzliches Body-Zitat aus dem Volltext” (S. 3) " + "Ein Uebergangssatz dazwischen. " + "„Zweites zusaetzliches Body-Zitat, ebenfalls belegt” (S. 5)" + ), + source_anchors=[ + TextAnchor( + quote="Diese Formulierung erscheint identisch im Originaltext", + page=None, + fuzzy_page=None, + ) + ], + related=[], + tags=[], + synthesis_confidence="medium", + ) + chunk_text = ( + "[S. 3] Diese Formulierung erscheint identisch im Originaltext. Mehr Kontext fuer die Marker-Erkennung." + ) + + verifier.run(draft, chunk_text=chunk_text) + + trace_file = tmp_path / "test-run.jsonl" + events = [json.loads(line) for line in trace_file.read_text(encoding="utf-8").splitlines()] + ev = next(e for e in events if e.get("type") == "anchor_stats") + + # Body-Sync hat tatsaechlich neue Anker angehaengt -- sonst testet dieser + # Fall den Bug gar nicht (Kontrolle gegen ein stillschweigend geaendertes Fixture). + assert len(draft.source_anchors) > ev["total_in"], "Fixture-Bug: Body-Sync hat nichts ergaenzt" + + assert ev["confirmation_rate"] <= 1.0, f"confirmation_rate > 100%: {ev}" + assert ev["confirmation_rate"] == round(ev["confirmed"] / len(draft.source_anchors), 3) diff --git a/lib/decision_engine/__init__.py b/lib/decision_engine/__init__.py index 495625d..22e0093 100644 --- a/lib/decision_engine/__init__.py +++ b/lib/decision_engine/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations -from decision_engine.models import ClaimDecision, ClaimInput, Label, Metric, QualityFlag +from decision_engine.models import ClaimDecision, ClaimInput, Label, Metric, QualityFlag, normalize_decision_source from decision_engine.pipeline import determine_decision from decision_engine.rules import RulesConfig, DEFAULT_CONFIG @@ -19,4 +19,5 @@ "QualityFlag", "RulesConfig", "determine_decision", + "normalize_decision_source", ] diff --git a/lib/decision_engine/models.py b/lib/decision_engine/models.py index 296734c..51f13fe 100644 --- a/lib/decision_engine/models.py +++ b/lib/decision_engine/models.py @@ -91,6 +91,21 @@ class ClaimDecision: source: str # "primary" | "audit_override" | "system" | "downgrade" +# #318: historische eval_version=4.1-Zeilen tragen decision_source="audit" (67 +# JSONL-Zeilen, Eval-Doku-Audit #313) -- ein Namensstand vor einem frueheren +# decision_engine-Refactor. Kein Code-Pfad schreibt diesen Wert mehr; Leser die +# nach decision_source filtern/gruppieren muessen ihn trotzdem kennen. KEINE +# Mutation der Bestandsdaten -- nur Lese-Seiten-Normalisierung. +DECISION_SOURCE_ALIASES: dict[str, str] = {"audit": "audit_override"} + + +def normalize_decision_source(value: str) -> str: + """Normalisiert einen GELESENEN `decision_source`-Wert auf das aktuelle + Vokabular (#318). Legacy-Alias "audit" -> "audit_override"; alle anderen + Werte (inkl. unbekannte) unveraendert durchgereicht.""" + return DECISION_SOURCE_ALIASES.get(value, value) + + @dataclass(frozen=True) class Metric: """Rate-Metrik mit Validity-Flag. value=-1.0 sentinel wenn nicht messbar.""" diff --git a/lib/decision_engine/tests/test_models.py b/lib/decision_engine/tests/test_models.py new file mode 100644 index 0000000..6e1430d --- /dev/null +++ b/lib/decision_engine/tests/test_models.py @@ -0,0 +1,29 @@ +"""#318: historische eval_version=4.1-Zeilen tragen `decision_source="audit"` +(91 Claim-Vorkommen in 67 JSONL-Zeilen, Eval-Doku-Audit #313) -- aktueller Code +schreibt nur noch `"audit_override"` (rules.py::rule_audit_stricter_override). +Kein Code-Pfad schreibt den alten Wert mehr; Leser die nach `decision_source` +filtern/gruppieren muessen aber beide Werte kennen, sonst werden historische +Audit-Overrides beim Filtern auf "audit_override" stillschweigend unterschlagen. + +`normalize_decision_source()` normalisiert einen GELESENEN Wert auf das +aktuelle Vokabular -- KEINE Mutation der Bestandsdaten (JSONL/DB bleiben +unveraendert), nur eine Lese-Seiten-Normalisierung. +""" + +from __future__ import annotations + +from decision_engine.models import normalize_decision_source + + +def test_normalize_decision_source_maps_legacy_audit_alias(): + assert normalize_decision_source("audit") == "audit_override" + + +def test_normalize_decision_source_current_vocabulary_passthrough(): + for value in ("primary", "audit_override", "system", "downgrade"): + assert normalize_decision_source(value) == value + + +def test_normalize_decision_source_unknown_value_passthrough(): + # Kein stiller Datenverlust bei unbekannten Werten -- unveraendert durchreichen. + assert normalize_decision_source("future_value") == "future_value"