diff --git a/generative/eval_dashboard.py b/generative/eval_dashboard.py index 10b2dd0..a5102fe 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) @@ -770,7 +768,20 @@ def _calc_kpis( "total_generated": total_generated, "total_accepted": total_accepted, "total_merged": total_merged, - "total_dropped": sum(r.get("n_dropped", 0) or 0 for r in all_log_runs), + # Punkt 6 (D5, Reviews 15.07.): "n_dropped" existiert strukturell NUR + # im DB-Fallback-Pfad (eval_dashboard_server.py, pipeline_runs-Tabelle) + # -- der primaere Log-Pfad (_read_all_log_runs) baut Zeilen aus + # [DRY-RUN]->(Vault|Inbox)-Treffern; verworfene Kandidaten (nie bis + # zum Draft gekommen) hinterlassen dort GAR KEINE Zeile und sind somit + # nicht ermittelbar, nicht nur zufaellig 0. `.get("n_dropped", 0)` + # gab bisher still 0 zurueck -- ununterscheidbar von "wirklich 0". Kein + # Log-Run traegt den Key -> None (Client zeigt "–" statt einer Zahl, + # die eine Genauigkeit vortaeuscht, die die Quelle nicht hat). + "total_dropped": ( + sum(r.get("n_dropped", 0) or 0 for r in all_log_runs) + if any("n_dropped" in r for r in all_log_runs) + else None + ), "total_tokens": total_tokens, "total_dur_h": round(total_dur_s / 3600, 1), "cur_tokens": cur_tokens, @@ -971,7 +982,7 @@ def _words_for(runs: list[dict]) -> int | None: at = _dedup_latest_per_note(at) n_notes = _distinct_notes(at) hall = _pooled_hall_pct(at) - cov_vals = [v for r in at if (v := r.get("coverage_factual") or r.get("coverage_rate")) is not None and v >= 0] + cov_vals = [v for r in at if (v := _row_coverage(r)) is not None and v >= 0] cov = round(_median(cov_vals) * 100, 1) if cov_vals else None runs = log_by_group.get(gk, []) accept, accept_ver, accept_n, n_merge = _accept_from_runs(runs, ver, current_version) @@ -1043,7 +1054,7 @@ def _chart_scatter(quality_rows: list[dict]) -> dict: pdf_map: dict[str, str] = {} for r in quality_rows: hall = r.get("hallucination_rate") - cov = r.get("coverage_factual") or r.get("coverage_rate") + cov = _row_coverage(r) if hall is None or cov is None or float(hall) < 0 or float(cov) < 0: continue label = r.get("note") or r.get("note_title") or "?" @@ -1109,12 +1120,22 @@ def _chart_longitudinal(log_data: dict) -> dict: _DELTA_MIN_PDF_OVERLAP = 0.5 -def version_delta(kpi_trend: dict, metric: str) -> dict: +def version_delta(kpi_trend: dict, metric: str, n_field: str = "n") -> dict: """Delta der neuesten Version gegen die letzte belastbare Vorversion. `kpi_trend["versions"]` ist aufsteigend sortiert (neueste = letzte Position), die Metrik-Arrays laufen parallel dazu. + Punkt 4 (D3, Matrix-Rendering-Fix+Politur-Bündel): `n_field` waehlt, welches + kpi_trend-Array als n>=_DELTA_MIN_N-Reliability-Guard dient (Default "n" = + Zahl LLM-evaluierter Notes, korrekt fuer hall/cov/dur/tokens/cost). Fuer + "accept" ist "n" der FALSCHE Nenner: die Akzeptanzrate poolt ueber ALLE + gerouteten (generierten) Notes, nicht nur die evaluierte Stichprobe -- der + Server ruft hier mit `n_field="accept_n"` (Summe n_total je Version, + dieselbe Basis wie `_pooled_accept`) auf, sonst zeigt das Delta + faelschlich reliable:false, obwohl es auf hunderten gerouteten Notes + beruht (aktuell konservativ, kein falsches Delta -- nur unnoetig grau). + #196 P5: Vergleichsbasis ist die jüngste FRÜHERE Version mit einem vorhandenen Metrik-Wert UND n>=_DELTA_MIN_N — nicht starr die direkte Vorversion. Direkte Nachbarversionen sind oft Einzel-Note-Wegwerfläufe @@ -1136,7 +1157,7 @@ def version_delta(kpi_trend: dict, metric: str) -> dict: (PDF-Mix)"). """ values = kpi_trend.get(metric) or [] - ns = kpi_trend.get("n") or [] + ns = kpi_trend.get(n_field) or [] versions = kpi_trend.get("versions") or [] pdf_notes = kpi_trend.get("pdf_notes") or [] latest = values[-1] if values else None @@ -1200,6 +1221,31 @@ def _pdf_notes_at(i: int) -> dict: } +def _is_reeval_series(quality_rows: list[dict], pipeline_runs: list[dict]) -> bool: + """True, wenn ALLE `quality_rows` (typischerweise die aktive eval_version, + ungefiltert -- s. `_matrix_base_rows` im Server) an einen `pipeline_runs`- + Eintrag mit `pipeline_version == "reeval"` haengen. + + Nachbesserung Punkt 4 (Statistiker-Empfehlung 2026-07-15): unter + eval_version 4.3 haengen alle note_evals-Zeilen an `pipeline_version` + v0.3.144 -- das ist der CODE-STAND des Re-Eval-Sweeps + (`reeval_baseline.py`), NICHT die Erzeugungsversion der Notes. Der + zugehoerige Lauf in `pipeline_runs` traegt dafuer den expliziten Marker + `pipeline_version="reeval"` (`reeval_baseline.py`, INSERT OR IGNORE INTO + pipeline_runs ... VALUES (..., 'reeval', 'baseline-reeval', 'reeval', ...)`) -- + verknuepft ueber `run_id`. Zeigt eine eval_version AUSSCHLIESSLICH solche + Re-Eval-Runs, misst sie den Eval-Code-Stand, nicht die Notes-Erzeugung. + + Leere `quality_rows` -> False (nichts zu kennzeichnen, kein falsches + Positiv). Zeilen ohne matchenden `pipeline_runs`-Eintrag (fehlender/ + unbekannter run_id) zaehlen NICHT als reeval -- nur eine explizite + Bestaetigung (jede Zeile matcht 'reeval') schaltet das Flag.""" + if not quality_rows: + return False + run_to_pver = {r.get("run_id"): r.get("pipeline_version") for r in pipeline_runs if r.get("run_id")} + return all(run_to_pver.get(row.get("run_id")) == "reeval" for row in quality_rows) + + # --------------------------------------------------------------------------- # Versions×PDF-Paarvergleich (Multi-Perspektiven-Dashboard-Review 2026-07-15, # P1-Empfehlung aller 3 Statistiker, vom adversarialen Statistiker modifiziert) @@ -1223,7 +1269,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 +1277,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: @@ -1581,6 +1635,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"], @@ -1588,7 +1656,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"], } @@ -1609,7 +1677,7 @@ def _build_quality_chart_data(quality_rows: list[dict]) -> dict: pdf = r.get("pdf") or r.get("source_pdf") or "unbekannt" ver = r.get("version") or "unknown" hall = r.get("hallucination_rate") - cov = r.get("coverage_factual") or r.get("coverage_rate") + cov = _row_coverage(r) anch_total = r.get("anchors_total") or 0 anch_conf = r.get("anchors_confirmed") or 0 rows_clean.append( diff --git a/generative/eval_dashboard_server.py b/generative/eval_dashboard_server.py index 4cecb89..dcde42a 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( @@ -408,6 +409,16 @@ def build_data( _jsonl_fallback = True available_versions = _available_eval_versions(all_quality_rows) + # Punkt 1 (Till-Wunsch): Zeilenzahl je eval_version fuer die Dropdown- + # Anzeige "4.3 (n=27)" -- ungefiltert (alle Quality-Rows dieser Version), + # damit die Zahl unabhaengig vom aktiven PDF-/Modell-/etc.-Filter stabil + # bleibt (dieselbe "Dropdown-Optionen VOR allen Filtern"-Konvention wie + # bei _all_pdfs_opts/_all_pvers_opts unten). + _eval_ver_counts: dict[str, int] = {} + for _r in all_quality_rows: + _v = _r.get("eval_version") + if _v: + _eval_ver_counts[_v] = _eval_ver_counts.get(_v, 0) + 1 # Default: neueste Version if eval_version is None or eval_version not in available_versions: @@ -552,9 +563,35 @@ def build_data( # ── Dropdown-Optionen VOR allen Filtern snapshotten ────────────── # Smoke-/Test-Modelle aus dem Filter halten (z. B. "m", "test", "smoke-model"). _MODEL_DENYLIST = {"m", "test", "smoke-model"} - _all_models_opts = sorted( - {m for tr in token_runs if (m := tr.get("model", "")) and m not in _MODEL_DENYLIST and "smoke" not in m.lower()} + # Punkt 2 (Reviews 15.07.): n-valid-Badge je Modell -- Zahl VALIDER + # Eval-Zeilen (hallucination_rate >= 0) je Modell (Gemini-Fehllesungs- + # Schutz: dort tragen alle Zeilen den bestehenden -1.0-Sentinel fuer + # "ungueltig", vgl. _chart_scatter/_matrix_cell_stats -- n_valid muss 0 + # zeigen, nicht die volle aber wertlose Zeilenzahl). note_evals traegt + # kein eigenes "model"-Feld (das lebt in pipeline_runs) -- Join ueber + # run_id -> token_runs.model, derselbe Mechanismus wie der bestehende + # Modell-Einzelwert-Filter unten. Basis _matrix_base_rows (eval_version- + # skopiert, VOR pipeline_version/language/pdf/run-Filtern) statt + # quality_rows -- dieselbe "vor allen Filtern"-Konvention wie die + # Options-Liste selbst, sonst wuerde z. B. ein aktiver PDF-Filter die + # angezeigten Zaehler unerwartet mitverschieben. + _run_model_map: dict[str, str] = {tr["run_id"]: tr.get("model", "") for tr in token_runs if tr.get("run_id")} + _model_valid_n: dict[str, int] = {} + for _r in _matrix_base_rows: + _m = _run_model_map.get(_r.get("run_id"), "") + if not _m: + continue + _hall = _r.get("hallucination_rate") + if _hall is not None and float(_hall) >= 0: + _model_valid_n[_m] = _model_valid_n.get(_m, 0) + 1 + _model_names = sorted( + { + mdl + for tr in token_runs + if (mdl := tr.get("model", "")) and mdl not in _MODEL_DENYLIST and "smoke" not in mdl.lower() + } ) + _all_models_opts = [{"model": m, "n_valid": _model_valid_n.get(m, 0)} for m in _model_names] # ── token_runs + quality_rows + all_log_runs gemeinsam filtern ────── # Alle Filter auf token_runs anwenden → run_ids extrahieren → quality_rows + log_runs ebenfalls filtern @@ -757,7 +794,7 @@ def _vkey(v): hall_val = r.get("hallucination_rate") if hall_val is not None and float(hall_val) >= 0: d2["hall"].append(float(hall_val) * 100) - cov = r.get("coverage_factual") or r.get("coverage_rate") + cov = D._row_coverage(r) if cov is not None and float(cov) >= 0: d2["cov"].append(float(cov) * 100) # n = distinct Notes (nicht Eval-Instanzen), identisch zur „Evaluierte @@ -826,6 +863,12 @@ def _pooled_accept(ver: str) -> float | None: "cov": [quality_by_version[v].get("median_cov") for v in sorted_pipeline_versions], "n": [quality_by_version[v]["n"] for v in sorted_pipeline_versions], "accept": [_pooled_accept(v) for v in sorted_pipeline_versions], + # Punkt 4 (D3): n-Guard-Basis fuer das accept-Delta -- "n" (evaluierte + # Notes) ist fuer hall/cov korrekt, fuer accept aber der FALSCHE Nenner + # (Akzeptanzrate poolt ueber ALLE gerouteten Notes, s. _pooled_accept + # oben). Summe derselben (n_vault, n_total)-Paare -- SSoT, keine + # zweite Zaehlung. + "accept_n": [sum(t for _, t in accept_pairs_by_ver.get(v, [])) for v in sorted_pipeline_versions], "dur": [ round(sum(dur_by_ver.get(v, [])) / len(dur_by_ver[v]), 1) if dur_by_ver.get(v) else None for v in sorted_pipeline_versions @@ -851,7 +894,11 @@ def _pooled_accept(ver: str) -> float | None: # Delta neueste-vs-Vorversion pro KPI (mit N-Guard, #36 P4) kpi_trend["deltas"] = { - m: D.version_delta(kpi_trend, m) for m in ("hall", "cov", "n", "accept", "dur", "tokens", "cost") + # Punkt 4 (D3): "accept" haertet auf accept_n (geroutete Notes) statt + # dem Default "n" (evaluierte Notes, falscher/zu kleiner Nenner fuer + # diese Metrik -- s. version_delta-Docstring). + m: D.version_delta(kpi_trend, m, n_field="accept_n" if m == "accept" else "n") + for m in ("hall", "cov", "n", "accept", "dur", "tokens", "cost") } # ── Lauf-Dropdown-Optionen (#211): immer ungefiltert, jüngste zuerst ── @@ -909,10 +956,27 @@ def _pooled_accept(ver: str) -> float | None: pair_matrix["excluded_archived_versions"] = _excluded_archived pair_matrix["eval_version"] = eval_version + # Nachbesserung Punkt 4 (Statistiker-Empfehlung 2026-07-16): zeigt die + # aktive eval_version AUSSCHLIESSLICH Re-Eval-Runs (reeval_baseline.py, + # pipeline_runs.pipeline_version=="reeval"), misst sie den Eval-Code- + # Stand des Re-Eval-Sweeps, NICHT die Erzeugungsversion der Notes + # (Produktionsmuster: eval_version 4.3 haengt komplett an v0.3.144, das + # war aber der Code-Stand beim Re-Eval-Lauf). Basis: `_matrix_base_rows` + # (aktive eval_version, VOR Einzelwert-Filtern -- dieselbe "ungefiltert"- + # Konvention wie die Matrix selbst). + try: + from generative import db as _db_reeval + + _all_pipeline_runs = _db_reeval.query_pipeline_runs() + except Exception: + _all_pipeline_runs = [] + is_reeval_series = D._is_reeval_series(_matrix_base_rows, _all_pipeline_runs) + return { "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "eval_version": eval_version, - "available_eval_versions": available_versions, + "is_reeval_series": is_reeval_series, + "available_eval_versions": [{"version": v, "n": _eval_ver_counts.get(v, 0)} for v in available_versions], "warnings": warnings, "kpis": D._calc_kpis(log_data, all_log_runs, quality_rows, token_runs), "pdf_table": (_pdf_table := D._calc_pdf_table(log_data, all_log_runs, quality_rows)), @@ -992,7 +1056,7 @@ def _chart_scatter_versioned(quality_rows: list[dict]) -> dict: for r in deduped_rows: hall = r.get("hallucination_rate") - cov = r.get("coverage_factual") or r.get("coverage_rate") + cov = D._row_coverage(r) # Sentinel-Werte (-1.0 = ungültig) überspringen if hall is None or cov is None or float(hall) < 0 or float(cov) < 0: continue 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 diff --git a/generative/tests/test_dashboard_accept_hall_overlay.py b/generative/tests/test_dashboard_accept_hall_overlay.py new file mode 100644 index 0000000..d7f7c43 --- /dev/null +++ b/generative/tests/test_dashboard_accept_hall_overlay.py @@ -0,0 +1,249 @@ +"""Punkt 3 (S2-C3, Multi-Perspektiven-Dashboard-Review 2026-07-15): +Accept×Hall-Overlay in der bestehenden Versions-Trend-Ansicht. + +Befund: note_evals enthält nur AKZEPTIERTE Notes (Selektions-Bias) -- sinkt +die Akzeptanzquote gleichzeitig mit einer Fehlerquoten-Änderung, ist +Letztere teils Stichproben-Verschiebung statt reiner Qualitätseffekt, ohne +dass das im bestehenden Fehlerquote-Spark-Chart sichtbar wäre. Fix: die +Akzeptanzquote als zweite (gestrichelte, gedämpfte) Serie im BESTEHENDEN +Fehlerquote-Spark-Chart (_trendChart, Klick auf die Fehlerquote-KPI-Kachel) +-- kein neues Chart (Spec-Vorgabe). + +Nachbesserung (Statistiker MEDIUM + UX-P3, Reviews 15./16.07 zu PR #312): +die gemeinsame 0-100%-Achse stauchte die Fehlerquoten-Serie auf ~1/3 der +Chart-Höhe, 100%-Accept-Punkte aus n=1-3-Läufen waren optisch nicht von +belastbaren unterscheidbar, der Punkt-Tooltip zeigte nur die Fehlerquote, +die Legende nur die Zweitserie. Fix (4 Teile): (a) Overlay zuschaltbar, +Default AUS (`_acceptOverlayOn`, localStorage) -- aus bleibt die Achse +hall-adaptiv wie vor dem PR; (b) Punkt-Tooltip zeigt beide Werte; (c) +Legende benennt beide Serien + unterschiedliche Basis; (d) Accept-Punkte +mit accept_n<20 gedimmt. + +Dieser Test führt die ECHTE _trendChart-Funktion (+ Abhängigkeiten _fmtDE/ +_autoUnit) aus dem Dashboard-HTML in Node aus (Extraktion per Klammer- +Balancer, kein Nachbau/keine Kopie der Logik). + +`internal/dashboard/eval_dashboard.html` enthält ein bewusstes NUL-Byte — +Zugriff ausschließlich über `Path.read_text(encoding="utf-8")`, nie über +bash grep/sed. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +_HTML_PATH = Path(__file__).resolve().parents[2] / "internal" / "dashboard" / "eval_dashboard.html" + + +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 _run_trend_chart(hall, versions, unit="%", accept=None, label2="Akzeptanzquote", accept_n=None): + node = shutil.which("node") + if node is None: + pytest.skip("node nicht verfügbar") + + html = _HTML_PATH.read_text(encoding="utf-8") + fn_names = ["_fmtDE", "_autoUnit", "_trendChart", "_escHTML"] + fns = "\n".join(_extract_js_function(html, n) for n in fn_names) + + script = f""" + function cssv(v) {{ return '#000'; }} + {fns} + const svg = _trendChart({json.dumps(hall)}, {json.dumps(versions)}, '#c0392b', {json.dumps(unit)}, {json.dumps(accept)}, {json.dumps(label2)}, {json.dumps(accept_n)}); + process.stdout.write(svg); + """ + result = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30) + assert result.returncode == 0, f"node stderr: {result.stderr}" + return result.stdout + + +def test_overlay_renders_second_dashed_series_when_accept_provided(): + svg = _run_trend_chart( + hall=[5.0, 8.0, 12.0], + versions=["v1", "v2", "v3"], + accept=[90.0, 70.0, 40.0], + ) + assert "stroke-dasharray" in svg + assert "Akzeptanzquote" in svg + + +def test_overlay_absent_by_default_no_second_series(): + """Regressions-Wächter: OHNE points2 (alle anderen KPI-Kacheln -- cov, + accept selbst, dur, tokens, cost) bleibt das Chart exakt wie zuvor, + kein zweiter Pfad/keine Legende.""" + svg = _run_trend_chart(hall=[5.0, 8.0, 12.0], versions=["v1", "v2", "v3"], accept=None) + assert "stroke-dasharray" not in svg + assert "Akzeptanzquote" not in svg + + +def test_overlay_absent_when_unit_is_not_percent(): + """Eine gemeinsame Y-Achse ergibt nur bei gleicher Einheit (%) Sinn -- + z. B. die Dauer-Kachel (Einheit h) darf nicht versehentlich eine + %-Akzeptanzquote auf ihrer eigenen Skala einzeichnen.""" + svg = _run_trend_chart(hall=[1.0, 2.0, 1.5], versions=["v1", "v2", "v3"], unit="h", accept=[90.0, 70.0, 40.0]) + assert "stroke-dasharray" not in svg + + +def test_overlay_scale_accommodates_accept_values_above_hall_range(): + """Die gemeinsame Skala muss BEIDE Serien abdecken -- ein Akzeptanzwert + weit ueber dem Fehlerquote-Wertebereich darf nicht ausserhalb des + sichtbaren SVG-Bereichs (0..90 y, s. _trendChart-Konstanten) landen.""" + svg = _run_trend_chart(hall=[2.0, 3.0, 2.5], versions=["v1", "v2", "v3"], accept=[95.0, 92.0, 90.0]) + # Alle y-Koordinaten im Pfad (M/L-Kommandos) muessen im SVG-Canvas liegen + # (padT=16 .. padT+ih=70 laut Konstanten in _trendChart). + import re + + coords = re.findall(r"[ML]([\d.]+),([\d.]+)", svg) + assert coords, "Kein Pfad gefunden" + ys = [float(y) for _, y in coords] + assert all(0 <= y <= 90 for y in ys), f"y-Koordinaten ausserhalb des Canvas: {ys}" + + +def test_overlay_mismatched_length_ignored_no_crash(): + """points2 mit abweichender Laenge (Server-Datenfehler oder Version- + Mismatch) darf nicht crashen -- wird ignoriert (has2-Guard).""" + svg = _run_trend_chart(hall=[5.0, 8.0, 12.0], versions=["v1", "v2", "v3"], accept=[90.0, 70.0]) + assert "stroke-dasharray" not in svg + + +# ── Nachbesserung (a): Legende benennt BEIDE Serien ──────────────────────── + + +def test_overlay_legend_names_both_series(): + """Vorher nur die Zweitserie benannt -- unklar, wofuer die durchgezogene + Linie steht. Legende zeigt jetzt "Fehlerquote" UND den Zweitserien-Namen.""" + svg = _run_trend_chart(hall=[5.0, 8.0, 12.0], versions=["v1", "v2", "v3"], accept=[90.0, 70.0, 40.0]) + assert "Fehlerquote" in svg + assert "Akzeptanzquote" in svg + + +# ── Nachbesserung (b): Punkt-Tooltip zeigt beide Werte ───────────────────── + + +def test_overlay_point_tooltip_shows_both_values(): + """Der Tooltip an einem Datenpunkt zeigte bisher nur die Fehlerquote -- + bei aktivem Overlay fehlte der Akzeptanzwert derselben Version komplett. + Beide Werte muessen jetzt im selben Tooltip-Text stehen.""" + svg = _run_trend_chart(hall=[5.0, 8.0, 12.0], versions=["v1", "v2", "v3"], accept=[90.0, 70.0, 40.0]) + assert "Akzeptanz" in svg + # Letzter Punkt: Fehlerquote 12,0% UND Akzeptanz 40,0% im selben Label. + assert "12,0%" in svg and "40,0%" in svg + + +def test_overlay_point_tooltip_unchanged_without_overlay(): + """Regressionswaechter: ohne Overlay (points2=None) bleibt der Tooltip- + Text exakt wie vorher (nur die Fehlerquote, kein "Akzeptanz").""" + svg = _run_trend_chart(hall=[5.0, 8.0, 12.0], versions=["v1", "v2", "v3"], accept=None) + assert "Akzeptanz " not in svg # "Akzeptanzquote" (Legende) waere ohnehin abwesend + + +# ── Nachbesserung (d): Accept-Punkte mit accept_n<20 gedimmt ─────────────── + + +def test_overlay_dims_points_with_low_accept_n(): + """100%-Accept-Punkte aus n=1-3-Laeufen sahen bisher optisch nicht anders + aus als belastbare Werte (n>=20) -- Punkte mit accept_n<20 werden jetzt + gedimmt (reduzierte Opacity) dargestellt.""" + svg = _run_trend_chart( + hall=[5.0, 8.0, 12.0], + versions=["v1", "v2", "v3"], + accept=[90.0, 70.0, 40.0], + accept_n=[3, 25, 30], + ) + assert 'opacity="0.35"' in svg # v1 (n=3) gedimmt + # v2/v3 (n>=20) nicht gedimmt -- mindestens 2 volle Kreise ohne Dimmen. + assert svg.count('opacity="1"') >= 2 + + +def test_overlay_no_dimming_when_accept_n_not_provided(): + """Rueckwaertskompatibel: ohne accept_n (aeltere Aufrufer/Tests) wird + NICHTS gedimmt -- kein Crash, kein falsches Dimmen.""" + svg = _run_trend_chart(hall=[5.0, 8.0, 12.0], versions=["v1", "v2", "v3"], accept=[90.0, 70.0, 40.0]) + assert 'opacity="0.35"' not in svg + + +# ── Frontend-Anker: Aufrufer-Verdrahtung (nur Fehlerquote-Kachel) ────────── + + +def test_html_hall_tile_wires_accept_as_second_series(): + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + start = html.index("const overlayAvailable =") + end = html.index("\n", html.index("_trendChart(vals, vers, sparkColor", start)) + block = html[start:end] + assert "kd.key === 'hall'" in block + assert "_acceptOverlayOn" in block + assert "kpiTrend?.accept" in block + assert "kpiTrend?.accept_n" in block + assert "_trendChart(vals, vers, sparkColor, kd.u, accVals, 'Akzeptanzquote', acceptNVals)" in block + + +# ── Nachbesserung (a): Toggle zuschaltbar, Default AUS, localStorage ─────── + + +def test_html_overlay_toggle_defaults_to_off_via_localstorage(): + """Default AUS: `_acceptOverlayOn` liest aus localStorage, Startwert nur + bei explizitem 'true' aktiv (analog theme-mode-Konvention).""" + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + assert "let _acceptOverlayOn = localStorage.getItem('accept-overlay-on') === 'true'" in html + + +def test_html_overlay_toggle_function_persists_state(): + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + start = html.index("function _toggleAcceptOverlay") + end = html.index("\n}", start) + block = html[start:end] + assert "localStorage.setItem('accept-overlay-on'" in block + assert "renderKpis" in block + + +def test_html_overlay_toggle_checkbox_only_for_hall_tile(): + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + start = html.index("const overlayToggleHtml") + end = html.index("const overlayCaveatHtml", start) + block = html[start:end] + assert "overlayAvailable" in block + assert "spark-overlay-toggle" in block + assert 'onchange="_toggleAcceptOverlay(this.checked)"' in block + + +# ── Nachbesserung (c): Caveat zur unterschiedlichen Basis ────────────────── + + +def test_html_overlay_caveat_mentions_different_basis(): + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + start = html.index("const overlayCaveatHtml") + end = html.index("const inner = document.getElementById('spark-inner')", start) + block = html[start:end] + assert "geroutete" in block + assert "evaluierte" in block + assert "accept_n" in block or "gerouteten Notes sind gedimmt" in block 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 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_eval_version_dropdown.py b/generative/tests/test_dashboard_eval_version_dropdown.py new file mode 100644 index 0000000..d943a8c --- /dev/null +++ b/generative/tests/test_dashboard_eval_version_dropdown.py @@ -0,0 +1,227 @@ +"""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: Select, das +den `eval_version`-Param in JEDEN `/data.json`-Fetch übernimmt (inkl. +15s-Auto-Refresh), Optionen aus `available_eval_versions` inkl. Zeilenzahl +je Version für die Anzeige "4.3 (n=27)". + +Design-Iteration (Till-Feedback nach erster Sichtprüfung 2026-07-16): initial +als eigenes Filterbar-Element gebaut — brach deren Einzeilen-Layout bereits +bei 1440px (6. Filter-Group + Warn-Badge passten nicht mehr in eine Zeile). +Verworfen zugunsten eines Inline-Selects am bestehenden eval_version-Meta- +Label in der Sidebar (#sf-ev-select) — kein zusätzliches Filterbar-Element +mehr, #pm-evalver (Matrix-Sektion) bleibt Read-only-Text und folgt der +Auswahl unverändert. + +`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_no_eval_version_element(): + """Design-Iteration: das Eval-Version-Select ist bewusst KEIN Filterbar- + Element (haette deren Einzeilen-Layout gebrochen, s. Modulkommentar). + Regressionswaechter gegen ein Wieder-Einfuegen dort.""" + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + section = html[html.index('
') : html.index('id="filter-badges"')] + # Substring-Check auf das ELEMENT (id="..."), nicht auf den blossen Namen + # -- der Abschnitt enthaelt einen erklaerenden Kommentar, der "sf-ev- + # select" als Verweis nennt (wohin das Feature gezogen wurde). + assert "onEvalVerChange" not in section + assert 'id="sf-ev-select"' not in section + + +def test_html_sidebar_has_inline_eval_version_select(): + """Fix: das bestehende eval_version-Meta-Label in der Sidebar (Punkt 1) + wird selbst zum Select -- kompakt, kein separates Filterbar-Element.""" + 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-select"' in side_foot + assert 'onchange="onEvalVerChange()"' in side_foot + + +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 + + +# ── Sichtpruefungs-Fund 2026-07-16: sf-ev-select-Wert blieb nach manuellem ─ +# Dropdown-Wechsel auf dem alten Wert stehen. 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) -- sel.value 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_select_value_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 das von _initEvalVerDropdown angefasste Element. + function makeEl() {{ return {{ dataset: {{}}, innerHTML: '', value: '' }}; }} + const els = {{ 'sf-ev-select': 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 + // (Select-Wert vom Nutzer geaendert) -- Optionsmenge bleibt UNVERAENDERT. + _currentEvalVersion = '4.1'; + _initEvalVerDropdown({json.dumps(available)}, '4.1'); + + process.stdout.write(JSON.stringify({{ selectValue: els['sf-ev-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["selectValue"] == "4.1", f"Select-Wert blieb stale: {out}" diff --git a/generative/tests/test_dashboard_model_filter_n_valid.py b/generative/tests/test_dashboard_model_filter_n_valid.py new file mode 100644 index 0000000..a6316ec --- /dev/null +++ b/generative/tests/test_dashboard_model_filter_n_valid.py @@ -0,0 +1,140 @@ +"""Punkt 2 (Reviews 15.07.): n-valid-Badge je Modell-Filter-Option. + +Befund: Das Modell-Dropdown zeigt nur Modellnamen, keine Zeilenzahl. Die +Task-Vorgabe: Optionen sollen die Anzahl VALIDER Eval-Zeilen +(hallucination_rate >= 0) je Modell zeigen -- Gemini-Fehllesungs-Schutz: +dort tragen Zeilen den bestehenden -1.0-Sentinel fuer "ungueltig" +(vgl. _chart_scatter/_matrix_cell_stats), n_valid muss fuer so ein Modell +0 zeigen, nicht die volle (aber wertlose) Zeilenzahl. + +Modell-Zuordnung: note_evals traegt KEIN eigenes "model"-Feld (das lebt in +pipeline_runs) -- Join ueber run_id -> token_runs (bereits mit .model +angereichert, s. build_data()) -> quality_rows.run_id, derselbe Mechanismus +wie der bestehende Modell-EINZELWERT-Filter (Server-Kommentar: "Alle Filter +auf token_runs anwenden -> run_ids extrahieren -> quality_rows ... ebenfalls +filtern").""" + +from __future__ import annotations + + +def _token_run(run_id, **over): + base = { + "date": "01.01 00:00", + "run_id": run_id, + "pdf_label": "", + "tokens_in": 100, + "tokens_out": 50, + "tokens_cache": 0, + "tokens_cache_read": 0, + "tokens_cache_create": 0, + "duration_min": 1.0, + "calls": 1, + } + base.update(over) + return base + + +def _pipeline_run(run_id, model, ver="v0.3.144", **over): + base = { + "run_id": run_id, + "model": model, + "pipeline_version": ver, + "pdf_label": "a.pdf", + "pdf_source": "a.pdf", + "cost_usd": 0.01, + "wall_clock_s": 10.0, + } + base.update(over) + return base + + +def _eval_row(note, run_id, hall, ts, ver="v0.3.144", eval_version="4.1"): + return { + "run_id": run_id, + "note_path": note, + "pipeline_version": ver, + "version": ver, + "hallucination_rate": hall, + "anchors_total": 10, + "anchors_hallucinated": 0, + "coverage_factual": 0.5, + "pdf": "a.pdf", + "eval_version": eval_version, + "timestamp": ts, + } + + +def _patched_build_data(monkeypatch, evals, pipeline_runs, token_runs, 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: pipeline_runs) + 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: token_runs) + return S.build_data(**kwargs) + + +def test_all_models_carries_n_valid_count_excludes_negative_sentinel(monkeypatch): + """Anthropic-Modell: 3 gueltige + 1 Sentinel-Zeile (-1.0) -> n_valid=3. + Gemini-Modell (Fehllesungs-Schutz): 2 Zeilen, BEIDE Sentinel -> n_valid=0, + nicht 2 (die Zeilen existieren, sind aber wertlos).""" + pipeline_runs = [ + _pipeline_run("r-claude", "anthropic/claude-sonnet-4-6"), + _pipeline_run("r-gemini", "gemini/gemini-2.5-flash"), + ] + token_runs = [_token_run("r-claude"), _token_run("r-gemini")] + evals = [ + _eval_row("n1", "r-claude", 0.1, "2026-01-01T00:00:01"), + _eval_row("n2", "r-claude", 0.2, "2026-01-01T00:00:02"), + _eval_row("n3", "r-claude", 0.0, "2026-01-01T00:00:03"), + _eval_row("n4", "r-claude", -1.0, "2026-01-01T00:00:04"), # Sentinel + _eval_row("n5", "r-gemini", -1.0, "2026-01-01T00:00:05"), + _eval_row("n6", "r-gemini", -1.0, "2026-01-01T00:00:06"), + ] + data = _patched_build_data(monkeypatch, evals, pipeline_runs, token_runs) + by_model = {o["model"]: o["n_valid"] for o in data["all_models"]} + assert by_model["anthropic/claude-sonnet-4-6"] == 3 + assert by_model["gemini/gemini-2.5-flash"] == 0 + + +def test_all_models_n_valid_none_hallucination_not_counted_as_valid(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"), + _eval_row("n2", "r1", None, "2026-01-01T00:00:02"), + ] + data = _patched_build_data(monkeypatch, evals, pipeline_runs, token_runs) + by_model = {o["model"]: o["n_valid"] for o in data["all_models"]} + assert by_model["anthropic/claude-haiku-4-5"] == 1 + + +def test_all_models_still_excludes_denylisted_smoke_models(monkeypatch): + """Regressions-Wächter: die bestehende _MODEL_DENYLIST-Filterung + (Smoke-/Testmodelle) bleibt unveraendert bei der Umstellung auf + {model, n_valid}-Objekte.""" + pipeline_runs = [_pipeline_run("r1", "smoke-model"), _pipeline_run("r2", "anthropic/claude-opus-4-7")] + token_runs = [_token_run("r1"), _token_run("r2")] + evals = [_eval_row("n1", "r2", 0.1, "2026-01-01T00:00:01")] + data = _patched_build_data(monkeypatch, evals, pipeline_runs, token_runs) + models = {o["model"] for o in data["all_models"]} + assert models == {"anthropic/claude-opus-4-7"} + + +# ── Frontend-Anker: Dropdown zeigt "(n=X)", value bleibt der Modellname ──── + + +def test_html_model_filter_shows_n_valid_badge_in_option_label(): + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + start = html.index("function _initGlobalModelFilter") + end = html.index("\n}", start) + block = html[start:end] + assert "m.model" in block + assert "m.n_valid" in block 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/generative/tests/test_dashboard_reeval_series_flag.py b/generative/tests/test_dashboard_reeval_series_flag.py new file mode 100644 index 0000000..6478147 --- /dev/null +++ b/generative/tests/test_dashboard_reeval_series_flag.py @@ -0,0 +1,176 @@ +"""Nachbesserung Punkt 4 (Statistiker-Empfehlung, Reviews 2026-07-15/16 zu PR #312): +Re-Eval-Kennzeichnung. + +Befund: unter eval_version 4.3 haengen alle 27 note_evals-Zeilen an +`pipeline_version` v0.3.144 -- das ist der CODE-STAND des Re-Eval-Sweeps +(`reeval_baseline.py`, ausgefuehrt zum Zeitpunkt v0.3.144), NICHT die +Erzeugungsversion der Notes. Der zugehoerige Lauf in `pipeline_runs` traegt +dafuer den expliziten Marker `pipeline_version="reeval"` (verifiziert in +generative/reeval_baseline.py: `INSERT OR IGNORE INTO pipeline_runs (run_id, +timestamp, pipeline_version, ...) VALUES (?, datetime('now'), 'reeval', ...)`), +verknuepft ueber `run_id`. Ohne Kennzeichnung liest die Matrix/das Dashboard +"v0.3.144" faelschlich als Erzeugungsversion. + +Fix: `D._is_reeval_series(quality_rows, pipeline_runs)` prueft, ob JEDE Zeile +der aktiven eval_version (`_matrix_base_rows`, vor Einzelwert-Filtern) an +einen `pipeline_runs`-Eintrag mit `pipeline_version=="reeval"` haengt. Server +exponiert das Ergebnis als `is_reeval_series` im Payload; das Matrix-Insight +zeigt bei True eine erklaerende Zeile. + +`internal/dashboard/eval_dashboard.html` enthält ein bewusstes NUL-Byte -- +Zugriff ausschließlich über `_build_live_html()` (Muster: +test_dashboard_version_pdf_matrix.py), nie über bash grep/sed. +""" + +from __future__ import annotations + +from generative.eval_dashboard import _is_reeval_series + +# ── _is_reeval_series (reine Aggregationsfunktion) ───────────────────────── + + +def test_is_reeval_series_true_when_every_row_maps_to_reeval_marker(): + rows = [{"run_id": "r1"}, {"run_id": "r2"}] + pipeline_runs = [ + {"run_id": "r1", "pipeline_version": "reeval"}, + {"run_id": "r2", "pipeline_version": "reeval"}, + ] + assert _is_reeval_series(rows, pipeline_runs) is True + + +def test_is_reeval_series_false_when_any_row_is_normal_generation(): + """Gemischter Fall: EINE Zeile stammt aus einem normalen Generierungslauf + (pipeline_version="v0.3.144") -- die eval_version ist dann keine reine + Re-Eval-Serie, das Flag darf nicht faelschlich True zeigen.""" + rows = [{"run_id": "r1"}, {"run_id": "r2"}] + pipeline_runs = [ + {"run_id": "r1", "pipeline_version": "reeval"}, + {"run_id": "r2", "pipeline_version": "v0.3.144"}, + ] + assert _is_reeval_series(rows, pipeline_runs) is False + + +def test_is_reeval_series_false_when_quality_rows_empty(): + """Leere eval_version -> nichts zu kennzeichnen, kein falsches Positiv.""" + assert _is_reeval_series([], []) is False + assert _is_reeval_series([], [{"run_id": "r1", "pipeline_version": "reeval"}]) is False + + +def test_is_reeval_series_false_when_run_id_missing_from_pipeline_runs(): + """Zeile ohne matchenden pipeline_runs-Eintrag (fehlender/unbekannter + run_id) zaehlt NICHT als reeval -- nur eine explizite Bestaetigung + (jede Zeile matcht 'reeval') schaltet das Flag.""" + rows = [{"run_id": "unknown"}] + assert _is_reeval_series(rows, []) is False + + +def test_is_reeval_series_false_when_row_has_no_run_id(): + rows = [{"note_path": "x.md"}] + pipeline_runs = [{"run_id": "r1", "pipeline_version": "reeval"}] + assert _is_reeval_series(rows, pipeline_runs) is False + + +# ── Server-Integration: build_data() liefert is_reeval_series ───────────── + + +def _dbrow(note, ver, pdf, hall, ts, run_id, eval_version="4.3"): + return { + "run_id": run_id, + "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 _patched_build_data(monkeypatch, evals, pipeline_runs, **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", "v0.3.144") + monkeypatch.setattr(_gdb, "query_pipeline_runs", lambda *a, **k: pipeline_runs) + 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) + + +def test_build_data_flags_pure_reeval_eval_version(monkeypatch): + """Nachbau des #232/Punkt-4-Produktionsmusters: eval_version 4.3, alle + Zeilen haengen an einen einzigen Reeval-Run.""" + evals = [ + _dbrow(f"n{i}", "v0.3.144", "a.pdf", 0.1, f"2026-07-15T00:00:{i:02d}", run_id="reeval-run-1") for i in range(3) + ] + pipeline_runs = [ + {"run_id": "reeval-run-1", "pipeline_version": "reeval", "pdf_source": "baseline-reeval"}, + ] + data = _patched_build_data(monkeypatch, evals, pipeline_runs) + assert data["is_reeval_series"] is True + + +def test_build_data_does_not_flag_mixed_eval_version(monkeypatch): + """Normale eval_version (echte Generierungs-Runs) darf NICHT als + Re-Eval-Serie markiert werden -- Regressionswaechter gegen ein zu + aggressives Flag.""" + evals = [ + _dbrow(f"n{i}", "v0.3.144", "a.pdf", 0.1, f"2026-07-15T00:00:{i:02d}", run_id="normal-run-1") for i in range(3) + ] + pipeline_runs = [ + {"run_id": "normal-run-1", "pipeline_version": "v0.3.144", "pdf_source": "a.pdf"}, + ] + data = _patched_build_data(monkeypatch, evals, pipeline_runs) + assert data["is_reeval_series"] is False + + +def test_build_data_reeval_flag_scoped_to_active_eval_version(monkeypatch): + """Das Flag bezieht sich NUR auf die aktive eval_version (_matrix_base_ + rows) -- eine andere eval_version mit normalen Runs darf das Flag der + aktiven Re-Eval-Serie nicht verwaessern.""" + evals = [ + _dbrow("n1", "v0.3.144", "a.pdf", 0.1, "2026-07-15T00:00:00", run_id="reeval-run-1", eval_version="4.3"), + _dbrow("m1", "v0.3.140", "a.pdf", 0.1, "2026-06-01T00:00:00", run_id="normal-run-1", eval_version="4.1"), + ] + pipeline_runs = [ + {"run_id": "reeval-run-1", "pipeline_version": "reeval"}, + {"run_id": "normal-run-1", "pipeline_version": "v0.3.140"}, + ] + data = _patched_build_data(monkeypatch, evals, pipeline_runs, eval_version="4.3") + assert data["eval_version"] == "4.3" + assert data["is_reeval_series"] is True + + +# ── Frontend-Anker: Matrix-Insight zeigt den Re-Eval-Hinweis ─────────────── + + +def _pairmatrix_js_block() -> str: + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + start = html.index("function renderPairMatrix") + end = html.index("/* ── Charts", start) + return html[start:end] + + +def test_html_has_reeval_note_element(): + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + assert 'id="pm-reeval-note"' in html + + +def test_html_render_pair_matrix_shows_reeval_sentence_when_flagged(): + block = _pairmatrix_js_block() + assert "is_reeval_series" in block + assert "Re-Eval-Serie" in block + assert "Werte messen den Eval-Stand, nicht die Erzeugungsversion der Notes" in block diff --git a/generative/tests/test_dashboard_responsive_1100px.py b/generative/tests/test_dashboard_responsive_1100px.py new file mode 100644 index 0000000..be16e6a --- /dev/null +++ b/generative/tests/test_dashboard_responsive_1100px.py @@ -0,0 +1,50 @@ +"""Punkt 9 (U8/U9, Multi-Perspektiven-Review 2026-07-15): Responsive-Fixes. + +U8: bei <=1200px (kpis-perf: 4 Spalten) UND <=900px (2 Spalten) liess die +5. (letzte) KPI-Kachel eine Zeile allein mit leeren Spalten daneben stehen +(5%4=1 bzw. 5%2=1) -- sichtbare Lücke statt sauberem Umbruch. Fix: letzte +Kachel spannt die volle Breite, wenn sie allein in ihrer Zeile steht. + +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. + +Playwright-Sichtprüfung (isolierter Testserver, Live-Daten read-only, +1100px): kpis-perf letzte Kachel spannt jetzt die volle Breite (kein +206px-Rest-Tile mit Lücke mehr), Filterbar-Gruppen kompakter. Diese Datei +sichert die CSS-Regeln als Regressionswächter (HTML-Anker-Muster, kein +bash grep/sed auf dem bewussten NUL-Byte). +""" + +from __future__ import annotations + +from generative.eval_dashboard_server import _build_live_html + + +def _responsive_css_block(max_width: int) -> str: + html = _build_live_html() + start = html.index(f"@media (max-width: {max_width}px)") + end = html.index("\n}", start) + return html[start:end] + + +def test_kpis_perf_last_tile_spans_full_row_at_1200px_breakpoint(): + block = _responsive_css_block(1200) + assert ".kpis-perf .kpi:last-child" in block + assert "grid-column: 1 / -1" in block + + +def test_kpis_perf_last_tile_spans_full_row_at_900px_breakpoint(): + block = _responsive_css_block(900) + assert ".kpis-perf .kpi:last-child" in block + assert "grid-column: 1 / -1" in block + + +def test_filter_group_padding_compacted_at_1200px_breakpoint(): + block = _responsive_css_block(1200) + assert ".filter-group { padding: 0 10px; }" in block + + +def test_fselect_max_width_reduced_at_1200px_breakpoint(): + block = _responsive_css_block(1200) + assert ".fselect { max-width: 150px; }" in block 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)" 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/generative/tests/test_dashboard_version_pdf_matrix.py b/generative/tests/test_dashboard_version_pdf_matrix.py index 8b75762..e06f8ce 100644 --- a/generative/tests/test_dashboard_version_pdf_matrix.py +++ b/generative/tests/test_dashboard_version_pdf_matrix.py @@ -705,6 +705,15 @@ def test_html_pair_matrix_shows_version_cap_footnote(): assert "nicht gezeigt" in block +def test_html_pair_matrix_cap_footnote_singular_plural(): + """Nachbesserung Punkt 5 (trivial): 'weitere Version(en)' war keine echte + Singular/Plural-Behandlung -- bei genau 1 uebrig gebliebenen Version stand + weiterhin die Klammer "(en)" da. Dieselbe Technik wie die bereits gefixte + Matrix-Insight-Zeile (test_pairmatrix_insight_singular_version_and_pdf_source).""" + block = _pairmatrix_js_block() + assert "n_versions_dropped === 1 ? 'weitere Version' : 'weitere Versionen'" in block + + def test_html_pair_matrix_table_has_dedicated_scroll_wrapper(): """Till-Live-Befund am gemergten #305: die Matrix (16 Spalten, ~1500px Mindestbreite via nowrap/min-width) war oberhalb des 1200px-Breakpoints @@ -723,3 +732,142 @@ 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 + + +# ── Nachbesserung Punkt 1 (4 externe Reviews nach PR #312, UX konvergent) ── +# Der pauschale `thead th`-Reset oben (Punkt 0) traf auch th:first-child +# ("Quell-PDF") -- beim Horizontal-Scroll scrollte die Kopfzelle mit dem +# Rest des Headers weg, waehrend die per td:first-child gepinnte PDF-Namen- +# Spalte im tbody stehen blieb: der jeweils linkeste sichtbare Versions- +# Header landete optisch UEBER der gepinnten Spalte (Beleg: +# C:/tmp/ux-review-312/desktop/a04-matrix-41-light-scroll400.png). + + +def test_html_pair_matrix_thead_reset_excludes_first_child(): + """Der position:static-Reset darf th:first-child NICHT mehr treffen -- + sonst verliert die "Quell-PDF"-Kopfzelle ihre Sticky-Left-Positionierung + und scrollt mit den Versions-Headern weg.""" + 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:not(:first-child)") + reset_block = css[reset_start : reset_start + 220] + 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_thead_first_child_stays_sticky_left(): + """Fix: eigene Regel pinnt die "Quell-PDF"-Kopfzelle links -- top:auto + hebt das top:49px der >=1201px-Media-Query-Regel (Zeile ~495) explizit + auf, z-index haelt sie ueber den (jetzt statischen) Versions-Headern und + der generischen th:first-child/td:first-child-Regel (z-index 2).""" + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + css_start = html.index("") + css = html[css_start:css_end] + rule_start = css.index(".table-wrap.pm-scroll table.cmp thead th:first-child") + rule_block = css[rule_start : rule_start + 280] + assert "position: sticky" in rule_block + assert "left: 0" in rule_block + assert "top: auto" in rule_block + assert "background: var(--bg-card)" in rule_block + import re + + z = re.search(r"z-index:\s*(\d+)", rule_block) + assert z is not None and int(z.group(1)) > 2 + + +# ── Nachbesserung Punkt 2 (UX-Review 16.07): Paarvergleichstabelle ohne +# pm-scroll -- bei 1100px war die letzte Spalte "Paarung" hart abgeschnitten +# und zentrierte Hinweiszeilen (PDF-Key-Drift) wurden mitten im Wort geclippt +# (Beleg: C:/tmp/ux-review-312/responsive/c-drift-v0.3.140-vs-v0.3.60-1100- +# light.png); bei 1280px nur 2px Restluft (fragil, gleiche Bugklasse wie +# #305/Punkt 0 oberhalb 1201px). + + +def test_html_pair_compare_table_wrapper_has_pm_scroll(): + """Fix: derselbe pm-scroll-Mechanismus wie an der Matrix -- haelt den + Scroll-Container in ALLEN Viewport-Breiten aktiv statt sich auf die + fragile 2px-Restluft bei 1280px zu verlassen.""" + from generative.eval_dashboard_server import _build_live_html + + html = _build_live_html() + section = html[html.index('id="s-pairmatrix"') : html.index('id="pm-compare-table"')] + assert 'class="table-wrap pm-scroll"' in section + + +def test_html_pair_compare_drift_hint_moved_to_summary_not_table_row(): + """Der laengere Drift-Hinweis darf nicht mehr Teil der zentrierten + -Zeile IN der (potenziell ueberbreiten) Tabelle sein -- er + lebt jetzt in `summary`, ausserhalb des Scroll-Containers, wo die + Section-Breite (nicht die Tabellenbreite) die Umbruchgrenze setzt.""" + block = _pairmatrix_js_block() + + body_start = block.index("body.innerHTML = rows.length") + body_end = block.index("'", body_start) + len("'") + body_stmt = block[body_start:body_end] + assert "maybeDrift" not in body_stmt + assert "Corpus vollständig ausgetauscht." in body_stmt + + summary_start = block.rindex("summary.innerHTML = ") + summary_end = block.index(");", summary_start) + summary_stmt = block[summary_start:summary_end] + assert "maybeDrift" in summary_stmt diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index 3c747ba..ddad041 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -152,6 +152,19 @@ } .side-foot .eval-row { display: flex; align-items: center; gap: 6px; color: var(--ink-3); } .side-foot .ev-v { font-size: 10px; padding: 1px 5px; background: var(--tint-amber); color: var(--amber); border-radius: 2px; } +/* Punkt 1 (Design-Iteration nach Till-Feedback): .ev-v bleibt die visuelle + Basis (Pill-Optik unveraendert) -- .ev-select macht daraus ein natives +
auto-refresh 15 s