From 7c29921c1c08f2d430d647a87e4a3a5ffa48d22d Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Fri, 17 Jul 2026 14:16:39 +0200 Subject: [PATCH 1/6] fix(dashboard): Scatter-Legende auf kanonisierte PDF-Labels umstellen (#323) _chart_scatter_versioned baute die PDF-Legende des Qualitaets-Scatters aus dem rohen pdf-String als Dict-Key -- ohne die Kanonisierung ueber D._pdf_group_key(), die #311 fuer die anderen Charts eingefuehrt hat. Zwei Rohvarianten derselben Quelle (z.B. "Bates - 2017 - Information Behavior.pdf" vs. "bates-2017") landeten dadurch als zwei separate Legenden-Eintraege in zwei Farben. Dict-Key jetzt ueber D._pdf_group_key(pdf), Label weiter aus D._PDF_LABELS/_pdf_short_name (derselbe Pfad wie _calc_pdf_table). points[].pdf traegt jetzt ebenfalls den kanonischen Key, nicht mehr den Rohstring -- noetig, damit das Frontend (renderScatter: pt.pdf === legendEntry.raw) Punkte weiter korrekt ihrer Legenden-Gruppe zuordnet. --- generative/eval_dashboard_server.py | 15 ++-- ..._dashboard_scatter_pdf_canonicalization.py | 68 +++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 generative/tests/test_dashboard_scatter_pdf_canonicalization.py diff --git a/generative/eval_dashboard_server.py b/generative/eval_dashboard_server.py index 2e390c8..3d92ca9 100644 --- a/generative/eval_dashboard_server.py +++ b/generative/eval_dashboard_server.py @@ -1117,8 +1117,15 @@ def _chart_scatter_versioned(quality_rows: list[dict], pipeline_runs: list[dict] label = _re.sub(r"^(vault|inbox)__", "", label).replace(".md", "") pdf = r.get("pdf") or r.get("source_pdf") or "unbekannt" ver = r.get("version") or r.get("pipeline_version") or "unbekannt" - if pdf not in pdf_map: - pdf_map[pdf] = D._pdf_short_name(pdf) + # #323: Dict-Key ueber den kanonischen Gruppen-Schluessel bilden statt + # ueber den Rohstring -- sonst landen zwei Rohvarianten derselben Quelle + # (z. B. "Bates (2017)" und "bates-2017") als zwei separate Legenden- + # Eintraege in zwei Farben. Derselbe Kanonisierungs-Pfad wie die + # per-PDF-Tabelle (`_calc_pdf_table`/`_label_for`, #311); Label weiter + # aus `_PDF_LABELS`/`_pdf_short_name`. + pdf_key = D._pdf_group_key(pdf) or pdf + if pdf_key not in pdf_map: + pdf_map[pdf_key] = D._PDF_LABELS.get(pdf_key) or D._pdf_short_name(pdf) if ver not in versions: versions.append(ver) points.append( @@ -1126,8 +1133,8 @@ def _chart_scatter_versioned(quality_rows: list[dict], pipeline_runs: list[dict] "x": round(float(hall) * 100, 1), "y": round(float(cov) * 100, 1), "label": label, - "pdf": pdf, - "pdf_label": pdf_map[pdf], + "pdf": pdf_key, + "pdf_label": pdf_map[pdf_key], "version": ver, # Drill-Down-Drawer: Identifikation + Refresh-Persistenz-Key "run_id": r.get("run_id", ""), diff --git a/generative/tests/test_dashboard_scatter_pdf_canonicalization.py b/generative/tests/test_dashboard_scatter_pdf_canonicalization.py new file mode 100644 index 0000000..87b3c41 --- /dev/null +++ b/generative/tests/test_dashboard_scatter_pdf_canonicalization.py @@ -0,0 +1,68 @@ +"""#323: Scatter-Legende (ch2) zeigt Roh-PDF-Keys statt kanonisierter Labels. + +Befund (UX-Review 16.07.): `_chart_scatter_versioned` baute die PDF-Legende +aus dem ROHEN `pdf`-String als Dict-Key -- ohne vorherige Kanonisierung ueber +`D._pdf_group_key()`. Zwei Rohvarianten derselben Quelle (z. B. die belegte +Bates-Drift-Aliase, `_PDF_GROUP_ALIASES`) landeten dadurch als zwei separate +Legenden-Eintraege in zwei Farben. Fix: Dict-Key ueber `D._pdf_group_key(pdf)` +statt Rohstring -- derselbe Kanonisierungs-Pfad wie `_calc_pdf_table`/#311. +""" + +from __future__ import annotations + +from generative.eval_dashboard_server import _chart_scatter_versioned + + +def _row(note, pdf, hall, cov, ver="v0.3.144", run_id="r1"): + return { + "run_id": run_id, + "note_path": note, + "pipeline_version": ver, + "version": ver, + "hallucination_rate": hall, + "coverage_factual": cov, + "pdf": pdf, + } + + +def test_scatter_collapses_raw_pdf_variants_of_same_source_into_one_legend_entry(): + """ "Bates - 2017 - Information Behavior.pdf" und "bates-2017" bezeichnen + dieselbe Quelle (_pdf_group_key kollabiert beide auf "bates-2017") -- die + Scatter-Legende darf dafuer nur EINEN Eintrag zeigen, nicht zwei.""" + rows = [ + _row("n1", "Bates - 2017 - Information Behavior.pdf", 0.1, 0.5, run_id="r1"), + _row("n2", "bates-2017", 0.2, 0.6, run_id="r2"), + ] + data = _chart_scatter_versioned(rows) + assert len(data["pdfs"]) == 1 + assert data["pdfs"][0]["raw"] == "bates-2017" + + +def test_scatter_points_pdf_field_matches_canonical_legend_key(): + """Frontend gruppiert Punkte in Datasets ueber `pt.pdf === legendEntry.raw` + (renderScatter in eval_dashboard.html) -- beide Felder muessen nach der + Kanonisierung uebereinstimmen, sonst faellt jeder Punkt aus seiner + Legenden-Gruppe.""" + rows = [ + _row("n1", "Bates - 2017 - Information Behavior.pdf", 0.1, 0.5, run_id="r1"), + _row("n2", "bates-2017", 0.2, 0.6, run_id="r2"), + ] + data = _chart_scatter_versioned(rows) + raw_keys = {p["raw"] for p in data["pdfs"]} + for pt in data["points"]: + assert pt["pdf"] in raw_keys + assert pt["pdf"] == "bates-2017" + assert pt["pdf_label"] == data["pdfs"][0]["label"] + + +def test_scatter_keeps_distinct_sources_separate(): + """Regressions-Waechter: unterschiedliche Quellen (verschiedene Autor-Jahr- + Keys) duerfen NICHT zusammengefasst werden -- nur belegte Drift-Varianten + derselben Quelle.""" + rows = [ + _row("n1", "Bates - 2017 - Information Behavior.pdf", 0.1, 0.5, run_id="r1"), + _row("n2", "Beutelspacher - 2022 - Kryptographie.pdf", 0.2, 0.6, run_id="r2"), + ] + data = _chart_scatter_versioned(rows) + assert len(data["pdfs"]) == 2 + assert {p["raw"] for p in data["pdfs"]} == {"bates-2017", "beutelspacher-2022"} From f9a3509882907d981c4c72949eb3cc86ae0f9e89 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Fri, 17 Jul 2026 14:19:21 +0200 Subject: [PATCH 2/6] fix(dashboard): Badge-Hinweis fuer modellose Eval-Zeilen im Modell-Dropdown (#320) Valide Eval-Zeilen ohne run_id-Match zu einem Pipeline-Lauf zaehlen in KEINER Modell-Dropdown-Option -- das Dropdown machte diese Luecke bisher nicht kenntlich (Statistiker-Review-Bilanz: 479 valide 4.1-Zeilen = 442 mit Modell-Zuordnung + 37 ohne). Server zaehlt die Luecke jetzt im selben Durchlauf wie n_valid je Modell mit (models_unmatched_n, keine separate Neuberechnung), Frontend zeigt sie als Hint-Marker (bestehendes .hint/ .hint-pop-Muster) neben dem Modell-Label -- nur sichtbar, wenn N > 0. --- generative/eval_dashboard_server.py | 16 +++++- .../test_dashboard_model_filter_n_valid.py | 55 +++++++++++++++++++ internal/dashboard/eval_dashboard.html | 22 +++++++- 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/generative/eval_dashboard_server.py b/generative/eval_dashboard_server.py index 3d92ca9..a4a216e 100644 --- a/generative/eval_dashboard_server.py +++ b/generative/eval_dashboard_server.py @@ -600,15 +600,24 @@ def build_data( # 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. + # #320: valide Zeilen ohne run_id-Match zu einem Modell (kein Token-Run + # gefunden, oder Token-Run traegt kein model) zaehlen in KEINER der obigen + # Modell-Optionen mit -- Dropdown macht diese Luecke sonst nicht kenntlich + # (Bilanz-Beispiel Statistiker-Review: 479 valide 4.1-Zeilen = 442 mit + # Modell-Zuordnung + 37 ohne). Zahl kommt direkt aus demselben Zaehl- + # Durchlauf wie _model_valid_n, keine separate Neuberechnung. _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] = {} + _models_unmatched_n = 0 for _r in _matrix_base_rows: + _hall = _r.get("hallucination_rate") + if _hall is None or float(_hall) < 0: + continue _m = _run_model_map.get(_r.get("run_id"), "") if not _m: + _models_unmatched_n += 1 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_valid_n[_m] = _model_valid_n.get(_m, 0) + 1 _model_names = sorted( { mdl @@ -1039,6 +1048,7 @@ def _pooled_accept(ver: str) -> float | None: "kpi_trend": kpi_trend, "all_langs": all_langs, "all_models": _all_models_opts, + "models_unmatched_n": _models_unmatched_n, "all_pvers": _all_pvers_opts, "all_pdfs": _all_pdfs_opts, "all_runs": _all_runs_opts, diff --git a/generative/tests/test_dashboard_model_filter_n_valid.py b/generative/tests/test_dashboard_model_filter_n_valid.py index a6316ec..d4c2287 100644 --- a/generative/tests/test_dashboard_model_filter_n_valid.py +++ b/generative/tests/test_dashboard_model_filter_n_valid.py @@ -126,6 +126,44 @@ def test_all_models_still_excludes_denylisted_smoke_models(monkeypatch): assert models == {"anthropic/claude-opus-4-7"} +# ── #320: valide Zeilen ohne run_id-Match zu einem Modell ────────────────── + + +def test_models_unmatched_n_counts_valid_rows_without_run_id_match(monkeypatch): + """Zeile n2 haengt an run_id "r-orphan", zu der es KEINEN token_run gibt + (kein DB-Join moeglich) -- sie zaehlt in KEINER Modell-Option, muss aber + in models_unmatched_n auftauchen (Statistiker-Review-Bilanz: 479 valide + Zeilen = 442 mit Modell-Zuordnung + 37 ohne).""" + pipeline_runs = [_pipeline_run("r-claude", "anthropic/claude-sonnet-4-6")] + token_runs = [_token_run("r-claude")] + evals = [ + _eval_row("n1", "r-claude", 0.1, "2026-01-01T00:00:01"), + _eval_row("n2", "r-orphan", 0.2, "2026-01-01T00:00:02"), + ] + data = _patched_build_data(monkeypatch, evals, pipeline_runs, token_runs) + assert data["models_unmatched_n"] == 1 + by_model = {o["model"]: o["n_valid"] for o in data["all_models"]} + assert by_model["anthropic/claude-sonnet-4-6"] == 1 + + +def test_models_unmatched_n_excludes_invalid_sentinel_rows(monkeypatch): + """Der -1.0-Sentinel (ungueltige Zeile) darf models_unmatched_n NICHT + erhoehen -- dieselbe Validitaetsregel wie n_valid je Modell.""" + pipeline_runs: list[dict] = [] + token_runs: list[dict] = [] + evals = [_eval_row("n1", "r-orphan", -1.0, "2026-01-01T00:00:01")] + data = _patched_build_data(monkeypatch, evals, pipeline_runs, token_runs) + assert data["models_unmatched_n"] == 0 + + +def test_models_unmatched_n_zero_when_all_valid_rows_matched(monkeypatch): + pipeline_runs = [_pipeline_run("r1", "anthropic/claude-haiku-4-5")] + token_runs = [_token_run("r1")] + evals = [_eval_row("n1", "r1", 0.1, "2026-01-01T00:00:01")] + data = _patched_build_data(monkeypatch, evals, pipeline_runs, token_runs) + assert data["models_unmatched_n"] == 0 + + # ── Frontend-Anker: Dropdown zeigt "(n=X)", value bleibt der Modellname ──── @@ -138,3 +176,20 @@ def test_html_model_filter_shows_n_valid_badge_in_option_label(): block = html[start:end] assert "m.model" in block assert "m.n_valid" in block + + +def test_html_model_unmatched_hint_markup_and_wiring(): + """#320: Hint-Span existiert (initial hidden), _initGlobalModelFilter + schaltet ihn ueber models_unmatched_n frei, und der Aufruf reicht das Feld + aus dem Server-Payload durch.""" + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + assert 'id="model-unmatched-hint"' in html + assert 'class="hint" id="model-unmatched-hint" tabindex="0" hidden' in html + start = html.index("function _initGlobalModelFilter") + end = html.index("\n}", start) + block = html[start:end] + assert "unmatchedN" in block + assert "model-unmatched-hint" in block + assert "d.models_unmatched_n" in html diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index 43dbc65..bbae054 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -908,6 +908,13 @@
Modell + + @@ -1405,7 +1412,7 @@ return m; } -function _initGlobalModelFilter(models) { +function _initGlobalModelFilter(models, unmatchedN) { // Punkt 2: models = [{model, n_valid}] (Server) -- n_valid = Zahl VALIDER // Eval-Zeilen (hallucination_rate >= 0) je Modell, Gemini-Fehllesungs- // Schutz (-1.0-Sentinel) zeigt hier korrekt 0 statt der vollen Zeilenzahl. @@ -1413,6 +1420,17 @@ _rebuildFilterSelect(sel, models.map(m=>m.model).join(','), `` + models.map(m => ``).join(''), 'model'); + // #320: N valide Zeilen ohne run_id-Match zu einem Modell zaehlen in keiner + // Option oben -- Badge macht die Luecke am Dropdown selbst sichtbar. + const hintEl = document.getElementById('model-unmatched-hint'); + if (hintEl) { + const n = unmatchedN || 0; + hintEl.hidden = n <= 0; + if (n > 0) { + const pop = hintEl.querySelector('.hint-pop'); + if (pop) pop.textContent = `${n} Zeile${n===1?'':'n'} ohne Modell-Zuordnung (kein run_id-Match zu einem Pipeline-Lauf) — zählen in keiner der Modell-Optionen oben mit.`; + } + } } function _initGlobalRunFilter(runs) { @@ -3008,7 +3026,7 @@ if (d.all_pvers?.length) _initGlobalPverFilter(d.all_pvers); if (d.all_pdfs?.length) _initGlobalPdfFilter(d.all_pdfs); if (d.all_langs?.length) _initGlobalLangFilter(d.all_langs); - if (d.all_models?.length) _initGlobalModelFilter(d.all_models); + if (d.all_models?.length || d.models_unmatched_n) _initGlobalModelFilter(d.all_models||[], d.models_unmatched_n||0); if (d.all_runs?.length) _initGlobalRunFilter(d.all_runs); const sfTime=document.getElementById('sf-time'); if(sfTime) sfTime.textContent=d.generated_at||'–'; const footTime=document.getElementById('foot-time'); if(footTime) footTime.textContent=d.generated_at||'–'; From e5d943d4ab1757ad0bb639a844e9f9e85e8169f1 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Fri, 17 Jul 2026 14:24:18 +0200 Subject: [PATCH 3/6] fix(dashboard): Zebra-/Hover-Naht an der sticky PDF-Spalte der Matrix (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die sticky erste Spalte der Versions×PDF-Matrix setzte Zebra- und Hover- Zeilen bislang deckend auf den generischen var(--bg-card) zurueck (noetig, damit darunter gescrollte Zellen nicht durchscheinen) -- dadurch fehlte der Zebra-/Hover-Tint sichtbar genau in dieser einen Spalte: eine Naht zur restlichen Zeile, Row-Hover erfasste die PDF-Zelle nie. Fix: vorberechnete opake Zebra-/Hover-Mischfarben (--bg-card-zebra/ --bg-card-hover, je Theme in :root/body.light) statt des generischen Card-Hintergrunds -- reproduziert denselben 1,5/3,5-Prozent-Tint wie die rgba-Overlays der uebrigen Spalten, bleibt aber deckend fuer den Sticky- Scroll-Effekt. --- internal/dashboard/eval_dashboard.html | 28 ++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index bbae054..0628697 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -15,6 +15,12 @@ :root { --bg: #0b1120; --bg-elev: #111827; --bg-card: #141e2e; + /* #321: vorberechnete opake Zebra-/Hover-Mischfarben fuer die sticky + PDF-Spalte der Matrix (s. .table-wrap.pm-scroll ... td:first-child unten) + -- --bg-card + die jeweilige rgba-Tint-Deckkraft von table.cmp + tbody tr:nth-child(even)/:hover td, arithmetisch vorgemischt (kein + color-mix() noetig, Werte aendern sich nur mit den Basisfarben oben). */ + --bg-card-zebra: #182131; --bg-card-hover: #1c2635; --ink-1: #f0f4f8; --ink-2: #c4cdd9; --ink-3: #8fa3b8; --ink-4: #7e92a6; --ink-5: #3d4f60; --amber: #e8b53b; --coral: #e07a5f; --mint: #6dbf8c; @@ -36,6 +42,8 @@ } body.light { --bg: #f4f1eb; --bg-elev: #ece7dc; --bg-card: #ffffff; + /* #321: s. :root -- gleiche Mischrechnung mit den Light-Basisfarben. */ + --bg-card-zebra: #fbfbfb; --bg-card-hover: #f7f7f7; --ink-1: #0d0d0d; --ink-2: #333333; --ink-3: #5c5c5c; --ink-4: #6b6b6b; --ink-5: #cccccc; --amber: #855900; --coral: #a8391e; --mint: #1e7a45; @@ -608,12 +616,20 @@ .table-wrap.pm-scroll td:first-child { position: sticky; left: 0; background: var(--bg-card); z-index: 2; } /* Die Zebra-/Hover-Overlays von table.cmp sind semi-transparent (rgba) — in der sticky-Spalte scheinen sonst die darunter durchgescrollten Zellen - durch. Die Light-Zebra-Regel `body.light table.cmp ... td` (0,3,4) - schlaegt die Regel oben (0,3,1), darum hier mit hoeherer Spezifitaet - (0,5,4) opak halten. Bewusster Trade-off: der 1,5–3,5%-Tint (Zebra/Hover) - entfaellt nur in dieser einen Spalte. */ -.table-wrap.pm-scroll table.cmp tbody tr:nth-child(even) td:first-child, -.table-wrap.pm-scroll table.cmp tbody tr:hover td:first-child { background: var(--bg-card); } + durch. Vorher (bis #321): beide Zustaende deckend auf var(--bg-card) + zurueckgesetzt -- sichtbare Naht zur Zeile (Zebra-Tint fehlte in genau + dieser Spalte) und Hover erfasste die PDF-Zelle nie (Screenshots + C:/tmp/ux-review-312/desktop/a05, a07). Fix: vorberechnete OPAKE + Zebra-/Hover-Mischfarben (--bg-card-zebra/--bg-card-hover, s. :root/ + body.light oben -- --bg-card + dieselbe rgba-Tint-Deckkraft wie die + Basisregeln unten arithmetisch vorgemischt) statt des generischen + var(--bg-card). Spezifitaet (0,5,4) schlaegt weiterhin die Light-Zebra- + Regel `body.light table.cmp ... td` (0,3,4). Hover-Selektor bewusst NACH + dem Zebra-Selektor notiert (gleiche Spezifitaet, Kaskade entscheidet) -- + dieselbe Reihenfolge wie die Basisregeln unten, damit Hover bei + zebra+hover weiterhin gewinnt. */ +.table-wrap.pm-scroll table.cmp tbody tr:nth-child(even) td:first-child { background: var(--bg-card-zebra); } +.table-wrap.pm-scroll table.cmp tbody tr:hover td:first-child { background: var(--bg-card-hover); } 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 From 06032c4599652ffb143144ed044e873525fd04fc Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Fri, 17 Jul 2026 14:25:10 +0200 Subject: [PATCH 4/6] fix(dashboard): n=0-Banner-Wortlaut widerspricht Matrix-Verhalten (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei einer Filterkombination mit 0 evaluierten Notes behauptete das Empty- Banner unbedingt "Kennzahlen und Charts sind leer" -- die Versions×PDF- Matrix ignoriert aber bewusst JEDEN aktiven Filter (PDF/Version/Modell/ Sprache/Lauf, s. _matrix_base_rows in build_data()) und bleibt gefuellt. Beide Banner-Textvarianten (mit/ohne Betriebskennzahlen) sowie der statische Default-Text ergaenzen jetzt den Hinweis, dass die Matrix filterunabhaengig weiter den vollen Corpus zeigt. --- .../tests/test_dashboard_null_eval_honesty.py | 20 +++++++++++++++++++ internal/dashboard/eval_dashboard.html | 13 +++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/generative/tests/test_dashboard_null_eval_honesty.py b/generative/tests/test_dashboard_null_eval_honesty.py index f476564..1a7ce3c 100644 --- a/generative/tests/test_dashboard_null_eval_honesty.py +++ b/generative/tests/test_dashboard_null_eval_honesty.py @@ -35,3 +35,23 @@ def test_empty_banner_conditionalizes_on_operational_data(): # vorhanden sind — "Charts sind leer" darf dann nicht mehr behauptet werden. assert "nur Betriebskennzahlen" in html assert "kpi_accept_n" in html + + +def test_empty_banner_notes_matrix_stays_filled(monkeypatch): + """#322: die Versions×PDF-Matrix ignoriert JEDEN aktiven Filter (bewusster + Design-Entscheid, test_pair_matrix_ignores_active_single_value_pdf_and_ + version_filters) und bleibt bei 0-Notes-Filterkombinationen gefuellt -- + das Banner darf "Charts sind leer" darum nicht mehr unbedingt behaupten, + weder im statischen Default-Text noch in den beiden dynamischen JS- + Varianten.""" + html = _build_live_html() + matrix_hint = "außer der Versions×PDF-Matrix" + # (a) statischer Default-Text (vor dem ersten Datenladen) + default_start = html.index('id="empty-banner-text"') + default_end = html.index("", default_start) + assert matrix_hint in html[default_start:default_end] + # (b) beide dynamischen JS-Textvarianten (hasOperationalData true/false) + js_start = html.index("const hasOperationalData") + js_end = html.index("\n }", js_start) + js_block = html[js_start:js_end] + assert js_block.count(matrix_hint) == 2 diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index 0628697..432d1d3 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -947,7 +947,7 @@
@@ -3063,12 +3063,19 @@ // Merge-only-/0-Eval-Lauf trotzdem Betriebskennzahlen (Strip, Akzeptanz- // Basis aus Log-/Routing-Daten) liefert — nur die Eval-basierten // Qualitaets-Charts (Fehlerquote/Belegrate) sind dann tatsaechlich leer. + // #322: beide Varianten behaupteten zusaetzlich unbedingt "Charts sind + // leer" -- die Versions×PDF-Matrix (pair_matrix) rendert aber IMMER den + // vollen Corpus der aktiven eval_version, unabhaengig von JEDEM aktiven + // Filter (PDF/Version/Modell/Sprache/Lauf, s. Server-Kommentar + // _matrix_base_rows in build_data() + test_pair_matrix_ignores_active_ + // single_value_pdf_and_version_filters) -- Widerspruch, wenn das Banner + // "leer" behauptet, waehrend die Matrix daneben gefuellt bleibt. const bannerText = document.getElementById('empty-banner-text'); if (!banner.hidden && bannerText) { const hasOperationalData = (d.kpis?.kpi_accept_n??0) > 0; bannerText.innerHTML = hasOperationalData - ? 'Diese Filter-Kombination ergibt 0 evaluierte Notes — nur Betriebskennzahlen (Strip, Akzeptanz-Basis) verfügbar, Qualitäts-Charts (Fehlerquote/Belegrate) bleiben leer.' - : 'Diese Filter-Kombination ergibt 0 evaluierte Notes — Kennzahlen und Charts sind leer.'; + ? 'Diese Filter-Kombination ergibt 0 evaluierte Notes — nur Betriebskennzahlen (Strip, Akzeptanz-Basis) verfügbar, Qualitäts-Charts (Fehlerquote/Belegrate) bleiben leer, außer der Versions×PDF-Matrix (rendert filterunabhängig weiter den vollen Corpus).' + : 'Diese Filter-Kombination ergibt 0 evaluierte Notes — Kennzahlen und Charts sind leer, außer der Versions×PDF-Matrix (rendert filterunabhängig weiter den vollen Corpus).'; } } renderStrip(d.kpis); From c361a50bcfb96161110b830d89617cf16fb13f21 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Fri, 17 Jul 2026 14:27:53 +0200 Subject: [PATCH 5/6] fix(dashboard): Filterbar-Breakpoint-Luecke 1201-1430px schliessen (#324) Die Kompakt-Styles der Filterbar (.filter-group Padding, .fselect max-width) griffen nur innerhalb @media (max-width: 1200px); der naechste Breakpoint war @media (min-width: 1201px) fuer die Matrix-Sticky-Regel (andere Zustaendigkeit). Dazwischen (1201-~1430px) blieb die volle, unkompaktierte Breite aktiv -- bei 1280px brach "Modell" als letzte Filter-Group allein in eine zweite Zeile um. Fix: die beiden Kompakt-Regeln in eine eigene @media (max-width: 1440px) verschoben, statt die 1200px-Schwelle der unbeteiligten KPI-Grid-Regeln (kpis-perf) mit anzuheben. Bestehende Regressionswaechter-Tests (test_dashboard_responsive_1100px.py) auf den neuen Breakpoint umgestellt, neuer Test sichert, dass die Regeln NICHT mehr im 1200px-Block stehen. --- .../tests/test_dashboard_responsive_1100px.py | 28 +++++++++++++++---- internal/dashboard/eval_dashboard.html | 22 +++++++++++---- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/generative/tests/test_dashboard_responsive_1100px.py b/generative/tests/test_dashboard_responsive_1100px.py index be16e6a..3dfae0a 100644 --- a/generative/tests/test_dashboard_responsive_1100px.py +++ b/generative/tests/test_dashboard_responsive_1100px.py @@ -7,7 +7,14 @@ U9: das Modell-Filter brach als letzte Filter-Group allein in eine neue Zeile -- kompaktere Gruppierung durch engeres .filter-group-Padding und -schmalere .fselect-Maximalbreite bei <=1200px. +schmalere .fselect-Maximalbreite, urspruenglich bei <=1200px. + +#324 (Folge-Fund UX-Review 16.07.): der naechste Breakpoint war @media +(min-width: 1201px) (Matrix-Sticky-Regel, andere Zustaendigkeit) -- dazwischen +(1201–~1430px) blieb die volle unkompaktierte Breite aktiv, "Modell" brach bei +1280px allein in Zeile 2 um. Fix: die U9-Kompakt-Styles in eine eigene, auf +<=1440px erweiterte Media-Query verschoben (die kpis-perf-Regeln bleiben +bewusst bei <=1200px, davon nicht betroffen). Playwright-Sichtprüfung (isolierter Testserver, Live-Daten read-only, 1100px): kpis-perf letzte Kachel spannt jetzt die volle Breite (kein @@ -40,11 +47,22 @@ def test_kpis_perf_last_tile_spans_full_row_at_900px_breakpoint(): assert "grid-column: 1 / -1" in block -def test_filter_group_padding_compacted_at_1200px_breakpoint(): - block = _responsive_css_block(1200) +def test_filter_group_padding_compacted_at_1440px_breakpoint(): + """#324: auf <=1440px erweitert, schliesst die 1201–1430px-Luecke.""" + block = _responsive_css_block(1440) assert ".filter-group { padding: 0 10px; }" in block -def test_fselect_max_width_reduced_at_1200px_breakpoint(): - block = _responsive_css_block(1200) +def test_fselect_max_width_reduced_at_1440px_breakpoint(): + """#324: auf <=1440px erweitert, schliesst die 1201–1430px-Luecke.""" + block = _responsive_css_block(1440) assert ".fselect { max-width: 150px; }" in block + + +def test_filter_group_compaction_no_longer_gated_at_1200px(): + """Regressions-Waechter (#324): die Kompakt-Styles duerfen NICHT mehr im + engeren 1200px-Block stehen -- sonst waere die 1201–1430px-Luecke wieder + da, nur unbemerkt durch eine zufaellige Duplizierung.""" + block = _responsive_css_block(1200) + assert ".filter-group { padding: 0 10px; }" not in block + assert ".fselect { max-width: 150px; }" not in block diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index 432d1d3..b57113c 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -773,6 +773,19 @@ ::-webkit-scrollbar-thumb { background: var(--hair-strong); border-radius: 5px; border: 2px solid var(--bg); } /* ── Responsive ─────────────────────────────────────────────────── */ +/* #324: die Filterbar-Kompakt-Styles (U9 unten) galten bislang nur bis zur + 1200er-Schwelle -- der naechste Breakpoint (min-width: 1201px, Matrix- + Sticky-Regel oben) hat eine andere Zustaendigkeit. Dazwischen (1201– + ~1430px) blieb die volle unkompaktierte Breite aktiv: bei 1280px brach + "Modell" als letzte Filter-Group allein in Zeile 2 um (Screenshot + a-top-1280-light.png). Eigene, breitere Media-Query statt die Schwelle + der KPI-Grid-Regeln (kpis-perf, Block darunter) anzuheben -- die sind von + der Luecke nicht betroffen, eine gemeinsame Schwelle waere unnoetige + Fernwirkung auf eine unbeteiligte Ansicht. */ +@media (max-width: 1440px) { + .filter-group { padding: 0 10px; } + .fselect { max-width: 150px; } +} @media (max-width: 1200px) { /* .kpis-quality hat bereits 4 Spalten (siehe Basis-Regel oben) — passt hier ohne Änderung in eine Zeile, kein Umbruch, kein extra Rand nötig. */ @@ -790,12 +803,9 @@ letzte Filter-Group allein in eine neue Zeile -- kompaktere Gruppierung durch engeres Padding UND schmalere Select-Maximalbreite (200px liess bei 1100px 3 der 5 Groups an die Kappung stossen, ~370px Ueberschuss - ueber die verfuegbaren ~880px). (Punkt 1 haengt hier bewusst KEIN - eigenes Filter-Element mehr an, s. Design-Iteration im Eval-Version- - Kommentar -- das Eval-Version-Select sitzt inline in der Sidebar, nicht - in der Filterbar.) */ - .filter-group { padding: 0 10px; } - .fselect { max-width: 150px; } + ueber die verfuegbaren ~880px). Kompakt-Styles selbst jetzt in der + breiteren @media (max-width: 1440px) oben (#324, schliesst die + 1201–1430px-Luecke) -- hier nur noch der historische Befund-Kontext. */ } @media (max-width: 900px) { .strip { grid-template-columns: repeat(4,1fr); } From 2b82909f44afd49836c13798f44290dbd323acc7 Mon Sep 17 00:00:00 2001 From: TillQuandel Date: Fri, 17 Jul 2026 14:58:41 +0200 Subject: [PATCH 6/6] fix(dashboard): Test-Anker fuer #320 nachziehen (Modell-Hint vs. altes updated-Icon) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_html_header_updated_icon_removed pruefte bislang pauschal "kein hint-pop ueberhaupt im Filterbar-Abschnitt" als Proxy dafuer, dass das alte ↻-Updated-Icon dort weg ist. #320 fuegt der Filterbar legitim einen ANDEREN, eigenstaendigen Hint-Marker hinzu (#model-unmatched-hint) -- die pauschale Assertion kollidierte damit. Auf den eigentlich relevanten Anker verengt (kein '') : html.index('id="filter-badges"')] assert '