""" 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:30–02: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: " 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(" None: if typ == 8: lies(zahl(" 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/ 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}