diff --git a/generative/eval_dashboard_server.py b/generative/eval_dashboard_server.py index 2e390c8..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, @@ -1117,8 +1127,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 +1143,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_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/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/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/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"} diff --git a/generative/tests/test_dashboard_updated_icon_sidebar_only.py b/generative/tests/test_dashboard_updated_icon_sidebar_only.py index dd10e49..64b158f 100644 --- a/generative/tests/test_dashboard_updated_icon_sidebar_only.py +++ b/generative/tests/test_dashboard_updated_icon_sidebar_only.py @@ -31,12 +31,14 @@ def test_html_header_updated_icon_removed(): assert 'id="updated-at"' not in html assert 'class="updated hint"' not in html # Engerer Kopfzeilen-Anker (_build_live_html-Muster wie im - # eval_version-Dropdown-Test): kein Hint-Trigger-Element (nur noch ein - # erklaerender Kommentar, der "↻" beschreibend erwaehnt) im Filterbar- - # Abschnitt selbst. + # eval_version-Dropdown-Test): kein "updated"-Hint-Trigger-Element im + # Filterbar-Abschnitt selbst. #320 (Folge-PR): die Filterbar traegt seither + # legitim einen ANDEREN Hint-Marker (`#model-unmatched-hint`, Badge fuer + # modellose Eval-Zeilen) -- die vorherige blanket "kein hint-pop ueberhaupt"- + # Assertion war ein zu grober Proxy fuer "das updated-Icon ist weg" und + # kollidiert jetzt mit dieser neuen, separaten Funktionalitaet. section = html[html.index('