From e6250f15166e523364d396c15b44515fe92485a2 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Thu, 16 Jul 2026 10:53:15 +0200 Subject: [PATCH 01/16] fix(dashboard): Matrix-Rendering-Bruch bei wenigen Versionen behoben Till-Live-Befund auf master 64d8895 (eval_version 4.3 = 6 PDFs x 1 Version v0.3.144): Der Versions-Spaltenkopf "v0.3.144" rendert nicht in der Kopfzeile, sondern ~49px tiefer und ueberlappt Zeile 1 (Bates), die Kopfzeile selbst wirkt leer. Ursache (per Repro auf isoliertem Testserver mit read-only kopierten Live-Daten + Computed-Style-Diagnose bestaetigt, nicht geraten): die >=1201px-Sticky-Regel `table.cmp thead th` (#203 P3) trifft ueber die gemeinsame .cmp-Klasse auch table.pm-matrix. Der bestehende Kommentar ("sticky entfaellt fuer die Matrix ohnehin, da der pm-scroll-Wrapper ihr eigener Containing-Block ist") war ein Trugschluss: position:sticky verschiebt die Box bereits im Ruhezustand (ganz ohne Scrollen) um den top-Offset, sobald die statische Ausgangsposition die Constraint verletzt -- exakt der beobachtete 49px-Versatz. Zusaetzlich klebte die einzige Versions-Spalte am rechten Tabellenrand mit grosser Leerflaeche zur PDF-Spalte: `table.cmp { width:100% }` stretcht bei wenigen Spalten die Restbreite in die einzige Datenspalte. Fix: - `.table-wrap.pm-scroll table.cmp thead th` setzt position/box-shadow/ border-bottom explizit zurueck (Spezifitaet 0,3,3 schlaegt 0,1,3). - `table.cmp.pm-matrix { width: auto }` laesst die Matrix auf Inhaltsbreite schrumpfen -- Datenspalten ruecken links neben die PDF-Spalte, bei vielen Versionen bleibt der bestehende Scroll-Mechanismus (.pm-scroll) unveraendert wirksam. - Minor: Insight-Text zeigte "1 Versionen" statt "1 Version"; Singular/Plural-Fix gleich fuer "PDF-Quelle(n)" mitgezogen (identische Zeile/Technik). Nachweis: HTML-Anker-Tests (_build_live_html-Muster) fuer CSS-Reset, width:auto und die Singular/Plural-Ternaries; Playwright-Repro vor/nach Fix (Screenshot + computed styles: thPosition sticky->static, thRect/ firstCellRect-Ueberlappung aufgeloest, tableWidth 1162->336px) auf isoliertem Testserver mit read-only kopierten Live-Daten (eval_version 4.3). --- .../test_dashboard_version_pdf_matrix.py | 52 +++++++++++++++++++ internal/dashboard/eval_dashboard.html | 37 ++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/generative/tests/test_dashboard_version_pdf_matrix.py b/generative/tests/test_dashboard_version_pdf_matrix.py index 8b75762..76c480c 100644 --- a/generative/tests/test_dashboard_version_pdf_matrix.py +++ b/generative/tests/test_dashboard_version_pdf_matrix.py @@ -723,3 +723,55 @@ def test_html_pair_matrix_table_has_dedicated_scroll_wrapper(): # CSS: Regel mit hoeherer Spezifitaet als die >=1200px-Freigabe # (.table-wrap.pm-scroll schlaegt .table-wrap in der Media-Query) assert ".table-wrap.pm-scroll" in html + + +# ── Punkt 0 (Till-Live-Befund 2026-07-16): Matrix-Rendering kaputt ───────── +# Repro auf dem isolierten Testserver (Live-Daten read-only kopiert, eval_ +# version 4.3 = 6 PDFs x 1 Version v0.3.144): #pm-thead rendert leer, der +# Versions-Header "v0.3.144" erscheint stattdessen ~49px tiefer, ueberlappt +# Zeile 1 (Bates) -- Screenshot C:/tmp/buendel-verify/00-repro-vor-fix-4.3.png, +# Computed-Style-Diagnose bestaetigt position:sticky/top:49px auf dem +# th UND vmax-Rect-Ueberlappung mit der ersten tbody-Zeile. Ursache: die +# >=1201px-Sticky-Regel `table.cmp thead th` (#203 P3) trifft ueber die +# gemeinsame .cmp-Klasse auch table.pm-matrix -- der Kommentar dort +# ("entfaellt fuer die Matrix ohnehin") war falsch, da sticky bereits ohne +# jeden Scroll den Ausgangszustand um den top-Offset verschiebt. + + +def test_html_pair_matrix_resets_sticky_thead_inside_scroll_wrapper(): + """Fix: `.table-wrap.pm-scroll table.cmp thead th` (Spezifitaet 0,3,3) + setzt position/box-shadow/border-bottom explizit zurueck -- schlaegt die + Media-Query-Regel `table.cmp thead th` (Spezifitaet 0,1,3) unabhaengig + von der Regel-Reihenfolge im Stylesheet.""" + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + css_start = html.index("") + css = html[css_start:css_end] + reset_start = css.index(".table-wrap.pm-scroll table.cmp thead th") + reset_block = css[reset_start : reset_start + 200] + assert "position: static" in reset_block + assert "box-shadow: none" in reset_block + assert "border-bottom: 1px solid var(--hair)" in reset_block + + +def test_html_pair_matrix_table_does_not_force_full_width(): + """Fix: `table.cmp { width:100% }` (Bestandsregel) stretcht bei wenigen + Versionen die einzige Datenspalte auf die gesamte Restbreite (riesige + Leerflaeche, Versions-Header klebt am rechten Rand). `.pm-matrix` + schrumpft stattdessen auf Inhaltsbreite -- Datenspalten ruecken links + neben die PDF-Spalte.""" + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + assert "table.cmp.pm-matrix { width: auto; }" in html + + +def test_pairmatrix_insight_singular_version_and_pdf_source(): + """Minor-Fund: eval_version 4.3 (1 Pipeline-Version) zeigte '1 Versionen' + statt '1 Version'. Dieselbe Bugklasse fuer 'PDF-Quelle(n)' gleich mit + gefixt (identische Zeile/Technik).""" + block = _pairmatrix_js_block() + assert "versions.length === 1 ? 'Version' : 'Versionen'" in block + assert "pdfs.length === 1 ? 'PDF-Quelle' : 'PDF-Quellen'" in block diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index 3c747ba..cfecda9 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -521,6 +521,24 @@ entfaellt fuer die Matrix ohnehin (Scroll-Container waere sein Containing-Block, #203-P3-Begruendung). */ .table-wrap.pm-scroll { overflow-x: auto; overflow-y: hidden; } +/* Till-Live-Befund 2026-07-16 (Punkt 0): die >=1201px-Sticky-Regel oben + (`table.cmp thead th`, Zeile ~482) trifft über die gemeinsame .cmp-Klasse + AUCH die Matrix-Tabelle -- der Kommentar oben ("sticky-thead entfaellt fuer + die Matrix ohnehin") war ein Trugschluss: der Sticky-Offset (top:49px) + verschiebt den th-Kasten permanent 49px nach unten, auch OHNE dass je + gescrollt wird (die statische Ausgangsposition verletzt die top-Constraint + sofort, position:sticky greift dann unabhaengig vom Scroll-Zustand) -- + sichtbar als leeres thead + ein in Zeile 1 der Kopfzeile ueberlappender + Versions-Header (Repro: C:/tmp/buendel-verify/00-repro-vor-fix-4.3.png). + Fix: fuer Tabellen im pm-scroll-Wrapper Sticky-Positionierung + ihre + Rand-Ersatz-Regeln (border-bottom:0 + box-shadow statt Border) explizit + zuruecksetzen -- Spezifitaet (0,3,3) schlaegt die Media-Query-Regel (0,1,3) + unabhaengig von der Regel-Reihenfolge. */ +.table-wrap.pm-scroll table.cmp thead th { + position: static; + box-shadow: none; + border-bottom: 1px solid var(--hair); +} /* Erste Spalte (PDF-Namen) beim Horizontal-Scroll fixiert — opaker Card-Hintergrund, sonst schiebt sich der Zellinhalt beim Scrollen sichtbar darunter durch. */ @@ -536,6 +554,17 @@ .table-wrap.pm-scroll table.cmp tbody tr:hover td:first-child { background: var(--bg-card); } table.pm-matrix td, table.pm-matrix th { padding: 9px 12px; } table.pm-matrix td.pdf { white-space: nowrap; } +/* Till-Live-Befund 2026-07-16 (Punkt 0): `table.cmp { width:100% }` (Zeile + ~490) galt bislang auch fuer die Matrix -- bei wenigen Versionen (z. B. + 1 Spalte) stretcht der Browser die einzige Datenspalte auf fast die + gesamte Restbreite: riesige Leerflaeche zwischen PDF-Spalte und Werten, + der Versions-Header (text-align:right) klebt dadurch am rechten + Tabellenrand. `width:auto` laesst die Matrix auf ihre Inhaltsbreite + schrumpfen -- Datenspalten ruecken direkt neben die PDF-Spalte. Bei vielen + Versionen (16 Spalten, ~1500px) macht `width:auto` KEINEN Unterschied: die + Tabelle ist ohnehin breiter als der Wrapper und `.pm-scroll` scrollt sie + wie bisher (kein width:100%-Bedarf fuer den Scroll-Mechanismus). */ +table.cmp.pm-matrix { width: auto; } .pm-cell { text-align: center; min-width: 74px; line-height: 1.35; } .pm-cell .pm-cov { display: block; font-size: 11px; color: var(--ink-3); } .pm-cell .pm-n { display: block; font-size: 9.5px; color: var(--ink-4); margin-top: 1px; } @@ -1946,8 +1975,14 @@ const dropNote = (pm.n_versions_dropped || 0) > 0 ? ` · ${pm.n_versions_dropped} weitere Version(en) mit Eval-Daten nicht gezeigt (Top-15-Deckelung, min. n≥3 je Version — die neueste immer dabei).` : ''; + // Till-Live-Befund 2026-07-16 (Punkt 0, Minor): Singular/Plural -- bei + // eval_version 4.3 (1 Pipeline-Version) stand hier "1 Versionen". Dieselbe + // Bugklasse trifft "PDF-Quellen" bei einer gefilterten Einzel-PDF-Ansicht + // gleich mit -- selbe Zeile, selbe Technik, hier direkt mitgefixt. + const pdfWord = pdfs.length === 1 ? 'PDF-Quelle' : 'PDF-Quellen'; + const verWord = versions.length === 1 ? 'Version' : 'Versionen'; _setInsight('ins-pairmatrix', - `${pdfs.length} PDF-Quellen × ${versions.length} Versionen${dropNote ? '' : '.'}${dropNote} Zellwert = Median über deduplizierte Notes ` + + `${pdfs.length} ${pdfWord} × ${versions.length} ${verWord}${dropNote ? '' : '.'}${dropNote} Zellwert = Median über deduplizierte Notes ` + `(neueste Zeile je Note, keine Pseudoreplikation) — Fehlerquote oben, Belegrate darunter, n je Zelle. ` + `Zellen mit n<3 sind gedimmt, aber nicht versteckt — Tooltip zeigt Min–Max-Spanne.`); From 2ad8a1877a6a10c65a1b605a1b7d131de65e1b70 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Thu, 16 Jul 2026 11:06:33 +0200 Subject: [PATCH 02/16] feat(dashboard): eval_version-Dropdown mit Zeilenzahl je Version Till-Wunsch + Zusatzbefund: Das Dashboard defaultete unveraenderlich auf die neueste eval_version (4.3, 27 Baseline-Zeilen). Der URL-Param ?eval_version=4.1 hatte im Browser KEINE Wirkung -- der Client-Fetch von /data.json reichte ihn nie durch (Bestands-Kommentar: "Eval-Version- Filter entfernt: nie aus URL/State setzen"), obwohl der Server den Query- Param bereits korrekt verarbeitete. Verifiziert: Seite zeigte unter beiden URLs identisch 4.3. Die Client-Funktionen fuer ein Eval-Version-Dropdown (onEvalVerChange, _initEvalVerDropdown, _getEvalVerFromUrl/_setEvalVerInUrl) existierten bereits verwaist im Code (Markup + Fetch-Wiring waren entfernt) -- wieder angeschlossen statt neu gebaut: - Filterbar: neues Dropdown "Eval-Version" (Optionen aus available_eval_versions, Anzeige "4.3 (n=27)"), Warn-Badge bei >1 Version. - Server: available_eval_versions traegt jetzt {version, n} statt nur den Versions-String -- n = Zeilenzahl je Version, ungefiltert (dieselbe "Optionen vor allen Filtern"-Konvention wie all_pdfs_opts). - loadAndRender() haengt eval_version an JEDEN /data.json-Fetch (inkl. 15s-Auto-Refresh, da derselbe Codepfad) -- /live.json bewusst NICHT angefasst: separate, eval_version-fremde Live-Run-Progress-Seite ohne eval_version-Konzept im Payload. - Sichtpruefungs-Fund (Klick-Pfad 4.3->4.1->zurueck): sf-ev-pill blieb nach manuellem Wechsel auf dem alten Wert stehen -- der Post-Fetch- Early-Return in _initEvalVerDropdown griff faelschlich, weil onEvalVerChange() _currentEvalVersion schon VOR dem Fetch setzt. Rebuild jetzt nur noch an die Optionsmenge gekoppelt, pill/warn immer aktualisiert. - Sidebar: persistenter eval-Pill (#sf-ev-pill) neben #sf-time, konsistent mit #pm-evalver in der Matrix-Sektion (beide aus d.eval_version derselben Antwort). Nachweis: Server-Tests (Zeilenzahl je Version, filterunabhaengig, Query- Param-Aufloesung), HTML-Anker-Tests (_build_live_html-Muster) fuer Markup/Fetch/Boot-Seed/Dropdown-Wiring, Node-Ausfuehrungstest fuer den Pill-Guard-Fund. Playwright-Klick-Pfad auf isoliertem Testserver (Live- Daten read-only): Dropdown zeigt "1.3 (n=87)/4.1 (n=515)/4.3 (n=27)", Wechsel 4.3->4.1->zurueck aktualisiert Kopfzeile+Matrix+Pill korrekt, Deep-Link ?eval_version=4.1 greift, Auswahl bleibt nach 17s (>1 Auto- Refresh-Zyklus) stabil auf 4.1, 0 pageerrors. --- generative/eval_dashboard_server.py | 12 +- .../test_dashboard_eval_version_dropdown.py | 218 ++++++++++++++++++ internal/dashboard/eval_dashboard.html | 53 ++++- 3 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 generative/tests/test_dashboard_eval_version_dropdown.py diff --git a/generative/eval_dashboard_server.py b/generative/eval_dashboard_server.py index 4cecb89..fc8293d 100644 --- a/generative/eval_dashboard_server.py +++ b/generative/eval_dashboard_server.py @@ -408,6 +408,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: @@ -912,7 +922,7 @@ def _pooled_accept(ver: str) -> float | None: return { "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "eval_version": eval_version, - "available_eval_versions": available_versions, + "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)), diff --git a/generative/tests/test_dashboard_eval_version_dropdown.py b/generative/tests/test_dashboard_eval_version_dropdown.py new file mode 100644 index 0000000..6015301 --- /dev/null +++ b/generative/tests/test_dashboard_eval_version_dropdown.py @@ -0,0 +1,218 @@ +"""Tests für Punkt 1 (Till-Wunsch + Zusatzbefund 2026-07-16): eval_version-Dropdown. + +Befund: Das Dashboard defaultete unveränderlich auf die neueste eval_version +(4.3). Der URL-Param `?eval_version=4.1` wirkte im Browser NICHT — der +Client-Fetch von `/data.json` reichte ihn nie durch (Kommentar im Bestand: +"Eval-Version-Filter entfernt: nie aus URL/State setzen"). Fix: Dropdown in +der Filterbar (Optionen aus `available_eval_versions`, inkl. Zeilenzahl je +Version für die Anzeige "4.3 (n=27)"), das den `eval_version`-Param in JEDEN +`/data.json`-Fetch übernimmt (inkl. 15s-Auto-Refresh). + +`internal/dashboard/eval_dashboard.html` enthält ein bewusstes NUL-Byte — +Zugriff ausschließlich über `_build_live_html()`/`Path.read_text(encoding= +"utf-8")`, nie über bash grep/sed. +""" + +from __future__ import annotations + +import pytest + + +def _eval(note, ver, pdf, hall, ts, eval_version="4.1", total=10, hallucinated=0, cov=0.5): + return { + "run_id": f"r-{note}", + "note_path": note, + "pipeline_version": ver, + "version": ver, + "hallucination_rate": hall, + "anchors_total": total, + "anchors_hallucinated": hallucinated, + "coverage_factual": cov, + "pdf": pdf, + "eval_version": eval_version, + "timestamp": ts, + } + + +def _patched_build_data(monkeypatch, evals, current_version="v0.3.144", **kwargs): + from generative import config as _cfg + from generative import db as _gdb + from generative import eval_dashboard as D + + monkeypatch.setattr(_cfg, "AGENT_VERSION", current_version) + monkeypatch.setattr(_gdb, "query_pipeline_runs", lambda *a, **k: []) + monkeypatch.setattr(_gdb, "query_note_evals", lambda *a, **k: evals) + monkeypatch.setattr(_gdb, "query_archived_pipeline_versions", lambda *a, **k: []) + monkeypatch.setattr(D, "_read_all_log_runs", lambda: []) + monkeypatch.setattr(D, "_read_token_runs", lambda: []) + + from generative import eval_dashboard_server as S + + return S.build_data(**kwargs) + + +# ── Server: available_eval_versions traegt Zeilenzahl je Version ────────── + + +def test_available_eval_versions_carries_row_count_per_version(monkeypatch): + evals = [ + _eval(f"n{i}", "v0.3.144", "a.pdf", 0.1, f"2026-01-01T00:00:{i:02d}", eval_version="4.3") for i in range(27) + ] + evals += [ + _eval(f"m{i}", "v0.3.100", "a.pdf", 0.1, f"2026-01-01T00:00:{i:02d}", eval_version="4.1") for i in range(5) + ] + data = _patched_build_data(monkeypatch, evals) + versions = {o["version"]: o["n"] for o in data["available_eval_versions"]} + assert versions == {"4.1": 5, "4.3": 27} + + +def test_available_eval_versions_count_ignores_active_filters(monkeypatch): + """Dropdown-Optionen zeigen die UNGEFILTERTE Zeilenzahl -- dieselbe + Konvention wie all_pdfs_opts/all_pvers_opts (Server-Kommentar: 'Dropdown- + Optionen VOR allen Filtern snapshotten').""" + evals = [ + _eval(f"a{i}", "v0.3.144", "a.pdf", 0.1, f"2026-01-01T00:00:{i:02d}", eval_version="4.3") for i in range(3) + ] + evals += [ + _eval(f"b{i}", "v0.3.144", "b.pdf", 0.1, f"2026-01-02T00:00:{i:02d}", eval_version="4.3") for i in range(4) + ] + data = _patched_build_data(monkeypatch, evals, pdf="a") + versions = {o["version"]: o["n"] for o in data["available_eval_versions"]} + assert versions["4.3"] == 7 # nicht nur die 3 gefilterten a.pdf-Zeilen + + +def test_eval_version_query_param_selects_requested_version(monkeypatch): + evals = [_eval("new", "v0.3.144", "a.pdf", 0.1, "2026-02-01T00:00:00", eval_version="4.3")] + evals += [_eval("old", "v0.3.100", "a.pdf", 0.2, "2026-01-01T00:00:00", eval_version="4.1")] + data = _patched_build_data(monkeypatch, evals, eval_version="4.1") + assert data["eval_version"] == "4.1" + assert data["pair_matrix"]["eval_version"] == "4.1" + + +# ── Frontend-Anker (_build_live_html-Muster, kein bash grep/sed auf dem NUL-Byte) ── + + +def test_html_filterbar_has_eval_version_select(): + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + filterbar = html[html.index('
') : html.index("
", html.index('id="global-model"'))] + section = html[html.index('
') : html.index('id="filter-badges"')] + assert 'id="eval-ver-select"' in section + assert 'onchange="onEvalVerChange()"' in section + assert filterbar # Filterbar-Ausschnitt nicht leer (Sanity) + + +def test_html_load_and_render_passes_current_eval_version_to_fetch(): + """Bug-Kern: der Fetch reichte eval_version NIE durch (Bestands-Kommentar + "nie aus URL/State setzen"). Fix muss _currentEvalVersion in die + URLSearchParams jedes /data.json-Fetches setzen -- gilt auch fuer den + 15s-Poll, da loadAndRender(false) denselben Codepfad nutzt.""" + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + start = html.index("async function loadAndRender") + end = html.index("\n}", html.index("fetch(url)", start)) + block = html[start:end] + assert "p.set('eval_version', _currentEvalVersion)" in block + + +def test_html_eval_ver_seeded_from_url_on_boot(): + """_getEvalVerFromUrl() war definiert, aber nirgends aufgerufen (totes + Deep-Link-Handling). Fix: _currentEvalVersion wird direkt bei der + Deklaration aus der URL geseedet.""" + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + assert "let _currentEvalVersion = _getEvalVerFromUrl();" in html + + +def test_html_render_with_data_wires_eval_ver_dropdown_from_payload(): + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + start = html.index("function _renderWithData") + end = html.index("if (d.all_pvers", start) + block = html[start:end] + assert "_initEvalVerDropdown(d.available_eval_versions, d.eval_version)" in block + + +def test_html_side_foot_shows_persistent_eval_version_pill(): + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + side_foot = html[html.index('class="side-foot"') : html.index("")] + assert 'id="sf-ev-pill"' in side_foot + + +# ── Sichtpruefungs-Fund 2026-07-16: sf-ev-pill blieb nach manuellem ──────── +# Dropdown-Wechsel auf dem alten Wert stehen (Klick-Pfad 4.3 -> 4.1 zeigte +# "eval 4.3" statt "eval 4.1"). Ursache: onEvalVerChange() setzt +# _currentEvalVersion schon VOR dem Fetch auf den neuen Wert -- der +# Post-Fetch-Early-Return in _initEvalVerDropdown ("nichts geaendert, wenn +# Optionsmenge+_currentEvalVersion===selected schon passen") griff dadurch +# faelschlich auch beim ERSTEN Render nach einem echten Wechsel. Test fuehrt +# die echte JS-Funktion in Node aus (kein Nachbau der Logik), simuliert genau +# diese Abfolge: Aufruf 1 (Erstladung 4.3) -> Aufruf 2 mit selected="4.1" +# (Wechsel, _currentEvalVersion vorab auf "4.1" gesetzt wie onEvalVerChange +# es tut) -- die Pill MUSS "4.1" zeigen, nicht "4.3" (stale). + + +def _extract_js_function(text: str, name: str) -> str: + start = text.index(f"function {name}(") + brace_start = text.index("{", start) + depth = 0 + i = brace_start + while True: + c = text[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + break + i += 1 + return text[start : i + 1] + + +def test_eval_ver_pill_updates_after_manual_switch_with_unchanged_option_set(): + import json + import shutil + import subprocess + + from generative.eval_dashboard_server import _build_live_html + + node = shutil.which("node") + if node is None: + pytest.skip("node nicht verfügbar") + + html = _build_live_html() + fn = _extract_js_function(html, "_initEvalVerDropdown") + + available = [{"version": "4.1", "n": 515}, {"version": "4.3", "n": 27}] + script = f""" + // Minimal-DOM-Stub: nur die von _initEvalVerDropdown angefassten Elemente. + function makeEl() {{ return {{ dataset: {{}}, innerHTML: '', value: '', style: {{}}, textContent: '' }}; }} + const els = {{ + 'eval-ver-select': makeEl(), + 'eval-ver-warn': makeEl(), + 'sf-ev-pill': makeEl(), + }}; + global.document = {{ getElementById: (id) => els[id] || null }}; + let _currentEvalVersion = null; + {fn} + + // Aufruf 1: Erstladung, Server liefert Default 4.3. + _initEvalVerDropdown({json.dumps(available)}, '4.3'); + // Aufruf 2: onEvalVerChange() haette VORHER _currentEvalVersion='4.1' gesetzt + // (Dropdown-Wert vom Nutzer geaendert) -- Optionsmenge bleibt UNVERAENDERT. + _currentEvalVersion = '4.1'; + _initEvalVerDropdown({json.dumps(available)}, '4.1'); + + process.stdout.write(JSON.stringify({{ pill: els['sf-ev-pill'].textContent, selectValue: els['eval-ver-select'].value }})); + """ + result = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30) + assert result.returncode == 0, f"node stderr: {result.stderr}" + out = json.loads(result.stdout) + assert out["pill"] == "4.1", f"Pill blieb stale: {out}" + assert out["selectValue"] == "4.1" diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index cfecda9..b897394 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -769,6 +769,10 @@
+ +
eval
auto-refresh 15 s
- + +
+ Eval-Version + + weitere Versionen verfügbar +
lädt…
@@ -1387,7 +1399,13 @@ } /* ── Eval-Version ─────────────────────────────────────────────── */ -let _currentEvalVersion = null; +// Punkt 1 (Till-Wunsch + Zusatzbefund 2026-07-16): das Dropdown war entfernt +// (Kommentare "entfernt" an den frueheren Markup-/Fetch-Stellen), die Client- +// Funktionen hier blieben aber verwaist stehen -- _getEvalVerFromUrl() wurde +// nirgends aufgerufen, ?eval_version=4.1 in der URL hatte dadurch NIE eine +// Wirkung (Seite zeigte unter beiden URLs dieselbe eval_version). Fix: Seed +// aus der URL beim Boot, Dropdown-Markup + Fetch-Param unten wieder verdrahtet. +let _currentEvalVersion = _getEvalVerFromUrl(); function _getEvalVerFromUrl() { return new URLSearchParams(window.location.search).get('eval_version'); } function _setEvalVerInUrl(ver) { const u = new URL(window.location.href); u.searchParams.set('eval_version', ver); window.history.replaceState(null,'',u.toString()); } function onEvalVerChange() { @@ -1397,11 +1415,24 @@ loadAndRender(); } function _initEvalVerDropdown(available, selected) { + // available: [{version, n}] (Server, Punkt 1) -- n = Zeilenzahl je Version + // fuer die Dropdown-Anzeige "4.3 (n=27)". const sel = document.getElementById('eval-ver-select'); if (!sel) return; - const key = available.join(','); - if (sel.dataset.loaded === key && _currentEvalVersion === selected) return; - sel.dataset.loaded = key; - sel.innerHTML = available.map(v=>``).join(''); + const key = available.map(o=>o.version).join(','); + // Klick-Pfad-Fund (Sichtpruefung 2026-07-16): der fruehere Early-Return + // ("nichts geaendert, wenn Optionsmenge+Auswahl schon passen") skippte nach + // einem manuellen Dropdown-Wechsel FAELSCHLICH auch pill/warn-Update -- + // onEvalVerChange() setzt _currentEvalVersion naemlich schon VOR dem Fetch + // auf den neuen Wert, wodurch die Bedingung beim Post-Fetch-Aufruf hier + // bereits erfuellt war (sf-ev-pill blieb auf dem alten Wert stehen). + // Rebuild (teuer, zerstoert natives Select-Fokus/Open-Fenster) nur bei + // geaenderter Optionsmenge; Auswahl-Sync + pill/warn IMMER aktualisieren. + if (sel.dataset.loaded !== key) { + sel.dataset.loaded = key; + sel.innerHTML = available.map(o=>``).join(''); + } else if (sel.value !== selected) { + sel.value = selected; + } _currentEvalVersion = selected; const warn = document.getElementById('eval-ver-warn'); // Klassen-Default ist display:none — '' fiele darauf zurück, explizit setzen @@ -2717,7 +2748,10 @@ function _renderWithData(d) { if(!d) return; window._lastData=d; const t = C(); - // Eval-Version-Dropdown entfernt — Server nutzt default (neueste eval_version) + // Punkt 1: eval_version-Dropdown -- synct _currentEvalVersion mit dem vom + // Server AUFGELOESTEN Wert (z. B. wenn die URL keine/eine ungueltige + // Version trug und der Server auf die neueste gefallen ist). + if (d.available_eval_versions?.length) _initEvalVerDropdown(d.available_eval_versions, d.eval_version); // Dropdowns: immer mit ALLEN Optionen befüllen (vor globalem Filter) if (d.all_pvers?.length) _initGlobalPverFilter(d.all_pvers); if (d.all_pdfs?.length) _initGlobalPdfFilter(d.all_pdfs); @@ -2816,8 +2850,11 @@ // hat (Nachbesserung #204/#221 P-a). if (manual && window._lastData) document.querySelector('.main')?.classList.add('refreshing'); try { - // Eval-Version-Filter entfernt: nie aus URL/State setzen → Server nutzt default (neueste) const p = new URLSearchParams(); + // Punkt 1: eval_version durchreichen -- gilt auch fuer den 15s-Auto- + // Refresh (derselbe Code-Pfad, manual=false), damit ein aktiver Auswahl + // die Version beim Poll nicht zurueck auf den Server-Default kippt. + if (_currentEvalVersion) p.set('eval_version', _currentEvalVersion); if (_globalFilters.lang && _globalFilters.lang !== '__all__') p.set('language', _globalFilters.lang); if (_globalFilters.model && _globalFilters.model !== '__all__') p.set('model', _globalFilters.model); if (_globalFilters.pdf && _globalFilters.pdf !== '__all__') p.set('pdf', _globalFilters.pdf); From c775d6045a7ff24b7b19c5f364e41eb2fd3b2bb3 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Thu, 16 Jul 2026 11:12:39 +0200 Subject: [PATCH 03/16] fix(dashboard): coverage_factual-or-Anti-Pattern an Bestandsstellen gefixt D4-Bestand (Reviews 15.07.): der #305-Helper `_row_coverage()` fixte das `coverage_factual or coverage_rate`-Anti-Pattern (verschluckt eine ECHTE 0.0 als falsy, 0%-Coverage-Zeilen existieren real, Jockisch-Faelle) bisher nur fuer die neuen Matrix-/Paarvergleichs-Funktionen -- der Docstring markierte die Bestands-Stellen explizit als separates Ticket. 7 Bestandsstellen mit demselben Muster auf _row_coverage umgestellt: - eval_dashboard.py: _calc_kpis (avg_cov-KPI-Kachel), _calc_pdf_table (cov je PDF-Zeile), _chart_scatter + _build_quality_chart_data (Legacy-main()-Standalone-Pfad). - eval_dashboard_server.py: _read_calibration_data (llm_cov), quality_by_version-Aggregation (kpi_trend.cov-Sparkline), _chart_scatter_versioned (aktiver Scatter-Chart). _read_calibration_data arbeitet auf sqlite3.Row (kein `.get()`) -- _row_coverage dafuer Mapping-kompatibel gemacht (`in r.keys()` statt `.get()`, funktioniert fuer dict UND Row identisch) statt die Fallback- Logik an dieser einen Stelle separat zu duplizieren. Nicht angefasst (Suche + Pruefung, nicht blind ersetzt): die "Routing-only"-Zeilen in _calc_pdf_table setzen `cov: None` hartkodiert (keine Eval-Daten vorhanden, kein Anti-Pattern moeglich). Nachweis (TDD): 8 RED-Tests je Bestandsstelle (assert 90.0 == 0.0, zeigt das Verschlucken) + 2 fuer die sqlite3.Row-Kompatibilitaet von _row_coverage selbst, alle 10 vor dem Fix rot, danach gruen. Sweep generative/tests -k "dashboard or coverage or calib": 310 passed, keine Regression. --- generative/eval_dashboard.py | 28 ++- generative/eval_dashboard_server.py | 15 +- ...dashboard_coverage_factual_zero_bestand.py | 223 ++++++++++++++++++ 3 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 generative/tests/test_dashboard_coverage_factual_zero_bestand.py diff --git a/generative/eval_dashboard.py b/generative/eval_dashboard.py index 10b2dd0..224cbab 100644 --- a/generative/eval_dashboard.py +++ b/generative/eval_dashboard.py @@ -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) @@ -971,7 +969,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) @@ -1043,7 +1041,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 "?" @@ -1223,7 +1221,7 @@ 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`. @@ -1231,10 +1229,18 @@ def _row_coverage(r: dict): `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: @@ -1609,7 +1615,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( diff --git a/generative/eval_dashboard_server.py b/generative/eval_dashboard_server.py index fc8293d..e19e5d5 100644 --- a/generative/eval_dashboard_server.py +++ b/generative/eval_dashboard_server.py @@ -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( @@ -767,7 +768,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 @@ -1002,7 +1003,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 diff --git a/generative/tests/test_dashboard_coverage_factual_zero_bestand.py b/generative/tests/test_dashboard_coverage_factual_zero_bestand.py new file mode 100644 index 0000000..18f9a03 --- /dev/null +++ b/generative/tests/test_dashboard_coverage_factual_zero_bestand.py @@ -0,0 +1,223 @@ +"""D4-Bestand (Multi-Perspektiven-Dashboard-Review 2026-07-15 + Punkt 5 des +Matrix-Rendering-Fix+Politur-Bündels): `_row_coverage()` (#305-Helper, s. +`_matrix_cell_stats`) fixt das `coverage_factual or coverage_rate`-Anti- +Pattern (verschluckt eine ECHTE 0.0-Coverage als falsy) NUR für die neuen +Matrix-/Paarvergleichs-Funktionen — der `_row_coverage`-Docstring selbst +markiert die Bestands-Stellen mit demselben Muster explizit als "fixt ein +separates Ticket". Dieses Ticket: + + eval_dashboard.py: _calc_kpis (avg_cov), _calc_pdf_table (cov), + _chart_scatter (Legacy-main()-Pfad), + _build_quality_chart_data (Legacy-main()-Pfad) + eval_dashboard_server.py: _read_calibration_data (llm_cov, sqlite3.Row!), + quality_by_version-Aggregation (d2["cov"]), + _chart_scatter_versioned (aktiver Scatter) + +Reale 0%-Coverage-Zeilen existieren (Jockisch-Fälle, s. bestehende Matrix- +Tests) — der Bug zeigt an all diesen Stellen faelschlich `coverage_rate` +(oder verwirft die Zeile) statt der echten 0.0. + +Jeder Test unten faellt VOR dem Fix (Stelle nutzt `... or ...`) und besteht +NACH dem Fix (Stelle nutzt `_row_coverage`/dieselbe None-nur-Fallback-Logik). +""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from generative import db +from generative.eval_dashboard import ( + _build_quality_chart_data, + _calc_kpis, + _calc_pdf_table, + _chart_scatter, + _row_coverage, +) +from generative.eval_dashboard_server import _read_calibration_data + + +# ── _row_coverage: muss jetzt auch sqlite3.Row (nicht nur dict) vertragen ── + + +def test_row_coverage_accepts_plain_dict_zero_not_swallowed(): + assert _row_coverage({"coverage_factual": 0.0, "coverage_rate": 0.8}) == 0.0 + + +def test_row_coverage_accepts_sqlite_row_zero_not_swallowed(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute("CREATE TABLE t (coverage_factual REAL, coverage_rate REAL)") + conn.execute("INSERT INTO t VALUES (0.0, 0.8)") + row = conn.execute("SELECT * FROM t").fetchone() + assert _row_coverage(row) == 0.0 + conn.close() + + +def test_row_coverage_sqlite_row_none_falls_back_to_coverage_rate(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute("CREATE TABLE t (coverage_factual REAL, coverage_rate REAL)") + conn.execute("INSERT INTO t VALUES (NULL, 0.8)") + row = conn.execute("SELECT * FROM t").fetchone() + assert _row_coverage(row) == 0.8 + conn.close() + + +# ── eval_dashboard.py: _calc_kpis (avg_cov) ──────────────────────────────── + + +def test_calc_kpis_avg_cov_zero_coverage_factual_not_swallowed(): + rows = [ + {"version": "v1", "hallucination_rate": 0.1, "coverage_factual": 0.0, "coverage_rate": 0.9}, + {"version": "v1", "hallucination_rate": 0.1, "coverage_factual": 0.0, "coverage_rate": 0.9}, + ] + kpis = _calc_kpis({}, [], rows, [], current_version="v1") + assert kpis["avg_cov"] == 0.0 # nicht 90.0 + + +# ── eval_dashboard.py: _calc_pdf_table (cov je PDF-Zeile) ────────────────── + + +def test_calc_pdf_table_cov_zero_coverage_factual_not_swallowed(): + rows = [ + { + "pdf": "a.pdf", + "note_path": "n1", + "version": "v1", + "hallucination_rate": 0.1, + "coverage_factual": 0.0, + "coverage_rate": 0.9, + "timestamp": "2026-01-01T00:00:00", + } + ] + table = _calc_pdf_table({}, [], rows) + assert len(table) == 1 + assert table[0]["cov"] == 0.0 # nicht 90.0 + + +# ── eval_dashboard.py: _chart_scatter (Legacy-main()-Pfad) ───────────────── + + +def test_chart_scatter_zero_coverage_factual_not_swallowed(): + rows = [ + { + "hallucination_rate": 0.1, + "coverage_factual": 0.0, + "coverage_rate": 0.9, + "note": "n1", + "pdf": "a.pdf", + } + ] + chart = _chart_scatter(rows) + assert len(chart["points"]) == 1 + assert chart["points"][0]["y"] == 0.0 # nicht 90.0 + + +# ── eval_dashboard.py: _build_quality_chart_data (Legacy-main()-Pfad) ────── + + +def test_build_quality_chart_data_cov_field_zero_not_swallowed(): + rows = [ + { + "hallucination_rate": 0.1, + "coverage_factual": 0.0, + "coverage_rate": 0.9, + "note": "n1", + "pdf": "a.pdf", + "version": "v1", + } + ] + out = _build_quality_chart_data(rows) + # _build_quality_chart_data gibt ein dict mit "rows" (Liste der clean rows) zurueck. + clean = out["rows"] + assert len(clean) == 1 + assert clean[0]["cov"] == 0.0 # nicht 90.0 + + +# ── eval_dashboard_server.py: _read_calibration_data (llm_cov, sqlite3.Row) ─ + + +def _seed_db(path): + db.init_db(path) + conn = sqlite3.connect(path) + conn.execute( + "INSERT INTO note_evals (run_id, note_path, hallucination_rate, " + "coverage_factual, coverage_rate, pipeline_version, pdf, " + "eval_version, timestamp) VALUES (?,?,?,?,?,?,?,?,?)", + ("run-1", "vault__n1.md", 0.1, 0.0, 0.9, "v0.3.135", "Bates.pdf", "4.1", "2026-06-01"), + ) + conn.commit() + conn.close() + + +@pytest.fixture() +def calib_db(tmp_path, monkeypatch): + path = tmp_path / "test.db" + _seed_db(path) + monkeypatch.setattr(db, "DB_PATH", path) + return path + + +def test_read_calibration_data_llm_cov_zero_coverage_factual_not_swallowed(calib_db): + rows = _read_calibration_data()["rows"] + assert len(rows) == 1 + assert rows[0]["llm_cov"] == 0.0 # nicht 90.0 + + +# ── eval_dashboard_server.py: quality_by_version-Aggregation (kpi_trend.cov) ─ + + +def _dbrow(note, ver, pdf, hall, ts, eval_version="4.1", cov_factual=0.0, cov_rate=0.9, run_id=None): + return { + "run_id": run_id or f"r-{note}", + "note_path": note, + "pipeline_version": ver, + "version": ver, + "hallucination_rate": hall, + "anchors_total": 10, + "anchors_hallucinated": 0, + "coverage_factual": cov_factual, + "coverage_rate": cov_rate, + "pdf": pdf, + "eval_version": eval_version, + "timestamp": ts, + } + + +def _patched_build_data(monkeypatch, evals, current_version="v0.3.144", **kwargs): + 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_version) + monkeypatch.setattr(_gdb, "query_pipeline_runs", lambda *a, **k: []) + monkeypatch.setattr(_gdb, "query_note_evals", lambda *a, **k: evals) + monkeypatch.setattr(_gdb, "query_archived_pipeline_versions", lambda *a, **k: []) + monkeypatch.setattr(D, "_read_all_log_runs", lambda: []) + monkeypatch.setattr(D, "_read_token_runs", lambda: []) + return S.build_data(**kwargs) + + +def test_quality_by_version_avg_cov_zero_coverage_factual_not_swallowed(monkeypatch): + evals = [ + _dbrow(f"n{i}", "v0.3.144", "a.pdf", 0.1, f"2026-01-01T00:00:{i:02d}", cov_factual=0.0, cov_rate=0.9) + for i in range(3) + ] + data = _patched_build_data(monkeypatch, evals) + qbv = data["quality_by_version"]["v0.3.144"] + assert qbv["avg_cov"] == 0.0 # nicht 90.0 + assert qbv["median_cov"] == 0.0 + + +# ── eval_dashboard_server.py: _chart_scatter_versioned (aktiver Scatter) ─── + + +def test_chart_scatter_versioned_zero_coverage_factual_not_swallowed(monkeypatch): + evals = [_dbrow("n1", "v0.3.144", "a.pdf", 0.1, "2026-01-01T00:00:00", cov_factual=0.0, cov_rate=0.9)] + data = _patched_build_data(monkeypatch, evals) + points = data["scatter"]["points"] + assert len(points) == 1 + assert points[0]["y"] == 0.0 # nicht 90.0 From 5e58a8e42182bac24d8bee7bd7f91370feea689e Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Thu, 16 Jul 2026 11:16:59 +0200 Subject: [PATCH 04/16] fix(dashboard): accept-Delta-Guard auf geroutete statt evaluierte Notes umgestellt D3 (Reviews 15.07.): version_delta() lief fuer ALLE KPI-Metriken (hall/cov/ n/accept/dur/tokens/cost) mit demselben kpi_trend["n"]-Array als n>=20- Reliability-Guard. Fuer hall/cov ist das korrekt (n = evaluierte Notes, das SIND Eval-Metriken). Fuer "accept" (Akzeptanzrate, gepoolt ueber ALLE gerouteten/generierten Notes, _pooled_accept) ist n der falsche, zu kleine Nenner -- nur eine Stichprobe wird LLM-evaluiert. Bug war "aktuell konservativ": kein falsches Delta, aber unnoetig graue/unreliable Chips, obwohl das Accept-Delta auf hunderten gerouteten Notes beruht. Fix: version_delta() bekommt optionalen n_field-Parameter (Default "n" -- rueckwaertskompatibel fuer hall/cov/dur/tokens/cost). Server uebergibt fuer "accept" n_field="accept_n" -- neues kpi_trend-Feld, Summe derselben (n_vault, n_total)-Paare wie _pooled_accept (SSoT, keine zweite Zaehlung). Nachweis (TDD): RED zeigte den Bug direkt (accept-Delta reliable=False bei n=8/9 evaluiert, obwohl 150/160 geroutete Notes vorlaegen) sowie den fehlenden n_field-Parameter/accept_n-Key. Nach Fix: 5/5 gruen, inkl. Rueckwaertskompatibilitaets-Test (hall-Delta ohne n_field unveraendert) und Server-Integrationstest (accept_n aus denselben Log-Runs wie _pooled_accept). Sweep generative/tests -k "dashboard or delta or accept": 325 passed, keine Regression. --- generative/eval_dashboard.py | 14 +- generative/eval_dashboard_server.py | 12 +- .../test_dashboard_accept_delta_n_guard.py | 137 ++++++++++++++++++ 3 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 generative/tests/test_dashboard_accept_delta_n_guard.py diff --git a/generative/eval_dashboard.py b/generative/eval_dashboard.py index 224cbab..7278a53 100644 --- a/generative/eval_dashboard.py +++ b/generative/eval_dashboard.py @@ -1107,12 +1107,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 @@ -1134,7 +1144,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 diff --git a/generative/eval_dashboard_server.py b/generative/eval_dashboard_server.py index e19e5d5..59ce693 100644 --- a/generative/eval_dashboard_server.py +++ b/generative/eval_dashboard_server.py @@ -837,6 +837,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 @@ -862,7 +868,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 ── diff --git a/generative/tests/test_dashboard_accept_delta_n_guard.py b/generative/tests/test_dashboard_accept_delta_n_guard.py new file mode 100644 index 0000000..c6667e9 --- /dev/null +++ b/generative/tests/test_dashboard_accept_delta_n_guard.py @@ -0,0 +1,137 @@ +"""Punkt 4 (D3, Reviews 15.07.): accept-Delta-Guard nutzt falsches n-Array. + +Befund: `version_delta()` wird fuer ALLE KPI-Metriken (hall/cov/n/accept/dur/ +tokens/cost) mit demselben `kpi_trend["n"]`-Array als Reliability-Guard +aufgerufen (kpi_trend["deltas"] = {m: D.version_delta(kpi_trend, m) for m in +(...)}). `kpi_trend["n"]` ist die Zahl EVALUIERTER Notes (distinct, per LLM- +Eval) -- fuer hall/cov ist das der richtige Nenner (das SIND Eval-Metriken). +Fuer "accept" (Akzeptanzrate = generierte -> in den Vault uebernommene +Notes, `_pooled_accept`, gepoolt aus ALLEN Log-Runs/Routing-Entscheidungen, +nicht nur den LLM-evaluierten) ist der Nenner falsch: die Zahl GEROUTETER +Notes (n_total aus den Pipeline-Runs) ist typischerweise um ein Vielfaches +groesser als die Zahl der LLM-evaluierten Notes (nur eine Stichprobe wird +evaluiert). Der Bug ist "aktuell konservativ" (zeigt reliable:false, obwohl +das Accept-Delta auf hunderten gerouteten Notes beruht) -- keine falschen +Deltas, aber unnoetig graue/unbelastbare Chips. + +Fix: `version_delta()` bekommt einen optionalen `n_field`-Parameter (Default +"n" -- rueckwaertskompatibel fuer hall/cov/dur/tokens/cost); der Server +uebergibt fuer "accept" `n_field="accept_n"` (neues kpi_trend-Feld: Summe der +n_total-Werte je Version aus denselben accept_pairs_by_ver, die auch +_pooled_accept speist -- SSoT, keine zweite Zaehlung).""" + +from __future__ import annotations + +from generative.eval_dashboard import version_delta + + +def _kpi_trend(**over): + base = { + "versions": ["v1", "v2"], + "accept": [70.0, 80.0], + "n": [8, 9], # nur 8/9 LLM-evaluierte Notes -- unter _DELTA_MIN_N=20 + "accept_n": [150, 160], # aber 150/160 GEROUTETE Notes -- weit ueber 20 + } + base.update(over) + return base + + +def test_accept_delta_uses_n_field_override_not_evaluated_n(): + """Kern-Regression: mit dem falschen ('n') Array waere reliable=False + (8/9 < 20). Mit dem richtigen ('accept_n') Array ist es reliable=True.""" + d = version_delta(_kpi_trend(), "accept", n_field="accept_n") + assert d["reliable"] is True + assert d["reason"] is None + + +def test_accept_delta_without_override_falls_back_to_n_and_is_conservative(): + """Ohne den Fix (Default n_field='n') bleibt das Delta unreliable -- + Beleg fuer den beschriebenen Bug/die Konservativitaet, kein Verhaltens- + wechsel am Default (Rueckwaertskompatibilitaet fuer hall/cov/etc.).""" + d = version_delta(_kpi_trend(), "accept") + assert d["reliable"] is False + assert d["reason"] == "n_lt_20" + + +def test_hall_delta_default_n_field_unchanged(): + """Regressions-Wächter: der Default fuer alle anderen Metriken (hall/cov/ + dur/tokens/cost) bleibt exakt das bisherige Verhalten -- kein n_field noetig.""" + trend = {"versions": ["v1", "v2"], "hall": [9.0, 8.0], "n": [25, 30]} + d = version_delta(trend, "hall") + assert d["reliable"] is True + + +def test_n_field_missing_key_behaves_like_missing_n_array(): + """Falls `accept_n` (aelterer Aufrufer/Test) fehlt: leeres Array, kein + Crash -- Guard verhaelt sich wie n=0 (nicht reliable), analog zum + bestehenden pdf_notes-Rueckwaertskompatibilitaets-Verhalten.""" + trend = {"versions": ["v1", "v2"], "accept": [70.0, 80.0]} + d = version_delta(trend, "accept", n_field="accept_n") + assert d["reliable"] is False + + +# ── Server-Integration: build_data() liefert kpi_trend["accept_n"] ───────── + + +def _eval(note, ver, pdf, hall, ts, eval_version="4.1"): + return { + "run_id": f"r-{note}", + "note_path": note, + "pipeline_version": ver, + "version": ver, + "hallucination_rate": hall, + "anchors_total": 10, + "anchors_hallucinated": 0, + "coverage_factual": 0.5, + "pdf": pdf, + "eval_version": eval_version, + "timestamp": ts, + } + + +def _log_run(ver, n_total, n_vault, key="a"): + return { + "key": key, + "label": key, + "ver": ver, + "n_total": n_total, + "n_vault": n_vault, + "n_merge": 0, + "n_inbox": n_total - n_vault, + "accept_pct": round(n_vault / n_total * 100, 1) if n_total else 0.0, + "words": 1000, + "pages": 5, + "chunks": 3, + } + + +def test_build_data_exposes_accept_n_as_routed_notes_sum(monkeypatch): + """accept_n MUSS aus denselben Log-Runs stammen wie _pooled_accept (SSoT) + -- hier: v1 hat 2 Runs mit zusammen 150 gerouteten Notes, v2 einen Run + mit 160.""" + 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 + + evals = [_eval(f"n{i}", "v1", "a.pdf", 0.1, f"2026-01-01T00:00:{i:02d}") for i in range(9)] + evals += [_eval(f"m{i}", "v2", "a.pdf", 0.1, f"2026-02-01T00:00:{i:02d}") for i in range(8)] + runs = [_log_run("v1", 100, 70), _log_run("v1", 50, 35), _log_run("v2", 160, 130)] + + monkeypatch.setattr(_cfg, "AGENT_VERSION", "v2") + monkeypatch.setattr(_gdb, "query_pipeline_runs", lambda *a, **k: []) + monkeypatch.setattr(_gdb, "query_note_evals", lambda *a, **k: evals) + monkeypatch.setattr(_gdb, "query_archived_pipeline_versions", lambda *a, **k: []) + monkeypatch.setattr(D, "_read_all_log_runs", lambda: runs) + monkeypatch.setattr(D, "_read_token_runs", lambda: []) + + data = S.build_data() + kt = data["kpi_trend"] + assert kt["versions"] == ["v1", "v2"] + assert kt["n"] == [9, 8] # evaluierte Notes -- unter 20, "konservativ" + assert kt["accept_n"] == [150, 160] # gerouteted Notes -- SSoT mit _pooled_accept + # Kern-Nachweis: das accept-Delta ist trotz n<20 (evaluiert) reliable, + # weil es auf accept_n (150/160 geroutet) gehartet ist. + vd = kt["deltas"]["accept"] + assert vd["reliable"] is True + assert vd["reason"] is None From ad64fb0fd6efe63e0cd6b68d1d1231f30dd297d1 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Thu, 16 Jul 2026 11:20:57 +0200 Subject: [PATCH 05/16] =?UTF-8?q?fix(dashboard):=20Dropped-KPI=20zeigt=20e?= =?UTF-8?q?hrlich=20"=E2=80=93"=20statt=20stiller=200=20im=20Log-Pfad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 tauchen im notes-Dict nie auf. `n_dropped` ist an dieser Quelle nicht zufaellig leer, sondern nicht ermittelbar. Die Kachel zeigte trotzdem eine stille "0" (`r.get("n_dropped", 0)`), ununterscheidbar von "wirklich 0 verworfen" -- die bestehende Tooltip-Erklaerung deckte das zwar schon auf, aber der angezeigte WERT selbst log weiter. Fix: `_calc_kpis` liefert `total_dropped=None`, wenn KEINE Zeile in all_log_runs den Key "n_dropped" traegt (Primaer-Pfad/leere Runs) -- die Summe (inkl. echter 0, wenn die DB-Spalte 0 ist) bleibt unveraendert, sobald mindestens eine Zeile den Key traegt (DB-Fallback-Pfad). Client zeigt "–" statt der Zahl, Tooltip-Text an das neue Verhalten angepasst. Bestehender Test `test_calc_kpis_total_dropped_zero_when_field_missing` kodierte selbst das Anti-Pattern (`assert ... == 0` bei fehlendem Key) -- auf die korrigierte Erwartung (`is None`) aktualisiert, nicht geloescht (Historie/Docstring-Verweis auf den D5-Fund ergaenzt). Nachweis (TDD): RED zeigte `0 is None`-Fehlschlaege an allen 3 betroffenen Stellen (kein Key, leere Runs, HTML-Anker fuer den JS-Ternary) vor dem Fix. Nach Fix 5/5 gruen + aktualisierter Bestandstest gruen. Sweep generative/tests -k dashboard: 312 passed, keine Regression. --- generative/eval_dashboard.py | 15 ++- .../test_dashboard_dropped_honest_unknown.py | 103 ++++++++++++++++++ generative/tests/test_dashboard_quick_wins.py | 9 +- internal/dashboard/eval_dashboard.html | 2 +- 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 generative/tests/test_dashboard_dropped_honest_unknown.py diff --git a/generative/eval_dashboard.py b/generative/eval_dashboard.py index 7278a53..7497f10 100644 --- a/generative/eval_dashboard.py +++ b/generative/eval_dashboard.py @@ -768,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, diff --git a/generative/tests/test_dashboard_dropped_honest_unknown.py b/generative/tests/test_dashboard_dropped_honest_unknown.py new file mode 100644 index 0000000..7f77961 --- /dev/null +++ b/generative/tests/test_dashboard_dropped_honest_unknown.py @@ -0,0 +1,103 @@ +"""Punkt 6 (D5, Reviews 15.07.): Dropped-Zaehlung existiert nur im DB-Fallback-Pfad. + +Befund: `_calc_kpis`s `total_dropped` summiert `r.get("n_dropped", 0)` ueber +`all_log_runs`. Die PRIMAERE Datenquelle (`_read_all_log_runs()`, Log-Dateien) +baut ihre Zeilen aus `_NOTE_RE` ("[DRY-RUN] -> (Vault|Inbox)...")-Treffern -- +verworfene Kandidaten (nie bis zum Draft gekommen) hinterlassen dort gar +keine Zeile und tauchen im `notes`-Dict nie auf. `n_dropped` ist an dieser +Quelle STRUKTURELL nicht ermittelbar, nicht nur zufaellig leer. Trotzdem +liefert `.get("n_dropped", 0)` in diesem Fall still `0` -- ununterscheidbar +von "wirklich 0 verworfen". Nur der DB-Fallback-Pfad +(`eval_dashboard_server.py`, `pipeline_runs`-Tabelle) setzt `n_dropped` +tatsaechlich (auch als echte 0, wenn die Spalte 0 ist). + +Fix: `_calc_kpis` liefert `total_dropped=None` (nicht 0), wenn KEINE Zeile in +`all_log_runs` den Key `n_dropped` traegt (Primaer-/Log-Pfad) -- der Client +zeigt dafuer ehrlich "–" mit Tooltip statt der stillen 0. Traegt mindestens +eine Zeile den Key (DB-Fallback-Pfad), bleibt die bisherige Summe (echte 0 +eingeschlossen) unveraendert.""" + +from __future__ import annotations + +from generative.eval_dashboard import _calc_kpis + + +def _log_run(**over): + """Primaer-Pfad-Zeile (_read_all_log_runs()) -- traegt NIE 'n_dropped'.""" + base = { + "key": "a", + "label": "A", + "ver": "v1", + "n_total": 10, + "n_vault": 5, + "n_merge": 2, + "n_inbox": 3, + "accept_pct": 50.0, + "words": 1000, + "pages": 5, + "chunks": 3, + } + base.update(over) + return base + + +def _db_fallback_run(n_dropped, **over): + """DB-Fallback-Pfad-Zeile (eval_dashboard_server.py) -- traegt IMMER 'n_dropped'.""" + base = { + "key": "a", + "label": "A", + "ver": "v1", + "n_total": 10, + "n_vault": 5, + "n_merge": 0, + "n_inbox": 5, + "n_dropped": n_dropped, + "n_words": 1000, + "words": 1000, + "pages": 0, + "accept_pct": 50.0, + } + base.update(over) + return base + + +def test_total_dropped_is_none_when_no_row_carries_the_key(): + """Primaer-/Log-Pfad: n_dropped strukturell nicht ermittelbar -> None, + nicht stille 0.""" + runs = [_log_run(), _log_run(ver="v2")] + kpis = _calc_kpis({}, runs, [], []) + assert kpis["total_dropped"] is None + + +def test_total_dropped_sums_real_zero_when_db_fallback_reports_zero(): + """DB-Fallback-Pfad mit echter 0 (Spalte gesetzt, aber 0 verworfen) -- + bleibt 0, nicht None (der Unterschied IST bekannt).""" + runs = [_db_fallback_run(0), _db_fallback_run(0)] + kpis = _calc_kpis({}, runs, [], []) + assert kpis["total_dropped"] == 0 + + +def test_total_dropped_sums_nonzero_db_fallback_values(): + runs = [_db_fallback_run(3), _db_fallback_run(7)] + kpis = _calc_kpis({}, runs, [], []) + assert kpis["total_dropped"] == 10 + + +def test_total_dropped_none_on_empty_all_log_runs(): + """Keine Runs ueberhaupt -- ebenfalls unbekannt (kein Pfad hat je etwas + gemeldet), nicht implizit 0.""" + kpis = _calc_kpis({}, [], [], []) + assert kpis["total_dropped"] is None + + +# ── Frontend-Anker: "–" mit Tooltip statt stiller 0 ──────────────────────── + + +def test_html_dropped_cell_shows_dash_when_total_dropped_is_null(): + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + i = html.index("cell('Dropped'") + line = html[i : html.index("\n", i)] + assert "kpis.total_dropped != null" in line or "kpis.total_dropped !== null" in line + assert "'–'" in line diff --git a/generative/tests/test_dashboard_quick_wins.py b/generative/tests/test_dashboard_quick_wins.py index 428b7d6..c6d9ac6 100644 --- a/generative/tests/test_dashboard_quick_wins.py +++ b/generative/tests/test_dashboard_quick_wins.py @@ -60,9 +60,14 @@ def test_calc_kpis_sums_total_dropped(): assert kpis["total_dropped"] == 5 -def test_calc_kpis_total_dropped_zero_when_field_missing(): +def test_calc_kpis_total_dropped_none_when_field_missing(): + # D5-Korrektur (Punkt 6, Matrix-Rendering-Fix+Politur-Bündel, 2026-07-16): + # war vormals `== 0` -- das war selbst das Anti-Pattern, das gefixt wurde. + # `n_dropped` fehlt hier komplett (Primaer-/Log-Pfad, _log_run() traegt + # den Key nie) -- strukturell nicht ermittelbar, nicht "wirklich 0". Siehe + # test_dashboard_dropped_honest_unknown.py fuer die volle Bugklasse. kpis = _calc_kpis({}, [_log_run()], [], []) - assert kpis["total_dropped"] == 0 + assert kpis["total_dropped"] is None # ── F5: Agent-Mapping ─────────────────────────────────────────────────────── diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index b897394..1dea127 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -1886,7 +1886,7 @@ cell('Notes generiert', kpis.total_generated||'–', '', '', 'Anzahl aller von der Pipeline erzeugten Draft-Notes, über alle Läufe und Versionen.') + cell('Direkt Vault', kpis.total_accepted||'–', '', `/ ${pct} %`, 'Notes, die ohne manuelles Zusammenführen direkt in den Vault übernommen wurden — kumuliert über ALLE Pipeline-Versionen (nicht nur die aktuelle, siehe Kachel „Automatisch akzeptiert" oben).') + cell('Merge-Stubs', kpis.total_merged||0, '', '', 'Notes, die nicht neu angelegt, sondern in eine bereits bestehende Vault-Note eingefügt/ergänzt wurden.') + - cell('Dropped', kpis.total_dropped||0, '', '', 'Von der Pipeline komplett verworfene Konzept-Kandidaten, die es nicht einmal bis zum Draft geschafft haben. Wird nur gezählt, wenn keine Log-Dateien vorliegen und die SQLite-Datenbank als Fallback dient — sonst 0.') + + cell('Dropped', kpis.total_dropped != null ? kpis.total_dropped : '–', '', '', 'Von der Pipeline komplett verworfene Konzept-Kandidaten, die es nicht einmal bis zum Draft geschafft haben. Nur ermittelbar, wenn die SQLite-Datenbank als Fallback dient (pipeline_runs-Tabelle) — Log-Dateien kennen verworfene Kandidaten strukturell nicht (kein Log-Eintrag). „–" heißt „nicht ermittelbar", nicht „0".') + // #204 P5: "Laufzeit" hier ist Lifetime (alle Versionen) — Scope-Suffix // gegen die Kachel "Laufzeit (aktuell)" oben; Zahl war zudem mit Punkt // statt Komma formatiert (Locale-Fund an derselben Stelle mitgefixt). From d6938af4ce9c04176b6a7bb80d06afe3e4416239 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Thu, 16 Jul 2026 11:26:29 +0200 Subject: [PATCH 06/16] fix(dashboard): Spark-Achsen-Eskalation fuer %- und h-Einheiten konsistent zur $-Eskalation #292-LOW (Reviews 15.07.): Die Nachkommastellen-Eskalation der Spark- Hilfslinien (_trendChart, kollidierende Achsenlabels bei enger Wertespanne) nutzte eine zweite, unit-fremde Formel (`_fmtDE(v, extra) + au.u`) statt der Basis-Formatierungsfunktion. Das passte zufaellig fuer "$"/generische Einheiten (deren Basis-fmt strukturell identisch ist), aber nicht fuer: - "%": Basis-Anzeige (_autoUnit) haengt GAR KEIN "%" an ("3,0" statt "3,0%") -- die Eskalationsformel dagegen IMMER (au.u="%"). Erst bei einer Werte-Kollision sprang das %-Zeichen ploetzlich auf, inkonsistent zur unmittelbar daneben unveraenderten Basis-Anzeige. - "h": Basis-Anzeige zeigt < 1h als gerundete Minuten ("2 min"), >= 1h als Stunden-Dezimalzahl ("1,2 h"). Die Eskalationsformel ignorierte das komplett und zeigte bei einer Kollision IMMER rohe Stunden-Dezimalen ("0,03h") -- ein Einheiten-Sprung weg von der Minuten-Konvention direkt daneben. Fix: `_autoUnit`s fmt-Funktionen nehmen jetzt einen optionalen Nachkomma- stellen-Parameter (Default = bisheriges hartkodiertes Verhalten je Zweig); die Eskalationsschleife ruft `au.fmt(v, extra)` statt der zweiten Formel -- SSoT, jede Einheit eskaliert automatisch in ihrer eigenen Konvention. "%" haengt dabei neu auch in der Basis-Anzeige den Suffix an (identisch zur Eskalation). Mischfall (Wertespanne straddled die 1h-Grenze nach Padding) zeigt weiterhin korrekt gemischte min/h-Einheiten -- kein Bug, dieselbe Logik wie die unveraenderte Basis-Anzeige, per Test abgesichert. Nachweis (TDD, Node-Ausfuehrung der echten JS-Funktionen, kein Nachbau): 2 RED-Failures vor dem Fix (fehlender %-Suffix in der Basis-Anzeige, Einheiten-Sprung auf Stunden-Dezimalen bei sub-1h-Kollision) + 6 bereits gruene Referenztests (u.a. $-Eskalation unveraendert). Nach Fix 9/9 gruen. Sweep generative/tests -k dashboard: 318 passed, keine Regression. --- .../test_dashboard_spark_axis_precision.py | 70 +++++++++++++++++++ internal/dashboard/eval_dashboard.html | 33 ++++++--- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/generative/tests/test_dashboard_spark_axis_precision.py b/generative/tests/test_dashboard_spark_axis_precision.py index 32e3253..324a40f 100644 --- a/generative/tests/test_dashboard_spark_axis_precision.py +++ b/generative/tests/test_dashboard_spark_axis_precision.py @@ -93,3 +93,73 @@ def test_tight_span_still_yields_distinct_labels(): Bedarf erhoehen, sonst kollabieren die Labels erneut auf denselben Wert.""" labels = _hairline_labels_via_node([0.0301, 0.0304, 0.0307], "$") assert len(set(labels)) == 3, f"Achsen-Labels nicht unterscheidbar: {labels}" + + +# ── #292-LOW: Eskalation fuer %- und h-Einheiten konsistent zur $-Eskalation ─ +# +# Befund: die Eskalations-Schleife in _trendChart ersetzte kollidierende +# Labels IMMER durch `_fmtDE(v, extra) + (au.u || '')` -- eine generische +# Formel, die zufaellig zur Basis-Formatierung von "$"/generischen Einheiten +# passt (beide haengen den Suffix direkt an), aber NICHT zur Basis- +# Formatierung von "%" (haengt in `_autoUnit` gar keinen Suffix an -- Labels +# ohne Eskalation zeigen KEIN "%") und "h" (< 1h zeigt "N min", die +# Eskalation ignorierte das und zeigte immer rohe Stunden-Dezimalzahlen + +# "h" -- Sprung von z. B. "2 min" auf "0,03h" bei Kollision). Fix: +# `_autoUnit`s fmt-Funktionen nehmen jetzt einen optionalen Nachkommastellen- +# Parameter; die Eskalation ruft `au.fmt(v, extra)` statt einer zweiten, +# unit-fremden Formel -- SSoT, automatisch konsistent fuer jede Einheit. + + +def test_percent_sparkline_base_label_has_percent_suffix(): + """Vor dem Fix: die Basis-%-Formatierung (au.fmt ohne Eskalation) liess + das %-Zeichen komplett weg ("3,0" statt "3,0%") -- inkonsistent zur + Eskalation, die (zufaellig) IMMER einen Suffix anhaengt.""" + labels = _hairline_labels_via_node([3.0, 10.0, 17.0], "%") + assert all(label.endswith("%") for label in labels), f"%-Suffix fehlt: {labels}" + + +def test_percent_sparkline_tight_span_escalates_to_distinct_labels(): + labels = _hairline_labels_via_node([5.001, 5.004, 5.007], "%") + assert len(set(labels)) == 3, f"Achsen-Labels nicht unterscheidbar: {labels}" + assert all(label.endswith("%") for label in labels) + + +def test_hour_sparkline_sub_1h_tight_span_stays_in_minutes_convention(): + """Vor dem Fix: sub-1h-Werte mit kollidierenden gerundeten Minuten + eskalierten auf rohe Stunden-Dezimalzahlen ("0,03h") statt einfach mehr + Nachkommastellen in derselben (Minuten-)Konvention zu zeigen -- inkon- + sistent zur Basis-Anzeige direkt daneben ("2 min").""" + labels = _hairline_labels_via_node([0.030, 0.034, 0.038], "h") + assert len(set(labels)) == 3, f"Achsen-Labels nicht unterscheidbar: {labels}" + assert all(label.endswith("min") for label in labels), f"Einheiten-Sprung zu Stunden: {labels}" + + +def test_hour_sparkline_over_1h_tight_span_escalates_in_hours(): + # 2.0-Bereich statt 1.0: bei knapp über 1h zieht das 20%-Padding + # (_trendChart: pad = (vmax-vmin)*0.2) die untere Hilfslinie sonst unter + # die 1h-Grenze -- dann korrekt gemischte Einheiten (s. Docstring unten), + # kein Bug. Hier bleiben alle 3 Hilfslinien auch MIT Padding über 1h. + labels = _hairline_labels_via_node([2.001, 2.004, 2.007], "h") + assert len(set(labels)) == 3, f"Achsen-Labels nicht unterscheidbar: {labels}" + assert all(label.endswith("h") for label in labels) + + +def test_hour_sparkline_straddling_1h_boundary_mixes_units_correctly(): + """Kein Bug, sondern Design-Konsequenz: liegt die untere Hilfslinie NACH + Padding-Abzug unter 1h, zeigt sie ("min") einheitenkorrekt an, waehrend + die oberen weiter in Stunden eskalieren -- dieselbe Logik wie die Basis- + Anzeige (_autoUnit: `v < 1 ? min : h`), nicht vereinheitlicht auf eine + Einheit. Regressions-Wächter fuer genau diesen Grenzfall.""" + labels = _hairline_labels_via_node([1.001, 1.004, 1.007], "h") + assert len(set(labels)) == 3, f"Achsen-Labels nicht unterscheidbar: {labels}" + assert labels[0].endswith("min") + assert labels[1].endswith("h") and labels[2].endswith("h") + + +def test_hour_sparkline_base_label_unchanged_rounded_minutes(): + """Regressions-Wächter: die BASIS-Anzeige (kein Eskalationsbedarf) bleibt + exakt wie zuvor -- ganzzahlige Minuten, keine Nachkommastelle. Bekannter + Referenzwert (unveraendert vor/nach Fix, per Node-Lauf verifiziert): + hairVals[0] (untere Hilfslinie) rundet bei diesem Wertebereich auf 0.""" + labels = _hairline_labels_via_node([0.05, 0.5, 0.9], "h") + assert labels[0] == "0 min" diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index 1dea127..4708297 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -1684,16 +1684,24 @@ function _autoUnit(values, unit) { // Wählt sinnvolle Einheit + Formatierung dynamisch nach Wertebereich const max = Math.max(...values.filter(v=>v!=null)); + // #292-LOW (Punkt 7, Matrix-Rendering-Fix+Politur-Bündel): jede fmt-Funktion + // nimmt jetzt einen optionalen Nachkommastellen-Parameter `d` (Default = + // bisheriges hartkodiertes Verhalten, s. je Zweig) -- die Eskalations- + // Schleife in _trendChart ruft `au.fmt(v, extra)` statt einer zweiten, + // unit-fremden Formel. Vorher kollidierte diese zweite Formel + // (`_fmtDE(v, extra) + au.u`) mit der Basis-Formatierung von "%" (die GAR + // keinen Suffix anhaengt) und "h" (< 1h zeigt "N min", nicht Stunden) -- + // ein sichtbarer Inkonsistenz-/Einheiten-Sprung erst BEI Kollision. // Explizit übergebene Einheiten: behalte sie, formatiere nur den Wert - if (unit === '%') return { u: '%', fmt: v => _fmtDE(v) }; - if (unit === 'h') return { u: 'h', fmt: v => v < 1 ? `${Math.round(v*60)} min` : _fmtDE(v)+' h' }; - if (unit === 'M') return { u: 'M', fmt: v => v < 0.1 ? `${Math.round(v*1000)} k` : _fmtDE(v, 2)+' M' }; - if (unit === 'k') return { u: 'k', fmt: v => v >= 1000 ? `${_fmtDE(v/1000)} M` : Math.round(v)+' k' }; + if (unit === '%') return { u: '%', fmt: (v, d) => `${_fmtDE(v, d ?? 1)}%` }; + if (unit === 'h') return { u: 'h', fmt: (v, d) => v < 1 ? `${_fmtDE(v*60, d ?? 0)} min` : `${_fmtDE(v, d ?? 1)} h` }; + if (unit === 'M') return { u: 'M', fmt: (v, d) => v < 0.1 ? `${Math.round(v*1000)} k` : `${_fmtDE(v, d ?? 2)} M` }; + if (unit === 'k') return { u: 'k', fmt: (v, d) => v >= 1000 ? `${_fmtDE(v/1000, d)} M` : `${Math.round(v)} k` }; // Kein unit: auto-scale nach Größenordnung - if (max >= 1e6) return { u: 'M', fmt: v => _fmtDE(v/1e6, 2)+' M' }; - if (max >= 1000) return { u: 'k', fmt: v => _fmtDE(v/1000)+' k' }; - if (max < 10) return { u: unit||'', fmt: v => _fmtDE(v, 2)+(unit||'') }; - return { u: unit||'', fmt: v => _fmtDE(v)+(unit||'') }; + if (max >= 1e6) return { u: 'M', fmt: (v, d) => `${_fmtDE(v/1e6, d ?? 2)} M` }; + if (max >= 1000) return { u: 'k', fmt: (v, d) => `${_fmtDE(v/1000, d ?? 1)} k` }; + if (max < 10) return { u: unit||'', fmt: (v, d) => `${_fmtDE(v, d ?? 2)}${unit||''}` }; + return { u: unit||'', fmt: (v, d) => `${_fmtDE(v, d ?? 1)}${unit||''}` }; } function _trendChart(points, versions, color, unit) { @@ -1779,10 +1787,17 @@ // Spannen reicht die Standard-Nachkommastellenzahl u.U. nicht zum // Unterscheiden — dann Nachkommastellen schrittweise erhoehen, bis alle 3 // Labels sich unterscheiden (Cap bei 6 Nachkommastellen). + // #292-LOW: Eskalation ruft au.fmt(v, extra) -- dieselbe Formatierungs- + // funktion wie die Basis-Anzeige, nur mit mehr Nachkommastellen. Vorher + // stand hier eine zweite, unit-fremde Formel (`_fmtDE(v, extra) + au.u`), + // die nur zufaellig zu $/generischen Einheiten passte, bei "%" den + // fehlenden Suffix "reparierte" (Inkonsistenz zur Basis-Anzeige direkt + // daneben) und bei "h" den Sprung von "N min" auf rohe Stunden-Dezimalen + // erzwang. const hairVals = [0, 0.5, 1].map(t => min + t*span); let hairLabels = hairVals.map(v => au.fmt(v)); for (let extra = 2; new Set(hairLabels).size < hairLabels.length && extra <= 6; extra++) { - hairLabels = hairVals.map(v => _fmtDE(v, extra) + (au.u || '')); + hairLabels = hairVals.map(v => au.fmt(v, extra)); } const hairLines = [0, 0.5, 1].map((t, i) => { const y = (padT + ih - t*ih).toFixed(1); From 91d78d6a45e7aa5adaa7779329ec38ab791055d5 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Thu, 16 Jul 2026 11:29:31 +0200 Subject: [PATCH 07/16] fix(dashboard): ch3/leg3 zeigen etikettierte PDF-Labels statt Rohkeys #294-Nebenfund (Reviews 15.07.): _chart_scaling() (Chart 3 "Scaling" + Legende 3) kopierte r["label"] unveraendert aus den all_log_runs-Zeilen. Im primaeren Log-Pfad (_read_all_log_runs) ist das `_PDF_LABELS.get(key, key)` -- fuer PDFs ausserhalb der 3 registrierten _PDF_LABELS-Eintraege (Regelfall) faellt das auf den rohen, klein- geschriebenen Log-Key zurueck (z. B. "cobaltite-paper" statt "Cobaltite Paper"). Dieselbe Bugklasse traf bereits Chart 2 (Trade-off, _chart_longitudinal) und wurde dort per U4-Fix lokal geloest (_PDF_LABELS.get(key) or re.sub(...).title()) -- ch3/leg3 bekam den Fix bisher nicht. Fix: dieselbe Formel in _chart_scaling, aber nur wenn label==key (der Fallback tatsaechlich griff) -- ein bereits besserer Label-Wert aus dem DB-Fallback-Pfad (pdf_label/pdf_source-Ableitung in eval_dashboard_server.py) bleibt unangetastet. Quelle bewusst NICHT zentral in _read_all_log_runs geaendert, um den Blast-Radius auf andere Konsumenten (_chart_acceptance u. a., nicht Teil dieses Fundes) nicht auszuweiten. Nachweis (TDD): RED zeigte den rohen Key in allen 3 Faellen (unregistrierte PDF, Multi-Separator-Key) vor dem Fix, GREEN danach (4/4) -- inkl. Regressionswaechter fuer den DB-Fallback-Fall (bereits guter Label bleibt erhalten). Sweep generative/tests -k dashboard: 322 passed, keine Regression. --- generative/eval_dashboard.py | 16 ++++- .../tests/test_dashboard_scaling_pdf_label.py | 66 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 generative/tests/test_dashboard_scaling_pdf_label.py diff --git a/generative/eval_dashboard.py b/generative/eval_dashboard.py index 7497f10..f0b1601 100644 --- a/generative/eval_dashboard.py +++ b/generative/eval_dashboard.py @@ -1610,6 +1610,20 @@ 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"], @@ -1617,7 +1631,7 @@ def _chart_scaling(all_log_runs: list[dict]) -> dict: "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"], } diff --git a/generative/tests/test_dashboard_scaling_pdf_label.py b/generative/tests/test_dashboard_scaling_pdf_label.py new file mode 100644 index 0000000..6109126 --- /dev/null +++ b/generative/tests/test_dashboard_scaling_pdf_label.py @@ -0,0 +1,66 @@ +"""Punkt 8 (#294-Nebenfund, Reviews 15.07.): Chart 3/Legende 3 zeigt rohe +kleingeschriebene PDF-Keys. + +Befund: `_chart_scaling()` (feeds ch3 "Scaling"/leg3 in +internal/dashboard/eval_dashboard.html) kopiert `r["label"]` unveraendert aus +den `all_log_runs`-Zeilen. `_read_all_log_runs()` setzt dort +`_PDF_LABELS.get(key, key)` -- fuer PDFs AUSSERHALB der 3 registrierten +_PDF_LABELS-Eintraege (Regelfall, s. #204 P8b/#294) faellt das auf den +rohen, kleingeschriebenen Log-Key zurueck (z. B. "cobaltite-paper" statt +"Cobaltite Paper"). Dieselbe Bugklasse traf bereits `_chart_longitudinal` +(Trade-off-Chart-2, U4-Fix, s. Kommentar dort) -- dort lokal per +`_PDF_LABELS.get(key) or re.sub(r"[-_]+", " ", key).strip().title()` +gefixt; ch3/leg3 bekam denselben Fix bisher nicht. + +Fix hier: identische Formel direkt in `_chart_scaling` (gleiche Quelle wie +_chart_longitudinal, nach #311 kanonisierte Keys + _PDF_LABELS) -- bewusst +NICHT an der Quelle (_read_all_log_runs) geaendert, um den Blast-Radius auf +andere Konsumenten von all_log_runs[...]["label"] (_chart_acceptance u. a., +nicht Teil dieses Fundes) nicht auszuweiten.""" + +from __future__ import annotations + +from generative.eval_dashboard import _chart_scaling + + +def _run(key, label, ver="v1", words=5000, n_total=8, n_vault=6, pages=10): + return { + "key": key, + "label": label, + "ver": ver, + "words": words, + "n_total": n_total, + "n_vault": n_vault, + "pages": pages, + "accept_pct": round(n_vault / n_total * 100, 1), + } + + +def test_scaling_prettifies_raw_key_when_not_in_pdf_labels(): + # "cobaltite-paper" ist NICHT in _PDF_LABELS (nur bates/kuhlthau/schlebbe) + # -- _read_all_log_runs faellt fuer solche Quellen auf den rohen Key + # zurueck (label == key), genau das reproduziert diese Fixture. + runs = [_run("cobaltite-paper", "cobaltite-paper")] + chart = _chart_scaling(runs) + assert chart["points"][0]["label"] == "Cobaltite Paper" + + +def test_scaling_uses_registered_pdf_labels_dict_when_available(): + runs = [_run("bates", "bates")] + chart = _chart_scaling(runs) + assert chart["points"][0]["label"] == "Bates 2017" + + +def test_scaling_prettify_collapses_multiple_separators(): + runs = [_run("multi__word--source", "multi__word--source")] + chart = _chart_scaling(runs) + assert chart["points"][0]["label"] == "Multi Word Source" + + +def test_scaling_preserves_already_proper_label_from_db_fallback_path(): + """DB-Fallback-Pfad (eval_dashboard_server.py) setzt label oft schon + ordentlich (aus pdf_label/pdf_source abgeleitet, NICHT der rohe Key) -- + dieser Fall darf nicht kaputtgehen: label != key bleibt unangetastet.""" + runs = [_run("cobaltite-paper", "Cobaltite Paper (DB)")] + chart = _chart_scaling(runs) + assert chart["points"][0]["label"] == "Cobaltite Paper (DB)" From ebfc098276917a78553db2d99b575f2c48fc9fd2 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Thu, 16 Jul 2026 11:34:58 +0200 Subject: [PATCH 08/16] feat(dashboard): n-valid-Badge je Modell-Filter-Option Reviews 15.07.: Modell-Dropdown-Optionen zeigen jetzt die Anzahl VALIDER Eval-Zeilen (hallucination_rate >= 0) je Modell -- Gemini-Fehllesungs- Schutz: dort tragen Zeilen den bestehenden -1.0-Sentinel fuer "ungueltig" (vgl. _chart_scatter/_matrix_cell_stats); n_valid zeigt fuer so ein Modell korrekt 0, 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 (bereits angereichert), derselbe Mechanismus wie der bestehende Modell-Einzelwert-Filter. Basis _matrix_base_rows (eval_version-skopiert, VOR pipeline_version/language/ pdf/run-Filtern) statt quality_rows -- dieselbe "Dropdown-Optionen vor allen Filtern"-Konvention wie die Options-Liste selbst (Bestands- Kommentar direkt darueber), sonst wuerde z. B. ein aktiver PDF-Filter die angezeigten Zaehler unerwartet mitverschieben. all_models wechselt von list[str] auf list[{model, n_valid}] (kein bestehender Test/Konsument referenzierte die alte Form direkt). Client: _initGlobalModelFilter zeigt "