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:
93
tests/test_statistik.py
Normale Datei
93
tests/test_statistik.py
Normale Datei
@@ -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}]
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren