diff --git a/generative/eval_dashboard.py b/generative/eval_dashboard.py index af26a79..db3fbde 100644 --- a/generative/eval_dashboard.py +++ b/generative/eval_dashboard.py @@ -584,6 +584,23 @@ def _calc_kpis( } n_canonical_pdfs = len(_eval_gk | _log_gk) + # "Evaluierte Notes"-Quote (#224): Zähler (n_notes, oben) und Nenner müssen + # dieselbe Versions-Basis teilen, sonst entstehen Werte >100 % (Zähler aus + # DB/note_evals früh gefiltert, alter Nenner `total_generated` aus + # all_log_runs über ALLE Versionen gepoolt). Nenner hier = all_log_runs + # exakt der kpi_version (`latest_pver`, derselbe Anker wie `latest_qrows` + # oben) — bei explizitem Versions-Filter sind quality_rows/all_log_runs + # bereits serverseitig auf dieselbe Version eingeschränkt, ungefiltert + # erzwingt der exakte Vergleich unten dieselbe Beschränkung nachträglich. + generated_kpi_ver = latest_pver + kpi_gen_runs = [r for r in all_log_runs if r.get("ver") == generated_kpi_ver] + kpi_generated = sum(r["n_total"] for r in kpi_gen_runs) + _n_notes = _distinct_notes(latest_qrows) + # Guard statt Absurd-Wert: Nenner 0/fehlend (keine Log-/DB-Zeile zur + # Version) oder Zähler > Nenner (Datenlücke, z. B. Re-Evals ohne + # zugehörigen Log-Run) → null, Client zeigt "–" statt >100 %. + notes_eval_pct = round(100 * _n_notes / kpi_generated, 1) if kpi_generated and _n_notes <= kpi_generated else None + return { "avg_accept": avg_accept, "avg_hall": avg_hall, @@ -598,7 +615,12 @@ def _calc_kpis( # blähten die Zahl auf (50 Instanzen / 39 Notes) und gewichteten die # Poolung implizit nach Testlauf-Häufigkeit. Fehlt ein Note-Identifier # (synthetische Rows), zählt der Laufindex jede Zeile einzeln. - "n_notes": _distinct_notes(latest_qrows), + "n_notes": _n_notes, + # Server-berechnete "% der Notes"-Quote + ihre Versions-Basis (#224) — + # Client rechnet nicht mehr selbst aus n_notes/total_generated (die + # unterschiedliche Versions-/Filterkontexte hatten). null = "–". + "notes_eval_pct": notes_eval_pct, + "generated_kpi_ver": generated_kpi_ver, "total_runs": len(all_log_runs), "n_pdfs": n_canonical_pdfs, "n_versions": len(all_versions), diff --git a/generative/tests/test_dashboard_current_version.py b/generative/tests/test_dashboard_current_version.py index e878850..e6fc937 100644 --- a/generative/tests/test_dashboard_current_version.py +++ b/generative/tests/test_dashboard_current_version.py @@ -137,3 +137,50 @@ def test_accept_ver_falls_back_to_latest_at_or_below_config(): runs = [_log_run("v0.1.0", 10, 5), _log_run("v0.3.135", 7, 5)] kpis = _calc_kpis({}, runs, [], [], current_version="v0.3.140") assert kpis["kpi_accept_ver"] == "v0.3.135" + + +# ── notes_eval_pct (#224): Zähler/Nenner auf derselben Versions-Basis ─────── + + +def test_notes_eval_pct_guards_against_over_100_pct(): + # Issue-Repro: 10 distinct Eval-Notes (Re-Evals) vs. nur 3 generierte Notes + # derselben Version in den Log-Runs -> vorher 333,3 %, jetzt null/"–". + qrows = [_qrow("v0.3.140", f"2026-07-05T20:00:{i:02d}", 0.0, 10, 0) for i in range(10)] + runs = [_log_run("v0.3.140", 3, 3)] + kpis = _calc_kpis({}, runs, qrows, [], current_version="v0.3.140") + assert kpis["n_notes"] == 10 + assert kpis["generated_kpi_ver"] == "v0.3.140" + assert kpis["notes_eval_pct"] is None + + +def test_notes_eval_pct_correct_when_denominator_covers_numerator(): + # Gesunder Fall: Nenner (20 generiert) >= Zähler (5 evaluiert), gleiche Version. + qrows = [_qrow("v0.3.140", f"2026-07-05T20:00:{i:02d}", 0.0, 10, 0) for i in range(5)] + runs = [_log_run("v0.3.140", 20, 15)] + kpis = _calc_kpis({}, runs, qrows, [], current_version="v0.3.140") + assert kpis["n_notes"] == 5 + assert kpis["generated_kpi_ver"] == "v0.3.140" + assert kpis["notes_eval_pct"] == 25.0 + + +def test_notes_eval_pct_none_when_no_runs_for_version(): + # Läufe existieren nur für eine andere Version -> Nenner der KPI-Version = 0. + qrows = [_qrow("v0.3.140", "2026-07-05T20:00:00", 0.0, 10, 0)] + runs = [_log_run("v0.3.139", 5, 5)] + kpis = _calc_kpis({}, runs, qrows, [], current_version="v0.3.140") + assert kpis["generated_kpi_ver"] == "v0.3.140" + assert kpis["notes_eval_pct"] is None + + +def test_notes_eval_pct_unfiltered_uses_kpi_version_not_all_versions(): + # Ungefiltert (mehrere Versionen in quality_rows/all_log_runs): der Nenner + # darf NICHT über alle Versionen summieren (das war die im tgtHint + # dokumentierte Basis-Mischung) -- nur die kpi_version-Zeilen zaehlen. + qrows = [_qrow("v0.3.130", f"2026-06-01T10:00:{i:02d}", 0.0, 10, 0) for i in range(30)] + qrows += [_qrow("v0.3.140", f"2026-07-05T20:00:{i:02d}", 0.0, 10, 0) for i in range(4)] + runs = [_log_run("v0.3.130", 100, 90), _log_run("v0.3.140", 10, 8)] + kpis = _calc_kpis({}, runs, qrows, [], current_version="v0.3.140") + assert kpis["kpi_version"] == "v0.3.140" + assert kpis["n_notes"] == 4 + assert kpis["generated_kpi_ver"] == "v0.3.140" + assert kpis["notes_eval_pct"] == 40.0 diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index 54a21ac..36e9a7d 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -1282,8 +1282,8 @@ tgtHint:`Schwellenwert für die Ampel-Bewertung: gut ab ${T.cov[0]} %, schlecht bei ${T.cov[1]} % oder darunter. Fest im Dashboard-Code hinterlegt, keine wissenschaftliche Norm.` }, { id:'kpi-n', key:'n', label:'Evaluierte Notes', val:kpis.n_notes, u:'', digits:0, hint:'Anzahl der Notes der aktuellen Pipeline-Version, die den automatischen Qualitäts-Check (Eval) durchlaufen haben — die Stichprobe hinter Fehlerquote und Belegrate.', - tone:'info', tgt: kpis.total_generated ? `${_fmtDE(100*kpis.n_notes/kpis.total_generated)} % der Notes` : '–', - tgtHint:'Zähler: evaluierte Notes NUR der aktuellen Pipeline-Version (wie die Kachel oben). Nenner: generierte Notes ALLER Pipeline-Versionen. Die Prozentzahl mischt damit zwei unterschiedliche Versions-Basen — keine reine Evaluierungs-Quote der aktuellen Version.' }, + tone:'info', tgt: kpis.notes_eval_pct!=null ? `${_fmtDE(kpis.notes_eval_pct)} % der Notes` : '–', + tgtHint:'Zähler und Nenner teilen dieselbe Versions- und Filterbasis: evaluierte Notes ÷ generierte Notes, beide NUR der aktuellen Pipeline-Version (Kachel oben). „–" wenn zu dieser Version keine generierten Notes protokolliert sind oder mehr Notes evaluiert als generiert wurden (Datenlücke, z. B. Re-Evals ohne zugehörigen Log-/DB-Lauf) — dann würde die Quote über 100 % ergeben.' }, // #204 P5: "Laufzeit" ohne Scope war eine von vier unterscheidbaren // Kennzahlen gleichen Namens (Kachel = aktuelle Version kumuliert, Strip- // Zelle = Lifetime, Kosten-Chart = Median je Version, Agenten-Chart =