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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions generative/calibration/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
126 changes: 121 additions & 5 deletions generative/eval_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -1013,28 +1102,55 @@ 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).
if len(values) >= 2:
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,
"delta": delta,
"reliable": reliable,
"prev_version": prev_version,
"prev_n": n_prev,
"pdf_overlap": pdf_overlap,
"reason": reason,
}


Expand Down
52 changes: 44 additions & 8 deletions generative/eval_dashboard_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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).
Expand All @@ -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) —
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading