From 0b1425b1ad5146aab8bcd96a9a57046c7c44df81 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Sat, 11 Jul 2026 23:11:13 +0200 Subject: [PATCH] feat(dashboard): Waisen-Banner, Kosten/akzeptierte Note, prompt-basierte Cache-Quote (#196 P1-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — Waisen-Versions-Banner: orphan_versions() meldet generative Pipeline-Versionen numerisch UEBER config.AGENT_VERSION (#191-Fehler- klasse meldet sich selbst; Hinweis-Charakter, bewusste Re-Evals sind legitim; foss/extractive ausgenommen). Dazu jsonl_fallback-Flag, wenn statt SQLite der JSONL-Fallback laeuft (deckt nur einen Teil der Historie — Silent-Staleness sichtbar). Client: amber Hinweis-Banner in der Uebersicht, versteckt wenn nichts anliegt. P2 — Kosten pro akzeptierter Note je Version (kpi_trend.cost_per_note): cost_sum/n_vault; None statt falschem 0-$-Wert fuer subscription-Runs (compute_cost_per_call liefert dort 0) und bei n_vault=0. Anzeige in der Kosten-Sektion-Headline (letzte Version mit API-Kosten). P3 — Cache-Effizienz: cache_c wurde aggregiert, aber nie ausgegeben (totes Feld); cache_pct hatte Output im Nenner und liess cache_creation weg — Planner zeigte 75,8 % statt 51,1 %. Neu: _cache_pct_prompt_based = cache_r/(input+cache_r+cache_c); cache_c in Response + Agent-Tooltip (dreizeilig, Label praezisiert). Empirisch nach Umbau: Planner exakt die im Audit vorhergesagten 51,1 %. TDD: 11 neue Tests (orphan_versions, Cache-Formel, build_data- Integration fuer Warnungen/Fallback/cost_per_note). Playwright- verifiziert: Banner default hidden + simuliert sichtbar (fetch-Patch, beide Meldungen), ins-cost "v0.3.56 (letzter API-Lauf): 0,0050 $ pro akzeptierter Note", Agent-Tooltip "88k tokens / Cache-Hit 51.1 % vom Prompt / Cache-Neuanlage 348 k tokens"; keine JS-Fehler. --- generative/eval_dashboard.py | 15 ++ generative/eval_dashboard_server.py | 49 ++++- generative/tests/test_dashboard_kennzahlen.py | 169 ++++++++++++++++++ internal/dashboard/eval_dashboard.html | 54 +++++- 4 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 generative/tests/test_dashboard_kennzahlen.py diff --git a/generative/eval_dashboard.py b/generative/eval_dashboard.py index 6b87616..3771a7f 100644 --- a/generative/eval_dashboard.py +++ b/generative/eval_dashboard.py @@ -236,6 +236,21 @@ def _top_versions(counts: dict, limit: int = 15, min_n: int = 3) -> list[str]: return keep[:limit] +def orphan_versions(versions, current: str | None) -> list[str]: + """Generative Pipeline-Versionen numerisch ÜBER `config.AGENT_VERSION` (#196 P1). + + Das ist die #191-Fehlerklasse (verwaiste WIP-Branch-Läufe kaperten die + Anzeige) — sie meldet sich damit im Dashboard selbst. Hinweis-Charakter: + bewusste Re-Evals/Branch-Läufe sind legitim. foss-/extractive-Versionen + haben eine eigene Nummernwelt und bleiben außen vor. + """ + if not current: + return [] + cur_key = _ver_sort_key(current) + out = {v for v in versions if v and not is_foss_version(v) and _ver_sort_key(v) > cur_key} + return sorted(out, key=_ver_sort_key, reverse=True) + + def _pdf_filter_key(raw: str) -> str: """„Bates - 2017" aus „Bates - 2017 - Information Behavior.pdf". diff --git a/generative/eval_dashboard_server.py b/generative/eval_dashboard_server.py index 6360603..3124ccf 100644 --- a/generative/eval_dashboard_server.py +++ b/generative/eval_dashboard_server.py @@ -112,6 +112,17 @@ def _current_db_version(db_runs: list[dict], current: str | None = None) -> str return max(gen_runs, key=lambda r: r.get("timestamp") or "")["pipeline_version"] +def _cache_pct_prompt_based(input_tokens: int, cache_r: int, cache_c: int) -> float: + """Cache-Hit-Quote prompt-basiert: cache_r / (input + cache_r + cache_c). + + Die frühere Formel hatte Output im Nenner (gehört nicht zum Prompt) und + ließ cache_creation weg — Planner zeigte 75,8 % statt 51,1 % (#196 P3, + Audit-Fund). Anschluss an tools/cache_report.py / #147. + """ + total = input_tokens + cache_r + cache_c + return round(cache_r / total * 100, 1) if total else 0 + + def _read_agent_stats(allowed_run_ids: set | None = None) -> dict: """Aggregiert Token- und Dauer-Statistiken je Agent aus runs/*.jsonl. Nur Runs der aktuellen Pipeline-Version werden berücksichtigt @@ -190,19 +201,18 @@ def _read_agent_stats(allowed_run_ids: set | None = None) -> dict: agents += [a for a in sorted(stats) if a not in _AGENT_ORDER] labels = [_AGENT_LABELS.get(a, a) for a in agents] - def _cache_pct(a): - total = stats[a]["input"] + stats[a]["output"] + stats[a]["cache_r"] - return round(stats[a]["cache_r"] / total * 100, 1) if total else 0 - return { "agents": agents, "labels": labels, "output": [stats[a]["output"] for a in agents], "input": [stats[a]["input"] for a in agents], "cache_r": [stats[a]["cache_r"] for a in agents], + "cache_c": [stats[a]["cache_c"] for a in agents], "dur_s": [round(stats[a]["dur_ms"] / 1000, 1) for a in agents], "calls": [stats[a]["calls"] for a in agents], - "cache_pct": [_cache_pct(a) for a in agents], + "cache_pct": [ + _cache_pct_prompt_based(stats[a]["input"], stats[a]["cache_r"], stats[a]["cache_c"]) for a in agents + ], "errors": [stats[a]["errors"] for a in agents], "cost_usd": [round(stats[a]["cost_usd"], 4) for a in agents], } @@ -343,12 +353,16 @@ def build_data( from generative import db as _db # Qualitaets-Daten aus SQLite (primaer) mit Fallback auf JSONL + _jsonl_fallback = False try: all_quality_rows = _db.query_note_evals() if not all_quality_rows: raise ValueError("DB leer") except Exception: + # JSONL deckt nur einen Teil der DB-Historie ab (~32 %) — das Banner + # im Client macht die Silent-Staleness sichtbar (#196 P1). all_quality_rows = D._read_quality_history() + _jsonl_fallback = True available_versions = _available_eval_versions(all_quality_rows) @@ -699,15 +713,40 @@ def _pooled_accept(ver: str) -> float | None: round(sum(cost_by_ver.get(v, [])), 4) if cost_by_ver.get(v) else None for v in sorted_pipeline_versions ], } + # Kosten pro akzeptierter Note je Version (#196 P2): nur für API-Runs mit + # Pricing aussagekräftig — subscription-Läufe kosten 0 (compute_cost_per_call) + # und ergeben None statt eines falschen 0-$-Werts; ebenso n_vault=0. + _vault_by_ver = {ver: sum(v for v, _ in pairs) for ver, pairs in accept_pairs_by_ver.items()} + kpi_trend["cost_per_note"] = [ + round(sum(cost_by_ver[v]) / _vault_by_ver[v], 4) if cost_by_ver.get(v) and _vault_by_ver.get(v) else None + for v in sorted_pipeline_versions + ] + # Delta neueste-vs-Vorversion pro KPI (mit N-Guard, #36 P4) kpi_trend["deltas"] = { m: D.version_delta(kpi_trend, m) for m in ("hall", "cov", "n", "accept", "dur", "tokens", "cost") } + # ── Warnungen (#196 P1): Waisen-Versionen + JSONL-Fallback ────── + try: + from generative import config as _cfg + + _cur_ver = _cfg.AGENT_VERSION + except Exception: + _cur_ver = None + _all_seen_versions = {r.get("version") or r.get("pipeline_version") for r in all_quality_rows} + _all_seen_versions |= {tr.get("ver") for tr in token_runs} + warnings = { + "orphan_versions": D.orphan_versions(_all_seen_versions, _cur_ver), + "jsonl_fallback": _jsonl_fallback, + "current_version": _cur_ver or "", + } + return { "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "eval_version": eval_version, "available_eval_versions": available_versions, + "warnings": warnings, "kpis": D._calc_kpis(log_data, all_log_runs, quality_rows, token_runs), "pdf_table": D._calc_pdf_table(log_data, all_log_runs, quality_rows), "accept": D._chart_acceptance(log_data), diff --git a/generative/tests/test_dashboard_kennzahlen.py b/generative/tests/test_dashboard_kennzahlen.py new file mode 100644 index 0000000..b021f92 --- /dev/null +++ b/generative/tests/test_dashboard_kennzahlen.py @@ -0,0 +1,169 @@ +"""Tests für #196 P1–P3: Waisen-Versions-Banner, Kosten pro akzeptierter Note, +prompt-basierte Cache-Effizienz. + +P1: Eval-Zeilen/Runs mit Pipeline-Version numerisch ÜBER config.AGENT_VERSION +sind die #191-Fehlerklasse (verwaiste WIP-Branch-Läufe) — das Dashboard meldet +sie als Hinweis-Banner selbst. Dazu: JSONL-Fallback-Flag (deckt nur einen Teil +der DB-Historie ab — Silent-Staleness sichtbar machen). + +P2: Kosten pro akzeptierter Note je Version — nur für API-Runs mit Pricing +(cost>0) aussagekräftig; subscription-Läufe (cost 0) und n_vault=0 → None. + +P3: cache_pct maß bisher cache_r/(input+output+cache_r) — Output gehört nicht +in den Nenner, cache_creation fehlte (Planner: 75,8 % angezeigt vs. 51,1 % +prompt-basiert). Neu: cache_r/(input+cache_r+cache_c); cache_c wird ausgegeben. +""" + +from __future__ import annotations + +from generative.eval_dashboard import orphan_versions +from generative.eval_dashboard_server import _cache_pct_prompt_based + + +# ── P1: orphan_versions ───────────────────────────────────────────────────── + + +def test_orphan_flags_versions_above_current(): + vers = {"v0.3.140", "v0.3.141", "v0.4.0", "v0.3.99"} + assert orphan_versions(vers, "v0.3.140") == ["v0.4.0", "v0.3.141"] + + +def test_orphan_empty_when_all_at_or_below_current(): + assert orphan_versions({"v0.3.140", "v0.3.99", "v0.1.0"}, "v0.3.140") == [] + + +def test_orphan_ignores_foss_and_empty(): + # foss-/extractive-Versionen haben eine eigene Nummernwelt — kein Vergleich. + vers = {"foss-v9.9.9", "extractive-v2.0", "", None, "v0.3.141"} + assert orphan_versions(vers, "v0.3.140") == ["v0.3.141"] + + +def test_orphan_without_current_returns_empty(): + assert orphan_versions({"v0.3.141"}, None) == [] + assert orphan_versions({"v0.3.141"}, "") == [] + + +# ── P3: prompt-basierte Cache-Formel ──────────────────────────────────────── + + +def test_cache_pct_prompt_based_excludes_output_includes_creation(): + # Audit-Fund: input=1000, cache_r=2000, cache_c=900, output=5000. + # Alt (mit Output, ohne Creation): 2000/8000 = 25 %. + # Prompt-basiert: 2000/(1000+2000+900) = 51,3 %. + assert _cache_pct_prompt_based(1000, 2000, 900) == 51.3 + + +def test_cache_pct_prompt_based_zero_denominator(): + assert _cache_pct_prompt_based(0, 0, 0) == 0 + + +# ── P1/P2: build_data-Integration ─────────────────────────────────────────── + + +def _pipeline_run(run_id, ver, cost=0.0, n_vault=3): + return { + "run_id": run_id, + "timestamp": "2026-07-01T00:00:00", + "pipeline_version": ver, + "pdf_source": "Testquelle - 2020 - Titel.pdf", + "pdf_key": "testquelle-2020", + "pdf_label": "Testquelle - 2020 - Titel", + "model": "test-model-x", + "cost_usd": cost, + "n_generated": 4, + "n_vault": n_vault, + "n_inbox": 1, + "n_merge": 0, + "n_words": 5000, + "n_dropped": 0, + "duration_s": 60.0, + } + + +def _eval_row(run_id, ver): + return { + "run_id": run_id, + "note_path": f"notes/{run_id}.md", + "acceptance_status": "vault", + "hallucination_rate": 0.05, + "coverage_factual": 0.8, + "pipeline_version": ver, + "version": ver, + "pdf": "Testquelle - 2020 - Titel.pdf", + "language": "DE→DE", + "eval_version": "4.1", + "anchors_total": 10, + "anchors_hallucinated": 1, + } + + +def _patched_build_data(monkeypatch, runs, evals, current="v0.3.140"): + from generative import config as _cfg + from generative import db as _gdb + from generative import eval_dashboard as D + from generative import eval_dashboard_server as S + + monkeypatch.setattr(_cfg, "AGENT_VERSION", current) + monkeypatch.setattr(_gdb, "query_pipeline_runs", lambda *a, **k: [dict(r) for r in runs]) + monkeypatch.setattr(_gdb, "query_note_evals", lambda *a, **k: [dict(r) for r in evals]) + monkeypatch.setattr(D, "_read_all_log_runs", lambda: []) + monkeypatch.setattr(D, "_read_token_runs", lambda: []) + return S.build_data() + + +def test_build_data_reports_orphan_versions(monkeypatch): + runs = [ + _pipeline_run("r1", "v0.3.140"), + _pipeline_run("r2", "v0.3.152"), # Waise: über AGENT_VERSION + ] + evals = [_eval_row("r1", "v0.3.140"), _eval_row("r2", "v0.3.152")] + data = _patched_build_data(monkeypatch, runs, evals, current="v0.3.140") + assert data["warnings"]["orphan_versions"] == ["v0.3.152"] + assert data["warnings"]["current_version"] == "v0.3.140" + assert data["warnings"]["jsonl_fallback"] is False + + +def test_build_data_no_orphans_is_empty_list(monkeypatch): + runs = [_pipeline_run("r1", "v0.3.140")] + evals = [_eval_row("r1", "v0.3.140")] + data = _patched_build_data(monkeypatch, runs, evals, current="v0.3.140") + assert data["warnings"]["orphan_versions"] == [] + + +def test_build_data_flags_jsonl_fallback(monkeypatch): + from generative import db as _gdb + from generative import eval_dashboard as D + from generative import eval_dashboard_server as S + + def _boom(*a, **k): + raise RuntimeError("DB weg") + + monkeypatch.setattr(_gdb, "query_note_evals", _boom) + monkeypatch.setattr(_gdb, "query_pipeline_runs", lambda *a, **k: []) + monkeypatch.setattr(D, "_read_quality_history", lambda: [_eval_row("r1", "v0.3.140")]) + monkeypatch.setattr(D, "_read_all_log_runs", lambda: []) + monkeypatch.setattr(D, "_read_token_runs", lambda: []) + data = S.build_data() + assert data["warnings"]["jsonl_fallback"] is True + + +def test_build_data_cost_per_accepted_note(monkeypatch): + # v0.3.139: API-Lauf mit Kosten 1.2 $ und 3 Vault-Notes → 0.4 $/Note. + # v0.3.140: subscription (cost 0) → None („–", kein falsches 0 $). + runs = [ + _pipeline_run("r1", "v0.3.139", cost=1.2, n_vault=3), + _pipeline_run("r2", "v0.3.140", cost=0.0, n_vault=3), + ] + evals = [_eval_row("r1", "v0.3.139"), _eval_row("r2", "v0.3.140")] + data = _patched_build_data(monkeypatch, runs, evals) + cpn = dict(zip(data["kpi_trend"]["versions"], data["kpi_trend"]["cost_per_note"])) + assert cpn["v0.3.139"] == 0.4 + assert cpn["v0.3.140"] is None + + +def test_build_data_cost_per_note_none_without_vault_notes(monkeypatch): + runs = [_pipeline_run("r1", "v0.3.140", cost=2.0, n_vault=0)] + evals = [_eval_row("r1", "v0.3.140")] + data = _patched_build_data(monkeypatch, runs, evals) + cpn = dict(zip(data["kpi_trend"]["versions"], data["kpi_trend"]["cost_per_note"])) + assert cpn["v0.3.140"] is None diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index 4f442ad..41d593d 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -393,6 +393,17 @@ } .calib-more:hover { color: var(--ink-1); border-color: var(--amber); } +/* ── Hinweis-Banner (#196 P1: Waisen-Versionen, JSONL-Fallback) ── */ +.warn-banner[hidden] { display: none; } +.warn-banner { + margin: 0 0 14px; padding: 8px 12px; + font-family: var(--f-mono); font-size: 11.5px; color: var(--amber); + background: var(--tint-amber); border-radius: 3px; + border: 1px solid rgba(232,181,59,.25); + display: flex; flex-direction: column; gap: 4px; +} +.warn-banner strong { font-weight: 600; } + /* ── Eval-Coverage-Gap unter den KPIs ──────────────────────────── */ .kpi-gap[hidden] { display: none; } .kpi-gap { @@ -609,6 +620,8 @@ Übersicht

+ +
@@ -1551,7 +1564,14 @@ getOrCreate('ch7',{type:'bar',data:{labels:agLabelsN,datasets:[{data:ag.output,backgroundColor:bgC,borderWidth:0,borderRadius:2,barPercentage:.65}]}, options:{indexAxis:'y',maintainAspectRatio:false,responsive:true,animation:{duration:500}, plugins:{legend:{display:false},tooltip:{callbacks:{ - label:ctx=>{const i=ctx.dataIndex;const costStr=(ag.cost_usd?.[i]>0)?` · $${ag.cost_usd[i].toFixed(4)}`:'';return `${(ctx.raw/1000).toFixed(0)}k tokens · Cache ${ag.cache_pct?.[i]} %${costStr}`;}, + // #196 P3: Cache-Hit prompt-basiert (read/(input+read+creation)) + + // Cache-Neuanlage sichtbar — cache_c war serverseitig aggregiert, + // wurde aber nie ausgegeben. + label:ctx=>{const i=ctx.dataIndex;const costStr=(ag.cost_usd?.[i]>0)?` · $${ag.cost_usd[i].toFixed(4)}`:''; + const lines=[`${(ctx.raw/1000).toFixed(0)}k tokens${costStr}`, + `Cache-Hit ${ag.cache_pct?.[i] ?? '–'} % vom Prompt (read/(input+read+creation))`]; + if (ag.cache_c?.[i] > 0) lines.push(`Cache-Neuanlage ${(ag.cache_c[i]/1000).toFixed(0)} k tokens`); + return lines;}, afterLabel:ctx=>{ if(!(ag.labels?.[ctx.dataIndex]||'').toLowerCase().startsWith('verifier')) return; return ctx.raw===0 @@ -1826,9 +1846,19 @@ if ((tok.labels||[]).length) { const totalTok = (tok.tokens_in||[]).reduce((a,b)=>a+(b||0),0) + (tok.tokens_out||[]).reduce((a,b)=>a+(b||0),0); const medDur = _median((tok.duration_min||[]).filter(v=>v>0).sort((a,b)=>a-b)); + // #196 P2: Kosten pro akzeptierter Note — neueste Version mit API-Kosten + // (subscription-Läufe liefern None und tauchen hier bewusst nicht auf). + const kt = d.kpi_trend || {}; + let cpnTxt = ''; + for (let i = (kt.versions||[]).length - 1; i >= 0; i--) { + if (kt.cost_per_note?.[i] != null) { + cpnTxt = ` ${esc(kt.versions[i])} (letzter API-Lauf): ${_fmtDE(kt.cost_per_note[i], 4)} $ pro akzeptierter Note.`; + break; + } + } _setInsight('ins-cost', `${tok.labels.length} Versionen verbrauchten ${_fmtDE(totalTok/1e6, 2)} M Tokens` + - (medDur != null ? `, Median-Laufzeit ${_fmtDE(medDur, 0)} min pro Version.` : '.')); + (medDur != null ? `, Median-Laufzeit ${_fmtDE(medDur, 0)} min pro Version.` : '.') + cpnTxt); } else { _setInsight('ins-cost', ''); } // Agenten: größter Output-Token-Verursacher @@ -1912,6 +1942,26 @@ renderCharts(d); renderCalibration(d.calibration); renderInsights(d); + renderWarnings(d.warnings); +} + +/* #196 P1: Hinweis-Banner — Waisen-Versionen melden die #191-Fehlerklasse + selbst (Hinweis, kein Fehler: bewusste Re-Evals/Branch-Läufe sind legitim); + JSONL-Fallback heißt SQLite nicht erreichbar → Zahlen unvollständig. */ +function renderWarnings(w) { + const el = document.getElementById('warn-banner'); + if (!el) return; + const parts = []; + if (w?.orphan_versions?.length) { + parts.push(`Eval-Daten mit Version über der Code-Version ${_escHTML(w.current_version||'?')}: ` + + w.orphan_versions.map(_escHTML).join(', ') + + ` — verwaiste/Branch-Läufe? (#191-Fehlerklasse; legitim bei bewussten Re-Evals)`); + } + if (w?.jsonl_fallback) { + parts.push(`Eval-DB nicht nutzbar (nicht erreichbar oder leer) — JSONL-Fallback aktiv: deckt nur einen Teil der Historie ab, Kennzahlen unvollständig.`); + } + el.innerHTML = parts.map(p => `
⚠ ${p}
`).join(''); + el.hidden = parts.length === 0; } window.resetAllFilters = function() {