From 7834de9592172be39c373c5c4c7caa92437b1188 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 25 Sep 2026 13:26:31 +0200 Subject: [PATCH] =?UTF-8?q?nutzung:=20Antwortzeiten,=20Fehler,=20Verlauf?= =?UTF-8?q?=20je=20Stunde=20und=20Ladevorg=C3=A4nge=20aus=20dem=20llama-sw?= =?UTF-8?q?ap-Journal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit „Wer nutzt die Modelle“ war zu dünn (nur Anzahl je Absender). Das Journal gibt je Anfrage auch Status und Dauer her — daraus rechnet die Box jetzt mehr, alles nur ergänzt; die alten Felder bleiben für den Flugplan (letzte_24h) und für ältere Oberflächen. - Je Absender: typische Antwortzeit (Median — einzelne 30-s-Wartezeiten bei Neustarts verziehen sonst den Schnitt), „9 von 10 bis“ (90-%-Wert als nächster Rang, also eine echte Antwortzeit), Rechenzeit (Summe aller Antwortzeiten), Fehler (Status nicht 2xx, je Code) und die letzte Anfrage. Go-Dauern werden samt ms/µs/ns und „1m2.3s“ gelesen. - Fenster = die letzten 7 Kalendertage bis jetzt statt 7×24 h rollend: je_tag hat genau 7 Tage, heute als letzten, auch leere — alle Zahlen beziehen sich auf dasselbe Fenster. - „stunden“: die letzten 24 h chronologisch mit Beginn (in UTC gerechnet, damit die Zeitumstellung keine Stunde verschluckt). letzte_24h bleibt Stunde des Tages wie bisher. - „ladevorgaenge“: je Modell Anzahl und die letzten Zeitpunkte („Health check passed“). - „reihe“: feste Reihenfolge aus der Namensliste, damit die Farbe dem Absender folgt und nicht dem Rang. NerdQuiz als größter Verbraucher zuerst (ruhiges Blau). Co-Authored-By: Claude Opus 5.5 --- backend/services/modell_nutzung.py | 144 +++++++++++++++++++---- backend/tests/test_modell_nutzung.py | 169 +++++++++++++++++++++++++-- 2 files changed, 277 insertions(+), 36 deletions(-) diff --git a/backend/services/modell_nutzung.py b/backend/services/modell_nutzung.py index 7ff0c30..288fd49 100644 --- a/backend/services/modell_nutzung.py +++ b/backend/services/modell_nutzung.py @@ -1,40 +1,62 @@ """ -Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026) +Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026; ausgebaut am 25.09.2026) -llama-swap protokolliert jede Anfrage mit Absender-IP im Journal. Daraus zählen wir je -Absender und Tag die Chat-Anfragen, je Stunde der letzten 24 h, und wann welches Modell -zuletzt geladen wurde ("Health check passed"). +llama-swap schreibt jede Anfrage ins Journal — Absender-IP, Pfad, Status, Antwortgröße, Programm und +am Zeilenende die Dauer als Go-Dauer ("388.09ms", "3.41s", "1m2.3s"): + + ... [INFO] Request 192.168.178.28 "POST /v1/chat/completions HTTP/1.1" 200 806 "Go-http-client/1.1" 388.09ms + +Daraus rechnen wir für die letzten TAGE Kalendertage (heute zählt bis jetzt): + +- je Absender die Chat-Anfragen, die Fehler (Status nicht 2xx, je Code), die typische Antwortzeit + (Median), den Wert, unter dem 9 von 10 Antworten blieben (90-%-Wert), die Summe aller Antwortzeiten + ("Rechenzeit"; gleichzeitige Anfragen zählen einzeln) und die letzte Anfrage; +- den Verlauf je Tag und je Stunde der letzten 24 h, beides je Absender; +- wann welches Modell geladen wurde ("Health check passed"). + +Welches Modell eine Anfrage bedient hat, steht nicht in diesen Zeilen — das zeigen wir darum nicht. Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25 Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben, bevor jemand nachsieht (gemessen am 23.09.2026). Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles, -was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst). +was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst). Die Reihenfolge der Namen +legt die Farbe im Cockpit fest ("reihe"): Ein Absender behält seine Farbe, auch wenn sich die +Rangfolge nach Anfragen ändert. """ +import math import os import re +import statistics import subprocess import threading import time from collections import Counter, defaultdict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from datetime import time as uhrzeit from kern.zeit import LOCAL_TZ CACHE_S = 600 TAGE = 7 +LADEZEITEN = 10 # so viele Ladezeitpunkte je Modell (neueste zuerst) reichen der Anzeige +# Die Reihenfolge ist die Farbe (Blau, Magenta, Gelb): Der größte Verbraucher bekommt das ruhige Blau. _STANDARD_NAMEN = { + "192.168.178.28": "NerdQuiz auf Arcane", "127.0.0.1": "Lucy und OpenChamber über MC2", "::1": "Lucy und OpenChamber über MC2", - "192.168.178.28": "NerdQuiz auf Arcane", "192.168.178.22": "NerdQuiz-Tests vom PC", } -_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)') +# Status ist optional: Fehlt er (anderes Zeilenformat), zählt die Anfrage, aber nicht als Fehler. +_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)[^"]*"(?: (\d{3}))?') _GELADEN = re.compile(r"<([^>]+)> Health check passed") +# Go schreibt Mikrosekunden mit dem Mikro-Zeichen (U+00B5); das griechische My und "us" gehen auch. +_DAUER_TEIL = re.compile(r"(\d+(?:\.\d+)?)(h|ms|m|s|µs|μs|us|ns)") +_DAUER_FAKTOR = {"h": 3600.0, "m": 60.0, "s": 1.0, "ms": 1e-3, "µs": 1e-6, "μs": 1e-6, "us": 1e-6, "ns": 1e-9} _lock = threading.Lock() _cache: dict = {"ts": 0.0, "daten": None} @@ -61,13 +83,44 @@ def _journal(seit: str) -> list[str]: return out.stdout.splitlines() +def dauer_s(text: str) -> float | None: + """Go-Dauer → Sekunden: "388.09ms" → 0.38809, "1m2.5s" → 62.5, "850µs" → 0.00085. Sonst None.""" + teile = list(_DAUER_TEIL.finditer(text)) + if not teile or "".join(t.group(0) for t in teile) != text: + return None + return sum(float(t.group(1)) * _DAUER_FAKTOR[t.group(2)] for t in teile) + + +def _kennwerte(dauern: list[float], summe: float) -> dict: + """Typisch (Median) und 9 von 10 höchstens (90-%-Wert als nächster Rang, also eine echte + Antwortzeit), dazu die Summe — alles in Sekunden.""" + werte = sorted(dauern) + return { + "dauer_typisch_s": round(statistics.median(werte), 3) if werte else None, + "dauer_p90_s": round(werte[math.ceil(0.9 * len(werte)) - 1], 3) if werte else None, + "dauer_summe_s": round(summe, 1), + } + + def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict: - """Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Stunde, Modell.""" - je_absender: Counter = Counter() - je_tag: dict[str, Counter] = defaultdict(Counter) - stunden: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender → Anzahl (letzte 24 h) - geladen: dict[str, str] = {} + """Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Tag, Stunde und Modell.""" + jetzt = jetzt.astimezone(LOCAL_TZ) + tage = [jetzt.date() - timedelta(days=TAGE - 1 - i) for i in range(TAGE)] + beginn = datetime.combine(tage[0], uhrzeit(0), tzinfo=LOCAL_TZ) + # Stunden in UTC gerechnet, damit die Zeitumstellung keine Stunde verschluckt oder verdoppelt. + stunde0 = jetzt.astimezone(timezone.utc).replace(minute=0, second=0, microsecond=0) - timedelta(hours=23) grenze_24h = jetzt - timedelta(hours=24) + + anfragen: Counter = Counter() + fehler: dict[str, Counter] = defaultdict(Counter) # Absender → Status → Anzahl + dauern: dict[str, list[float]] = defaultdict(list) # nur erfolgreiche Antworten + summe: Counter = Counter() # alle Antwortzeiten in Sekunden + zuletzt: dict[str, datetime] = {} + je_tag: dict[str, Counter] = {t.isoformat(): Counter() for t in tage} + je_stunde = [Counter() for _ in range(24)] # chronologisch, die letzte ist die laufende + stunden_alt: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender (für den Flugplan) + geladen: dict[str, list[datetime]] = defaultdict(list) + for z in zeilen: try: ts = datetime.fromisoformat(z.split(" ", 1)[0]) @@ -75,35 +128,78 @@ def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict continue if ts.tzinfo is None: ts = ts.replace(tzinfo=LOCAL_TZ) + if ts < beginn: + continue if (m := _GELADEN.search(z)): - geladen[m.group(1)] = ts.isoformat() + geladen[m.group(1)].append(ts) continue m = _ANFRAGE.search(z) if not m or not m.group(2).startswith("/v1/chat/completions"): continue wer = namen.get(m.group(1), m.group(1)) - je_absender[wer] += 1 - je_tag[ts.date().isoformat()][wer] += 1 + anfragen[wer] += 1 + ok = m.group(3) is None or m.group(3).startswith("2") + if not ok: + fehler[wer][m.group(3)] += 1 + if (d := dauer_s(z.rsplit(None, 1)[-1])) is not None: + summe[wer] += d + if ok: + dauern[wer].append(d) + if wer not in zuletzt or ts > zuletzt[wer]: + zuletzt[wer] = ts + if (tag := ts.astimezone(LOCAL_TZ).date().isoformat()) in je_tag: + je_tag[tag][wer] += 1 + if 0 <= (i := int((ts - stunde0).total_seconds() // 3600)) < 24: + je_stunde[i][wer] += 1 if ts >= grenze_24h: - stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1 + stunden_alt[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1 + + bekannt = list(dict.fromkeys(namen.values())) + fremde = [n for n, _ in anfragen.most_common() if n not in bekannt] + + def reihe(name: str) -> int: + return bekannt.index(name) if name in bekannt else len(bekannt) + fremde.index(name) + + alle_fehler: Counter = sum(fehler.values(), Counter()) + zuletzt_geladen = {modell: max(zeiten) for modell, zeiten in geladen.items()} return { "tage": TAGE, - "absender": [{"name": n, "anfragen": c} for n, c in je_absender.most_common()], - "je_tag": {tag: dict(c) for tag, c in sorted(je_tag.items())}, - "letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden.get(f"{h:02d}", {}))} + "seit": beginn.isoformat(), + "stand": jetzt.isoformat(timespec="seconds"), + "absender": [ + {"name": n, "anfragen": c, "reihe": reihe(n), + "fehler": sum(fehler[n].values()), "fehler_codes": dict(fehler[n]), + **_kennwerte(dauern[n], summe[n]), + "zuletzt": zuletzt[n].isoformat()} + for n, c in anfragen.most_common() + ], + "gesamt": { + "anfragen": sum(anfragen.values()), + "fehler": sum(alle_fehler.values()), "fehler_codes": dict(alle_fehler), + **_kennwerte([d for liste in dauern.values() for d in liste], sum(summe.values())), + }, + "je_tag": {tag: dict(c) for tag, c in je_tag.items()}, + "stunden": [{"beginn": (stunde0 + timedelta(hours=i)).astimezone(LOCAL_TZ).isoformat(), + "je_absender": dict(c)} for i, c in enumerate(je_stunde)], + "letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden_alt.get(f"{h:02d}", {}))} for h in range(24)], - "zuletzt_geladen": geladen, + "zuletzt_geladen": {modell: t.isoformat() for modell, t in zuletzt_geladen.items()}, + "ladevorgaenge": [ + {"modell": modell, "anzahl": len(geladen[modell]), + "zeiten": [t.isoformat() for t in sorted(geladen[modell], reverse=True)[:LADEZEITEN]]} + for modell in sorted(zuletzt_geladen, key=zuletzt_geladen.__getitem__, reverse=True) + ], } def nutzung() -> dict: - """Gecachte Auswertung der letzten TAGE Tage (10 min).""" + """Gecachte Auswertung der letzten TAGE Kalendertage (10 min).""" with _lock: if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S: return _cache["daten"] jetzt = datetime.now(LOCAL_TZ) - seit = (jetzt - timedelta(days=TAGE)).strftime("%Y-%m-%d %H:%M:%S") - daten = werte_aus(_journal(seit), jetzt, _namen()) + seit = datetime.combine(jetzt.date() - timedelta(days=TAGE - 1), uhrzeit(0)) + daten = werte_aus(_journal(seit.strftime("%Y-%m-%d %H:%M:%S")), jetzt, _namen()) with _lock: _cache.update(ts=time.time(), daten=daten) return daten diff --git a/backend/tests/test_modell_nutzung.py b/backend/tests/test_modell_nutzung.py index 6b0d64b..18c4584 100644 --- a/backend/tests/test_modell_nutzung.py +++ b/backend/tests/test_modell_nutzung.py @@ -1,7 +1,8 @@ -"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23.09.2026).""" +"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23. und 25.09.2026).""" from datetime import datetime +import pytest from services import modell_nutzung ZEILEN = [ @@ -19,18 +20,32 @@ ZEILEN = [ ] NAMEN = {"127.0.0.1": "Lucy und OpenChamber über MC2", "192.168.178.28": "NerdQuiz auf Arcane"} +JETZT = datetime.fromisoformat("2026-09-23T20:45:00+02:00") -def test_zaehlt_nur_chat_anfragen_je_absender(): - d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN) - assert d["absender"] == [ - {"name": "NerdQuiz auf Arcane", "anfragen": 3}, - {"name": "Lucy und OpenChamber über MC2", "anfragen": 1}, +def anfrage(zeit: str, ip: str = "192.168.178.28", status: int = 200, dauer: str = "1s", + programm: str = "Go-http-client/1.1") -> str: + return (f'{zeit} tobisniceaiarbeitstier llama-swap[237863]: [INFO] Request {ip} ' + f'"POST /v1/chat/completions HTTP/1.1" {status} 805 "{programm}" {dauer}') + + +def je_name(d: dict) -> dict: + return {a["name"]: a for a in d["absender"]} + + +def test_zaehlt_nur_chat_anfragen_der_letzten_sieben_tage_je_absender(): + d = modell_nutzung.werte_aus(ZEILEN, JETZT, NAMEN) + # Der Lauf vom 10.09. liegt vor dem Fenster (17.09. 00:00 bis jetzt), Embeddings zählen nicht. + assert [(a["name"], a["anfragen"]) for a in d["absender"]] == [ + ("NerdQuiz auf Arcane", 2), + ("Lucy und OpenChamber über MC2", 1), ] + assert d["seit"] == "2026-09-17T00:00:00+02:00" + assert d["gesamt"]["anfragen"] == 3 def test_letzte_24h_je_stunde_und_zuletzt_geladen(): - d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN) + d = modell_nutzung.werte_aus(ZEILEN, JETZT, NAMEN) stunden = {s["stunde"]: s["je_absender"] for s in d["letzte_24h"]} assert stunden["03"] == {"NerdQuiz auf Arcane": 2} # der Lauf vom 10.09. liegt außerhalb assert stunden["07"] == {"Lucy und OpenChamber über MC2": 1} @@ -38,8 +53,138 @@ def test_letzte_24h_je_stunde_und_zuletzt_geladen(): assert d["zuletzt_geladen"]["Qwen3.8-27B"].startswith("2026-09-17T19:59") -def test_unbekannte_ip_bleibt_als_ip_stehen(): - zeile = ('2026-09-23T10:00:00+02:00 box llama-swap[1]: [INFO] Request 10.0.0.9 ' - '"POST /v1/chat/completions HTTP/1.1" 200 1 "curl/8" 1s') - d = modell_nutzung.werte_aus([zeile], datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN) - assert d["absender"] == [{"name": "10.0.0.9", "anfragen": 1}] +def test_unbekannte_ip_bleibt_als_ip_stehen_und_kommt_nach_den_bekannten(): + zeile = anfrage("2026-09-23T10:00:00+02:00", ip="10.0.0.9", programm="curl/8") + d = modell_nutzung.werte_aus([zeile], JETZT, NAMEN) + assert [(a["name"], a["anfragen"], a["reihe"]) for a in d["absender"]] == [("10.0.0.9", 1, 2)] + + +@pytest.mark.parametrize(("text", "sekunden"), [ + ("388.092603ms", 0.388092603), + ("3.416448406s", 3.416448406), + ("31.899196571s", 31.899196571), + ("1m2.5s", 62.5), + ("2h0m0s", 7200.0), + ("850µs", 0.00085), + ("850μs", 0.00085), + ("12ns", 0.000000012), + ("0s", 0.0), +]) +def test_dauer_liest_go_dauern(text, sekunden): + assert modell_nutzung.dauer_s(text) == pytest.approx(sekunden) + + +@pytest.mark.parametrize("text", ["", "abc", "805", "1.5x", "s", '"Go-http-client/1.1"', "1s2"]) +def test_dauer_ohne_einheit_ist_keine_dauer(text): + assert modell_nutzung.dauer_s(text) is None + + +def test_antwortzeiten_fehler_und_letzte_anfrage_je_absender(): + zeilen = [ + anfrage("2026-09-23T09:00:00+02:00", dauer="400ms"), + anfrage("2026-09-23T09:01:00+02:00", dauer="1.2s"), + anfrage("2026-09-23T09:02:00+02:00", dauer="1m2s"), + anfrage("2026-09-23T09:03:00+02:00", status=502, dauer="31.9s"), + anfrage("2026-09-23T12:00:00+02:00", ip="127.0.0.1", status=499, dauer="5s", programm="python-httpx/0.28.1"), + anfrage("2026-09-23T12:01:00+02:00", ip="127.0.0.1", dauer="600ms", programm="python-httpx/0.28.1"), + # Ohne Dauer am Ende (anderes Format): zählt, geht aber in keine Zeit ein. + '2026-09-23T12:02:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 "POST /v1/chat/completions HTTP/1.1"', + ] + d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN) + nq, lucy = je_name(d)["NerdQuiz auf Arcane"], je_name(d)["Lucy und OpenChamber über MC2"] + + assert nq["anfragen"] == 4 + assert nq["fehler"] == 1 and nq["fehler_codes"] == {"502": 1} + # Typisch und 9 von 10 nur aus den erfolgreichen Antworten; die Rechenzeit zählt alle. + assert nq["dauer_typisch_s"] == pytest.approx(1.2) + assert nq["dauer_p90_s"] == pytest.approx(62.0) + assert nq["dauer_summe_s"] == pytest.approx(0.4 + 1.2 + 62 + 31.9, abs=0.05) + assert nq["zuletzt"] == "2026-09-23T09:03:00+02:00" + + assert lucy["anfragen"] == 3 + assert lucy["fehler_codes"] == {"499": 1} + assert lucy["dauer_typisch_s"] == pytest.approx(0.6) + assert lucy["zuletzt"] == "2026-09-23T12:02:00+02:00" + + assert d["gesamt"]["fehler"] == 2 + assert d["gesamt"]["fehler_codes"] == {"502": 1, "499": 1} + assert d["gesamt"]["dauer_typisch_s"] == pytest.approx(0.9) # Median aus 0,4 · 0,6 · 1,2 · 62 + assert d["gesamt"]["dauer_p90_s"] == pytest.approx(62.0) + + +def test_ohne_anfragen_bleiben_die_zeiten_leer(): + d = modell_nutzung.werte_aus([], JETZT, NAMEN) + assert d["absender"] == [] + assert d["gesamt"] == {"anfragen": 0, "fehler": 0, "fehler_codes": {}, "dauer_typisch_s": None, + "dauer_p90_s": None, "dauer_summe_s": 0.0} + assert list(d["je_tag"].values()) == [{}] * 7 + assert all(s["je_absender"] == {} for s in d["stunden"]) + + +def test_farbe_folgt_dem_absender_nicht_dem_rang(): + """Überholt Lucy NerdQuiz, bleibt die Reihe (= Farbe) gleich — sie kommt aus der Namensliste.""" + zeilen = [anfrage(f"2026-09-23T10:0{i}:00+02:00", ip="127.0.0.1") for i in range(5)] + zeilen.append(anfrage("2026-09-23T11:00:00+02:00")) + namen = {"192.168.178.28": "NerdQuiz auf Arcane", "127.0.0.1": "Lucy", "::1": "Lucy", "192.168.178.22": "PC"} + d = modell_nutzung.werte_aus(zeilen, JETZT, namen) + assert [(a["name"], a["reihe"]) for a in d["absender"]] == [("Lucy", 1), ("NerdQuiz auf Arcane", 0)] + + +def test_verlauf_je_tag_deckt_sieben_kalendertage_bis_heute(): + zeilen = [ + anfrage("2026-09-16T23:59:59+02:00"), # vor dem Fenster + anfrage("2026-09-17T00:00:00+02:00"), + anfrage("2026-09-20T22:30:00+00:00"), # in UTC geschrieben: in Berlin schon der 21.09. + anfrage("2026-09-23T20:00:00+02:00", ip="127.0.0.1"), + ] + d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN) + assert list(d["je_tag"]) == [f"2026-09-{t}" for t in range(17, 24)] + assert d["je_tag"]["2026-09-17"] == {"NerdQuiz auf Arcane": 1} + assert d["je_tag"]["2026-09-20"] == {} + assert d["je_tag"]["2026-09-21"] == {"NerdQuiz auf Arcane": 1} + assert d["je_tag"]["2026-09-23"] == {"Lucy und OpenChamber über MC2": 1} + + +def test_stunden_laufen_chronologisch_bis_zur_laufenden_stunde(): + zeilen = [ + anfrage("2026-09-22T20:59:00+02:00"), # vor der ersten Stunde (21:00 gestern) + anfrage("2026-09-22T21:10:00+02:00"), + anfrage("2026-09-23T00:30:00+02:00", ip="127.0.0.1"), + anfrage("2026-09-23T20:44:00+02:00"), + ] + d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN) + stunden = d["stunden"] + assert len(stunden) == 24 + assert stunden[0]["beginn"] == "2026-09-22T21:00:00+02:00" + assert stunden[-1]["beginn"] == "2026-09-23T20:00:00+02:00" + assert stunden[0]["je_absender"] == {"NerdQuiz auf Arcane": 1} + assert stunden[3]["je_absender"] == {"Lucy und OpenChamber über MC2": 1} + assert stunden[-1]["je_absender"] == {"NerdQuiz auf Arcane": 1} + + +def test_stunden_ueber_die_zeitumstellung(): + """Am 25.10.2026 gibt es 02:00 zweimal — die Stunden bleiben 24 echte Stunden.""" + d = modell_nutzung.werte_aus([], datetime.fromisoformat("2026-10-25T12:30:00+01:00"), NAMEN) + beginne = [s["beginn"] for s in d["stunden"]] + assert beginne[0] == "2026-10-24T14:00:00+02:00" + assert "2026-10-25T02:00:00+02:00" in beginne and "2026-10-25T02:00:00+01:00" in beginne + assert beginne[-1] == "2026-10-25T12:00:00+01:00" + + +def test_ladevorgaenge_je_modell_neueste_zuerst(): + geladen = "box llama-swap[1]: [INFO] <{}> Health check passed on http://localhost:5804/health" + zeilen = [ + f"2026-09-20T06:09:02+02:00 {geladen.format('Qwen3.6-35B-A3B')}", + f"2026-09-21T14:57:07+02:00 {geladen.format('Qwen3-VL-30B-A3B-Instruct')}", + f"2026-09-23T11:25:09+02:00 {geladen.format('Qwen3.6-35B-A3B')}", + f"2026-09-23T11:25:46+02:00 {geladen.format('Qwen3.6-35B-A3B')}", + f"2026-09-10T11:25:46+02:00 {geladen.format('Altes-Modell')}", # vor dem Fenster + ] + d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN) + assert d["ladevorgaenge"] == [ + {"modell": "Qwen3.6-35B-A3B", "anzahl": 3, + "zeiten": ["2026-09-23T11:25:46+02:00", "2026-09-23T11:25:09+02:00", "2026-09-20T06:09:02+02:00"]}, + {"modell": "Qwen3-VL-30B-A3B-Instruct", "anzahl": 1, "zeiten": ["2026-09-21T14:57:07+02:00"]}, + ] + assert d["zuletzt_geladen"] == {"Qwen3.6-35B-A3B": "2026-09-23T11:25:46+02:00", + "Qwen3-VL-30B-A3B-Instruct": "2026-09-21T14:57:07+02:00"}