Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions generative/eval_dashboard_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1117,17 +1127,24 @@ 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(
{
"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", ""),
Expand Down
55 changes: 55 additions & 0 deletions generative/tests/test_dashboard_model_filter_n_valid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────


Expand All @@ -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
20 changes: 20 additions & 0 deletions generative/tests/test_dashboard_null_eval_honesty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("</span>", 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
28 changes: 23 additions & 5 deletions generative/tests/test_dashboard_responsive_1100px.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
68 changes: 68 additions & 0 deletions generative/tests/test_dashboard_scatter_pdf_canonicalization.py
Original file line number Diff line number Diff line change
@@ -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"}
10 changes: 6 additions & 4 deletions generative/tests/test_dashboard_updated_icon_sidebar_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div class="filterbar">') : html.index('id="filter-badges"')]
assert '<span class="updated' not in section
assert "hint-pop" not in section


def test_html_sidebar_foot_has_collapsed_hint():
Expand Down
Loading
Loading