- llamaswap.sammeln(): Aenderungen lesen dieselbe Config aus dem Speicher, geschrieben wird einmal am Ende, bei einem Fehler gar nicht - Radar-Tausch und Hirn-Umstellung nutzen es; Hermes wird erst nach dem Schreiben umgestellt - Test-Attrappe fuer update_brain_model: der Radar-Test faesst auf der Box nie die echte Hermes-Config an - Doku: Ziel-Modell, strukturierter Verlauf, Sammel-Schreiben Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1516 lines
71 KiB
Python
1516 lines
71 KiB
Python
"""
|
||
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
|
||
|
||
import httpx
|
||
import psutil
|
||
from config import HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR
|
||
from kern.zeit import LOCAL_TZ
|
||
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) ----------------------------------------------------
|
||
|
||
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
|
||
ZWILLING = {"hirn": "vision", "coder": "coder-bild"} # Bild-Zwilling der Rolle (seit 24.09.)
|
||
ZWILLING_CTX = {"hirn": 65536, "coder": 131072} # Kontext der Zwillinge wie im Betrieb
|
||
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",
|
||
"draft": "sucht die schnellste Draft-Variante", "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,
|
||
mtp=bool((kopf or {}).get(f"{arch}.nextn_predict_layers")))
|
||
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", "mtp", "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 _bild_zwilling(rolle: str, heute: dict) -> dict | None:
|
||
"""Der Bild-Zwilling der Rolle: trägt ZWILLING[rolle], hat einen Projektor und dieselben Gewichte."""
|
||
pfad = heute.get("gguf_path")
|
||
try:
|
||
modelle = llamaswap.list_models()
|
||
except Exception:
|
||
return None
|
||
for m in modelle:
|
||
if (ZWILLING[rolle] in {str(a).lower() for a in m.get("aliases") or []}
|
||
and "--mmproj" in str(m.get("cmd") or "") and pfad and m.get("gguf_path") == pfad):
|
||
return m
|
||
return None
|
||
|
||
|
||
def _baseline(rolle: str, frist: float) -> dict | None:
|
||
"""Werte des heutigen Modells der Rolle (Cache, höchstens monatlich neu gemessen). Die Bild-Probe
|
||
macht der Bild-Zwilling, wenn es einen gibt — so zählt „versteht Bilder“ nicht als Vorteil eines
|
||
Kandidaten, obwohl das heutige Modell über seinen Zwilling längst sieht."""
|
||
heute = _heutige_modelle().get(rolle)
|
||
if not heute:
|
||
return None
|
||
befehl = str(heute.get("cmd") or "")
|
||
zwilling = _bild_zwilling(rolle, heute)
|
||
zwilling_befehl = str((zwilling or {}).get("cmd") or "")
|
||
kennung = f"{heute['name']}|{hashlib.sha1((befehl + zwilling_befehl).encode()).hexdigest()[:10]}"
|
||
return _pruefstand().baseline(rolle, str(BASELINE_PATH), kennung=kennung,
|
||
bild="--mmproj" in befehl or zwilling is not None,
|
||
bild_modell=ZWILLING[rolle] if zwilling else None,
|
||
frist=frist, max_alter_tage=BASELINE_TAGE, modell=ALIAS[rolle],
|
||
protokoll=_protokoll)
|
||
|
||
|
||
_ARCH_CACHE: dict[tuple[str, float], str | None] = {}
|
||
|
||
|
||
def _lokale_architektur(pfad: str) -> str | None:
|
||
"""general.architecture einer lokalen GGUF-Datei (die ersten 8 MB reichen, der Kopf steht vorn)."""
|
||
try:
|
||
schluessel = (pfad, os.path.getmtime(pfad))
|
||
except OSError:
|
||
return None
|
||
if schluessel not in _ARCH_CACHE:
|
||
try:
|
||
with open(pfad, "rb") as f:
|
||
kopf = gguf_kopf_aus_bytes(f.read(8 << 20))
|
||
except OSError:
|
||
kopf = None
|
||
_ARCH_CACHE[schluessel] = (kopf or {}).get("general.architecture")
|
||
return _ARCH_CACHE[schluessel]
|
||
|
||
|
||
def _live_draft(heute: dict) -> dict | None:
|
||
"""Spekulatives Dekodieren des heutigen Modells der Rolle: Typ, Draft-Datei, n_max und die Architektur."""
|
||
befehl = str(heute.get("cmd") or "")
|
||
typ = re.search(r"--spec-type\s+(\S+)", befehl)
|
||
if not typ:
|
||
return None
|
||
pfad = re.search(r"--spec-draft-model\s+(\S+)", befehl)
|
||
n_max = re.search(r"--spec-draft-n-max\s+(\d+)", befehl)
|
||
return {"typ": typ.group(1), "pfad": pfad.group(1) if pfad else None,
|
||
"n_max": int(n_max.group(1)) if n_max else None,
|
||
"arch": _lokale_architektur(str(heute.get("gguf_path") or ""))}
|
||
|
||
|
||
def _draft_varianten(k: dict, pfade: dict) -> list[dict]:
|
||
"""Womit der Kandidat spekulativ dekodieren könnte. Fairness: das heutige Modell misst mit seinem Draft
|
||
(Hirn 106 t/s, ohne Draft 68) — Ornith fiel am 24.09. ohne Draft durch, obwohl es dieselbe Architektur hat
|
||
und den Draft des Hirns hätte nutzen können. Ausprobiert werden: ohne Draft, ein eingebauter MTP-Kopf und
|
||
der Draft des heutigen Modells (nur bei gleicher Architektur und wenn der Speicher reicht). Ein Draft aus
|
||
der Merkliste ist eine bewusste Wahl und wird allein genommen. „ort“ sagt, wo die Draft-Datei liegt:
|
||
im Kandidaten-Ordner (wandert beim Übernehmen mit) oder fremd (fester Pfad)."""
|
||
ps = _pruefstand()
|
||
if (d := k.get("draft") or {}).get("typ"):
|
||
return [{"name": "Draft aus der Merkliste", "flags": ps.draft_flags(pfade.get("draft"), d["typ"], d.get("n_max")),
|
||
"draft": {"typ": d["typ"], "ort": "kandidat" if pfade.get("draft") else None, "n_max": d.get("n_max")}}]
|
||
varianten = [{"name": "ohne Draft", "flags": [], "draft": None}]
|
||
if k.get("mtp"):
|
||
varianten.append({"name": "eingebauter MTP-Kopf", "flags": ps.draft_flags(None, "draft-mtp"),
|
||
"draft": {"typ": "draft-mtp", "ort": None, "n_max": None}})
|
||
live = _live_draft(_heutige_modelle().get(k["rolle"]) or {})
|
||
if live and live["pfad"] and k.get("arch") and live["arch"] == k["arch"]:
|
||
try:
|
||
draft_gb = os.path.getsize(live["pfad"]) / 1e9
|
||
except OSError:
|
||
draft_gb = None
|
||
if draft_gb is not None and float(k.get("bedarf_gb") or 0) + draft_gb <= SPEICHER_GRENZE_GB - WARMSET_GB:
|
||
varianten.append({"name": "Draft des heutigen Modells",
|
||
"flags": ps.draft_flags(live["pfad"], live["typ"], live["n_max"]),
|
||
"draft": {"typ": live["typ"], "ort": "fremd", "pfad": live["pfad"], "n_max": live["n_max"]}})
|
||
return varianten
|
||
|
||
|
||
def _draft_text(wahl: dict) -> str:
|
||
"""„Gemessen mit …“ für die Begründung: gewählte Variante und die Tempi aller Varianten."""
|
||
tempi = [f"{name} {str(wert).replace('.', ',')} t/s" if isinstance(wert, (int, float)) else f"{name}: {wert}"
|
||
for name, wert in (wahl.get("messungen") or {}).items()]
|
||
return f" Gemessen {'mit' if wahl.get('draft') else ''} {wahl['name']}".replace(" ", " ") + (
|
||
f" (13k: {'; '.join(tempi)})." if len(tempi) > 1 else ".")
|
||
|
||
|
||
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, draft_betrieb = None, 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)
|
||
waechter = _Nachtwaechter(_nur_warmset())
|
||
waechter.start()
|
||
varianten = _draft_varianten(k, pfade)
|
||
if len(varianten) > 1:
|
||
_markiere_lauf(kid, "draft", frist)
|
||
wahl = ps.waehle_draft(pfade["gguf"], varianten, frist=frist, protokoll=_protokoll)
|
||
else:
|
||
wahl = {**varianten[0], "messungen": {}}
|
||
_markiere_lauf(kid, "test", frist)
|
||
flags = list(wahl["flags"]) + 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"]
|
||
werte["draft"] = {"gewaehlt": wahl["name"], "messungen": wahl.get("messungen") or {}}
|
||
draft_betrieb = wahl.get("draft")
|
||
if basis:
|
||
werte["baseline"] = {"modell": (basis.get("werte") or {}).get("modell"), "gemessen": _iso(basis.get("zeit"))}
|
||
ergebnis, satz = lauf["ergebnis"], lauf["zusammenfassung"] + _draft_text(wahl)
|
||
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
|
||
if kid in stand["kandidaten"]: # der Draft, mit dem gemessen wurde, läuft auch im Betrieb
|
||
stand["kandidaten"][kid]["draft_betrieb"] = draft_betrieb
|
||
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 wie heute im Betrieb: ohne Projektor (Bilder gehen an
|
||
den Zwilling), mit dem Draft, mit dem er getestet wurde, dazu die Betriebs-Flags der Rolle (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",
|
||
"--jinja", "--parallel", str(PARALLEL[rolle]), "-cram", "16384", "-ctk", "q8_0", "-ctv", "q8_0"]
|
||
draft = k.get("draft_betrieb")
|
||
if draft is None and (k.get("draft") or {}).get("typ"): # vor dem 25.09. getestet: Draft der Merkliste
|
||
draft = {"typ": k["draft"]["typ"], "ort": "kandidat", "n_max": k["draft"].get("n_max")}
|
||
if draft and draft.get("typ"):
|
||
teile += ["--spec-type", draft["typ"]]
|
||
pfad = pfade.get("draft") if draft.get("ort") == "kandidat" else draft.get("pfad")
|
||
if pfad:
|
||
teile += ["--spec-draft-model", pfad]
|
||
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 zwilling_befehl(rolle: str, pfade: dict, k: dict) -> str:
|
||
"""Bild-Zwilling im Betrieb (seit 24.09.): gleiche Gewichte plus Projektor, OHNE Draft — llama.cpp kann
|
||
Draft und Bild nicht zusammen. Das Gateway schickt nur Anfragen mit einem neuen Bild hierher."""
|
||
teile = ["llama-server", "-m", pfade["gguf"], "--host", "127.0.0.1", "--port", "${PORT}",
|
||
"-c", str(ZWILLING_CTX[rolle]), "-ngl", "999", "-fa", "on", "--load-mode", "none",
|
||
"--mmproj", pfade["mmproj"], "--jinja", "--parallel", "1", "-cram", "8192", "-ctk", "q8_0", "-ctv", "q8_0"]
|
||
return " ".join(teile + _pruefstand().ohne_draft([str(f) for f in k.get("flags") or []]))
|
||
|
||
|
||
def _zwilling_eintragen(cfg: dict, rolle: str, modell_id: str, pfade: dict, k: dict) -> str:
|
||
"""Bild-Zwilling des neuen Modells anlegen (Gruppe „bild“, verdrängt nichts). Der alte Zwilling der Rolle
|
||
fliegt aus der Config — ohne sein Hauptmodell ist er nutzlos; seine Dateien bleiben liegen und
|
||
tauchen im Aufräumen beim alten Modell auf."""
|
||
alias = ZWILLING[rolle]
|
||
models = cfg.setdefault("models", {})
|
||
groups = cfg.setdefault("groups", {})
|
||
for alt in [n for n, sp in models.items() if alias in {str(a).lower() for a in (sp or {}).get("aliases") or []}]:
|
||
del models[alt]
|
||
for gruppe in groups.values():
|
||
if isinstance(gruppe, dict) and alt in (gruppe.get("members") or []):
|
||
gruppe["members"] = [m for m in gruppe["members"] if m != alt]
|
||
name = f"{modell_id}-Bild"
|
||
models[name] = {"cmd": LiteralScalarString(zwilling_befehl(rolle, pfade, k) + "\n"), "ttl": 900, "aliases": [alias]}
|
||
bild = groups.setdefault("bild", {"swap": True, "exclusive": False, "persistent": False, "members": []})
|
||
bild["members"] = [m for m in bild.get("members") or [] if m != name] + [name]
|
||
return name
|
||
|
||
|
||
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)
|
||
# Eintragen, Befehl, Zwilling, Rollen, Gruppe und ttl als EIN Schreibvorgang (24.09.2026): Vorher
|
||
# schrieb ein Tausch die Config bis zu siebenmal, und jedes Mal entlud llama-swap alle Modelle.
|
||
with llamaswap.sammeln():
|
||
modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle],
|
||
ctx=int(k.get("ctx") or CTX_ROLLE[rolle]),
|
||
mmproj_path=None, 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
|
||
if pfade.get("mmproj"):
|
||
_zwilling_eintragen(cfg, rolle, modell_id, pfade, k)
|
||
llamaswap.write_config(cfg)
|
||
antwort: dict = {}
|
||
if rolle == "hirn":
|
||
from services.agent import set_agent_brain
|
||
# hermes-Alias, brains-Gruppe, ttl 0 — Hermes selbst erst nach dem Schreiben (unten).
|
||
antwort = set_agent_brain(modell_id, hermes_umstellen=False)
|
||
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")
|
||
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"]))
|
||
hinweis = None
|
||
if rolle == "hirn":
|
||
from services.agent import update_brain_model
|
||
update_brain_model("hermes") # Hermes-Config + Neustart, jetzt mit fertiger Config
|
||
hinweis = " ".join(h for h in (antwort.get("warning"), _warmset_umstellen((alt or {}).get("name"), modell_id))
|
||
if h) or None
|
||
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}
|