Files
mission-control-v2/backend/services/radar.py
T
HitonabiandClaude Opus 5.5 177c9a311c
Ampel / ampel (push) Failing after 21s
boxwart: Modell-Radar sucht, testet nachts selbst und empfiehlt (Hirn und Coder)
Suche aus Merkliste (deploy/radar-watchlist.json) und Hugging-Face-Entdeckung; nur
Kandidaten mit Bild-Projektor, die neben das Warm-Set passen (<= 115 GB inkl. KV-Cache).
Nachtlauf mc2-radar.timer 00:30, Tests nur bis 02:30 (um 03:00 kommt NerdQuiz),
hoechstens ein neuer Kandidat pro Woche, Notbremse 02:35, RuntimeMaxSec als letzte
Sicherung. Pruefstand als Modul (deploy/bench/pruefstand.py): Tempo, Werkzeuge,
Deutsch/JSON bzw. Programmieraufgaben und Bild-Probe gegen das heutige Modell der Rolle.
Durchgefallene werden geloescht, Bestandene gemeldet und erst nach "Uebernehmen" getauscht.

Beim Uebernehmen wandern die Zweitrollen mit (fast beim Hirn, heavy beim Coder), und das
Warm-Set des Stewards wird selbst umgestellt statt als Handgriff zu bleiben. Modell-Code
im Pruefstand darf keine Prozesse starten (RLIMIT_NPROC=0). Radar steht im Flugplan und
unter Waechter-Aufsicht; deploy.sh spielt seine Units ein. Oberflaeche: Vergleichswerte
je Kandidat, Rueckfrage vor Uebernehmen und Verwerfen, Status auf Deutsch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:29:38 +02:00

1375 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Modell-Radar (Box-Wart, Umbau 09/2026): sucht, prüft und empfiehlt neue Modelle für genau zwei
Rollen — das Hirn (Lucy: Hermes-Agent, ~100 Werkzeuge, Deutsch, ~13k Systemprompt) und den Coder
(agentisches Coding in OpenCode). Beide sollen auch Bilder verstehen.
Warum: Neue Modelle tauchten bisher nur als Radar-Meldung auf; ob eines wirklich besser ist, zeigte
erst ein Prüfstand von Hand (17.09.). Das Radar macht beides selbst, mit engen Leitplanken
(User-Entscheid 23.09.):
• Suche Merkliste deploy/radar-watchlist.json + discover.safe_discover. Ein Kandidat braucht
ein GGUF, ein mmproj und muss neben das Warm-Set passen
(27 GB inkl. Cache + Kandidat inkl. KV-Cache ≤ ~115 GB).
• Test nur nachts 00:3002:30 (um 03:00 braucht der NerdQuiz-Nachtlauf das Hirn), höchstens
ein neuer Kandidat pro Woche: Download → llama-server auf :5899 → Prüfstand
(deploy/bench/pruefstand.py) gegen das heutige Modell der Rolle → Server stoppen.
• Ergebnis Durchgefallene werden gelöscht. Bestandene bleiben liegen, bis der Nutzer entscheidet;
getauscht wird erst mit „Übernehmen“. Nur ein Bestandener wird gemeldet (radar_lauf.py →
notify.sh; nachts landet das im Morgen-Überblick), alles andere steht nur in MC2.
Status eines Kandidaten:
neu → wartet (als Nächster dran oder angefangen) → getestet (gemessen, Vergleich steht aus)
→ bestanden / durchgefallen → uebernommen / verworfen (vom Nutzer oder vom Radar mit Grund).
Zustand: MODELS_DIR/mc2-radar.json. Zwei Schreiber (der Nachtlauf radar_lauf.py und MC2 für die
Knöpfe) → jede Änderung läuft unter einer Datei-Sperre und wird atomar geschrieben.
Auf Windows (Entwicklung) scheitert alles Box-Nahe harmlos: kein Download, kein Server.
"""
import hashlib
import importlib.util
import json
import logging
import math
import os
import re
import shutil
import signal
import struct
import subprocess
import sys
import threading
import time
from contextlib import contextmanager
from datetime import date, datetime, timedelta
from datetime import time as uhrzeit
from pathlib import Path
from urllib.parse import quote
from zoneinfo import ZoneInfo
import httpx
import psutil
from config import HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR
from ruamel.yaml.scalarstring import LiteralScalarString
from services import discover, geheimnisse, hf, llamaswap
from services.fit import extract_active_params_b, extract_params_b
try:
import fcntl # Linux: Datei-Sperre zwischen MC2 und dem Nachtlauf
except ImportError: # Windows (Entwicklung)
fcntl = None
log = logging.getLogger(__name__)
# --- Grenzen (jede mit Env-Override) ----------------------------------------------------
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
FENSTER_START = os.environ.get("MC_RADAR_FENSTER_START", "00:30") # Tests nur nachts …
FENSTER_ENDE = os.environ.get("MC_RADAR_FENSTER_ENDE", "02:30") # … um 03:00 kommt NerdQuiz
KANDIDATEN_PRO_WOCHE = int(os.environ.get("MC_RADAR_PRO_WOCHE", "1"))
SPEICHER_GRENZE_GB = float(os.environ.get("MC_RADAR_GRENZE_GB", "115")) # Warm-Set + Kandidat
WARMSET_GB = float(os.environ.get("MC_RADAR_WARMSET_GB", "27")) # Hirn + embed + reranker inkl. Cache
CODER_HEUTE_GB = float(os.environ.get("MC_RADAR_CODER_GB", "29")) # Coder heute inkl. Cache
HIRN_HEUTE_GB = float(os.environ.get("MC_RADAR_HIRN_GB", "25")) # Hirn-Anteil am Warm-Set
ENG_ANTEIL = float(os.environ.get("MC_RADAR_ENG_ANTEIL", "0.9")) # ab 90 % des Budgets „eng“
CTX_ROLLE = {"hirn": int(os.environ.get("MC_RADAR_CTX_HIRN", "131072")), # Kontext wie im Betrieb
"coder": int(os.environ.get("MC_RADAR_CTX_CODER", "131072"))}
CTX_MIN = 32768 # darunter trägt der 13k-Test nicht mehr sinnvoll
PLATTE_MAX_PCT = float(os.environ.get("MC_RADAR_PLATTE_MAX_PCT", "80")) # = gelbe Grenze des Wächters
TESTZEIT_MIN = int(os.environ.get("MC_RADAR_TESTZEIT_MIN", "40")) # so viel Fenster muss nach dem Download bleiben
MIN_RESTZEIT_MIN = int(os.environ.get("MC_RADAR_MIN_RESTZEIT_MIN", "20")) # kurz vor Fensterende fängt nichts mehr an
VERSUCHE_MAX = int(os.environ.get("MC_RADAR_VERSUCHE", "3"))
BASELINE_TAGE = int(os.environ.get("MC_RADAR_BASELINE_TAGE", "30")) # Baseline höchstens monatlich neu messen
BILD_PFLICHT = os.environ.get("MC_RADAR_BILD_PFLICHT", "1") != "0" # ohne mmproj kein Kandidat
DISCOVER_MAX_JE_ROLLE = int(os.environ.get("MC_RADAR_DISCOVER_MAX", "3"))
MIN_PARAMS_B = float(os.environ.get("MC_RADAR_MIN_PARAMS_B", "20"))
HIRN_MAX_AKTIV_B = float(os.environ.get("MC_RADAR_HIRN_MAX_AKTIV_B", "12")) # Verdikt: dichte Modelle nie als Hirn
ABGELEHNT_TAGE = 30
TESTS_MAX = 100
STORE_PATH = Path(os.environ.get("MC_RADAR_STORE", str(MODELS_DIR / "mc2-radar.json")))
BASELINE_PATH = Path(os.environ.get("MC_RADAR_BASELINE", str(MODELS_DIR / "mc2-radar-baseline.json")))
RADAR_DIR = Path(os.environ.get("MC_RADAR_DIR", str(MODELS_DIR / "radar")))
_REPO = Path(__file__).resolve().parents[2]
WATCHLIST_PATH = Path(os.environ.get("MC_RADAR_WATCHLIST", str(_REPO / "deploy" / "radar-watchlist.json")))
PRUEFSTAND_PATH = _REPO / "deploy" / "bench" / "pruefstand.py"
ENGINE_LIB = Path(os.environ.get("MC_RADAR_ENGINE_LIB", "/opt/llamacpp-vulkan/libllama.so"))
STEWARD_WARMSET = Path(os.environ.get("MC_RADAR_STEWARD_WARMSET", str(
Path.home() / ".config" / "systemd" / "user" / "mc2-steward.service.d" / "warmset.conf")))
HF_BASIS = "https://huggingface.co"
ROLLEN = ("hirn", "coder")
ALIAS = {"hirn": "hermes", "coder": "coder"} # llama-swap-Alias der Rolle heute
DISCOVER_ROLLEN = {"coder": "coder", "fast": "hirn", "hermes": "hirn"}
PARALLEL = {"hirn": 2, "coder": 1} # Slots im Betrieb, wie heute
KANDIDAT_FELDER = ("id", "name", "rolle", "repo", "datei", "groesse_gb", "passt", "eng", "quelle", "status",
"begruendung")
TEST_FELDER = ("id", "kandidat", "rolle", "datum", "ergebnis", "werte", "vergleich", "zusammenfassung")
_REIHENFOLGE = {"wartet": 0, "neu": 1, "getestet": 2, "bestanden": 3, "uebernommen": 4, "durchgefallen": 5,
"verworfen": 6}
_SCHRITTE = {"download": "lädt herunter", "baseline": "misst das heutige Modell", "test": "testet den Kandidaten"}
class Abbruch(Exception):
"""Ein Lauf endet ohne Urteil. zaehlt=False: nicht die Schuld des Kandidaten (zählt nicht als Versuch)."""
def __init__(self, grund: str, zaehlt: bool = True):
super().__init__(grund)
self.grund, self.zaehlt = grund, zaehlt
# --- Zeit --------------------------------------------------------------------------------
def jetzt() -> datetime:
return datetime.now(LOCAL_TZ)
def _hhmm(text: str) -> uhrzeit:
stunde, minute = text.strip().split(":")
return uhrzeit(int(stunde), int(minute))
def _lokal(tag: date, uhr: uhrzeit) -> datetime:
"""Ortszeit als eindeutiger Zeitpunkt. Am Umstellungstag (fehlende/doppelte Stunde) gilt der
FRÜHERE Zeitpunkt — ein Fenster endet lieber zu früh als in den NerdQuiz-Lauf hinein."""
varianten = [datetime.combine(tag, uhr, tzinfo=LOCAL_TZ).replace(fold=f) for f in (0, 1)]
return min(varianten, key=lambda d: d.timestamp())
def im_fenster(dt: datetime) -> bool:
start, ende, t = _hhmm(FENSTER_START), _hhmm(FENSTER_ENDE), dt.astimezone(LOCAL_TZ).time()
return start <= t < ende if start < ende else (t >= start or t < ende)
def fenster_ende(dt: datetime) -> datetime:
"""Ende des Test-Fensters, in dem dt liegt (bzw. des nächsten)."""
dt = dt.astimezone(LOCAL_TZ)
start, ende = _hhmm(FENSTER_START), _hhmm(FENSTER_ENDE)
tag = dt.date() + timedelta(days=1) if (start > ende and dt.time() >= start) else dt.date()
return _lokal(tag, ende)
def naechster_start(dt: datetime) -> datetime:
"""Nächster Fensterbeginn nach dt — dann startet der Timer den nächsten Lauf. Mitten im Fenster ist
der Lauf dieser Nacht schon gestartet (oder bewusst nicht), also zählt erst die nächste Nacht."""
dt = dt.astimezone(LOCAL_TZ)
start = _lokal(dt.date(), _hhmm(FENSTER_START))
if start.timestamp() <= dt.timestamp():
start = _lokal(dt.date() + timedelta(days=1), _hhmm(FENSTER_START))
return start
def _tag_von(ts: float | None) -> date | None:
return datetime.fromtimestamp(ts, LOCAL_TZ).date() if ts else None
def _iso(ts: float | None) -> str | None:
return datetime.fromtimestamp(ts, LOCAL_TZ).isoformat(timespec="seconds") if ts else None
def _datum(ts: float) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.")
def _gb(wert: float) -> str:
return f"{wert:.1f}".replace(".", ",").removesuffix(",0")
# --- Namen ---------------------------------------------------------------------------------
def kandidat_id(rolle: str, repo: str, quant: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", f"{rolle}-{repo}-{quant}".lower()).strip("-")
def anzeigename(repo: str) -> str:
"""Basisname aus dem Repo: ohne Org und ohne -GGUF; bartowskis „Qwen_Qwen3.6-…“ → „Qwen3.6-…“."""
name = re.sub(r"[-_]GGUF$", "", repo.split("/")[-1], flags=re.IGNORECASE)
if "_" in name and (name.find("-") < 0 or name.index("_") < name.find("-")):
name = name.split("_", 1)[1]
return name
def stamm(name: str) -> str:
"""Vergleichs-Stamm eines Modells (Repo, Ordner, Datei oder llama-swap-Name)."""
s = anzeigename(name).lower()
s = re.sub(r"\.gguf$", "", s)
s = re.sub(r"-\d{5}-of-\d{5}$", "", s)
s = re.sub(r"[-_](ud-)?(i?q\d\w*|f16|bf16|fp16|f32|mxfp4)$", "", s)
return s.strip("-_ ")
_FAMILIEN = (("qwen", r"qwen[-_ ]?(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"),
("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("glm", r"glm[-_ ]?(\d+(?:\.\d+)?)"),
("nemotron", r"nemotron[-_ ]?(\d+(?:\.\d+)?)"), ("mistral", r"mistral[-_ ]?(\d+(?:\.\d+)?)"),
("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"), ("deepseek", r"deepseek[-_ ]?v?(\d+(?:\.\d+)?)"))
def generation(name: str) -> tuple[str, float] | None:
"""(Familie, Version) aus dem Namen, z. B. „Qwen3.6-35B-A3B“ → ("qwen", 3.6)."""
klein = anzeigename(name).lower()
for familie, muster in _FAMILIEN:
if (m := re.search(muster, klein)):
return familie, float(m.group(1))
return None
# --- Filter (rein, ohne Netz) -------------------------------------------------------------------
def entdeckung_ungeeignet(m: dict, rolle: str, bekannt: set[str], heute: dict[str, str]) -> str | None:
"""Warum ein discover-Fund fürs Radar nicht taugt; None = taugt. Discover rankt nach Beliebtheit —
ohne diese Vorauswahl verbrächte das Radar Wochen mit alten oder zu kleinen Modellen."""
repo = str(m.get("repo") or "")
if m.get("curated"):
return "steht schon im kuratierten Katalog"
if stamm(repo) in bekannt:
return "liegt schon auf der Box"
params = float(m.get("params_b") or 0)
if params < MIN_PARAMS_B:
return f"zu klein ({params:.0f}B)"
if rolle == "hirn":
aktiv = (m.get("caps") or {}).get("active_b") or extract_active_params_b(repo)
if not aktiv or float(aktiv) > HIRN_MAX_AKTIV_B:
return "kein MoE mit wenigen aktiven Parametern (dichte Modelle nie als Hirn)"
neu, alt = generation(repo), generation(heute.get(rolle, ""))
if neu and alt and neu[0] == alt[0] and neu[1] < alt[1]:
return f"ältere Generation als das heutige Modell ({heute[rolle]})"
return None
def _groesse(eintrag: dict) -> int:
return int(eintrag.get("size") or (eintrag.get("lfs") or {}).get("size") or 0)
def _ist_hilfsdatei(pfad: str) -> bool:
name = pfad.lower().rsplit("/", 1)[-1]
return name.startswith("mtp-") or "draft" in name or "dflash" in name or "/mtp/" in f"/{pfad.lower()}"
def waehle_dateien(baum: list[dict], quant: str) -> dict | None:
"""GGUF-Auswahl eines Repos: alle Teile des gewünschten Quants (Split-Dateien als Gruppe) und das
mmproj (F16 vor BF16 vor dem Rest). None, wenn es den Quant nicht gibt."""
ggufs = [(str(e.get("path", "")), _groesse(e)) for e in baum if str(e.get("path", "")).lower().endswith(".gguf")]
muster = re.compile(rf"(^|[-_.]){re.escape(quant.lower())}([-_.]|$)")
modell = [(p, s) for p, s in ggufs
if "mmproj" not in p.lower() and not _ist_hilfsdatei(p) and muster.search(p.lower().rsplit("/", 1)[-1])]
if not modell:
return None
teile = sorted((p, s) for p, s in modell if re.search(r"-\d{5}-of-\d{5}\.gguf$", p, re.IGNORECASE))
if teile:
praefix = re.sub(r"-\d{5}-of-\d{5}\.gguf$", "", teile[0][0], flags=re.IGNORECASE)
dateien = [[p, s] for p, s in teile if p.startswith(praefix)]
else:
dateien = [list(min(modell, key=lambda x: x[1]))]
def rang(pfad: str) -> int:
klein = pfad.lower()
return 1 if "bf16" in klein else (0 if "f16" in klein else 2)
projektoren = sorted(((p, s) for p, s in ggufs if "mmproj" in p.lower()), key=lambda x: (rang(x[0]), x[0]))
return {"dateien": dateien, "mmproj": list(projektoren[0]) if projektoren else None}
def speicher_passung(rolle: str, dateien_gb: float, kv_je_token_gb: float, eng_markiert: bool = False) -> dict:
"""Passt der Kandidat neben das Warm-Set? Kontext wie im Betrieb, sonst stufenweise kleiner.
Regel (User 23.09.): Warm-Set + Kandidat inkl. KV-Cache ≤ Grenze. Ein Coder-Kandidat ersetzt den
heutigen Coder, ein Hirn-Kandidat läuft im Test neben dem heutigen Hirn — beides zählt so."""
budget = SPEICHER_GRENZE_GB - WARMSET_GB
ziel = CTX_ROLLE[rolle]
for ctx in dict.fromkeys(c for c in (ziel, 65536, CTX_MIN) if c <= ziel):
bedarf = dateien_gb + kv_je_token_gb * ctx
if bedarf > budget:
continue
eng = eng_markiert or ctx < ziel or bedarf > ENG_ANTEIL * budget
text = (f"Passt{' knapp' if eng else ''}: braucht ~{_gb(bedarf)} GB inkl. KV-Cache (Kontext {ctx // 1024}k), "
f"neben dem Warm-Set sind {_gb(budget)} GB frei.")
if rolle == "hirn":
# Nach dem Tausch sitzt der Kandidat im Warm-Set — dann muss auch der Coder noch daneben passen.
betrieb = WARMSET_GB - HIRN_HEUTE_GB + bedarf + CODER_HEUTE_GB
if betrieb > SPEICHER_GRENZE_GB:
eng = True
text += f" Im Betrieb mit dem Coder wären es ~{_gb(betrieb)} GB das wird eng."
return {"passt": True, "eng": eng, "ctx": ctx, "bedarf_gb": round(bedarf, 1), "text": text}
bedarf = dateien_gb + kv_je_token_gb * CTX_MIN
return {"passt": False, "eng": True, "ctx": CTX_MIN, "bedarf_gb": round(bedarf, 1),
"text": f"Passt nicht: braucht selbst mit {CTX_MIN // 1024}k Kontext ~{_gb(bedarf)} GB, neben dem "
f"Warm-Set sind nur {_gb(budget)} GB frei."}
def platte_nach_download(benutzt: int, gesamt: int, zusaetzlich: int) -> float:
"""Füllstand der Platte in Prozent, wenn zusaetzlich Bytes dazukommen."""
return (benutzt + zusaetzlich) / gesamt * 100 if gesamt else 100.0
# --- GGUF-Kopf ------------------------------------------------------------------------------
_GGUF_SKALAR = {0: "<B", 1: "<b", 2: "<H", 3: "<h", 4: "<I", 5: "<i", 6: "<f", 7: "<?", 10: "<Q", 11: "<q", 12: "<d"}
def gguf_kopf_aus_bytes(daten: bytes) -> dict | None:
"""Skalare Metadaten vom Anfang einer GGUF-Datei (bis zum Tokenizer). Reicht für die Speicher-
Schätzung; die ersten paar MB kommen per Range-Anfrage, ohne das Modell zu laden."""
pos = 0
def lies(n: int) -> bytes:
nonlocal pos
if pos + n > len(daten):
raise EOFError
stueck = daten[pos:pos + n]
pos += n
return stueck
def zahl(fmt: str):
return struct.unpack(fmt, lies(struct.calcsize(fmt)))[0]
def text() -> str:
return lies(zahl("<Q")).decode("utf-8", "replace")
def ueberspringe(typ: int) -> None:
if typ == 8:
lies(zahl("<Q"))
elif typ in _GGUF_SKALAR:
lies(struct.calcsize(_GGUF_SKALAR[typ]))
elif typ == 9:
etyp, anzahl = zahl("<I"), zahl("<Q")
if etyp in _GGUF_SKALAR:
lies(anzahl * struct.calcsize(_GGUF_SKALAR[etyp]))
else:
for _ in range(anzahl):
ueberspringe(etyp)
else:
raise ValueError(typ)
try:
if lies(4) != b"GGUF":
return None
zahl("<I")
zahl("<Q")
kopf: dict = {}
for _ in range(zahl("<Q")):
schluessel, typ = text(), zahl("<I")
if schluessel.startswith("tokenizer."):
break
if typ == 8:
kopf[schluessel] = text()
elif typ in _GGUF_SKALAR:
kopf[schluessel] = zahl(_GGUF_SKALAR[typ])
else:
ueberspringe(typ)
return kopf
except (EOFError, ValueError, struct.error):
return None
def kv_je_token_gb(kopf: dict, bytes_je_wert: float = 1.0625) -> float | None:
"""KV-Cache in GB pro Kontext-Token bei q8_0. Hybrid-Modelle (full_attention_interval = n, z. B.
Qwen3.5/3.8) halten nur in jeder n-ten Schicht einen KV-Cache — services.gguf_meta rechnet hier mit
allen Schichten und käme auf das Vierfache."""
arch = kopf.get("general.architecture")
if not arch:
return None
def wert(schluessel: str):
return kopf.get(f"{arch}.{schluessel}")
schichten, koepfe = wert("block_count"), wert("attention.head_count")
kv_koepfe = wert("attention.head_count_kv") or koepfe
einbettung = wert("embedding_length")
k_laenge = wert("attention.key_length") or (einbettung // koepfe if einbettung and koepfe else None)
v_laenge = wert("attention.value_length") or k_laenge
if not (schichten and kv_koepfe and k_laenge and v_laenge):
return None
intervall = int(wert("full_attention_interval") or 1)
voll = math.ceil(schichten / intervall) if intervall > 1 else schichten
return voll * kv_koepfe * (k_laenge + v_laenge) * bytes_je_wert / 1e9
def _kv_schaetzung(repo: str, dateien_gb: float) -> float:
"""Ohne lesbaren Kopf: grobe, eher zu große Schätzung wie services.fit (f16 kalibriert, × 0,53 für q8_0)."""
params = extract_params_b(repo)
if params <= 7.0:
params = max(dateien_gb / 0.55, 7.0)
return (max(params, 7) / 7) ** 0.5 * 0.84 / 8192 * 0.53
def engine_kennt(arch: str) -> bool | None:
"""Kennt die installierte llama.cpp-Engine diese Architektur? Die Namen stehen als C-Strings in
libllama.so. None = nicht prüfbar (keine Engine, z. B. auf Windows)."""
try:
daten = ENGINE_LIB.read_bytes()
except OSError:
return None
return (b"\x00" + arch.encode() + b"\x00") in daten
# --- Hugging Face (Netz) ------------------------------------------------------------------------
def _hf_kopfzeilen() -> dict:
token = geheimnisse.hf_token()
return {"Authorization": f"Bearer {token}"} if token else {}
def _hf_baum(repo: str) -> list[dict]:
with httpx.Client(timeout=20.0, headers=_hf_kopfzeilen()) as c:
r = c.get(f"{HF_BASIS}/api/models/{repo}/tree/main", params={"recursive": "true"})
r.raise_for_status()
daten = r.json()
return daten if isinstance(daten, list) else []
def _gguf_kopf(repo: str, datei: str, max_bytes: int = 8 << 20) -> dict | None:
"""Die ersten MB einer GGUF-Datei per Range-Anfrage lesen und den Kopf auswerten."""
puffer = bytearray()
try:
with httpx.Client(timeout=30.0, follow_redirects=True, headers=_hf_kopfzeilen()) as c:
with c.stream("GET", f"{HF_BASIS}/{repo}/resolve/main/{quote(datei)}",
headers={"Range": f"bytes=0-{max_bytes - 1}"}) as r:
if r.status_code not in (200, 206):
return None
for stueck in r.iter_bytes():
puffer += stueck
if len(puffer) >= max_bytes:
break
except Exception:
log.debug("radar: GGUF-Kopf von %s/%s nicht lesbar", repo, datei, exc_info=True)
return None
return gguf_kopf_aus_bytes(bytes(puffer))
# --- Zustand ------------------------------------------------------------------------------
def _leer() -> dict:
return {"version": 1, "letzte_suche": 0.0, "kandidaten": {}, "tests": [], "abgelehnt": {}, "lauf": None}
def _lade() -> dict:
try:
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
daten = {}
stand = _leer()
if isinstance(daten, dict):
stand.update({k: v for k, v in daten.items() if k in stand and v is not None})
return stand
def lade_stand() -> dict:
return _lade()
def _speichere(stand: dict) -> None:
try:
tmp = STORE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(stand, ensure_ascii=False), encoding="utf-8")
tmp.replace(STORE_PATH)
except OSError:
log.warning("radar: Zustand %s nicht schreibbar", STORE_PATH, exc_info=True)
_thread_sperre = threading.Lock()
_sperr_tiefe = threading.local()
@contextmanager
def _gesperrt():
"""Sperre für Lesen-Ändern-Schreiben: Thread-Lock plus flock (MC2 und Nachtlauf sind zwei Prozesse).
Verschachtelt im selben Thread nur einmal sperren — flock über eine zweite Datei-Öffnung hinge sonst."""
if getattr(_sperr_tiefe, "n", 0):
_sperr_tiefe.n += 1
try:
yield
finally:
_sperr_tiefe.n -= 1
return
with _thread_sperre:
datei = None
if fcntl is not None:
try:
datei = open(STORE_PATH.with_suffix(".lock"), "a+")
fcntl.flock(datei, fcntl.LOCK_EX)
except OSError:
datei = None
_sperr_tiefe.n = 1
try:
yield
finally:
_sperr_tiefe.n = 0
if datei is not None:
try:
fcntl.flock(datei, fcntl.LOCK_UN)
finally:
datei.close()
def _aendere(aenderung):
with _gesperrt():
stand = _lade()
ergebnis = aenderung(stand)
_speichere(stand)
return ergebnis
def _aktiver_lauf(stand: dict) -> dict | None:
"""Der laufende Nachtlauf — nur, wenn sein Prozess noch lebt und die Frist nicht lange vorbei ist."""
lauf = stand.get("lauf")
if not isinstance(lauf, dict):
return None
try:
lebt = psutil.pid_exists(int(lauf.get("pid") or 0))
except Exception:
lebt = False
if not lebt or time.time() > float(lauf.get("frist") or 0) + 900:
return None
return lauf
def _hat_geschichte(k: dict) -> bool:
"""Einträge mit Geschichte fasst die Suche nicht mehr an (getestet, entschieden, angefangen)."""
return (k.get("status") in ("getestet", "bestanden", "durchgefallen", "uebernommen")
or (k.get("status") == "verworfen" and k.get("verworfen_von") == "nutzer")
or bool(k.get("erster_start")))
def warteschlange(stand: dict) -> list[dict]:
"""Testbare Kandidaten in Reihenfolge: Angefangene zuerst, dann Merkliste, dann Entdeckung."""
offen = [k for k in stand["kandidaten"].values() if k.get("status") in ("neu", "wartet") and k.get("passt")]
return sorted(offen, key=lambda k: (0 if k.get("erster_start") else 1, 0 if k.get("quelle") == "watchlist" else 1,
k.get("rang", 999), k.get("gefunden", 0)))
def _ordne(stand: dict) -> None:
"""Der Kopf der Warteschlange (und jeder Angefangene) steht auf „wartet“, alle anderen offenen auf „neu“."""
for i, k in enumerate(warteschlange(stand)):
k["status"] = "wartet" if i == 0 or k.get("erster_start") else "neu"
def wochen_frei_ab(stand: dict, heute: date) -> date | None:
"""Frühester Tag für einen NEUEN Kandidaten; None = heute schon frei. Ein Kandidat belegt die Woche
ab seinem ersten Start; Fortsetzungen (Download, Abbruch) belegen nichts Neues."""
if KANDIDATEN_PRO_WOCHE <= 0: # 0 = Radar testet gar nicht
return heute + timedelta(days=3650)
starts = sorted((d for k in stand["kandidaten"].values() if (d := _tag_von(k.get("erster_start")))),
reverse=True)
in_woche = [d for d in starts if 0 <= (heute - d).days < 7]
if len(in_woche) < KANDIDATEN_PRO_WOCHE:
return None
return in_woche[KANDIDATEN_PRO_WOCHE - 1] + timedelta(days=7)
def plane_test(stand: dict, zeit: datetime) -> dict:
"""Was jetzt zu tun ist: {"kandidat": id} oder {"kandidat": None, "grund": …}."""
if not im_fenster(zeit):
return {"kandidat": None, "grund": f"außerhalb des Test-Fensters {FENSTER_START}{FENSTER_ENDE}"}
rest = (fenster_ende(zeit).timestamp() - zeit.timestamp()) / 60
if rest < MIN_RESTZEIT_MIN:
return {"kandidat": None, "grund": f"nur noch {rest:.0f} Minuten bis {FENSTER_ENDE}"}
if _aktiver_lauf(stand):
return {"kandidat": None, "grund": "es läuft schon ein Test"}
schlange = warteschlange(stand)
if not schlange:
return {"kandidat": None, "grund": "kein Kandidat wartet"}
k = schlange[0]
if not k.get("erster_start") and (frei := wochen_frei_ab(stand, zeit.astimezone(LOCAL_TZ).date())):
return {"kandidat": None, "grund": f"diese Woche lief schon ein Kandidat, der nächste frühestens am "
f"{frei:%d.%m.}"}
return {"kandidat": k["id"], "grund": f"{k['name']} ({k['rolle']})"}
def suche_faellig(zeit: datetime) -> bool:
"""Der Nachtlauf sucht höchstens einmal am Tag."""
return _tag_von(_lade().get("letzte_suche")) != zeit.astimezone(LOCAL_TZ).date()
# --- Suche --------------------------------------------------------------------------------
def _heutige_modelle() -> dict[str, dict]:
"""Rolle → llama-swap-Modell, das heute den Alias der Rolle trägt."""
heute: dict[str, dict] = {}
try:
modelle = llamaswap.list_models()
except Exception:
log.debug("radar: llama-swap-Config nicht lesbar", exc_info=True)
return heute
for m in modelle:
aliase = {str(a).lower() for a in m.get("aliases") or []}
for rolle, alias in ALIAS.items():
if alias in aliase:
heute[rolle] = m
return heute
def _bekannte_staemme() -> set[str]:
"""Was schon auf der Box liegt: llama-swap-Modelle und Ordner unter MODELS_DIR."""
staemme: set[str] = set()
try:
for m in llamaswap.list_models():
staemme.add(stamm(m["name"]))
if m.get("gguf_path"):
staemme.update({stamm(Path(m["gguf_path"]).parent.name), stamm(Path(m["gguf_path"]).name)})
except Exception:
pass
try:
staemme.update(stamm(d.name) for d in MODELS_DIR.iterdir() if d.is_dir() and not d.name.startswith("."))
except OSError:
pass
return staemme
def _merkliste() -> tuple[list[dict], bool]:
try:
daten = json.loads(WATCHLIST_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
log.warning("radar: Merkliste %s nicht lesbar", WATCHLIST_PATH)
return [], False
eintraege = daten.get("kandidaten") if isinstance(daten, dict) else daten
funde = [{**e, "quelle": "watchlist", "rang": i} for i, e in enumerate(eintraege or [])
if isinstance(e, dict) and e.get("rolle") in ROLLEN and e.get("repo")]
return funde, True
def _entdeckung(bekannt: set[str], heute: dict[str, str], ausschluss: set[str]) -> tuple[list[dict], bool]:
try:
daten = discover.safe_discover(psutil.virtual_memory().total / 1024 ** 3)
except Exception:
log.warning("radar: Entdeckung fehlgeschlagen", exc_info=True)
daten = None
if not daten:
return [], False
funde: list[dict] = []
je_rolle = dict.fromkeys(ROLLEN, 0)
for kategorie in daten.get("categories") or []:
rolle = DISCOVER_ROLLEN.get(kategorie.get("role"))
if not rolle:
continue
for rang, m in enumerate(kategorie.get("models") or []):
if je_rolle[rolle] >= DISCOVER_MAX_JE_ROLLE:
break
if not m.get("repo") or stamm(m["repo"]) in ausschluss:
continue
if (grund := entdeckung_ungeeignet(m, rolle, bekannt, heute)):
log.debug("radar: %s übergangen (%s)", m.get("repo"), grund)
continue
funde.append({"rolle": rolle, "repo": m["repo"], "quant": "Q4_K_M", "quelle": "discover",
"rang": 100 + rang, "autor": m.get("author")})
je_rolle[rolle] += 1
return funde, True
def _bewerte(fund: dict) -> dict:
"""Aus einem Fund einen Kandidaten machen: Dateien auflösen, Größe, Engine-Unterstützung, Speicher.
Braucht Netz (Hugging Face) — wird deshalb außerhalb der Sperre aufgerufen."""
rolle = fund["rolle"]
repo = hf.normalize_repo(fund["repo"])
quant = str(fund.get("quant") or "Q4_K_M")
k: dict = {"id": kandidat_id(rolle, repo, quant), "name": fund.get("name") or anzeigename(repo), "rolle": rolle,
"repo": repo, "quant": quant, "quelle": fund["quelle"], "rang": fund.get("rang", 999),
"datei": "", "dateien": [], "mmproj": None, "draft": None,
"flags": [str(f) for f in fund.get("flags") or []], "groesse_gb": None, "passt": False,
"eng": bool(fund.get("eng")), "ctx": None, "bedarf_gb": None, "tauglich": False, "begruendung": ""}
herkunft = "Aus der Merkliste." if fund["quelle"] == "watchlist" else \
f"Aus der Hugging-Face-Entdeckung ({fund.get('autor') or repo.split('/')[0]})."
notiz = f" {fund['notiz']}" if fund.get("notiz") else ""
if fund.get("ueberspringen"):
k["begruendung"] = f"Übersprungen: {fund['ueberspringen']}"
return k
try:
baum = _hf_baum(repo)
except Exception as e:
k["netzfehler"] = True
k["begruendung"] = f"Hugging Face gerade nicht erreichbar ({str(e)[:80]})."
return k
wahl = waehle_dateien(baum, quant)
if not wahl:
k["begruendung"] = f"Kein GGUF mit {quant} im Repo."
return k
k.update(dateien=wahl["dateien"], mmproj=wahl["mmproj"], datei=Path(wahl["dateien"][0][0]).name)
if BILD_PFLICHT and not wahl["mmproj"]:
k["begruendung"] = "Versteht keine Bilder: kein mmproj im Repo."
return k
groesse = sum(s for _, s in wahl["dateien"]) + (wahl["mmproj"][1] if wahl["mmproj"] else 0)
if isinstance(fund.get("draft"), dict):
d = fund["draft"]
d_repo, d_datei = hf.normalize_repo(d.get("repo") or repo), d.get("datei")
d_bytes = 0
if d_datei:
try:
treffer = [e for e in (baum if d_repo == repo else _hf_baum(d_repo)) if e.get("path") == d_datei]
except Exception:
treffer = []
if not treffer:
k["begruendung"] = f"Draft-Datei {d_datei} fehlt im Repo {d_repo}."
return k
d_bytes = _groesse(treffer[0])
k["draft"] = {"repo": d_repo, "datei": d_datei, "bytes": d_bytes, "typ": d.get("typ") or "draft-simple",
"n_max": d.get("n_max")}
groesse += d_bytes
kopf = _gguf_kopf(repo, wahl["dateien"][0][0])
arch = (kopf or {}).get("general.architecture")
if arch and engine_kennt(arch) is False:
k["begruendung"] = f"Läuft noch nicht im offiziellen llama.cpp (Architektur {arch} unbekannt).{notiz}"
return k
kv = (kv_je_token_gb(kopf) if kopf else None) or _kv_schaetzung(repo, groesse / 1e9)
passung = speicher_passung(rolle, groesse / 1e9, kv, eng_markiert=bool(fund.get("eng")))
k.update(groesse_gb=round(groesse / 1e9, 1), passt=passung["passt"], eng=passung["eng"], ctx=passung["ctx"],
bedarf_gb=passung["bedarf_gb"], tauglich=passung["passt"], arch=arch)
k["begruendung"] = f"{herkunft} {passung['text']}{notiz}"
if not kopf and not passung["passt"]:
# Ohne Kopf rechnet die Schätzung den KV-Cache grob zu groß — darauf hin nichts verwerfen,
# die nächste Suche liest den Kopf erneut.
k.update(netzfehler=True, tauglich=False,
begruendung="Speicherbedarf unklar: der GGUF-Kopf ließ sich nicht lesen. Die nächste Suche "
"versucht es wieder.")
return k
_UEBERNEHMEN = ("name", "rolle", "repo", "quant", "quelle", "rang", "datei", "dateien", "mmproj", "draft", "flags",
"groesse_gb", "passt", "eng", "ctx", "bedarf_gb", "arch", "begruendung")
def _suche_anwenden(stand: dict, bewertet: list[dict], quellen_ok: dict[str, bool], zeit: float) -> dict:
"""Bewertete Funde in den Zustand übernehmen (rein, ohne Netz)."""
kandidaten = stand["kandidaten"]
geschichte = {(k["rolle"], stamm(k["repo"])): kid for kid, k in kandidaten.items() if _hat_geschichte(k)}
gesehen, neu = set(), []
for b in bewertet:
kid = b["id"]
gesehen.add(kid)
alt = kandidaten.get(kid)
if alt and _hat_geschichte(alt):
continue
frueher = geschichte.get((b["rolle"], stamm(b["repo"])))
if frueher and frueher != kid:
continue # dasselbe Modell lief schon (z. B. aus einem anderen Repo)
if b.get("netzfehler"):
continue # alte Bewertung behalten, nicht wegen eines Netz-Aussetzers verwerfen
if b["quelle"] == "discover" and not b.get("tauglich"):
stand["abgelehnt"][stamm(b["repo"])] = {"grund": b["begruendung"], "zeit": zeit}
kandidaten.pop(kid, None)
continue
eintrag = {**(alt or {}), **{f: b.get(f) for f in _UEBERNEHMEN}, "id": kid,
"gefunden": (alt or {}).get("gefunden", zeit), "geaendert": zeit}
if b.get("tauglich"):
eintrag.update(status=(alt or {}).get("status", "neu"), verworfen_von=None)
if eintrag["status"] not in ("neu", "wartet"):
eintrag["status"] = "neu"
if not alt or alt.get("status") == "verworfen":
neu.append(eintrag["name"])
else:
eintrag.update(status="verworfen", verworfen_von="radar")
kandidaten[kid] = eintrag
# Offene Einträge, die ihre (erreichbare) Quelle nicht mehr nennt, fliegen raus.
for kid in [kid for kid, k in kandidaten.items()
if kid not in gesehen and not _hat_geschichte(k) and quellen_ok.get(k.get("quelle"), False)]:
del kandidaten[kid]
stand["abgelehnt"] = {s: e for s, e in stand["abgelehnt"].items()
if zeit - float(e.get("zeit") or 0) < ABGELEHNT_TAGE * 86400}
_ordne(stand)
stand["letzte_suche"] = zeit
return {"ok": True, "neu": neu, "wartend": len(warteschlange(stand)), "gesamt": len(kandidaten),
"quellen": quellen_ok}
def suche() -> dict:
"""Merkliste und Entdeckung auswerten und in den Zustand übernehmen. Das Netz (Hugging Face) läuft
außerhalb der Sperre, damit Knöpfe und Nachtlauf nicht warten müssen."""
stand = _lade()
heute = {rolle: m["name"] for rolle, m in _heutige_modelle().items()}
ausschluss = {s for s, e in stand["abgelehnt"].items()
if time.time() - float(e.get("zeit") or 0) < ABGELEHNT_TAGE * 86400}
ausschluss |= {stamm(k["repo"]) for k in stand["kandidaten"].values() if _hat_geschichte(k)}
merkliste, merkliste_ok = _merkliste()
entdeckung, entdeckung_ok = _entdeckung(_bekannte_staemme(), heute, ausschluss)
bewertet = []
for fund in merkliste + entdeckung:
try:
bewertet.append(_bewerte(fund))
except Exception:
log.warning("radar: Bewertung von %s fehlgeschlagen", fund.get("repo"), exc_info=True)
ergebnis = _aendere(lambda s: _suche_anwenden(s, bewertet, {"watchlist": merkliste_ok,
"discover": entdeckung_ok}, time.time()))
log.info("radar: Suche fertig %s neu, %s warten, %s insgesamt", len(ergebnis["neu"]), ergebnis["wartend"],
ergebnis["gesamt"])
return ergebnis
# --- Test ---------------------------------------------------------------------------------
_PS = None
_DOWNLOAD: dict = {"prozess": None}
def _pruefstand():
"""deploy/bench/pruefstand.py laden — ein Skript-Ordner, kein Paket; darum per Pfad."""
global _PS
if _PS is None:
spec = importlib.util.spec_from_file_location("pruefstand", PRUEFSTAND_PATH)
modul = importlib.util.module_from_spec(spec)
sys.modules["pruefstand"] = modul
spec.loader.exec_module(modul)
_PS = modul
return _PS
def _protokoll(*teile) -> None:
log.info("radar: %s", " ".join(str(t) for t in teile))
def _markiere_lauf(kid: str, schritt: str, frist: float) -> None:
def aenderung(stand: dict) -> None:
k = stand["kandidaten"].get(kid)
if k:
k["erster_start"] = k.get("erster_start") or time.time()
k["status"] = "wartet"
alt = stand.get("lauf") if isinstance(stand.get("lauf"), dict) else {}
eigener = alt.get("pid") == os.getpid() and alt.get("kandidat") == kid # nicht der Rest einer Vornacht
stand["lauf"] = {"pid": os.getpid(), "kandidat": kid, "schritt": schritt,
"seit": alt.get("seit") if eigener else time.time(), "frist": frist}
_aendere(aenderung)
def _loesche_dateien(k: dict) -> None:
"""Kandidaten-Ordner löschen — nur, wenn er wirklich direkt unter RADAR_DIR liegt."""
ordner = k.get("ordner")
if not ordner:
return
pfad = Path(ordner)
try:
if pfad.resolve().parent != RADAR_DIR.resolve():
log.warning("radar: %s liegt nicht im Radar-Ordner nicht gelöscht", pfad)
return
shutil.rmtree(pfad, ignore_errors=True)
except OSError:
log.warning("radar: %s nicht löschbar", pfad, exc_info=True)
return
k["ordner"] = None
def _abschluss(stand: dict, kid: str, ergebnis: str | None, satz: str, *, werte: dict | None = None,
vergleich: dict | None = None, zaehlt: bool = True, zeit: float | None = None) -> dict | None:
"""Ergebnis eines Laufs in den Zustand schreiben: Status, Begründung, Test-Eintrag, ggf. Dateien löschen.
ergebnis None = gemessen, aber kein Vergleich möglich → „getestet“ (Urteil folgt später)."""
k = stand["kandidaten"].get(kid)
if not k:
return None
zeit = zeit or time.time()
tag = _datum(zeit)
k["geaendert"] = zeit
if ergebnis is None:
k.update(status="getestet", messung=werte,
begruendung=f"Gemessen am {tag}; der Vergleich mit dem heutigen Modell steht noch aus.")
_ordne(stand)
return None
zusammenfassung = satz
if ergebnis == "abgebrochen":
zusammenfassung = f"Abgebrochen: {satz}"
if zaehlt:
k["versuche"] = int(k.get("versuche") or 0) + 1
if int(k.get("versuche") or 0) >= VERSUCHE_MAX:
_loesche_dateien(k)
k.update(status="durchgefallen",
begruendung=f"{VERSUCHE_MAX}-mal abgebrochen, zuletzt am {tag}: {satz} Dateien gelöscht.")
else:
k.update(status="wartet", begruendung=f"Abgebrochen am {tag}: {satz} Nächster Versuch in der nächsten Nacht.")
elif ergebnis == "bestanden":
k.update(status="bestanden", begruendung=f"{satz} Getestet am {tag}; wartet auf deine Entscheidung.")
else:
_loesche_dateien(k)
k.update(status="durchgefallen", begruendung=f"{satz} Getestet am {tag}; Dateien gelöscht.")
k.pop("messung", None)
test = {"id": f"{kid}-{datetime.fromtimestamp(zeit, LOCAL_TZ):%Y%m%d-%H%M}", "kandidat": kid, "rolle": k["rolle"],
"datum": _iso(zeit), "ergebnis": ergebnis, "werte": werte or {}, "vergleich": vergleich or {},
"zusammenfassung": zusammenfassung}
stand["tests"].append(test)
del stand["tests"][:-TESTS_MAX]
_ordne(stand)
return test
def _erwartete_dateien(k: dict) -> dict[str, int]:
"""Dateiname im Kandidaten-Ordner → erwartete Größe (Modellteile, mmproj, Draft)."""
namen = {Path(p).name: int(s) for p, s in k.get("dateien") or []}
if k.get("mmproj"):
namen[Path(k["mmproj"][0]).name] = int(k["mmproj"][1])
if (k.get("draft") or {}).get("datei"):
namen[Path(k["draft"]["datei"]).name] = int(k["draft"].get("bytes") or 0)
return namen
def _vollstaendig(pfad: Path, groesse: int) -> bool:
try:
return pfad.is_file() and (not groesse or pfad.stat().st_size == groesse)
except OSError:
return False
def _lokale_pfade(k: dict, ordner: Path) -> dict:
return {"gguf": str(ordner / Path(k["dateien"][0][0]).name),
"mmproj": str(ordner / Path(k["mmproj"][0]).name) if k.get("mmproj") else None,
"draft": str(ordner / Path(k["draft"]["datei"]).name) if (k.get("draft") or {}).get("datei") else None}
def _ordner_bytes(ordner: Path) -> int:
summe = 0
for wurzel, _, dateien in os.walk(ordner):
for name in dateien:
try:
summe += os.path.getsize(os.path.join(wurzel, name))
except OSError:
pass
return summe
def _flach_legen(ordner: Path) -> None:
"""hf legt Dateien mit ihrem Repo-Pfad ab (UD-IQ3_XXS/…). llama-swap leitet die Modell-ID aus dem
Elternordner ab — darum alle GGUF-Dateien in den Kandidaten-Ordner selbst holen."""
for pfad in list(ordner.rglob("*.gguf")):
teile = pfad.relative_to(ordner).parts
if len(teile) == 1 or ".cache" in teile:
continue
pfad.replace(ordner / pfad.name)
for wurzel, _, _ in sorted(os.walk(ordner), key=lambda x: -len(x[0])):
if Path(wurzel) != ordner and ".cache" not in Path(wurzel).relative_to(ordner).parts:
try:
os.rmdir(wurzel)
except OSError:
pass
def _beende(prozess: subprocess.Popen) -> None:
try:
if os.name == "posix": # ganze Gruppe: hf startet eigene Kindprozesse
os.killpg(prozess.pid, signal.SIGTERM)
else:
prozess.terminate()
prozess.wait(timeout=30)
except Exception:
try:
prozess.kill()
except Exception:
pass
def _hf_download(repo: str, dateien: list[str], ordner: Path, bis: float) -> None:
befehl = [hf.hf_bin(), "download", repo, *dateien, "--local-dir", str(ordner)]
umgebung = {**os.environ, **HF_DOWNLOAD_ENV}
if (token := geheimnisse.hf_token()):
umgebung["HF_TOKEN"] = token
vorher = _ordner_bytes(ordner)
logpfad = RADAR_DIR / "download.log"
with open(logpfad, "w", encoding="utf-8") as logdatei:
prozess = subprocess.Popen(befehl, stdout=logdatei, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
env=umgebung, start_new_session=True)
_DOWNLOAD["prozess"] = prozess
try:
while prozess.poll() is None:
if time.time() > bis:
_beende(prozess)
geladen = (_ordner_bytes(ordner) - vorher) / 1e9
raise Abbruch(f"Download bis {datetime.fromtimestamp(bis, LOCAL_TZ):%H:%M} nicht fertig "
f"({_gb(geladen)} GB in dieser Nacht) geht in der nächsten Nacht weiter.",
zaehlt=geladen < 1.0)
time.sleep(5)
finally:
_DOWNLOAD["prozess"] = None
if prozess.returncode != 0:
try:
ende = logpfad.read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1:]
except OSError:
ende = []
raise Abbruch(f"Download von {repo} scheiterte: {(ende or ['ohne Meldung'])[0][:200]}")
def _download(k: dict, bis: float) -> Path:
"""Fehlende Dateien des Kandidaten nach RADAR_DIR/<id> holen; Platz vorher prüfen."""
ordner = RADAR_DIR / k["id"]
erwartet = _erwartete_dateien(k)
fehlend = {n: b for n, b in erwartet.items() if not _vollstaendig(ordner / n, b)}
if not fehlend:
return ordner
if os.name != "posix":
raise Abbruch("Herunterladen und Testen gehen nur auf der Box.", zaehlt=False)
try:
platte = shutil.disk_usage(MODELS_DIR)
except OSError as e:
raise Abbruch(f"Plattenplatz nicht prüfbar ({e}).", zaehlt=False) from e
nachher = platte_nach_download(platte.used, platte.total, sum(fehlend.values()))
if nachher > PLATTE_MAX_PCT:
raise Abbruch(f"Zu wenig Platz: nach dem Download wäre die Platte zu {nachher:.0f} % voll "
f"(Grenze {PLATTE_MAX_PCT:.0f} %).", zaehlt=False)
ordner.mkdir(parents=True, exist_ok=True)
_aendere(lambda s: s["kandidaten"].get(k["id"], {}).update(ordner=str(ordner)))
k["ordner"] = str(ordner)
haupt = [p for p, _ in k["dateien"]] + ([k["mmproj"][0]] if k.get("mmproj") else [])
draft = k.get("draft") or {}
if draft.get("datei") and draft.get("repo") == k["repo"]:
haupt.append(draft["datei"])
_hf_download(k["repo"], haupt, ordner, bis)
if draft.get("datei") and draft.get("repo") != k["repo"]:
_hf_download(draft["repo"], [draft["datei"]], ordner, bis)
_flach_legen(ordner)
if (noch := [n for n, b in erwartet.items() if not _vollstaendig(ordner / n, b)]):
raise Abbruch(f"Download unvollständig: {', '.join(noch)}")
return ordner
def _warmset() -> set[str]:
warm: set[str] = set()
for gruppe in (llamaswap.list_groups() or {}).values():
if isinstance(gruppe, dict) and (gruppe.get("persistent") or gruppe.get("persist")):
warm.update(gruppe.get("members") or [])
return warm
def _fremde_modelle(warm: set[str]) -> list[str]:
return [m for m in llamaswap.get_running_models() if m and m not in warm]
def _nur_warmset(warte_s: float = 90) -> set[str]:
"""Vor dem Kandidaten darf in llama-swap nur das Warm-Set laufen (der Coder z. B. nicht) —
sonst reicht die 115-GB-Rechnung nicht."""
warm = _warmset()
if not warm:
raise Abbruch("Das Warm-Set ist unbekannt (llama-swap-Config ohne persistente Gruppe) ohne sichere "
"Speicherrechnung kein Test.", zaehlt=False)
fremd = _fremde_modelle(warm)
for name in fremd:
try:
with httpx.Client(timeout=30.0) as c:
c.post(f"{LLAMA_SWAP_URL}/api/models/unload/{quote(name)}")
except Exception:
log.warning("radar: %s ließ sich nicht entladen", name, exc_info=True)
t0 = time.time()
while fremd and time.time() - t0 < warte_s:
time.sleep(3)
fremd = _fremde_modelle(warm)
if fremd:
raise Abbruch(f"{', '.join(fremd)} ließ sich nicht entladen ohne freien Speicher kein Test.", zaehlt=False)
return warm
class _Nachtwaechter(threading.Thread):
"""Passt während des Kandidaten-Tests auf: Lädt llama-swap nachts ein weiteres Modell (etwa den Coder
für OpenCode), reicht der Speicher nicht mehr — dann den Kandidaten sofort stoppen."""
def __init__(self, warm: set[str], intervall: float = 10.0):
super().__init__(daemon=True, name="radar-nachtwaechter")
self.warm, self.intervall, self.grund = warm, intervall, ""
self._halt = threading.Event()
def run(self) -> None:
while not self._halt.wait(self.intervall):
if (fremd := _fremde_modelle(self.warm)):
self.grund = (f"Während des Tests wurde {', '.join(fremd)} geladen abgebrochen, damit der "
f"Speicher reicht.")
_pruefstand().stoppe_alle(warte_s=10)
return
def halt(self) -> None:
self._halt.set()
def _baseline(rolle: str, frist: float) -> dict | None:
"""Werte des heutigen Modells der Rolle (Cache, höchstens monatlich neu gemessen)."""
heute = _heutige_modelle().get(rolle)
if not heute:
return None
befehl = str(heute.get("cmd") or "")
kennung = f"{heute['name']}|{hashlib.sha1(befehl.encode()).hexdigest()[:10]}"
return _pruefstand().baseline(rolle, str(BASELINE_PATH), kennung=kennung, bild="--mmproj" in befehl,
frist=frist, max_alter_tage=BASELINE_TAGE, modell=ALIAS[rolle],
protokoll=_protokoll)
def teste(kid: str, frist: float) -> dict | None:
"""Einen Kandidaten prüfen: Download → Baseline → nur Warm-Set in llama-swap → Kandidat auf :5899 →
Urteil. Gibt den Test-Eintrag zurück (None, wenn nur gemessen wurde oder es den Kandidaten nicht gibt)."""
ps = _pruefstand()
k = _lade()["kandidaten"].get(kid)
if not k or k.get("status") not in ("neu", "wartet"): # inzwischen verworfen o. Ä.
return None
_markiere_lauf(kid, "download", frist)
ergebnis, satz, werte, vergleich, zaehlt = "abgebrochen", "", None, None, True
waechter, von_aussen = None, None
try:
ordner = _download(k, frist - TESTZEIT_MIN * 60)
pfade = _lokale_pfade(k, ordner)
_markiere_lauf(kid, "baseline", frist)
basis = _baseline(k["rolle"], frist)
_markiere_lauf(kid, "test", frist)
waechter = _Nachtwaechter(_nur_warmset())
waechter.start()
draft = k.get("draft") or {}
flags = ps.draft_flags(pfade["draft"], draft.get("typ"), draft.get("n_max")) + list(k.get("flags") or [])
lauf = ps.pruefe_kandidat(k["rolle"], pfade["gguf"], mmproj=pfade["mmproj"], flags=flags,
ctx=int(k.get("ctx") or ps.CTX_STANDARD), basis=(basis or {}).get("werte"),
frist=frist, protokoll=_protokoll)
werte, vergleich = lauf["werte"], lauf["vergleich"]
if basis:
werte["baseline"] = {"modell": (basis.get("werte") or {}).get("modell"), "gemessen": _iso(basis.get("zeit"))}
ergebnis, satz = lauf["ergebnis"], lauf["zusammenfassung"]
except ps.Zeitende:
satz = f"Das Nachtfenster endete um {FENSTER_ENDE}, bevor der Test fertig war."
except ps.ServerFehler as e:
if "unknown model architecture" in str(e).lower():
ergebnis, satz = "durchgefallen", f"Durchgefallen: läuft nicht im offiziellen llama.cpp ({str(e)[:160]})."
else:
satz = f"Der Kandidat lief nicht: {str(e)[:240]}"
except Abbruch as e:
satz, zaehlt = e.grund, e.zaehlt
except Exception as e:
if not (waechter and waechter.grund): # Wächter-Abbruch ist erwartet, kein Fehler fürs Journal
log.exception("radar: Test von %s scheiterte", kid)
satz = f"Unerwarteter Fehler: {str(e)[:200]}"
except BaseException as e: # SIGTERM von systemd (SystemExit) oder Strg+C: festhalten, dann weiter
von_aussen = e
satz, zaehlt = "Der Lauf wurde von außen beendet.", False
finally:
if waechter:
waechter.halt()
ps.stoppe_alle()
if waechter and waechter.grund: # der Wächter hat abgebrochen — dann zählt kein Messwert als Urteil
ergebnis, satz, zaehlt, vergleich = "abgebrochen", waechter.grund, False, None
def abschluss(stand: dict):
stand["lauf"] = None
return _abschluss(stand, kid, ergebnis, satz, werte=werte, vergleich=vergleich, zaehlt=zaehlt)
test = _aendere(abschluss)
log.info("radar: %s%s (%s)", kid, ergebnis, satz)
if von_aussen is not None:
raise von_aussen
return test
def nachurteilen(frist: float) -> int:
"""„getestet“-Kandidaten (damals fehlte die Vergleichsmessung) jetzt beurteilen, falls es sie gibt."""
offen = [k for k in _lade()["kandidaten"].values() if k.get("status") == "getestet" and k.get("messung")]
if not offen:
return 0
ps, n = _pruefstand(), 0
for k in offen:
try:
basis = _baseline(k["rolle"], frist)
except Exception:
log.warning("radar: Baseline für %s nicht messbar", k["rolle"], exc_info=True)
break
if not basis:
continue
u = ps.urteil(k["rolle"], k["messung"], basis["werte"])
def abschluss(stand: dict, kid=k["id"], u=u, werte=k["messung"]):
return _abschluss(stand, kid, u["ergebnis"], u["zusammenfassung"], werte=werte, vergleich=u["vergleich"])
_aendere(abschluss)
n += 1
return n
def notstopp() -> None:
"""Notbremse des Nachtlaufs: Kandidaten-Server und Download sofort beenden (ohne Zustand anzufassen)."""
try:
if _PS is not None:
_PS.stoppe_alle(warte_s=10)
finally:
if (prozess := _DOWNLOAD.get("prozess")) is not None:
_beende(prozess)
# --- Übernehmen und Verwerfen ----------------------------------------------------------------
def betriebs_befehl(rolle: str, pfade: dict, k: dict) -> str:
"""llama-swap-Befehl für den übernommenen Kandidaten: die getesteten Flags plus die Betriebs-Flags
der Rolle wie heute (Hirn 2 Slots, Coder 1 Slot, KV q8_0, Prompt-Cache 16 GB)."""
teile = ["llama-server", "-m", pfade["gguf"], "--host", "127.0.0.1", "--port", "${PORT}",
"-c", str(int(k.get("ctx") or CTX_ROLLE[rolle])), "-ngl", "999", "-fa", "on", "--load-mode", "none"]
if pfade.get("mmproj"):
teile += ["--mmproj", pfade["mmproj"]]
teile += ["--jinja", "--parallel", str(PARALLEL[rolle]), "-cram", "16384", "-ctk", "q8_0", "-ctv", "q8_0"]
draft = k.get("draft") or {}
if draft.get("typ"):
teile += ["--spec-type", draft["typ"]]
if pfade.get("draft"):
teile += ["--spec-draft-model", pfade["draft"]]
if draft.get("n_max"):
teile += ["--spec-draft-n-max", str(draft["n_max"])]
return " ".join(teile + [str(f) for f in k.get("flags") or []])
def _steward_neu_starten() -> str | None:
"""Drop-in neu einlesen und den Steward neu starten (User-Dienst, ohne sudo). Fehlertext oder None."""
if os.name != "posix":
return None
for befehl in (["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "restart", "mc2-steward"]):
try:
subprocess.run(befehl, capture_output=True, timeout=60, check=True)
except (OSError, subprocess.SubprocessError) as e:
return str(e)
return None
def _warmset_umstellen(alt: str | None, neu: str) -> str | None:
"""Der Re-Warm-Wächter (mc2-steward) nennt sein Warm-Set per MC_WARMSET beim Namen (systemd-Drop-in).
Bliebe dort das alte Hirn stehen, lüde er es nach dem Tausch immer wieder neben das neue. Darum den
Namen hier tauschen und den Steward neu starten. Gibt nur dann einen Hinweis zurück, wenn das nicht ging."""
try:
text = STEWARD_WARMSET.read_text(encoding="utf-8")
except OSError:
return None # kein Drop-in: der Steward leitet das Warm-Set aus der brains-Gruppe ab
treffer = re.search(r"MC_WARMSET=([^\"\n]*)", text)
namen = treffer.group(1).split() if treffer else []
if not treffer or not alt or alt not in namen or neu in namen:
return None
neu_text = text[:treffer.start(1)] + " ".join(neu if n == alt else n for n in namen) + text[treffer.end(1):]
try:
tmp = STEWARD_WARMSET.with_name(STEWARD_WARMSET.name + ".tmp") # systemd liest nur *.conf
tmp.write_text(neu_text, encoding="utf-8")
os.replace(tmp, STEWARD_WARMSET)
except OSError as e:
return f"Das Warm-Set des Stewards ließ sich nicht umstellen ({e}); er hält noch {alt} warm."
if (fehler := _steward_neu_starten()):
return f"Warm-Set umgestellt, aber der Steward-Neustart scheiterte ({fehler}); es greift beim nächsten Neustart."
return None
def _zielordner(k: dict) -> Path:
name = k["repo"].split("/")[-1]
ziel = MODELS_DIR / name
if ziel.exists():
ziel = MODELS_DIR / f"{re.sub(r'[-_]GGUF$', '', name, flags=re.IGNORECASE)}-Radar-GGUF"
return ziel
def _tausche_ein(k: dict) -> dict:
"""Bestandenen Kandidaten in Betrieb nehmen: Ordner nach MODELS_DIR, in llama-swap registrieren, Rolle setzen."""
rolle = k["rolle"]
quelle = Path(k.get("ordner") or "")
if not k.get("ordner") or not Path(_lokale_pfade(k, quelle)["gguf"]).is_file():
raise RuntimeError("Die Modelldateien fehlen der Kandidat müsste neu getestet werden.")
alt = _heutige_modelle().get(rolle)
if quelle.resolve().parent == RADAR_DIR.resolve():
ziel = _zielordner(k)
if ziel.exists():
raise RuntimeError(f"{ziel} gibt es schon.")
quelle.replace(ziel) # gleiche Platte → nur umbenennen
k["ordner"] = str(ziel) # scheitert es danach, findet ein zweiter Klick die Dateien hier
else:
ziel = quelle # schon verschoben (früherer, abgebrochener Versuch)
pfade = _lokale_pfade(k, ziel)
modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle], ctx=int(k.get("ctx") or CTX_ROLLE[rolle]),
mmproj_path=pfade["mmproj"], jinja=True, set_alias=False)
cfg = llamaswap.read_config()
try: # die getesteten Flags statt der Vorlage aus register_model (sonst liefe ein ungetesteter Draft mit)
cfg["models"][modell_id]["cmd"] = LiteralScalarString(betriebs_befehl(rolle, pfade, k) + "\n")
except (KeyError, TypeError) as e:
raise RuntimeError(f"{modell_id} fehlt nach dem Eintragen in der llama-swap-Config.") from e
llamaswap.write_config(cfg)
hinweis = None
if rolle == "hirn":
from services.agent import set_agent_brain
antwort = set_agent_brain(modell_id) # hermes-Alias, brains-Gruppe, ttl 0, Hermes-Config
if not antwort.get("ok"):
raise RuntimeError(antwort.get("reason") or "Hirn-Wechsel fehlgeschlagen")
# Das Hirn ist heute hermes UND fast (Chat-Spur, warmup.sh, Oberfläche). Bliebe fast am alten
# Modell, lüde warmup.sh es nachts neben das neue Hirn.
if alt and "fast" in {str(a).lower() for a in alt.get("aliases") or []}:
llamaswap.add_role(modell_id, "fast")
hinweis = " ".join(h for h in (antwort.get("warning"), _warmset_umstellen((alt or {}).get("name"), modell_id))
if h) or None
else:
if not llamaswap.set_role(modell_id, "coder"):
raise RuntimeError(f"Die Rolle coder ließ sich nicht auf {modell_id} setzen.")
# heavy ist heute derselbe Coder (OpenCode plant damit). Bliebe es am alten Modell, lägen zwei Coder
# bereit und der alte ließe sich nie löschen.
if alt and "heavy" in {str(a).lower() for a in alt.get("aliases") or []}:
llamaswap.add_role(modell_id, "heavy")
if alt and alt.get("ttl") is not None:
llamaswap.set_ttl(modell_id, int(alt["ttl"]))
return {"modell_id": modell_id, "pfad": str(ziel), "alt": (alt or {}).get("name"), "hinweis": hinweis}
def _oeffentlich(k: dict) -> dict:
return {f: k.get(f) for f in KANDIDAT_FELDER}
def uebernehmen(kid: str) -> dict:
"""Knopf „Übernehmen“: nur bei Status „bestanden“ und nicht während eines Nachtlaufs."""
with _gesperrt():
stand = _lade()
k = stand["kandidaten"].get(kid)
if not k:
return {"ok": False, "status": 404, "detail": "Diesen Kandidaten gibt es nicht."}
if k.get("status") != "bestanden":
return {"ok": False, "status": 409,
"detail": f"Übernehmen geht nur bei bestandenen Kandidaten (Status: {k.get('status')})."}
if _aktiver_lauf(stand):
return {"ok": False, "status": 409, "detail": "Das Radar testet gerade bitte nach dem Nachtlauf übernehmen."}
try:
tausch = _tausche_ein(k)
except Exception as e:
_speichere(stand) # k["ordner"] kann sich schon geändert haben
log.warning("radar: Übernehmen von %s scheiterte", kid, exc_info=True)
return {"ok": False, "status": 409, "detail": f"Übernehmen ging nicht: {e}"}
rolle_text = "das Hirn" if k["rolle"] == "hirn" else "der Coder"
k.update(status="uebernommen", modell_id=tausch["modell_id"], pfad=tausch["pfad"], ordner=None,
geaendert=time.time(),
begruendung=f"Übernommen am {_datum(time.time())}: ist jetzt {rolle_text}"
+ (f" (vorher {tausch['alt']})." if tausch.get("alt") else ".")
+ (f" Noch zu tun: {tausch['hinweis']}" if tausch.get("hinweis") else ""))
_ordne(stand)
_speichere(stand)
return {"ok": True, "kandidat": _oeffentlich(k), "modell_id": tausch["modell_id"], "text": tausch["hinweis"]}
def verwerfen(kid: str) -> dict:
"""Knopf „Verwerfen“: Dateien weg, nie wieder testen."""
def aenderung(stand: dict) -> dict:
k = stand["kandidaten"].get(kid)
if not k:
return {"ok": False, "status": 404, "detail": "Diesen Kandidaten gibt es nicht."}
if k.get("status") == "uebernommen":
return {"ok": False, "status": 409, "detail": "Schon übernommen zurück geht es über die Modell-Rollen."}
if k.get("status") == "durchgefallen":
return {"ok": False, "status": 409, "detail": "Ist schon durchgefallen, die Dateien sind gelöscht."}
if (lauf := _aktiver_lauf(stand)) and lauf.get("kandidat") == kid:
return {"ok": False, "status": 409, "detail": "Wird gerade getestet nach dem Nachtlauf noch einmal."}
_loesche_dateien(k)
k.pop("messung", None)
k.update(status="verworfen", verworfen_von="nutzer", geaendert=time.time(),
begruendung=f"Von dir verworfen am {_datum(time.time())}.")
_ordne(stand)
return {"ok": True, "kandidat": _oeffentlich(k)}
return _aendere(aenderung)
# --- Übersicht (GET /api/radar) --------------------------------------------------------------
def naechster_test(stand: dict, zeit: datetime | None = None) -> str | None:
"""Wann der nächste Test frühestens beginnt (ISO-Zeit), None ohne wartenden Kandidaten.
Läuft gerade einer, ist es dessen Beginn."""
zeit = (zeit or jetzt()).astimezone(LOCAL_TZ)
if (lauf := _aktiver_lauf(stand)) and lauf.get("kandidat") in stand["kandidaten"]:
return _iso(lauf.get("seit"))
schlange = warteschlange(stand)
if not schlange:
return None
start = naechster_start(zeit)
if not schlange[0].get("erster_start") and (frei := wochen_frei_ab(stand, start.date())) and frei > start.date():
start = _lokal(frei, _hhmm(FENSTER_START))
return start.isoformat(timespec="seconds")
def uebersicht(zeit: datetime | None = None) -> dict:
"""GET /api/radar: nächster und letzter Test (ISO-Zeit), Kandidaten, Tests (neueste zuerst)."""
stand = _lade()
kandidaten = sorted(stand["kandidaten"].values(),
key=lambda k: (_REIHENFOLGE.get(k.get("status"), 9), 0 if k.get("quelle") == "watchlist" else 1,
k.get("rang", 999)))
oeffentlich = [_oeffentlich(k) for k in kandidaten]
if (lauf := _aktiver_lauf(stand)): # den laufenden Test in seiner Begründung zeigen (nicht gespeichert)
for k in oeffentlich:
if k["id"] == lauf.get("kandidat"):
seit = datetime.fromtimestamp(float(lauf.get("seit") or time.time()), LOCAL_TZ)
k["begruendung"] = (f"Läuft gerade seit {seit:%H:%M}: "
f"{_SCHRITTE.get(lauf.get('schritt'), lauf.get('schritt'))}.")
tests = [{f: t.get(f) for f in TEST_FELDER} for t in reversed(stand["tests"])]
return {"naechster_test": naechster_test(stand, zeit), "letzter_test": tests[0]["datum"] if tests else None,
"kandidaten": oeffentlich, "tests": tests}