diff --git a/generative/calibration/collect.py b/generative/calibration/collect.py index 4d1a248..52a92b9 100644 --- a/generative/calibration/collect.py +++ b/generative/calibration/collect.py @@ -205,13 +205,18 @@ def main() -> None: print(f" [warn] Pipeline-Labels für Agreement nicht ladbar: {_ke}", file=_sys.stderr) # LLM-Halluzinationsraten aus note_evals holen (kanonische DB: db.DB_PATH — - # nicht generative/.cache, dort liegt keine DB) + # nicht generative/.cache, dort liegt keine DB). note_evals kann mehrere + # Zeilen derselben Note tragen (Re-Evals, s. Statistik-Review 2026-07-15/ + # eval_dashboard._dedup_latest_per_note) — ohne ORDER BY war die Zeilen- + # reihenfolge unspezifiziert und "letzte gewinnt" damit nicht-deterministisch; + # `ORDER BY timestamp` macht "neueste Eval-Zeile gewinnt" explizit, dieselbe + # Dedup-Basis wie KPI-Kachel/per-PDF-Tabelle. _db.init_db(_db.DB_PATH) conn_plain = _sq.connect(str(_db.DB_PATH)) llm_rates = { r[0]: r[1] for r in conn_plain.execute( - "SELECT note_path, hallucination_rate FROM note_evals WHERE eval_version='4.1'" + "SELECT note_path, hallucination_rate FROM note_evals WHERE eval_version='4.1' ORDER BY timestamp" ).fetchall() } conn_plain.close() diff --git a/generative/eval_dashboard.py b/generative/eval_dashboard.py index 59090b5..490394f 100644 --- a/generative/eval_dashboard.py +++ b/generative/eval_dashboard.py @@ -51,6 +51,7 @@ _WORDS_RE = re.compile(r"(\d[\d.]*)\s+W") _PAGES_RE = re.compile(r"(\d+)\s+S\.") _CHUNKS_RE = re.compile(r"(\d+)\s+Chunks") +_NOTE_NS_PREFIX_RE = re.compile(r"^(?:vault|inbox|merge)__") _PDF_LABELS: dict[str, str] = { "bates": "Bates 2017", @@ -571,6 +572,13 @@ def _calc_kpis( if latest_pver else quality_rows ) + # Re-Eval-Dedup (Statistik-Review 2026-07-15): pro Note nur die neueste + # Eval-Zeile — sonst poolen hall/cov unten Anker mehrfach-evaluierter + # Notes mehrfach (Produktionsbeleg: 52 Zeilen / 40 distinct Notes bei + # v0.3.140). Ab hier ist `latest_qrows` die EINE Basis für hall, cov UND + # n_notes — die Kachel-n passt dadurch automatisch zur Pooling-Basis + # (vorher: Kachel n=40, Pooling-Basis 52 — inkonsistent). + latest_qrows = _dedup_latest_per_note(latest_qrows) all_versions = sorted({r["ver"] for r in all_log_runs if r.get("ver")}, key=_ver_sort_key) @@ -751,6 +759,58 @@ def _distinct_notes(rows: list[dict]) -> int: return len({r.get("note_path") or r.get("note") or i for i, r in enumerate(rows)}) +def _note_key(r: dict, idx: int) -> str: + """Normalisierter Note-Schlüssel für Dedup/Identitätsvergleiche. + + `note_path`/`note` mit gestripptem Namespace-Prefix (`vault__`/`inbox__`/ + `merge__`) — eine Note kann zwischen zwei Re-Evals den Namespace wechseln + (Routing-Änderung/Merge), ein reiner Feldvergleich würde dieselbe Note + sonst als zwei Identitäten zählen (Statistik-Review 2026-07-15). Fehlt ein + Identifier (synthetische Rows), ist der Schlüssel der Zeilenindex — + konsistent mit dem Fallback in `_distinct_notes`.""" + raw = r.get("note_path") or r.get("note") + if not raw: + return f"__row{idx}" + return _NOTE_NS_PREFIX_RE.sub("", str(raw)) + + +def _dedup_latest_per_note(rows: list[dict]) -> list[dict]: + """Pro (normalisierter) Note NUR die neueste Eval-Zeile. + + Statistik-Review 2026-07-15 (3 unabhängige Opus-Statistiker, konvergent + + adversarial bestätigt): note_evals enthält mehrere Zeilen derselben Note + innerhalb einer pipeline_version (Re-Evals + identische Duplikat-Inserts; + Produktionsbeleg v0.3.140 = 52 Zeilen / 40 distinct Notes, 12 Duplikate). + Ungefiltert poolt jede KPI-Aggregation (`_pooled_hall_stats`, Coverage- + Median, `kpi_trend` im Server) Anker mehrfach-evaluierter Notes mehrfach — + Pseudoreplikation, ~2pp Bias auf der gepoolten Fehlerquote (oft + re-evaluierte Notes haben tendenziell gute Raten, die die Poolung sonst + nach unten ziehen). + + Aufrufer MÜSSEN `rows` vorher auf eine einzelne pipeline_version + einschränken — der Dedup hier ist versionsblind (reine Note-Identität); + das Vermischen mehrerer Versionen ist Aufgabe des Callers, nicht dieser + Funktion. + + „Neueste" = größter `timestamp`-String (ISO 8601, lexikographisch + sortierbar); Tie-Break `eval_id` (ebenfalls Timestamp-präfixiert), dann + Listenposition — rein für Determinismus bei exaktem Timestamp+eval_id- + Gleichstand (Rows ohne beides: die letzte in der Liste gewinnt, was zur + `ORDER BY timestamp`-Reihenfolge von `db.query_note_evals` passt). Rows + ohne Note-Identifier zählen einzeln (s. `_note_key`).""" + best: dict[str, tuple] = {} + order: list[str] = [] + for i, r in enumerate(rows): + key = _note_key(r, i) + sort_key = (str(r.get("timestamp") or ""), str(r.get("eval_id") or ""), i) + prev = best.get(key) + if prev is None or sort_key > prev[0]: + if prev is None: + order.append(key) + best[key] = (sort_key, r) + return [best[k][1] for k in order] + + def _newest_capped_version(versions: list[str], current: str | None) -> str | None: """Neueste Version, die nicht NEUER als die Config-Version ist (#191); fällt auf die neueste vorhandene zurück, wenn ALLE Versionen Orphans sind @@ -864,6 +924,10 @@ def _words_for(runs: list[dict]) -> int | None: # „neueste Eval-Version" zeigen (#194 P5). orphan = bool(vers) and _capped_latest_version(vers, current_version) is None at = [r for r in qrows if _row_version(r) == ver] + # Re-Eval-Dedup (Statistik-Review 2026-07-15): dieselbe Basis wie die + # KPI-Kachel (`_calc_kpis`) — sonst zeigen Kachel und per-PDF-Tabelle + # unterschiedliche gepoolte Raten für dieselbe Version. + 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] @@ -985,6 +1049,19 @@ def _chart_longitudinal(log_data: dict) -> dict: _DELTA_MIN_N = 20 # unter N=20 kein Besser/Schlechter-Urteil (Apophenie-Schutz) +# Corpus-Overlap-Guard (Statistik-Review 2026-07-15, 3 unabhängige Opus- +# Statistiker, konvergent + adversarial bestätigt): n>=20 in beiden Versionen +# allein härtet ein Delta NICHT gegen PDF-Mix-Artefakte. Produktionsbeleg +# v0.3.140 -> v0.3.143: beide n>=20 (40/22 distinct Notes), aber nur 3 von 9/5 +# PDFs geteilt — von den 22 Notes der neueren Version stammen nur 8 (36 %) aus +# einer PDF, die auch in v0.3.140 vorkommt. Das dort gemessene +2,7pp-Hall- +# Delta ist damit größtenteils ein ausgetauschter Corpus, kein echter +# Versions-Effekt, wurde vor diesem Fix aber als "reliable" (grün/rot) gezeigt. +# Schwelle 50 %: unter der Hälfte notengewichteter Quellen-Überlappung ist der +# Corpus faktisch ausgetauscht. Als Konstante konfigurierbar statt hart +# inline verdrahtet, falls sich das in der Praxis als zu streng/lax zeigt. +_DELTA_MIN_PDF_OVERLAP = 0.5 + def version_delta(kpi_trend: dict, metric: str) -> dict: """Delta der neuesten Version gegen die letzte belastbare Vorversion. @@ -998,12 +1075,24 @@ def version_delta(kpi_trend: dict, metric: str) -> dict: (n=1–2), gegen die jedes Delta reliable:false wäre. Existiert KEINE frühere Version mit n>=_DELTA_MIN_N, greift der bisherige Fallback (direkte Vorversion, Chip bleibt grau/reliable:false). `prev_version`/`prev_n` machen - im Client-Tooltip transparent, WOGEGEN verglichen wird. `reliable` bleibt an - den n>=_DELTA_MIN_N-Guard in BEIDEN Versionen gebunden. + im Client-Tooltip transparent, WOGEGEN verglichen wird. + + Statistik-Review 2026-07-15: `reliable` ist zusätzlich an den Corpus- + Overlap gekoppelt (`_DELTA_MIN_PDF_OVERLAP`) — der Notes-Anteil der + neuesten Version, dessen PDF-Quelle auch in der Vergleichsversion + vorkommt. `kpi_trend["pdf_notes"]` (optional, vom Server befüllt, s. + eval_dashboard_server.py) trägt dafür je Version ein + `{pdf_group_key: n_notes}`-Dict. Fehlt der Key (ältere Aufrufer/Tests ohne + `pdf_notes`), greift der Guard nicht — reines n>=20-Verhalten bleibt + rückwärtskompatibel. `reason` unterscheidet im Rückgabewert, WARUM + `reliable` False ist (`"n_lt_20"` vs. `"pdf_mix"`), damit der Client die + beiden Fälle unterschiedlich betexten kann ("n<20" vs. "nicht vergleichbar + (PDF-Mix)"). """ values = kpi_trend.get(metric) or [] ns = kpi_trend.get("n") or [] versions = kpi_trend.get("versions") or [] + pdf_notes = kpi_trend.get("pdf_notes") or [] latest = values[-1] if values else None n_latest = ns[-1] if ns else None @@ -1013,11 +1102,14 @@ def _n_at(i: int) -> int: def _ver_at(i: int): return versions[i] if i < len(versions) else None + def _pdf_notes_at(i: int) -> dict: + return pdf_notes[i] if i < len(pdf_notes) and pdf_notes[i] else {} + # Jüngste frühere Version mit Wert und n>=_DELTA_MIN_N suchen (rückwärts). - prev = prev_version = n_prev = None + prev = prev_version = n_prev = prev_idx = None for i in range(len(values) - 2, -1, -1): if values[i] is not None and _n_at(i) >= _DELTA_MIN_N: - prev, n_prev, prev_version = values[i], _n_at(i), _ver_at(i) + prev, n_prev, prev_version, prev_idx = values[i], _n_at(i), _ver_at(i), i break else: # Fallback: direkte Vorversion (bisheriges Verhalten; reliable bleibt False). @@ -1025,9 +1117,31 @@ def _ver_at(i: int): prev = values[-2] n_prev = ns[-2] if len(ns) >= 2 else None prev_version = _ver_at(len(values) - 2) + prev_idx = len(values) - 2 delta = None if (latest is None or prev is None) else round(latest - prev, 4) - reliable = delta is not None and (n_latest or 0) >= _DELTA_MIN_N and (n_prev or 0) >= _DELTA_MIN_N + n_reliable = delta is not None and (n_latest or 0) >= _DELTA_MIN_N and (n_prev or 0) >= _DELTA_MIN_N + + # Notengewichteter Corpus-Overlap: Anteil der Notes der NEUESTEN Version, + # deren PDF-Quelle auch in der Vergleichsversion (prev_idx) vorkommt. + pdf_overlap = None + if prev_idx is not None: + latest_pdf_notes = _pdf_notes_at(len(values) - 1) + prev_pdf_notes = _pdf_notes_at(prev_idx) + total = sum(latest_pdf_notes.values()) + if total: + shared = sum(n for pdf, n in latest_pdf_notes.items() if pdf in prev_pdf_notes) + pdf_overlap = round(shared / total, 3) + pdf_ok = pdf_overlap is None or pdf_overlap >= _DELTA_MIN_PDF_OVERLAP + + reliable = n_reliable and pdf_ok + reason = None + if delta is not None: + if not n_reliable: + reason = "n_lt_20" + elif not pdf_ok: + reason = "pdf_mix" + return { "latest": latest, "prev": prev, @@ -1035,6 +1149,8 @@ def _ver_at(i: int): "reliable": reliable, "prev_version": prev_version, "prev_n": n_prev, + "pdf_overlap": pdf_overlap, + "reason": reason, } diff --git a/generative/eval_dashboard_server.py b/generative/eval_dashboard_server.py index 1efdd3a..437b073 100644 --- a/generative/eval_dashboard_server.py +++ b/generative/eval_dashboard_server.py @@ -713,12 +713,6 @@ def _safe_median(lst): if ver not in quality_by_version: quality_by_version[ver] = {"hall": [], "cov": [], "accept": [], "n": 0, "rows": []} quality_by_version[ver]["rows"].append(r) - hall_val = r.get("hallucination_rate") - if hall_val is not None and float(hall_val) >= 0: - quality_by_version[ver]["hall"].append(float(hall_val) * 100) - cov = r.get("coverage_factual") or r.get("coverage_rate") - if cov is not None and float(cov) >= 0: - quality_by_version[ver]["cov"].append(float(cov) * 100) # Statistiken berechnen (Median ist primär, Mean sekundär) def _vkey(v): @@ -730,6 +724,19 @@ def _vkey(v): sorted_pipeline_versions = sorted(quality_by_version.keys(), key=_vkey) for ver, d2 in quality_by_version.items(): + # Re-Eval-Dedup (Statistik-Review 2026-07-15): pro Note nur die + # neueste Eval-Zeile — VOR der hall/cov-Extraktion, damit Kachel + # (_calc_kpis), per-PDF-Tabelle und diese Sparkline dieselbe Basis + # teilen (Produktionsbeleg: 52 Zeilen / 40 distinct Notes bei + # v0.3.140, gepoolte Rate sonst um ~2pp nach unten verzerrt). + d2["rows"] = D._dedup_latest_per_note(d2["rows"]) + for r in d2["rows"]: + 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") + if cov is not None and float(cov) >= 0: + d2["cov"].append(float(cov) * 100) # n = distinct Notes (nicht Eval-Instanzen), identisch zur „Evaluierte # Notes"-KPI-Kachel (_calc_kpis.n_notes) — sonst zählt die Kachel distinct # (40) und die kpi_trend.n-Sparkline Instanzen (52) (#194 #4). @@ -741,6 +748,17 @@ def _vkey(v): d2["avg_cov"] = round(sum(d2["cov"]) / len(d2["cov"]), 1) if d2["cov"] else None d2["median_hall"] = _safe_median(d2["hall"]) d2["median_cov"] = _safe_median(d2["cov"]) + # Notes je PDF-Quelle (Statistik-Review 2026-07-15, Fix 2): Basis für + # den Corpus-Overlap-Guard in D.version_delta — kanonischer + # PDF-Gruppen-Schlüssel (SSoT mit PDF-Filter/-Tabelle, #194/#202) + # statt Rohtitel, sonst zählen Volltitel- und Kebab-Varianten + # derselben Quelle als "verschiedene" PDFs. + _pdf_notes: dict[str, int] = {} + for r in d2["rows"]: + gk = D._pdf_group_key(r.get("pdf")) if r.get("pdf") else "" + if gk: + _pdf_notes[gk] = _pdf_notes.get(gk, 0) + 1 + d2["pdf_notes"] = _pdf_notes # Trend-Daten fuer KPI-Drill-Down (sortierte Listen parallel zu sorted_pipeline_versions) # Akzeptanzrate je Pipeline-Version: gepoolt (sum vault / sum total) — @@ -795,6 +813,9 @@ def _pooled_accept(ver: str) -> float | None: "cost": [ round(sum(cost_by_ver.get(v, [])), 4) if cost_by_ver.get(v) else None for v in sorted_pipeline_versions ], + # pdf_notes (Statistik-Review 2026-07-15, Fix 2): {pdf_group_key: n_notes} + # je Version — Basis für den Corpus-Overlap-Guard in D.version_delta(). + "pdf_notes": [quality_by_version[v].get("pdf_notes", {}) for v in sorted_pipeline_versions], } # Kosten pro akzeptierter Note je Version (#196 P2): nur für API-Runs mit # Pricing aussagekräftig — subscription-Läufe kosten 0 (compute_cost_per_call) @@ -891,14 +912,29 @@ def _vault_name() -> str: def _chart_scatter_versioned(quality_rows: list[dict]) -> dict: - """Scatter-Daten mit Version-Info fuer den Version-Filter.""" + """Scatter-Daten mit Version-Info fuer den Version-Filter. + + Re-Eval-Dedup (Nachbesserung adversariale Kontrolle #293): pro + pipeline_version nur die neueste Eval-Zeile je Note — dieselbe Basis wie + KPI-Kachel/per-PDF-Tabelle/kpi_trend (`_dedup_latest_per_note`). Vorher + zeigte der Scatter Eval-Instanzen (52 Punkte bei v0.3.140), während die + Kachel korrekt 40 distinct Notes auswies; re-evaluierte Notes erschienen + doppelt (z. B. "Asynchronous E-Learning" bei x=0,0 UND x=29,4) — die + "Instanzen vs. distinct"-Bugklasse (#194). Gruppierung JE Version, nicht + global: dieselbe Note in zwei Versionen bleibt zwei Punkte (der + Versions-Filter des Scatters vergleicht Versionen).""" + by_ver: dict[str, list[dict]] = {} + for r in quality_rows: + by_ver.setdefault(r.get("version") or r.get("pipeline_version") or "unbekannt", []).append(r) + deduped_rows = [r for rows in by_ver.values() for r in D._dedup_latest_per_note(rows)] + points: list[dict] = [] pdf_map: dict[str, str] = {} versions: list[str] = [] import re as _re - for r in quality_rows: + for r in deduped_rows: hall = r.get("hallucination_rate") cov = r.get("coverage_factual") or r.get("coverage_rate") # Sentinel-Werte (-1.0 = ungültig) überspringen diff --git a/generative/tests/test_dashboard_delta_pdf_overlap.py b/generative/tests/test_dashboard_delta_pdf_overlap.py new file mode 100644 index 0000000..425c411 --- /dev/null +++ b/generative/tests/test_dashboard_delta_pdf_overlap.py @@ -0,0 +1,188 @@ +"""Tests für den Corpus-Overlap-Guard bei Versions-Deltas (Statistik-Review 2026-07-15). + +Befund (3 unabhängige Opus-Statistiker, konvergent + adversarial bestätigt): +`version_delta()` markierte ein Delta als "reliable" allein ab n>=20 in beiden +Versionen (`_DELTA_MIN_N`) — unabhängig davon, ob die beiden Versionen +überhaupt denselben Corpus (PDF-Quellen) evaluieren. Produktionsbeleg +v0.3.140 -> v0.3.143: beide n>=20 (40/22 distinct Notes), aber nur 3 von 9/5 +PDFs geteilt — von den 22 Notes der neueren Version stammen nur 8 (36 %) aus +einer PDF, die auch in v0.3.140 vorkommt. Das +2,7pp-Hall-Delta ist damit +größtenteils ein PDF-Mix-Artefakt, kein echter Versions-Effekt, wurde aber +grün/rot als belastbar angezeigt. + +Fix: `reliable` zusätzlich an den Notes-Anteil der neueren Version gekoppelt, +dessen PDF auch in der Vergleichsversion vorkommt (>= `_DELTA_MIN_PDF_OVERLAP` += 50 %). Unter der Schwelle bleibt das Delta sichtbar, aber `reliable=False` +mit `reason="pdf_mix"` statt `"n_lt_20"` — der Client kann die beiden Fälle +im Tooltip unterscheiden ("nicht vergleichbar (PDF-Mix)" statt "n<20"). +""" + +from __future__ import annotations + +import pytest + +from generative.eval_dashboard import version_delta + +# Produktionsbeleg v0.3.140 -> v0.3.143 (dedupliziert, s. test_dashboard_reeval_dedup.py): +# pdf_notes = {pdf_group_key: distinct-Notes-Zahl} je Version. +_PROD_PDF_NOTES = [ + { + "assfalg-2013": 2, + "ebner-und-gegenfurtner-2019": 7, + "hrastinski-2008": 6, + "knowles-from-pedagogy-to-andragogy": 9, + "mahmood-und-university-of-the-punjab-2016": 4, + "merrill-first-principles-of-instruction": 5, + "reimer-2013": 1, + "schlebbe-und-greifeneder-2022": 1, + "zettelkasten-primer": 5, + }, + { + "bates-information-behavior": 4, + "hrastinski-2008": 2, + "s-hl-strohmenger-2008": 10, + "schlebbe-und-greifeneder-2022": 2, + "zettelkasten-primer": 4, + }, +] + + +def _kpi_trend(**over): + base = { + "versions": ["v0.3.140", "v0.3.143"], + "hall": [9.46, 12.01], + "n": [40, 22], + "pdf_notes": [dict(d) for d in _PROD_PDF_NOTES], + } + base.update(over) + return base + + +def test_production_delta_140_143_not_reliable_due_to_pdf_mix(): + # n>=20 in beiden Versionen (40/22), aber nur 8/22 = 36 % Notes-Overlap + # -> unter der 50%-Schwelle, nicht belastbar trotz ausreichendem n. + d = version_delta(_kpi_trend(), "hall") + assert d["reliable"] is False + assert d["reason"] == "pdf_mix" + assert d["pdf_overlap"] == pytest.approx(8 / 22, abs=0.001) + + +def test_full_overlap_stays_reliable(): + trend = _kpi_trend(pdf_notes=[{"a": 20}, {"a": 20}]) # 100 % Overlap + d = version_delta(trend, "hall") + assert d["reliable"] is True + assert d["reason"] is None + assert d["pdf_overlap"] == 1.0 + + +def test_overlap_exactly_at_threshold_is_reliable(): + trend = _kpi_trend( + pdf_notes=[ + {"a": 10, "b": 10}, # prev + {"a": 10, "c": 10}, # latest: a geteilt (10), c nicht (10) -> 50 % + ] + ) + d = version_delta(trend, "hall") + assert d["pdf_overlap"] == 0.5 + assert d["reliable"] is True # genau an der Schwelle gilt als belastbar + + +def test_disjoint_corpus_zero_overlap(): + trend = _kpi_trend(pdf_notes=[{"a": 30}, {"b": 30}]) + d = version_delta(trend, "hall") + assert d["pdf_overlap"] == 0.0 + assert d["reliable"] is False + assert d["reason"] == "pdf_mix" + + +def test_n_guard_reason_takes_priority_over_pdf_mix(): + # latest zu klein (n<20) UND PDF-Mix -> reason meldet den n-Guard, nicht pdf_mix + trend = _kpi_trend(n=[40, 5], pdf_notes=[{"a": 30}, {"b": 5}]) + d = version_delta(trend, "hall") + assert d["reliable"] is False + assert d["reason"] == "n_lt_20" + + +def test_missing_pdf_notes_key_skips_overlap_guard_backward_compat(): + # Ohne "pdf_notes" (ältere Aufrufer/Tests) bleibt das reine n>=20-Verhalten + # unverändert -- der Guard greift nur, wenn der Server die Daten liefert. + trend = { + "versions": ["v1", "v2"], + "hall": [12.0, 9.7], + "n": [25, 22], + } + d = version_delta(trend, "hall") + assert d["reliable"] is True + assert d["pdf_overlap"] is None + assert d["reason"] is None + + +def test_overlap_guard_applies_to_fallback_prev_too(): + # Kein früherer n>=20-Vergleichspunkt -> Fallback auf direkte Vorversion + # (bisheriges Verhalten, reliable bleibt False) -- pdf_overlap wird + # trotzdem gegen den Fallback-prev berechnet, nicht gecrasht. + trend = { + "versions": ["v1", "v2"], + "hall": [5.0, 9.7], + "n": [3, 25], + "pdf_notes": [{"a": 3}, {"a": 25}], + } + d = version_delta(trend, "hall") + assert d["reliable"] is False + assert d["reason"] == "n_lt_20" + + +# ── Server-Integration: build_data() verdrahtet pdf_notes bis in kpi_trend ── + + +def _eval(note, ver, pdf, hall, ts, total=10, hallucinated=0): + 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": 0.5, + "pdf": pdf, + "eval_version": "4.1", + "timestamp": ts, + } + + +def test_build_data_flags_pdf_mix_delta_via_kpi_trend(monkeypatch): + """End-to-End: build_data() -> quality_by_version -> kpi_trend["pdf_notes"] + -> version_delta() erkennt einen weitgehend ausgetauschten Corpus, obwohl + n>=20 in beiden Versionen (Produktionsmuster v0.3.140 -> v0.3.143).""" + 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 = [] + # v0.3.140: 25 Notes, davon 20 auf "Shared.pdf", 5 auf "Only140.pdf". + for i in range(20): + evals.append(_eval(f"shared-{i}.md", "v0.3.140", "Shared.pdf", 0.0, f"2026-06-01T00:00:{i:02d}")) + for i in range(5): + evals.append(_eval(f"only140-{i}.md", "v0.3.140", "Only140.pdf", 0.0, f"2026-06-02T00:00:{i:02d}")) + # v0.3.143: 22 Notes, nur 3 teilen "Shared.pdf" mit v0.3.140 -> 3/22 = 13.6 % Overlap. + for i in range(3): + evals.append(_eval(f"shared2-{i}.md", "v0.3.143", "Shared.pdf", 0.5, f"2026-06-10T00:00:{i:02d}", 10, 5)) + for i in range(19): + evals.append(_eval(f"only143-{i}.md", "v0.3.143", "Only143.pdf", 0.5, f"2026-06-11T00:00:{i:02d}", 10, 5)) + + monkeypatch.setattr(_cfg, "AGENT_VERSION", "v0.3.143") + monkeypatch.setattr(_gdb, "query_pipeline_runs", lambda *a, **k: []) + monkeypatch.setattr(_gdb, "query_note_evals", lambda *a, **k: evals) + monkeypatch.setattr(D, "_read_all_log_runs", lambda: []) + monkeypatch.setattr(D, "_read_token_runs", lambda: []) + + data = S.build_data() + kt = data["kpi_trend"] + assert kt["versions"] == ["v0.3.140", "v0.3.143"] + assert kt["n"] == [25, 22] # n>=20 in beiden -> der alte Guard allein hätte reliable=True ergeben + vd = kt["deltas"]["hall"] + assert vd["reliable"] is False + assert vd["reason"] == "pdf_mix" + assert vd["pdf_overlap"] == pytest.approx(3 / 22, abs=0.001) diff --git a/generative/tests/test_dashboard_reeval_dedup.py b/generative/tests/test_dashboard_reeval_dedup.py new file mode 100644 index 0000000..80af791 --- /dev/null +++ b/generative/tests/test_dashboard_reeval_dedup.py @@ -0,0 +1,255 @@ +"""Tests für Re-Eval-Dedup in den KPI-/Trend-Aggregationen (Statistik-Review 2026-07-15). + +Befund (3 unabhängige Opus-Statistiker, konvergent + adversarial bestätigt): +note_evals enthält mehrere Eval-Zeilen derselben Note innerhalb einer +pipeline_version (Re-Evals + identische Duplikat-Inserts; Produktionsbeleg +v0.3.140 = 52 Zeilen / 40 distinct Notes, 12 Duplikate; v0.3.143 = 8 Duplikate). +Ungefiltert poolt jede KPI-/Trend-Aggregation (_calc_kpis, _calc_pdf_table, +kpi_trend im Server) Anker mehrfach-evaluierter Notes mehrfach — Pseudo- +replikation, ~2pp Bias nach unten auf der Fehlerquote (Produktionskopie: +gepoolte Hall-Rate v0.3.140 7,53 % -> 9,46 % nach Dedup, weil oft +re-evaluierte Notes tendenziell gute Raten haben). Zusätzlich: die Kachel +zeigte n=40 (distinct Notes), die Pooling-Basis war aber 52 Zeilen — +inkonsistent. + +Fix: pro (pipeline_version, note) NUR die neueste Eval-Zeile (max timestamp, +Tie-Break eval_id) in jede Aggregation. Note-Identität normalisiert über +`_note_key` (Namespace-Prefix `vault__`/`inbox__`/`merge__` gestrippt) — Notes +können zwischen zwei Re-Evals den Namespace wechseln (Routing-Änderung). +""" + +from __future__ import annotations + +from generative.eval_dashboard import ( + _build_log_data, + _calc_kpis, + _calc_pdf_table, + _dedup_latest_per_note, + _note_key, + _pooled_hall_pct, +) + + +def _qrow(note, ver, hall, total, hallucinated, ts, eval_id=None, cov=0.5): + return { + "note_path": note, + "version": ver, + "hallucination_rate": hall, + "anchors_total": total, + "anchors_hallucinated": hallucinated, + "coverage_factual": cov, + "timestamp": ts, + "eval_id": eval_id or f"{ts}__{note}", + } + + +# ── _note_key: Namespace-Prefix-Drift ─────────────────────────────────────── + + +def test_note_key_strips_vault_prefix(): + assert _note_key({"note_path": "vault__Zettelkasten.md"}, 0) == "Zettelkasten.md" + + +def test_note_key_strips_inbox_prefix(): + assert _note_key({"note_path": "inbox__Foo.md"}, 0) == "Foo.md" + + +def test_note_key_unifies_prefix_drift_between_reevals(): + # Dieselbe Note kann zwischen zwei Re-Evals den Namespace wechseln + # (Routing-Änderung) — ohne Normalisierung zählten vault__X und X als + # zwei Identitäten. + a = _note_key({"note_path": "vault__Zettelkasten.md"}, 0) + b = _note_key({"note_path": "Zettelkasten.md"}, 1) + assert a == b + + +def test_note_key_falls_back_to_row_index_without_identifier(): + assert _note_key({}, 3) == "__row3" + assert _note_key({}, 3) != _note_key({}, 4) + + +# ── _dedup_latest_per_note ─────────────────────────────────────────────────── + + +def test_dedup_keeps_only_latest_row_per_note(): + rows = [ + _qrow("a.md", "v1", 0.0, 17, 0, "2026-06-21T19:50:12"), + _qrow("a.md", "v1", 0.0, 17, 0, "2026-06-21T21:20:53"), + _qrow("a.md", "v1", 0.294, 17, 5, "2026-07-05T19:59:18"), # neueste + ] + out = _dedup_latest_per_note(rows) + assert len(out) == 1 + assert out[0]["hallucination_rate"] == 0.294 + + +def test_dedup_normalizes_namespace_prefix_drift(): + rows = [ + _qrow("vault__X.md", "v1", 0.0, 10, 0, "2026-06-01T00:00:00"), + _qrow("X.md", "v1", 0.5, 10, 5, "2026-06-02T00:00:00"), # gleiche Note, Prefix weg + ] + out = _dedup_latest_per_note(rows) + assert len(out) == 1 + assert out[0]["hallucination_rate"] == 0.5 + + +def test_dedup_tie_breaks_deterministically_on_eval_id(): + rows = [ + _qrow("a.md", "v1", 0.0, 10, 0, "2026-06-01T00:00:00", eval_id="20260601-000000__a.md"), + _qrow("a.md", "v1", 0.5, 10, 5, "2026-06-01T00:00:00", eval_id="20260601-000001__a.md"), + ] + out = _dedup_latest_per_note(rows) + assert len(out) == 1 + assert out[0]["hallucination_rate"] == 0.5 # größerer eval_id gewinnt bei Timestamp-Gleichstand + + +def test_dedup_rows_without_identifier_all_kept(): + rows = [{"hallucination_rate": 0.0}, {"hallucination_rate": 0.5}] + out = _dedup_latest_per_note(rows) + assert len(out) == 2 # synthetische Rows zählen einzeln (Fallback Zeilenindex) + + +def test_dedup_leaves_distinct_notes_untouched(): + rows = [_qrow("a.md", "v1", 0.0, 10, 0, "t1"), _qrow("b.md", "v1", 0.5, 10, 5, "t2")] + out = _dedup_latest_per_note(rows) + assert len(out) == 2 + + +def test_dedup_empty_is_empty(): + assert _dedup_latest_per_note([]) == [] + + +# ── Integration: gepoolte Rate steigt nach Dedup (Produktionsmuster) ──────── + + +def test_pooled_hall_increases_after_dedup_when_duplicates_are_clean(): + # 3 identische "gute" Duplikat-Zeilen + 1 "schlechte" neueste Zeile für + # dieselbe Note (Produktionsmuster "Asynchronous E-Learning.md", + # v0.3.140): ungefiltert drückt die 3x wiederholte 0%-Rate die Poolung + # nach unten. + rows = [ + _qrow("dup.md", "v1", 0.0, 17, 0, "2026-06-21T19:50:12"), + _qrow("dup.md", "v1", 0.0, 17, 0, "2026-06-21T21:20:53"), + _qrow("dup.md", "v1", 0.0, 17, 0, "2026-06-21T21:30:36"), + _qrow("dup.md", "v1", 0.294, 17, 5, "2026-07-05T19:59:18"), + _qrow("clean.md", "v1", 0.0, 20, 0, "2026-06-25T00:00:00"), + ] + raw_pct = _pooled_hall_pct(rows) # ungefiltert: 5 / (17*4 + 20) = 5,6 % + deduped_pct = _pooled_hall_pct(_dedup_latest_per_note(rows)) # dedup: 5 / (17 + 20) = 13,5 % + assert raw_pct < deduped_pct + assert deduped_pct == 13.5 + + +# ── _calc_kpis nutzt die deduplizierte Basis ──────────────────────────────── + + +def test_calc_kpis_hall_uses_deduped_basis(): + rows = [ + _qrow("dup.md", "v1", 0.0, 10, 0, "2026-06-21T00:00:00"), + _qrow("dup.md", "v1", 1.0, 10, 10, "2026-06-22T00:00:00"), # neueste gewinnt + _qrow("solo.md", "v1", 0.0, 10, 0, "2026-06-21T00:00:00"), + ] + kpis = _calc_kpis({}, [], rows, [], current_version="v1") + # dedupliziert: dup.md zählt nur mit der neuesten Zeile (10 Anker, 10 + # halluziniert) + solo.md (10 Anker, 0 halluziniert) -> 10/20 = 50 % + assert kpis["avg_hall"] == 50.0 + assert kpis["n_notes"] == 2 + # Kachel-n muss zur Pooling-Basis passen (Produktionsbefund: Kachel n=40, + # Pooling-Basis 52 Zeilen — inkonsistent vor dem Fix). + assert kpis["hall_rows_n"] == 2 + assert kpis["hall_anchors_total"] == 20 + + +def test_calc_kpis_coverage_uses_deduped_basis(): + rows = [ + _qrow("dup.md", "v1", 0.0, 10, 0, "2026-06-21T00:00:00", cov=0.2), + _qrow("dup.md", "v1", 0.0, 10, 0, "2026-06-22T00:00:00", cov=0.9), # neueste gewinnt + ] + kpis = _calc_kpis({}, [], rows, [], current_version="v1") + assert kpis["avg_cov"] == 90.0 # nicht Median(20, 90) = 55.0 — nur die neueste Zeile zählt + + +# ── _calc_pdf_table nutzt dieselbe Basis (SSoT mit der KPI-Kachel) ───────── + + +def test_calc_pdf_table_hall_uses_deduped_basis(): + quality_rows = [ + { + "pdf": "Quelle - 2020 - X.pdf", + "note_path": "dup.md", + "pipeline_version": "v1", + "hallucination_rate": 0.0, + "anchors_total": 10, + "anchors_hallucinated": 0, + "coverage_factual": 0.5, + "timestamp": "2026-06-21T00:00:00", + }, + { + "pdf": "Quelle - 2020 - X.pdf", + "note_path": "dup.md", + "pipeline_version": "v1", + "hallucination_rate": 1.0, + "anchors_total": 10, + "anchors_hallucinated": 10, + "coverage_factual": 0.5, + "timestamp": "2026-06-22T00:00:00", + }, + ] + all_log_runs = [ + { + "key": "quelle-2020", + "label": "Quelle", + "ver": "v1", + "n_total": 1, + "n_vault": 1, + "accept_pct": 100.0, + "words": None, + } + ] + log_data = _build_log_data(all_log_runs) + rows = _calc_pdf_table(log_data, all_log_runs, quality_rows, current_version="v1") + row = next(r for r in rows if r["key"].startswith("quelle")) + assert row["hall"] == 100.0 # nur neueste Zeile (10/10), nicht gepoolt über beide (5/20=25%) + assert row["n_notes"] == 1 + + +# ── Scatter nutzt dieselbe Basis (Nachbesserung adversariale Kontrolle #293) ─ +# Befund: _chart_scatter_versioned deduplizierte NICHT — der Scatter zeigte 52 +# Punkte (v0.3.140), während die KPI-Kachel nach Fix 1 korrekt "40 evaluierte +# Notes" sagte; re-evaluierte Notes erschienen doppelt (z. B. "Asynchronous +# E-Learning" bei x=0,0 UND x=29,4). Dieselbe "Instanzen vs. distinct"- +# Bugklasse (#194), die dieser PR schließt. + + +def test_scatter_shows_distinct_notes_per_version(): + from generative.eval_dashboard_server import _chart_scatter_versioned + + rows = [ + # dup.md: 3 Re-Evals in v1 -> nur der neueste Punkt (x=29.4) darf erscheinen + _qrow("dup.md", "v1", 0.0, 17, 0, "2026-06-21T19:50:12"), + _qrow("dup.md", "v1", 0.0, 17, 0, "2026-06-21T21:20:53"), + _qrow("dup.md", "v1", 0.294, 17, 5, "2026-07-05T19:59:18"), + _qrow("solo.md", "v1", 0.1, 10, 1, "2026-06-25T00:00:00"), + ] + for r in rows: + r["pdf"] = "Quelle - 2020 - X.pdf" + out = _chart_scatter_versioned(rows) + assert len(out["points"]) == 2, f"Scatter zeigt Eval-Instanzen statt distinct Notes: {len(out['points'])} Punkte" + dup_points = [p for p in out["points"] if p["label"] == "dup"] + assert len(dup_points) == 1 + assert dup_points[0]["x"] == 29.4 # neueste Eval-Zeile gewinnt, nicht die alte 0,0 + + +def test_scatter_dedup_is_per_version_not_global(): + from generative.eval_dashboard_server import _chart_scatter_versioned + + # Dieselbe Note in ZWEI Versionen bleibt zwei Punkte (der Versions-Filter + # des Scatters vergleicht Versionen) — Dedup nur INNERHALB einer Version. + rows = [ + _qrow("a.md", "v1", 0.1, 10, 1, "2026-06-01T00:00:00"), + _qrow("a.md", "v2", 0.2, 10, 2, "2026-06-02T00:00:00"), + ] + for r in rows: + r["pdf"] = "Quelle - 2020 - X.pdf" + out = _chart_scatter_versioned(rows) + assert len(out["points"]) == 2 + assert sorted(p["version"] for p in out["points"]) == ["v1", "v2"] diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html index 43cbdab..5c08a58 100644 --- a/internal/dashboard/eval_dashboard.html +++ b/internal/dashboard/eval_dashboard.html @@ -1417,7 +1417,13 @@ // starr die direkte Vorversion) — im Tooltip transparent benennen, WOGEGEN // verglichen wird, inkl. deren n. const prevRef = vd.prev_version ? `Version ${vd.prev_version}${vd.prev_n != null ? ` (n=${vd.prev_n})` : ''}` : 'der vorherigen Pipeline-Version'; - const chipHint = `Veränderung gegenüber ${prevRef}, serverseitig berechnet.${vd.reliable ? '' : ' Erst ab n≥20 in beiden Versionen gilt das Delta als belastbar — hier nicht der Fall.'}`; + // Statistik-Review 2026-07-15: n≥20 allein härtet nicht gegen PDF-Mix + // (Corpus faktisch ausgetauscht) — zwei unterschiedliche Gründe für + // "nicht belastbar" brauchen zwei unterschiedliche Erklärungen. + const unreliableMsg = vd.reason === 'pdf_mix' + ? ` Nicht vergleichbar: die Quell-PDFs beider Versionen überschneiden sich kaum${vd.pdf_overlap != null ? ` (nur ${_fmtDE(vd.pdf_overlap * 100, 0)} % der Notes teilen eine PDF-Quelle mit ${prevRef})` : ''} — das Delta spiegelt eher einen Corpus-Wechsel als einen echten Versions-Effekt.` + : ' Erst ab n≥20 in beiden Versionen gilt das Delta als belastbar — hier nicht der Fall.'; + const chipHint = `Veränderung gegenüber ${prevRef}, serverseitig berechnet.${vd.reliable ? '' : unreliableMsg}`; chip = `${arrow} ${_fmtDE(Math.abs(dnum))}${chipHint}`; } // #219-Nachbesserung Punkt 1: kein tabindex auf den 4 Hint-Triggern hier — @@ -1475,8 +1481,13 @@ const lowerIsBetter = ['hall','dur','tokens','cost'].includes(kd.key); const dClass = (dnum!=null && vd.reliable && dnum!==0) ? ((dnum>0) === lowerIsBetter ? 'dneg' : 'dpos') : ''; // 0.0 = neutral, keine Farbe const sign = dnum!=null && dnum>0 ? '+' : ''; + // Statistik-Review 2026-07-15: "(n<20)" war die einzige Nicht-belastbar- + // Erklärung — ein PDF-Mix-Delta (n≥20, aber Corpus ausgetauscht) sah + // damit faelschlich wie ein Stichprobenproblem statt ein Vergleichbarkeits- + // problem aus. + const caveatSuffix = (vd && !vd.reliable) ? (vd.reason === 'pdf_mix' ? ' (PDF-Mix)' : ' (n<20)') : ''; const deltaHtml = dnum!=null - ? `Δ vs. ${prevVer}: ${sign}${_fmtDE(dnum)} ${kd.u}${vd.reliable ? '' : ' (n<20)'}` + ? `Δ vs. ${prevVer}: ${sign}${_fmtDE(dnum)} ${kd.u}${caveatSuffix}` : `${vers.length} Pipeline-Versionen`; const inner = document.getElementById('spark-inner'); if (inner) { @@ -2245,8 +2256,13 @@ if (vd && vd.prev != null) { const prevVer = esc(vd.prev_version ?? vers[vers.length - 2] ?? '–'); const nPrev = vd.prev_n ?? (kt.n || [])[(kt.n || []).length - 2]; + // Statistik-Review 2026-07-15: "n<20" war die einzige Caveat-Erklärung — + // ein PDF-Mix-Delta (n≥20, aber Corpus ausgetauscht) braucht eine eigene. + const caveatTxt = vd.reason === 'pdf_mix' + ? ` (PDF-Mix — nicht vergleichbar)` + : ` (n<20 — nicht belastbar)`; deltaTxt = ` vs. ${prevVer}: ${_fmtDE(vd.prev)} % (n=${nPrev})` + - (vd.reliable ? '' : ` (n<20 — nicht belastbar)`); + (vd.reliable ? '' : caveatTxt); } _setInsight('ins-overview', `Pipeline ${esc(k.kpi_version||'–')}: Fehlerquote ${_fmtDE(k.avg_hall)} %, ` +