Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion generative/agents/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
},
)

Expand Down
5 changes: 4 additions & 1 deletion generative/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions generative/eval_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
7 changes: 5 additions & 2 deletions generative/eval_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion generative/eval_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down
13 changes: 9 additions & 4 deletions generative/eval_quality_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
]
Expand Down
39 changes: 26 additions & 13 deletions generative/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}")
Expand Down
8 changes: 6 additions & 2 deletions generative/pipeline/figure_alt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
68 changes: 68 additions & 0 deletions generative/tests/test_dashboard_slope_sentinel_filter.py
Original file line number Diff line number Diff line change
@@ -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]
68 changes: 68 additions & 0 deletions generative/tests/test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading