Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e6250f1
fix(dashboard): Matrix-Rendering-Bruch bei wenigen Versionen behoben
Tilltime Jul 16, 2026
2ad8a18
feat(dashboard): eval_version-Dropdown mit Zeilenzahl je Version
Tilltime Jul 16, 2026
c775d60
fix(dashboard): coverage_factual-or-Anti-Pattern an Bestandsstellen g…
Tilltime Jul 16, 2026
5e58a8e
fix(dashboard): accept-Delta-Guard auf geroutete statt evaluierte Not…
Tilltime Jul 16, 2026
ad64fb0
fix(dashboard): Dropped-KPI zeigt ehrlich "–" statt stiller 0 im Log-…
Tilltime Jul 16, 2026
d6938af
fix(dashboard): Spark-Achsen-Eskalation fuer %- und h-Einheiten konsi…
Tilltime Jul 16, 2026
91d78d6
fix(dashboard): ch3/leg3 zeigen etikettierte PDF-Labels statt Rohkeys
Tilltime Jul 16, 2026
ebfc098
feat(dashboard): n-valid-Badge je Modell-Filter-Option
Tilltime Jul 16, 2026
e2c6f15
fix(dashboard): Punkt 1 Nachbesserung — eval_version-Select an Meta-L…
Tilltime Jul 16, 2026
f6a5964
fix(dashboard): Responsive-Bruch bei 1100px behoben (U8 KPI-Luecke, U…
Tilltime Jul 16, 2026
bd9be06
feat(dashboard): Accept×Hall-Overlay in der Fehlerquote-Versions-Tren…
Tilltime Jul 16, 2026
23c1a7e
fix(dashboard): Sticky-Header-Kollision der Matrix-PDF-Spalte behoben
Tilltime Jul 16, 2026
1d437ff
fix(dashboard): Singular/Plural bei der Matrix-Cap-Fussnote korrigiert
Tilltime Jul 16, 2026
df30a22
fix(dashboard): Paarvergleichstabelle bei 1100px scrollbar gemacht
Tilltime Jul 16, 2026
0ea507b
feat(dashboard): Re-Eval-Serien-Kennzeichnung fuer die Matrix ergaenzt
Tilltime Jul 16, 2026
9c19ed2
fix(dashboard): Accept×Hall-Overlay zuschaltbar + Tooltip/Legende/Dimmen
Tilltime Jul 16, 2026
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
98 changes: 83 additions & 15 deletions generative/eval_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -647,9 +647,7 @@ def _calc_kpis(
hall_stats = _pooled_hall_stats(latest_qrows)
avg_hall = hall_stats["pct"] if hall_stats else None

cov_vals = [
v for r in latest_qrows if (v := r.get("coverage_factual") or r.get("coverage_rate")) is not None and v >= 0
]
cov_vals = [v for r in latest_qrows if (v := _row_coverage(r)) is not None and v >= 0]
avg_cov = round(_median(cov_vals) * 100, 1) if cov_vals else None
total_generated = sum(r["n_total"] for r in all_log_runs)
total_accepted = sum(r["n_vault"] for r in all_log_runs)
Expand Down Expand Up @@ -770,7 +768,20 @@ def _calc_kpis(
"total_generated": total_generated,
"total_accepted": total_accepted,
"total_merged": total_merged,
"total_dropped": sum(r.get("n_dropped", 0) or 0 for r in all_log_runs),
# Punkt 6 (D5, Reviews 15.07.): "n_dropped" existiert strukturell NUR
# im DB-Fallback-Pfad (eval_dashboard_server.py, pipeline_runs-Tabelle)
# -- der primaere Log-Pfad (_read_all_log_runs) baut Zeilen aus
# [DRY-RUN]->(Vault|Inbox)-Treffern; verworfene Kandidaten (nie bis
# zum Draft gekommen) hinterlassen dort GAR KEINE Zeile und sind somit
# nicht ermittelbar, nicht nur zufaellig 0. `.get("n_dropped", 0)`
# gab bisher still 0 zurueck -- ununterscheidbar von "wirklich 0". Kein
# Log-Run traegt den Key -> None (Client zeigt "–" statt einer Zahl,
# die eine Genauigkeit vortaeuscht, die die Quelle nicht hat).
"total_dropped": (
sum(r.get("n_dropped", 0) or 0 for r in all_log_runs)
if any("n_dropped" in r for r in all_log_runs)
else None
),
"total_tokens": total_tokens,
"total_dur_h": round(total_dur_s / 3600, 1),
"cur_tokens": cur_tokens,
Expand Down Expand Up @@ -971,7 +982,7 @@ def _words_for(runs: list[dict]) -> int | None:
at = _dedup_latest_per_note(at)
n_notes = _distinct_notes(at)
hall = _pooled_hall_pct(at)
cov_vals = [v for r in at if (v := r.get("coverage_factual") or r.get("coverage_rate")) is not None and v >= 0]
cov_vals = [v for r in at if (v := _row_coverage(r)) is not None and v >= 0]
cov = round(_median(cov_vals) * 100, 1) if cov_vals else None
runs = log_by_group.get(gk, [])
accept, accept_ver, accept_n, n_merge = _accept_from_runs(runs, ver, current_version)
Expand Down Expand Up @@ -1043,7 +1054,7 @@ def _chart_scatter(quality_rows: list[dict]) -> dict:
pdf_map: dict[str, str] = {}
for r in quality_rows:
hall = r.get("hallucination_rate")
cov = r.get("coverage_factual") or r.get("coverage_rate")
cov = _row_coverage(r)
if hall is None or cov is None or float(hall) < 0 or float(cov) < 0:
continue
label = r.get("note") or r.get("note_title") or "?"
Expand Down Expand Up @@ -1109,12 +1120,22 @@ def _chart_longitudinal(log_data: dict) -> dict:
_DELTA_MIN_PDF_OVERLAP = 0.5


def version_delta(kpi_trend: dict, metric: str) -> dict:
def version_delta(kpi_trend: dict, metric: str, n_field: str = "n") -> dict:
"""Delta der neuesten Version gegen die letzte belastbare Vorversion.

`kpi_trend["versions"]` ist aufsteigend sortiert (neueste = letzte Position),
die Metrik-Arrays laufen parallel dazu.

Punkt 4 (D3, Matrix-Rendering-Fix+Politur-Bündel): `n_field` waehlt, welches
kpi_trend-Array als n>=_DELTA_MIN_N-Reliability-Guard dient (Default "n" =
Zahl LLM-evaluierter Notes, korrekt fuer hall/cov/dur/tokens/cost). Fuer
"accept" ist "n" der FALSCHE Nenner: die Akzeptanzrate poolt ueber ALLE
gerouteten (generierten) Notes, nicht nur die evaluierte Stichprobe -- der
Server ruft hier mit `n_field="accept_n"` (Summe n_total je Version,
dieselbe Basis wie `_pooled_accept`) auf, sonst zeigt das Delta
faelschlich reliable:false, obwohl es auf hunderten gerouteten Notes
beruht (aktuell konservativ, kein falsches Delta -- nur unnoetig grau).

#196 P5: Vergleichsbasis ist die jüngste FRÜHERE Version mit einem
vorhandenen Metrik-Wert UND n>=_DELTA_MIN_N — nicht starr die direkte
Vorversion. Direkte Nachbarversionen sind oft Einzel-Note-Wegwerfläufe
Expand All @@ -1136,7 +1157,7 @@ def version_delta(kpi_trend: dict, metric: str) -> dict:
(PDF-Mix)").
"""
values = kpi_trend.get(metric) or []
ns = kpi_trend.get("n") or []
ns = kpi_trend.get(n_field) or []
versions = kpi_trend.get("versions") or []
pdf_notes = kpi_trend.get("pdf_notes") or []
latest = values[-1] if values else None
Expand Down Expand Up @@ -1200,6 +1221,31 @@ def _pdf_notes_at(i: int) -> dict:
}


def _is_reeval_series(quality_rows: list[dict], pipeline_runs: list[dict]) -> bool:
"""True, wenn ALLE `quality_rows` (typischerweise die aktive eval_version,
ungefiltert -- s. `_matrix_base_rows` im Server) an einen `pipeline_runs`-
Eintrag mit `pipeline_version == "reeval"` haengen.

Nachbesserung Punkt 4 (Statistiker-Empfehlung 2026-07-15): unter
eval_version 4.3 haengen alle note_evals-Zeilen an `pipeline_version`
v0.3.144 -- das ist der CODE-STAND des Re-Eval-Sweeps
(`reeval_baseline.py`), NICHT die Erzeugungsversion der Notes. Der
zugehoerige Lauf in `pipeline_runs` traegt dafuer den expliziten Marker
`pipeline_version="reeval"` (`reeval_baseline.py`, INSERT OR IGNORE INTO
pipeline_runs ... VALUES (..., 'reeval', 'baseline-reeval', 'reeval', ...)`) --
verknuepft ueber `run_id`. Zeigt eine eval_version AUSSCHLIESSLICH solche
Re-Eval-Runs, misst sie den Eval-Code-Stand, nicht die Notes-Erzeugung.

Leere `quality_rows` -> False (nichts zu kennzeichnen, kein falsches
Positiv). Zeilen ohne matchenden `pipeline_runs`-Eintrag (fehlender/
unbekannter run_id) zaehlen NICHT als reeval -- nur eine explizite
Bestaetigung (jede Zeile matcht 'reeval') schaltet das Flag."""
if not quality_rows:
return False
run_to_pver = {r.get("run_id"): r.get("pipeline_version") for r in pipeline_runs if r.get("run_id")}
return all(run_to_pver.get(row.get("run_id")) == "reeval" for row in quality_rows)


# ---------------------------------------------------------------------------
# Versions×PDF-Paarvergleich (Multi-Perspektiven-Dashboard-Review 2026-07-15,
# P1-Empfehlung aller 3 Statistiker, vom adversarialen Statistiker modifiziert)
Expand All @@ -1223,18 +1269,26 @@ def _pdf_notes_at(i: int) -> dict:
# ihre Dominanz wird ueber n/Min/Max je Zelle sichtbar statt versteckt.


def _row_coverage(r: dict):
def _row_coverage(r):
"""Coverage-Wert einer Eval-Zeile: `coverage_factual`, wenn NICHT None
(auch bei echter 0.0!), NUR bei None Fallback auf `coverage_rate`.

D4-Anti-Pattern-Fix (Dashboard-Review 2026-07-15): das verbreitete
`coverage_factual or coverage_rate` verschluckt eine ECHTE 0.0 (falsy)
und nimmt faelschlich coverage_rate bzw. verwirft die Zeile, wenn
coverage_rate fehlt — 0%-Coverage-Zeilen existieren real (Jockisch-
Faelle). Nur fuer die neuen Matrix-/Paarvergleichs-Funktionen unten;
die Bestands-Stellen mit demselben Muster fixt ein separates Ticket."""
v = r.get("coverage_factual")
return v if v is not None else r.get("coverage_rate")
Faelle).

Punkt 5 (D4-Bestand, Matrix-Rendering-Fix+Politur-Bündel): urspruenglich
nur fuer die Matrix-/Paarvergleichs-Funktionen gedacht ("die Bestands-
Stellen mit demselben Muster fixt ein separates Ticket") -- jetzt SSoT
fuer ALLE Bestands-Stellen (siehe Aufrufer). `r` ist Mapping-kompatibel:
akzeptiert sowohl `dict` als auch `sqlite3.Row` (KEIN `.get()` -- Row
unterstuetzt das nicht; `in r.keys()` funktioniert fuer beide)."""
v = r["coverage_factual"] if "coverage_factual" in r.keys() else None
if v is not None:
return v
return r["coverage_rate"] if "coverage_rate" in r.keys() else None


def _matrix_cell_stats(rows: list[dict]) -> dict | None:
Expand Down Expand Up @@ -1581,14 +1635,28 @@ def _chart_tokens_by_version(runs: list[dict]) -> dict:


def _chart_scaling(all_log_runs: list[dict]) -> dict:
# Punkt 8 (#294-Nebenfund, Reviews 15.07.): `r["label"]` ist im primaeren
# Log-Pfad (_read_all_log_runs) `_PDF_LABELS.get(key, key)` -- fuer PDFs
# ausserhalb der 3 registrierten _PDF_LABELS-Eintraege (Regelfall) faellt
# das auf den rohen, kleingeschriebenen Log-Key zurueck (label == key).
# Gleiche Bugklasse + gleicher Fallback wie _chart_longitudinal (Trade-
# off-Chart-2, U4-Fix, s. Kommentar dort) -- NUR wenn label==key
# (der Fallback tatsaechlich griff) neu ableiten, sonst den vom
# DB-Fallback-Pfad ggf. schon besseren Label-Wert unangetastet lassen.
def _scaling_label(r: dict) -> str:
label, key = r["label"], r["key"]
if label != key:
return label
return _PDF_LABELS.get(key) or re.sub(r"[-_]+", " ", key).strip().title()

points = [
{
"x": r["words"],
"y": r["n_total"],
"y_vault": r["n_vault"],
"pages": r["pages"],
"key": r["key"],
"label": r["label"],
"label": _scaling_label(r),
"ver": r["ver"],
"pct": r["accept_pct"],
}
Expand All @@ -1609,7 +1677,7 @@ def _build_quality_chart_data(quality_rows: list[dict]) -> dict:
pdf = r.get("pdf") or r.get("source_pdf") or "unbekannt"
ver = r.get("version") or "unknown"
hall = r.get("hallucination_rate")
cov = r.get("coverage_factual") or r.get("coverage_rate")
cov = _row_coverage(r)
anch_total = r.get("anchors_total") or 0
anch_conf = r.get("anchors_confirmed") or 0
rows_clean.append(
Expand Down
86 changes: 75 additions & 11 deletions generative/eval_dashboard_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,12 @@ def _read_calibration_data(allowed_note_paths: set | None = None, eval_version:
else None,
# #233: coverage_factual ist die mit dem v2/v4-Umbau abgeschaffte
# v1.3-Metrik — seit v4 durchgehend NULL. Fallback auf
# coverage_rate ("Belegrate" im Dashboard), gleiches Muster wie
# eval_dashboard.py:595/815/883/1063.
"llm_cov": round(v * 100, 1)
if (v := r["coverage_factual"] or r["coverage_rate"]) is not None and v >= 0
else None,
# coverage_rate ("Belegrate" im Dashboard) ueber den D4-Bestands-
# Fix D._row_coverage (Punkt 5, Matrix-Rendering-Fix+Politur-
# Bündel) -- das vorherige `coverage_factual or coverage_rate`
# haette eine ECHTE 0.0 (falsy) verschluckt; _row_coverage prueft
# explizit auf None (Mapping-kompatibel, auch fuer sqlite3.Row).
"llm_cov": round(v * 100, 1) if (v := D._row_coverage(r)) is not None and v >= 0 else None,
"pdf": r["pdf"],
}
for r in conn.execute(
Expand Down Expand Up @@ -408,6 +409,16 @@ def build_data(
_jsonl_fallback = True

available_versions = _available_eval_versions(all_quality_rows)
# Punkt 1 (Till-Wunsch): Zeilenzahl je eval_version fuer die Dropdown-
# Anzeige "4.3 (n=27)" -- ungefiltert (alle Quality-Rows dieser Version),
# damit die Zahl unabhaengig vom aktiven PDF-/Modell-/etc.-Filter stabil
# bleibt (dieselbe "Dropdown-Optionen VOR allen Filtern"-Konvention wie
# bei _all_pdfs_opts/_all_pvers_opts unten).
_eval_ver_counts: dict[str, int] = {}
for _r in all_quality_rows:
_v = _r.get("eval_version")
if _v:
_eval_ver_counts[_v] = _eval_ver_counts.get(_v, 0) + 1

# Default: neueste Version
if eval_version is None or eval_version not in available_versions:
Expand Down Expand Up @@ -552,9 +563,35 @@ def build_data(
# ── Dropdown-Optionen VOR allen Filtern snapshotten ──────────────
# Smoke-/Test-Modelle aus dem Filter halten (z. B. "m", "test", "smoke-model").
_MODEL_DENYLIST = {"m", "test", "smoke-model"}
_all_models_opts = sorted(
{m for tr in token_runs if (m := tr.get("model", "")) and m not in _MODEL_DENYLIST and "smoke" not in m.lower()}
# Punkt 2 (Reviews 15.07.): n-valid-Badge je Modell -- Zahl VALIDER
# Eval-Zeilen (hallucination_rate >= 0) je Modell (Gemini-Fehllesungs-
# Schutz: dort tragen alle Zeilen den bestehenden -1.0-Sentinel fuer
# "ungueltig", vgl. _chart_scatter/_matrix_cell_stats -- n_valid muss 0
# zeigen, nicht die volle aber wertlose Zeilenzahl). note_evals traegt
# kein eigenes "model"-Feld (das lebt in pipeline_runs) -- Join ueber
# run_id -> token_runs.model, derselbe Mechanismus wie der bestehende
# Modell-Einzelwert-Filter unten. Basis _matrix_base_rows (eval_version-
# skopiert, VOR pipeline_version/language/pdf/run-Filtern) statt
# quality_rows -- dieselbe "vor allen Filtern"-Konvention wie die
# Options-Liste selbst, sonst wuerde z. B. ein aktiver PDF-Filter die
# angezeigten Zaehler unerwartet mitverschieben.
_run_model_map: dict[str, str] = {tr["run_id"]: tr.get("model", "") for tr in token_runs if tr.get("run_id")}
_model_valid_n: dict[str, int] = {}
for _r in _matrix_base_rows:
_m = _run_model_map.get(_r.get("run_id"), "")
if not _m:
continue
_hall = _r.get("hallucination_rate")
if _hall is not None and float(_hall) >= 0:
_model_valid_n[_m] = _model_valid_n.get(_m, 0) + 1
_model_names = sorted(
{
mdl
for tr in token_runs
if (mdl := tr.get("model", "")) and mdl not in _MODEL_DENYLIST and "smoke" not in mdl.lower()
}
)
_all_models_opts = [{"model": m, "n_valid": _model_valid_n.get(m, 0)} for m in _model_names]

# ── token_runs + quality_rows + all_log_runs gemeinsam filtern ──────
# Alle Filter auf token_runs anwenden → run_ids extrahieren → quality_rows + log_runs ebenfalls filtern
Expand Down Expand Up @@ -757,7 +794,7 @@ def _vkey(v):
hall_val = r.get("hallucination_rate")
if hall_val is not None and float(hall_val) >= 0:
d2["hall"].append(float(hall_val) * 100)
cov = r.get("coverage_factual") or r.get("coverage_rate")
cov = D._row_coverage(r)
if cov is not None and float(cov) >= 0:
d2["cov"].append(float(cov) * 100)
# n = distinct Notes (nicht Eval-Instanzen), identisch zur „Evaluierte
Expand Down Expand Up @@ -826,6 +863,12 @@ def _pooled_accept(ver: str) -> float | None:
"cov": [quality_by_version[v].get("median_cov") for v in sorted_pipeline_versions],
"n": [quality_by_version[v]["n"] for v in sorted_pipeline_versions],
"accept": [_pooled_accept(v) for v in sorted_pipeline_versions],
# Punkt 4 (D3): n-Guard-Basis fuer das accept-Delta -- "n" (evaluierte
# Notes) ist fuer hall/cov korrekt, fuer accept aber der FALSCHE Nenner
# (Akzeptanzrate poolt ueber ALLE gerouteten Notes, s. _pooled_accept
# oben). Summe derselben (n_vault, n_total)-Paare -- SSoT, keine
# zweite Zaehlung.
"accept_n": [sum(t for _, t in accept_pairs_by_ver.get(v, [])) for v in sorted_pipeline_versions],
"dur": [
round(sum(dur_by_ver.get(v, [])) / len(dur_by_ver[v]), 1) if dur_by_ver.get(v) else None
for v in sorted_pipeline_versions
Expand All @@ -851,7 +894,11 @@ def _pooled_accept(ver: str) -> float | None:

# 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")
# Punkt 4 (D3): "accept" haertet auf accept_n (geroutete Notes) statt
# dem Default "n" (evaluierte Notes, falscher/zu kleiner Nenner fuer
# diese Metrik -- s. version_delta-Docstring).
m: D.version_delta(kpi_trend, m, n_field="accept_n" if m == "accept" else "n")
for m in ("hall", "cov", "n", "accept", "dur", "tokens", "cost")
}

# ── Lauf-Dropdown-Optionen (#211): immer ungefiltert, jüngste zuerst ──
Expand Down Expand Up @@ -909,10 +956,27 @@ def _pooled_accept(ver: str) -> float | None:
pair_matrix["excluded_archived_versions"] = _excluded_archived
pair_matrix["eval_version"] = eval_version

# Nachbesserung Punkt 4 (Statistiker-Empfehlung 2026-07-16): zeigt die
# aktive eval_version AUSSCHLIESSLICH Re-Eval-Runs (reeval_baseline.py,
# pipeline_runs.pipeline_version=="reeval"), misst sie den Eval-Code-
# Stand des Re-Eval-Sweeps, NICHT die Erzeugungsversion der Notes
# (Produktionsmuster: eval_version 4.3 haengt komplett an v0.3.144, das
# war aber der Code-Stand beim Re-Eval-Lauf). Basis: `_matrix_base_rows`
# (aktive eval_version, VOR Einzelwert-Filtern -- dieselbe "ungefiltert"-
# Konvention wie die Matrix selbst).
try:
from generative import db as _db_reeval

_all_pipeline_runs = _db_reeval.query_pipeline_runs()
except Exception:
_all_pipeline_runs = []
is_reeval_series = D._is_reeval_series(_matrix_base_rows, _all_pipeline_runs)

return {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"eval_version": eval_version,
"available_eval_versions": available_versions,
"is_reeval_series": is_reeval_series,
"available_eval_versions": [{"version": v, "n": _eval_ver_counts.get(v, 0)} for v in available_versions],
"warnings": warnings,
"kpis": D._calc_kpis(log_data, all_log_runs, quality_rows, token_runs),
"pdf_table": (_pdf_table := D._calc_pdf_table(log_data, all_log_runs, quality_rows)),
Expand Down Expand Up @@ -992,7 +1056,7 @@ def _chart_scatter_versioned(quality_rows: list[dict]) -> dict:

for r in deduped_rows:
hall = r.get("hallucination_rate")
cov = r.get("coverage_factual") or r.get("coverage_rate")
cov = D._row_coverage(r)
# Sentinel-Werte (-1.0 = ungültig) überspringen
if hall is None or cov is None or float(hall) < 0 or float(cov) < 0:
continue
Expand Down
Loading
Loading