feat(statistik): Hauptreiter Statistik mit kompletter Betriebsstatistik

Neuer Reiter zwischen Audit-Log und Verbrauchsrechner, Anlass war die
Dashboard-Kachel API-Kosten gesamt, die nirgendwo hinführte (sie springt
jetzt hierher). Kennzahlen-Set nach Betreiber-Best-Practices für
nutzungsbasierte KI-Produkte (Kostenverlauf, Marge je Kunde, Betrieb).

- GET /api/statistik?months=N liefert alles in einem JSON. Kosten und
  Läufe je Monat und Lagentyp (führende Quelle refresh_log mit
  lückenlosem incidents-Join), Quellen-Split aus token_usage_monthly,
  Erfolgsquote, normalisiertes Fehler-Ranking, Dauer je Typ, Wachstum,
  MAU/DAU aus user_activity_days und je Organisation Ist-Kosten plus
  Margen-Schätzung zu heutigen Sätzen (Recherche-Anlage zählt dreifach,
  weil der Multi-Pass nur eine Log-Zeile erzeugt). Aggregation als pure,
  unit-getestete Funktionen, Monatsachse lückenlos gefüllt, Mediane nur
  über Läufe mit Kosten (Erfassung begann erst 03/2026).
- statistik.js injiziert sich selbst nach dem rechner.js-Muster,
  Platzierung doppelt gesichert (Ladereihenfolge mit async=false plus
  insertBefore). Charts als Inline-SVG ohne Bibliothek, eine Achse je
  Chart, Hover-Titel mit großen Trefferflächen, Legenden, Serienfarben
  je Theme mit dem dataviz-Palette-Validator geprüft (alle Checks PASS).
  Zeitraum 6/12/Alles, Datenstand-Hinweis, ehrliche Fußnote unter der
  Margen-Tabelle, Leerzustand für die junge Aktivitäts-Erfassung.
- Migration user_activity_days für die Portal-Staging-DB (auf Live legt
  sie der Monitor an, dort läuft die Erfassung in get_current_user).
- 10 neue Unit-Tests plus Auth-Smoke für den Endpoint, 96 Tests grün
  (die zwei bekannten shared-Import-Fehlschläge bestehen unverändert).

Gegenstück im Monitor: Commit d367c60 (Aktivitäts-Erfassung).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 15:09:34 +00:00
Ursprung 5fd4e64b4c
Commit 25ec80c8e4
9 geänderte Dateien mit 1020 neuen und 7 gelöschten Zeilen

93
tests/test_statistik.py Normale Datei
Datei anzeigen

@@ -0,0 +1,93 @@
"""Tests für die puren Aggregationsfunktionen der Betriebsstatistik."""
from routers.statistik import (
baue_monatsachse, normalisiere_fehler, aggregiere_refresh,
berechne_credits, aggregiere_orgs, fuelle_monatsserie,
)
TARIF = {"monitor_adhoc": 45, "monitor_research": 40, "chat": 1, "enhance": 1, "globe": 1}
def test_monatsachse_lueckenlos_ueber_jahresgrenze():
assert baue_monatsachse("2025-11", "2026-02") == ["2025-11", "2025-12", "2026-01", "2026-02"]
def test_monatsachse_leer_bei_unsinn():
assert baue_monatsachse("2026-05", "2026-02") == []
assert baue_monatsachse("", "2026-02") == []
def test_fehler_normalisierung():
assert normalisiere_fehler("Endgültig fehlgeschlagen nach 3 Versuchen: Claude CLI Timeout nach 420s") == "Claude CLI Timeout nach 420s"
assert normalisiere_fehler("Verwaist (Neustart, x)") == "Verwaist (Neustart, automatisch bereinigt)"
assert normalisiere_fehler(None) == "Unbekannt"
def _lauf(monat="2026-03", typ="adhoc", status="completed", kosten=5.0, **kw):
return {"monat": monat, "typ": typ, "status": status, "trigger_type": "auto",
"total_cost_usd": kosten, "input_tokens": 100, "output_tokens": 50,
"articles_found": 3, "error_message": None, "dauer_s": 600.0, **kw}
def test_median_ignoriert_nullkosten():
"""Läufe mit 0 $ (Kostenerfassung fehlte) dürfen den Median nicht verzerren."""
rows = [_lauf(kosten=0.0), _lauf(kosten=4.0), _lauf(kosten=6.0)]
agg = aggregiere_refresh(rows, ["2026-03"])
assert agg["kpi"]["median_kosten_lauf"] == 5.0
assert agg["kpi"]["laeufe_ok"] == 3
def test_betrieb_und_fehler():
rows = [_lauf(), _lauf(status="error", kosten=0.0,
error_message="Endgültig fehlgeschlagen nach 3 Versuchen: Timeout X"),
_lauf(status="error", kosten=0.0, error_message="Timeout X")]
agg = aggregiere_refresh(rows, ["2026-03"])
b = agg["monat_betrieb"][0]
assert b["completed"] == 1 and b["error"] == 2
assert agg["fehler_top"][0] == {"grund": "Timeout X", "n": 2}
assert agg["kpi"]["erfolgsquote"] == 33.3
def test_aggregiere_refresh_leer():
agg = aggregiere_refresh([], [])
assert agg["kpi"]["laeufe_gesamt"] == 0
assert agg["kpi"]["erfolgsquote"] is None
assert agg["monat_typ"] == []
def test_credits_formel_erstlauf_dreifach():
"""Anlage einer Recherche = 3 Durchläufe, erscheint aber als 1 Log-Zeile."""
credits = berechne_credits(adhoc_ok=2, research_ok=3, research_erstlaeufe=1,
neben_aufrufe={"chat": 4}, tarif=TARIF)
# 2*45 + (3 + 2*1)*40 + 4*1 = 90 + 200 + 4
assert credits == 294
def test_credits_formel_ohne_erstlauf():
"""Grenzfall, der chronologisch erste Lauf war ein error. Dann zählt kein
Erstlauf-Zuschlag (die Heuristik greift erst beim ersten completed Lauf,
was die SQL-Seite über rn=1 auf completed-Zeilen abbildet)."""
assert berechne_credits(0, 2, 0, {}, TARIF) == 80
def test_orgs_marge():
rows_orgs = [{"tenant_id": 1, "ist_kosten": 10.0, "laeufe_ok": 2, "laeufe_gesamt": 3,
"adhoc_ok": 2, "research_ok": 0, "research_erstlaeufe": 0}]
rows_neben = [{"organization_id": 1, "source": "chat", "api_calls": 10, "kosten": 0.5}]
orgs = aggregiere_orgs(rows_orgs, rows_neben, {1: "Test-Org"}, TARIF, 0.20)
o = orgs[0]
assert o["credits_geschaetzt"] == 100 # 2*45 + 10*1
assert o["gegenwert_usd"] == 20.0
assert o["kosten_usd"] == 10.5
assert o["marge_usd"] == 9.5
def test_orgs_ohne_laeufe_taucht_trotzdem_auf():
orgs = aggregiere_orgs([], [], {5: "Leer-Org"}, TARIF, 0.20)
assert orgs[0]["org_id"] == 5
assert orgs[0]["credits_geschaetzt"] == 0
def test_fuelle_monatsserie():
rows = [{"monat": "2026-03", "n": 2}]
out = fuelle_monatsserie(rows, ["2026-02", "2026-03"], "monat", ["n"])
assert out == [{"monat": "2026-02", "n": 0}, {"monat": "2026-03", "n": 2}]