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
15 changes: 15 additions & 0 deletions generative/eval_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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".

Expand Down
49 changes: 44 additions & 5 deletions generative/eval_dashboard_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
}
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -704,15 +718,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),
Expand Down
169 changes: 169 additions & 0 deletions generative/tests/test_dashboard_kennzahlen.py
Original file line number Diff line number Diff line change
@@ -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
54 changes: 52 additions & 2 deletions internal/dashboard/eval_dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,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 {
Expand Down Expand Up @@ -638,6 +649,8 @@
<span class="sec-name">Übersicht</span>
</div>
<p class="insight" id="ins-overview"></p>
<!-- #196 P1: Hinweis-Banner — Waisen-Versionen (#191-Fehlerklasse) + JSONL-Fallback -->
<div id="warn-banner" class="warn-banner" hidden></div>

<!-- Gesamt: alle Versionen + Runs -->
<div class="sub-head">
Expand Down Expand Up @@ -1595,7 +1608,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)?` · ${_fmtDE(ag.cost_usd[i], 4)} $`:'';return `${(ctx.raw/1000).toFixed(0)}k tokens · Cache ${ag.cache_pct?.[i]!=null?_fmtDE(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. Zahlenformat DE via _fmtDE (#204 P1).
label:ctx=>{const i=ctx.dataIndex;const costStr=(ag.cost_usd?.[i]>0)?` · ${_fmtDE(ag.cost_usd[i], 4)} $`:'';
const lines=[`${(ctx.raw/1000).toFixed(0)}k tokens${costStr}`,
`Cache-Hit ${ag.cache_pct?.[i]!=null?_fmtDE(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
Expand Down Expand Up @@ -1873,9 +1893,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 = ` <strong>${esc(kt.versions[i])}</strong> (letzter API-Lauf): <strong>${_fmtDE(kt.cost_per_note[i], 4)} $</strong> pro akzeptierter Note.`;
break;
}
}
_setInsight('ins-cost',
`<strong>${tok.labels.length}</strong> Versionen verbrauchten <strong>${_fmtDE(totalTok/1e6, 2)} M</strong> Tokens` +
(medDur != null ? `, Median-Laufzeit <strong>${_fmtDE(medDur, 0)} min</strong> pro Version.` : '.'));
(medDur != null ? `, Median-Laufzeit <strong>${_fmtDE(medDur, 0)} min</strong> pro Version.` : '.') + cpnTxt);
} else { _setInsight('ins-cost', ''); }

// Agenten: größter Output-Token-Verursacher
Expand Down Expand Up @@ -1959,6 +1989,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 <strong>${_escHTML(w.current_version||'?')}</strong>: ` +
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) — <strong>JSONL-Fallback aktiv</strong>: deckt nur einen Teil der Historie ab, Kennzahlen unvollständig.`);
}
el.innerHTML = parts.map(p => `<div>⚠ ${p}</div>`).join('');
el.hidden = parts.length === 0;
}

window.resetAllFilters = function() {
Expand Down
Loading