Files
mission-control-v2/backend/services/modell_nutzung.py
T
HitonabiandClaude Opus 5.5 12dadfe6ef umbau(boxwart): Backend auf Box-Wart umgestellt – Waechter, Modell-Nutzung, Zeitplan
MC2 wird Updater, Waechter und Modell-Radar (Konzept „MC2 als Box-Wart“, 23.09.2026).

- Neuer Waechter (services/waechter.py) loest sentry.py ab: Dienste, Timer-Laeufe,
  Hermes-Jobs samt Werkzeugfehlern, Kern-HTTP-Proben, Platte. Abgestuerzte Dienste
  startet er selbst neu (max. 2/h), rote Hinweise gehen an Telegram und Lucy.
  Laeuft im mc2-steward; waehrend eines Updates haelt er still.
- Neue Schnittstellen (routers/boxwart.py): /api/start, /api/hinweise (+ Aktionen),
  /api/modelle/nutzung, /api/zeitplan.
- Modell-Nutzung aus dem llama-swap-Journal (wer fragt wie oft, 24 h je Stunde).
- Entfernt: Ideen, Wissen, Chronik, Skills, Verbinden, Konsolen-Proxy, /api/events;
  Lucys Werkzeug idee_notieren; box_status nennt jetzt die offenen Hinweise.
- Behoben: projekte-sync ueberspringt leere Gitea-Repos (lief seit 07.09. stuendlich rot);
  Motor-Version kam aus dem verwaisten /opt/llamacpp statt /opt/llamacpp-vulkan.
- Erste Backend-Tests (8) fuer Waechter und Modell-Nutzung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:16:18 +02:00

110 lines
4.0 KiB
Python

"""
Wer nutzt die Modelle? (Box-Wart, Umbau 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").
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).
"""
import os
import re
import subprocess
import threading
import time
from collections import Counter, defaultdict
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
CACHE_S = 600
TAGE = 7
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
_STANDARD_NAMEN = {
"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/_]+)')
_GELADEN = re.compile(r"<([^>]+)> Health check passed")
_lock = threading.Lock()
_cache: dict = {"ts": 0.0, "daten": None}
def _namen() -> dict[str, str]:
namen = dict(_STANDARD_NAMEN)
for paar in os.environ.get("MC_NUTZER_NAMEN", "").split(";"):
if "=" in paar:
ip, name = paar.split("=", 1)
namen[ip.strip()] = name.strip()
return namen
def _journal(seit: str) -> list[str]:
"""Nur die Zeilen, die uns interessieren — llama-swap loggt sonst ~6.000 Status-Abfragen
am Tag. Ohne systemd (Windows-Dev) leer."""
cmd = (f"journalctl -u llama-swap --since '{seit}' -o short-iso --no-pager 2>/dev/null"
" | grep -E 'POST /v1/|Health check passed'")
try:
out = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=60)
except Exception:
return []
return out.stdout.splitlines()
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] = {}
grenze_24h = jetzt - timedelta(hours=24)
for z in zeilen:
try:
ts = datetime.fromisoformat(z.split(" ", 1)[0])
except ValueError:
continue
if ts.tzinfo is None:
ts = ts.replace(tzinfo=LOCAL_TZ)
if (m := _GELADEN.search(z)):
geladen[m.group(1)] = ts.isoformat()
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
if ts >= grenze_24h:
stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
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}", {}))}
for h in range(24)],
"zuletzt_geladen": geladen,
}
def nutzung() -> dict:
"""Gecachte Auswertung der letzten TAGE Tage (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())
with _lock:
_cache.update(ts=time.time(), daten=daten)
return daten