boxwart: Modell-Radar sucht, testet nachts selbst und empfiehlt (Hirn und Coder)
Ampel / ampel (push) Failing after 21s
Ampel / ampel (push) Failing after 21s
Suche aus Merkliste (deploy/radar-watchlist.json) und Hugging-Face-Entdeckung; nur Kandidaten mit Bild-Projektor, die neben das Warm-Set passen (<= 115 GB inkl. KV-Cache). Nachtlauf mc2-radar.timer 00:30, Tests nur bis 02:30 (um 03:00 kommt NerdQuiz), hoechstens ein neuer Kandidat pro Woche, Notbremse 02:35, RuntimeMaxSec als letzte Sicherung. Pruefstand als Modul (deploy/bench/pruefstand.py): Tempo, Werkzeuge, Deutsch/JSON bzw. Programmieraufgaben und Bild-Probe gegen das heutige Modell der Rolle. Durchgefallene werden geloescht, Bestandene gemeldet und erst nach "Uebernehmen" getauscht. Beim Uebernehmen wandern die Zweitrollen mit (fast beim Hirn, heavy beim Coder), und das Warm-Set des Stewards wird selbst umgestellt statt als Handgriff zu bleiben. Modell-Code im Pruefstand darf keine Prozesse starten (RLIMIT_NPROC=0). Radar steht im Flugplan und unter Waechter-Aufsicht; deploy.sh spielt seine Units ein. Oberflaeche: Vergleichswerte je Kandidat, Rueckfrage vor Uebernehmen und Verwerfen, Status auf Deutsch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4b226d5d22
commit
177c9a311c
@@ -32,6 +32,7 @@ from routers import (
|
|||||||
hermes_ui,
|
hermes_ui,
|
||||||
maintenance,
|
maintenance,
|
||||||
models,
|
models,
|
||||||
|
radar,
|
||||||
routing,
|
routing,
|
||||||
system,
|
system,
|
||||||
voice,
|
voice,
|
||||||
@@ -107,6 +108,7 @@ async def no_cache(
|
|||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
||||||
|
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||||
app.include_router(models.router)
|
app.include_router(models.router)
|
||||||
app.include_router(routing.router)
|
app.include_router(routing.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Nächtlicher Lauf des Modell-Radars (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
|
||||||
|
|
||||||
|
Warum ein eigener Einstieg: Der Test lädt ein bis zu ~85 GB großes Modell neben Lucys Hirn. Das
|
||||||
|
darf nur nachts passieren und muss um 02:30 sicher vorbei sein, weil um 03:00 der NerdQuiz-
|
||||||
|
Nachtlauf das Hirn braucht. Ablauf:
|
||||||
|
|
||||||
|
1. Suchen — höchstens einmal am Tag (Merkliste + Entdeckung).
|
||||||
|
2. Offene Urteile nachholen („getestet“: damals fehlte die Vergleichsmessung).
|
||||||
|
3. Testen — nur im Fenster 00:30–02:30 und höchstens ein neuer Kandidat pro Woche.
|
||||||
|
|
||||||
|
Die Messungen prüfen die Frist selbst. Hängt trotzdem etwas, zieht fünf Minuten nach der Frist die
|
||||||
|
Notbremse: Kandidaten-Server und Download werden gestoppt, der Prozess endet hart. systemd
|
||||||
|
(RuntimeMaxSec) ist die letzte Sicherung. Ist nichts fällig, endet der Lauf sofort mit Code 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from services import radar
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||||
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("radar_lauf")
|
||||||
|
|
||||||
|
NOTBREMSE_S = int(os.environ.get("MC_RADAR_NOTBREMSE_S", "300"))
|
||||||
|
|
||||||
|
|
||||||
|
def _beim_beenden(signum, _frame) -> None:
|
||||||
|
"""SIGTERM (systemd stop, RuntimeMaxSec) → normal auslaufen, damit der Kandidaten-Server gestoppt wird."""
|
||||||
|
raise SystemExit(f"Signal {signum}")
|
||||||
|
|
||||||
|
|
||||||
|
def _notbremse(frist: float) -> threading.Timer:
|
||||||
|
def ziehen() -> None:
|
||||||
|
log.error("Radar: Notbremse – %s s nach der Frist läuft noch etwas, der Lauf wird hart beendet.", NOTBREMSE_S)
|
||||||
|
try:
|
||||||
|
radar.notstopp()
|
||||||
|
finally:
|
||||||
|
os._exit(3)
|
||||||
|
|
||||||
|
bremse = threading.Timer(max(frist - time.time(), 0) + NOTBREMSE_S, ziehen)
|
||||||
|
bremse.daemon = True
|
||||||
|
bremse.start()
|
||||||
|
return bremse
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFY = Path(__file__).resolve().parents[1] / "deploy" / "notify.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def melde_bestanden(test: dict) -> None:
|
||||||
|
"""Ein bestandener Kandidat wartet auf eine Entscheidung — einmal Bescheid geben, sonst liegt er
|
||||||
|
unbemerkt herum (Lehre der Pins vom September). notify.sh sammelt Nachtmeldungen für den Morgen."""
|
||||||
|
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
|
||||||
|
rolle = "das Hirn" if test.get("rolle") == "hirn" else "den Coder"
|
||||||
|
text = (f"{k.get('name') or test.get('kandidat')} hat den Nachttest für {rolle} bestanden. "
|
||||||
|
f"{str(test.get('zusammenfassung') or '')[:300]} "
|
||||||
|
"In MC2 unter Modelle kannst du ihn übernehmen oder verwerfen.")
|
||||||
|
try:
|
||||||
|
subprocess.run(["bash", str(NOTIFY), "-s", "[Modell-Radar]", text], timeout=120, check=False,
|
||||||
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
log.warning("Radar: Meldung über den bestandenen Kandidaten ging nicht raus.", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
beginn = radar.jetzt()
|
||||||
|
if radar.suche_faellig(beginn):
|
||||||
|
try:
|
||||||
|
ergebnis = radar.suche()
|
||||||
|
log.info("Radar: Suche – %s neu, %s warten", len(ergebnis.get("neu") or []), ergebnis.get("wartend"))
|
||||||
|
except Exception:
|
||||||
|
log.warning("Radar: Suche fehlgeschlagen", exc_info=True)
|
||||||
|
else:
|
||||||
|
log.info("Radar: heute schon gesucht.")
|
||||||
|
|
||||||
|
if not radar.im_fenster(radar.jetzt()):
|
||||||
|
log.info("Radar: außerhalb des Test-Fensters %s–%s, nichts zu testen.", radar.FENSTER_START,
|
||||||
|
radar.FENSTER_ENDE)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
frist = radar.fenster_ende(radar.jetzt()).timestamp()
|
||||||
|
signal.signal(signal.SIGTERM, _beim_beenden)
|
||||||
|
bremse = _notbremse(frist)
|
||||||
|
try:
|
||||||
|
if (nachgeholt := radar.nachurteilen(frist)):
|
||||||
|
log.info("Radar: %s offene Urteile nachgeholt.", nachgeholt)
|
||||||
|
plan = radar.plane_test(radar.lade_stand(), radar.jetzt())
|
||||||
|
if not plan.get("kandidat"):
|
||||||
|
log.info("Radar: nichts fällig (%s).", plan.get("grund"))
|
||||||
|
return 0
|
||||||
|
log.info("Radar: teste %s bis spätestens %s.", plan.get("grund"), radar.FENSTER_ENDE)
|
||||||
|
test = radar.teste(plan["kandidat"], frist)
|
||||||
|
if test:
|
||||||
|
log.info("Radar: %s – %s", test.get("ergebnis"), test.get("zusammenfassung"))
|
||||||
|
if test.get("ergebnis") == "bestanden":
|
||||||
|
melde_bestanden(test)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
bremse.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026).
|
||||||
|
|
||||||
|
GET /api/radar Nächster und letzter Test, Kandidaten, Test-Verlauf
|
||||||
|
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung)
|
||||||
|
POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
|
||||||
|
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
|
||||||
|
|
||||||
|
Dünn: die Logik liegt in services/radar.py. Getestet wird nur nachts (backend/radar_lauf.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from services import radar
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["radar"])
|
||||||
|
|
||||||
|
|
||||||
|
def _antwort(ergebnis: dict) -> dict:
|
||||||
|
if not ergebnis.get("ok"):
|
||||||
|
raise HTTPException(status_code=int(ergebnis.get("status") or 409),
|
||||||
|
detail=ergebnis.get("detail") or "Das ging nicht.")
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/radar")
|
||||||
|
def radar_stand() -> dict:
|
||||||
|
return radar.uebersicht()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/radar/suche")
|
||||||
|
def radar_suche() -> dict:
|
||||||
|
"""Sucht sofort (Netz: Hugging Face) und liefert danach den neuen Stand."""
|
||||||
|
ergebnis = radar.suche()
|
||||||
|
return {**radar.uebersicht(), "suche": ergebnis}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/radar/{kandidat_id}/uebernehmen")
|
||||||
|
def radar_uebernehmen(kandidat_id: str) -> dict:
|
||||||
|
return _antwort(radar.uebernehmen(kandidat_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/radar/{kandidat_id}/verwerfen")
|
||||||
|
def radar_verwerfen(kandidat_id: str) -> dict:
|
||||||
|
return _antwort(radar.verwerfen(kandidat_id))
|
||||||
@@ -474,6 +474,21 @@ def set_role(model_id: str, role: str | None) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def add_role(model_id: str, role: str) -> bool:
|
||||||
|
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
|
||||||
|
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
|
||||||
|
cfg = read_config()
|
||||||
|
models = cfg.get("models") or {}
|
||||||
|
rolle = (role or "").strip().lower()
|
||||||
|
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
|
||||||
|
return False
|
||||||
|
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle]
|
||||||
|
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg
|
||||||
|
models[model_id]["aliases"] = bisher + [rolle]
|
||||||
|
write_config(cfg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def set_ctx(model_id: str, ctx: int) -> bool:
|
def set_ctx(model_id: str, ctx: int) -> bool:
|
||||||
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ SYSTEM_SERVICES = {"llama-swap"}
|
|||||||
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py).
|
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py).
|
||||||
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
|
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
|
||||||
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
|
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
|
||||||
"projekte-sync", "mc2-backup"}
|
"projekte-sync", "mc2-backup", "mc2-radar"}
|
||||||
|
|
||||||
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
||||||
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -83,6 +83,7 @@ DIENSTE: dict[str, tuple[bool, bool, str]] = {
|
|||||||
TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
|
TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
|
||||||
"projekte-sync": ("Projekte-Abgleich", False),
|
"projekte-sync": ("Projekte-Abgleich", False),
|
||||||
"mc2-backup": ("Sicherung", True),
|
"mc2-backup": ("Sicherung", True),
|
||||||
|
"mc2-radar": ("Modell-Radar", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
|
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from services import modell_nutzung, waechter
|
from services import modell_nutzung, waechter
|
||||||
|
|
||||||
TIMER = {"mc2-backup.timer": "Sicherung"}
|
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar"}
|
||||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
"""Tests für das Modell-Radar: reine Logik (Zeitfenster, Wochengrenze, Filter, Speicher-Rechnung,
|
||||||
|
Zustandsübergänge) und die Urteile und Programmieraufgaben des Prüfstands — ohne Netz und ohne Box.
|
||||||
|
Zahlen wie auf der Box am 23.09.2026 (Ornith 21,7 GB + mmproj, Flash-Next UD-IQ3_XXS 82 GB)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import zlib
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from services import radar
|
||||||
|
|
||||||
|
BERLIN = radar.LOCAL_TZ
|
||||||
|
|
||||||
|
|
||||||
|
def _zeit(text: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(text).replace(tzinfo=BERLIN)
|
||||||
|
|
||||||
|
|
||||||
|
def _kandidat(kid: str, rolle: str = "hirn", status: str = "neu", quelle: str = "watchlist", rang: int = 0,
|
||||||
|
**extra) -> dict:
|
||||||
|
return {"id": kid, "name": kid, "rolle": rolle, "repo": f"org/{kid}-GGUF", "quant": "Q4_K_M", "quelle": quelle,
|
||||||
|
"rang": rang, "datei": f"{kid}-Q4_K_M.gguf", "dateien": [[f"{kid}-Q4_K_M.gguf", 100]],
|
||||||
|
"mmproj": ["mmproj-F16.gguf", 10], "draft": None, "flags": [], "groesse_gb": 21.7, "passt": True,
|
||||||
|
"eng": False, "ctx": 131072, "status": status, "begruendung": "", **extra}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def zustand(monkeypatch, tmp_path):
|
||||||
|
"""Radar-Zustand und Radar-Ordner in einem Temp-Ordner statt unter /srv/models."""
|
||||||
|
monkeypatch.setattr(radar, "STORE_PATH", tmp_path / "mc2-radar.json")
|
||||||
|
monkeypatch.setattr(radar, "RADAR_DIR", tmp_path / "radar")
|
||||||
|
(tmp_path / "radar").mkdir()
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zeitfenster und Wochengrenze ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_zeitfenster_nur_von_0030_bis_0230():
|
||||||
|
assert not radar.im_fenster(_zeit("2026-09-29T00:29:59"))
|
||||||
|
assert radar.im_fenster(_zeit("2026-09-29T00:30:00"))
|
||||||
|
assert radar.im_fenster(_zeit("2026-09-29T02:29:59"))
|
||||||
|
assert not radar.im_fenster(_zeit("2026-09-29T02:30:00"))
|
||||||
|
assert not radar.im_fenster(_zeit("2026-09-29T14:00:00"))
|
||||||
|
assert radar.fenster_ende(_zeit("2026-09-29T00:45:00")) == _zeit("2026-09-29T02:30:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fensterende_am_umstellungstag_nie_nach_0300():
|
||||||
|
# 28.03.2027: 02:00 springt auf 03:00, 02:30 gibt es nicht. Lieber früher enden als in den NerdQuiz-Lauf.
|
||||||
|
ende = radar.fenster_ende(_zeit("2027-03-28T00:45:00"))
|
||||||
|
assert ende.timestamp() <= datetime(2027, 3, 28, 3, 0, tzinfo=BERLIN).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def test_naechster_start_ist_die_naechste_nacht():
|
||||||
|
assert radar.naechster_start(_zeit("2026-09-29T00:10:00")) == _zeit("2026-09-29T00:30:00")
|
||||||
|
assert radar.naechster_start(_zeit("2026-09-29T01:00:00")) == _zeit("2026-09-30T00:30:00")
|
||||||
|
assert radar.naechster_start(_zeit("2026-09-29T14:00:00")) == _zeit("2026-09-30T00:30:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wochengrenze_ein_neuer_kandidat_pro_woche():
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"a": _kandidat("a", status="bestanden",
|
||||||
|
erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||||
|
"b": _kandidat("b")}
|
||||||
|
assert radar.wochen_frei_ab(stand, date(2026, 10, 3)) == date(2026, 10, 6)
|
||||||
|
assert radar.wochen_frei_ab(stand, date(2026, 10, 6)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_testet_nur_im_fenster_mit_freier_woche_und_setzt_angefangene_fort():
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"a": _kandidat("a", status="bestanden",
|
||||||
|
erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||||
|
"b": _kandidat("b", rang=1)}
|
||||||
|
radar._ordne(stand)
|
||||||
|
assert stand["kandidaten"]["b"]["status"] == "wartet"
|
||||||
|
|
||||||
|
plan = radar.plane_test(stand, _zeit("2026-10-03T00:31:00"))
|
||||||
|
assert plan["kandidat"] is None and "06.10." in plan["grund"]
|
||||||
|
assert radar.plane_test(stand, _zeit("2026-10-06T00:31:00"))["kandidat"] == "b"
|
||||||
|
assert "außerhalb" in radar.plane_test(stand, _zeit("2026-10-06T14:00:00"))["grund"]
|
||||||
|
assert "Minuten" in radar.plane_test(stand, _zeit("2026-10-06T02:15:00"))["grund"]
|
||||||
|
|
||||||
|
# angefangen (z. B. Download unterbrochen): darf in der nächsten Nacht weiter, die Woche gilt ab dem 1. Start
|
||||||
|
stand["kandidaten"]["b"]["erster_start"] = _zeit("2026-10-06T00:31:00").timestamp()
|
||||||
|
assert radar.plane_test(stand, _zeit("2026-10-07T00:31:00"))["kandidat"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufender_test_blockiert_zweiten_lauf_und_zeigt_sich(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["b"] = _kandidat("b", status="wartet", erster_start=time.time())
|
||||||
|
stand["lauf"] = {"pid": os.getpid(), "kandidat": "b", "schritt": "test", "seit": time.time(),
|
||||||
|
"frist": time.time() + 3600}
|
||||||
|
assert radar.plane_test(stand, _zeit("2026-10-06T00:40:00"))["grund"] == "es läuft schon ein Test"
|
||||||
|
radar._speichere(stand)
|
||||||
|
assert radar.uebersicht()["kandidaten"][0]["begruendung"].startswith("Läuft gerade seit")
|
||||||
|
stand["lauf"]["pid"] = 999_999_999 # Prozess tot → Lauf gilt als beendet
|
||||||
|
assert radar._aktiver_lauf(stand) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Namen, Filter, Dateien, Speicher -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_namen_und_staemme():
|
||||||
|
assert radar.anzeigename("bartowski/Qwen_Qwen3.6-35B-A3B-GGUF") == "Qwen3.6-35B-A3B"
|
||||||
|
assert radar.anzeigename("unsloth/Qwen3.8-Flash-Next-GGUF") == "Qwen3.8-Flash-Next"
|
||||||
|
assert radar.stamm("Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf") == "qwen3.8-flash-next"
|
||||||
|
assert radar.stamm("Qwen3.6-35B-A3B") == radar.stamm("unsloth/Qwen3.6-35B-A3B-GGUF")
|
||||||
|
assert radar.generation("Qwen3.8-27B") == ("qwen", 3.8)
|
||||||
|
assert radar.kandidat_id("hirn", "ornith-ai/Ornith-1.5-35B-A3B-GGUF", "Q4_K_M") == \
|
||||||
|
"hirn-ornith-ai-ornith-1-5-35b-a3b-gguf-q4-k-m"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entdeckung_filtert_alte_kleine_bekannte_und_dichte_hirne():
|
||||||
|
heute = {"hirn": "Qwen3.6-35B-A3B", "coder": "Qwen3.8-27B"}
|
||||||
|
bekannt = {"qwen3.6-35b-a3b", "qwen3.8-27b"}
|
||||||
|
|
||||||
|
def fund(repo, params, aktiv=None, kuratiert=False):
|
||||||
|
return {"repo": repo, "params_b": params, "caps": {"active_b": aktiv}, "curated": kuratiert}
|
||||||
|
|
||||||
|
def grund(m, rolle):
|
||||||
|
return radar.entdeckung_ungeeignet(m, rolle, bekannt, heute)
|
||||||
|
|
||||||
|
assert "Katalog" in grund(fund("unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF", 30, 3, kuratiert=True), "coder")
|
||||||
|
assert "auf der Box" in grund(fund("bartowski/Qwen_Qwen3.6-35B-A3B-GGUF", 35, 3), "hirn")
|
||||||
|
assert "zu klein" in grund(fund("lmstudio-community/Qwen2.5-Coder-14B-Instruct-GGUF", 14), "coder")
|
||||||
|
assert "dichte" in grund(fund("bartowski/Hermes-3-Llama-3.1-70B-GGUF", 70), "hirn")
|
||||||
|
assert "ältere Generation" in grund(fund("bartowski/Qwen_Qwen3.5-35B-A3B-GGUF", 35, 3), "hirn")
|
||||||
|
assert grund(fund("unsloth/Qwen-AgentWorld-35B-A3B-GGUF", 35, 3), "hirn") is None
|
||||||
|
|
||||||
|
|
||||||
|
BAUM_FLASH = [
|
||||||
|
{"type": "directory", "path": "UD-IQ3_XXS"},
|
||||||
|
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf", "size": 10_000_000},
|
||||||
|
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00002-of-00003.gguf", "size": 49_570_000_000},
|
||||||
|
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00003-of-00003.gguf", "lfs": {"size": 32_380_000_000}},
|
||||||
|
{"path": "UD-IQ4_XS/Qwen3.8-Flash-Next-UD-IQ4_XS-00001-of-00003.gguf", "size": 10_000_000},
|
||||||
|
{"path": "MTP/mtp-Qwen3.8-Flash-Next-Q8_0.gguf", "size": 4_140_000_000},
|
||||||
|
{"path": "mmproj-BF16.gguf", "size": 910_000_000},
|
||||||
|
{"path": "mmproj-F16.gguf", "size": 900_000_000},
|
||||||
|
]
|
||||||
|
KOPF_FLASH = {"general.architecture": "qwen4exp", "qwen4exp.block_count": 48, "qwen4exp.attention.head_count": 24,
|
||||||
|
"qwen4exp.attention.head_count_kv": 2, "qwen4exp.attention.key_length": 256,
|
||||||
|
"qwen4exp.attention.value_length": 256, "qwen4exp.full_attention_interval": 4}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dateiauswahl_nimmt_alle_teile_des_quants_und_das_f16_mmproj():
|
||||||
|
wahl = radar.waehle_dateien(BAUM_FLASH, "UD-IQ3_XXS")
|
||||||
|
assert [p.rsplit("-", 3)[-3] for p, _ in wahl["dateien"]] == ["00001", "00002", "00003"]
|
||||||
|
assert all(p.startswith("UD-IQ3_XXS/") for p, _ in wahl["dateien"])
|
||||||
|
assert wahl["mmproj"] == ["mmproj-F16.gguf", 900_000_000]
|
||||||
|
assert radar.waehle_dateien(BAUM_FLASH, "Q4_K_M") is None
|
||||||
|
assert radar.waehle_dateien(BAUM_FLASH, "Q8_0") is None # der MTP-Kopf ist kein Modell
|
||||||
|
ornith = [{"path": "Ornith-1.5-35B-Q4_K_M.gguf", "size": 21_710_000_000},
|
||||||
|
{"path": "Ornith-1.5-35B-Q8_0.gguf", "size": 37_800_000_000},
|
||||||
|
{"path": "mmproj-Ornith-1.5-35B-BF16.gguf", "size": 900_000_000}]
|
||||||
|
wahl = radar.waehle_dateien(ornith, "Q4_K_M")
|
||||||
|
assert wahl["dateien"] == [["Ornith-1.5-35B-Q4_K_M.gguf", 21_710_000_000]]
|
||||||
|
assert wahl["mmproj"][0] == "mmproj-Ornith-1.5-35B-BF16.gguf"
|
||||||
|
|
||||||
|
|
||||||
|
def _gguf_kopf_bytes(werte: dict) -> bytes:
|
||||||
|
"""Minimaler GGUF-Kopf wie von llama.cpp: Architektur-Schlüssel, danach der Tokenizer (abgeschnitten)."""
|
||||||
|
def s(text: str) -> bytes:
|
||||||
|
return struct.pack("<Q", len(text.encode())) + text.encode()
|
||||||
|
|
||||||
|
teile = [b"GGUF", struct.pack("<I", 3), struct.pack("<Q", 0), struct.pack("<Q", len(werte) + 1)]
|
||||||
|
for k, v in werte.items():
|
||||||
|
if isinstance(v, str):
|
||||||
|
teile += [s(k), struct.pack("<I", 8), s(v)]
|
||||||
|
elif isinstance(v, list):
|
||||||
|
teile += [s(k), struct.pack("<I", 9), struct.pack("<I", 5), struct.pack("<Q", len(v)),
|
||||||
|
struct.pack(f"<{len(v)}i", *v)]
|
||||||
|
else:
|
||||||
|
teile += [s(k), struct.pack("<I", 4), struct.pack("<I", v)]
|
||||||
|
teile += [s("tokenizer.ggml.tokens"), struct.pack("<I", 9)]
|
||||||
|
return b"".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kv_cache_aus_dem_gguf_kopf_kennt_hybrid_modelle():
|
||||||
|
daten = _gguf_kopf_bytes({"general.architecture": "qwen35moe", "qwen35moe.block_count": 41,
|
||||||
|
"qwen35moe.attention.head_count": 16, "qwen35moe.attention.head_count_kv": 2,
|
||||||
|
"qwen35moe.rope.dimension_sections": [11, 11, 10, 0],
|
||||||
|
"qwen35moe.attention.key_length": 256, "qwen35moe.attention.value_length": 256,
|
||||||
|
"qwen35moe.full_attention_interval": 4})
|
||||||
|
kopf = radar.gguf_kopf_aus_bytes(daten)
|
||||||
|
assert kopf["general.architecture"] == "qwen35moe" and kopf["qwen35moe.block_count"] == 41
|
||||||
|
# 11 von 41 Schichten mit KV-Cache × 2 KV-Köpfe × (256 + 256) × 1,0625 Byte (q8_0)
|
||||||
|
assert radar.kv_je_token_gb(kopf) == pytest.approx(11 * 2 * 512 * 1.0625 / 1e9)
|
||||||
|
assert radar.kv_je_token_gb(kopf) * 131072 == pytest.approx(1.57, abs=0.01)
|
||||||
|
ohne_hybrid = {k: v for k, v in kopf.items() if not k.endswith("full_attention_interval")}
|
||||||
|
assert radar.kv_je_token_gb(ohne_hybrid) == pytest.approx(41 * 2 * 512 * 1.0625 / 1e9)
|
||||||
|
assert radar.gguf_kopf_aus_bytes(b"kein gguf") is None
|
||||||
|
assert radar.gguf_kopf_aus_bytes(daten[:20]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_speicher_passung_nach_der_115_gb_regel():
|
||||||
|
budget = radar.SPEICHER_GRENZE_GB - radar.WARMSET_GB
|
||||||
|
p = radar.speicher_passung("hirn", 22.6, 11968 / 1e9) # Ornith: bequem, voller Kontext
|
||||||
|
assert p["passt"] and not p["eng"] and p["ctx"] == 131072
|
||||||
|
p = radar.speicher_passung("coder", 82.9, 13056 / 1e9, eng_markiert=True) # Flash-Next: knapp
|
||||||
|
assert p["passt"] and p["eng"] and p["bedarf_gb"] <= budget
|
||||||
|
p = radar.speicher_passung("coder", 82.9, 52224 / 1e9) # großer KV-Cache → halber Kontext
|
||||||
|
assert p["passt"] and p["eng"] and p["ctx"] == 65536
|
||||||
|
p = radar.speicher_passung("coder", 94.0, 13056 / 1e9) # UD-IQ4_XS passt nicht
|
||||||
|
assert not p["passt"] and p["text"].startswith("Passt nicht")
|
||||||
|
p = radar.speicher_passung("hirn", 85.0, 1e-6) # Test ja, im Betrieb neben dem Coder knapp
|
||||||
|
assert p["passt"] and p["eng"] and "Im Betrieb" in p["text"]
|
||||||
|
assert radar.platte_nach_download(265, 1900, 82) == pytest.approx(18.26, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bewertung_ohne_netz(monkeypatch):
|
||||||
|
monkeypatch.setattr(radar, "_hf_baum", lambda repo: BAUM_FLASH)
|
||||||
|
monkeypatch.setattr(radar, "_gguf_kopf", lambda repo, datei: KOPF_FLASH)
|
||||||
|
monkeypatch.setattr(radar, "engine_kennt", lambda arch: True)
|
||||||
|
fund = {"name": "Qwen3.8-Flash-Next", "rolle": "coder", "repo": "unsloth/Qwen3.8-Flash-Next-GGUF",
|
||||||
|
"quant": "UD-IQ3_XXS", "eng": True, "quelle": "watchlist", "rang": 1}
|
||||||
|
k = radar._bewerte(fund)
|
||||||
|
assert k["tauglich"] and k["passt"] and k["eng"] and k["ctx"] == 131072
|
||||||
|
assert k["datei"] == "Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf"
|
||||||
|
assert k["groesse_gb"] == pytest.approx(82.9, abs=0.05)
|
||||||
|
assert k["begruendung"].startswith("Aus der Merkliste. Passt knapp")
|
||||||
|
|
||||||
|
monkeypatch.setattr(radar, "engine_kennt", lambda arch: False)
|
||||||
|
k = radar._bewerte(fund)
|
||||||
|
assert not k["tauglich"] and "qwen4exp" in k["begruendung"]
|
||||||
|
|
||||||
|
# Kopf nicht lesbar: die grobe Schätzung sagt „passt nicht“ — das darf nicht zum Verwerfen führen
|
||||||
|
monkeypatch.setattr(radar, "_gguf_kopf", lambda repo, datei: None)
|
||||||
|
k = radar._bewerte(fund)
|
||||||
|
assert k["netzfehler"] and not k["tauglich"] and k["begruendung"].startswith("Speicherbedarf unklar")
|
||||||
|
|
||||||
|
|
||||||
|
def test_merkliste_im_repo():
|
||||||
|
funde, ok = radar._merkliste()
|
||||||
|
assert ok
|
||||||
|
nach_name = {f["name"]: f for f in funde}
|
||||||
|
assert nach_name["Ornith-1.5-35B-A3B"]["rolle"] == "hirn"
|
||||||
|
assert nach_name["Qwen3.8-Flash-Next"]["quant"] == "UD-IQ3_XXS" and nach_name["Qwen3.8-Flash-Next"]["eng"] is True
|
||||||
|
k2 = radar._bewerte(nach_name["K2-Horizon-MoVA-36B-A4B"]) # übersprungen, ganz ohne Netz
|
||||||
|
assert not k2["tauglich"] and k2["begruendung"].startswith("Übersprungen") and "llama.cpp" in k2["begruendung"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zustandsübergänge ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_suche_uebernimmt_merkliste_und_laesst_geschichte_in_ruhe():
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"alt": _kandidat("alt", status="durchgefallen", quelle="discover"),
|
||||||
|
"weg": _kandidat("weg", quelle="discover"),
|
||||||
|
"nein": _kandidat("nein", status="verworfen", verworfen_von="nutzer")}
|
||||||
|
bewertet = [
|
||||||
|
{**_kandidat("ornith", rang=0), "tauglich": True, "begruendung": "Aus der Merkliste. Passt."},
|
||||||
|
{**_kandidat("k2", rang=2), "tauglich": False, "passt": False,
|
||||||
|
"begruendung": "Übersprungen: läuft noch nicht im offiziellen llama.cpp."},
|
||||||
|
{**_kandidat("alt", quelle="discover"), "tauglich": True},
|
||||||
|
{**_kandidat("nein"), "tauglich": True},
|
||||||
|
{**_kandidat("klein", quelle="discover"), "tauglich": False, "begruendung": "Kein GGUF mit Q4_K_M im Repo."},
|
||||||
|
]
|
||||||
|
ergebnis = radar._suche_anwenden(stand, bewertet, {"watchlist": True, "discover": True}, 1000.0)
|
||||||
|
k = stand["kandidaten"]
|
||||||
|
assert k["ornith"]["status"] == "wartet" # Kopf der Warteschlange
|
||||||
|
assert k["k2"]["status"] == "verworfen" and k["k2"]["verworfen_von"] == "radar"
|
||||||
|
assert k["alt"]["status"] == "durchgefallen" # Geschichte bleibt
|
||||||
|
assert k["nein"]["status"] == "verworfen" and k["nein"]["verworfen_von"] == "nutzer"
|
||||||
|
assert "weg" not in k and "klein" not in k
|
||||||
|
assert "klein" in stand["abgelehnt"]
|
||||||
|
assert ergebnis["neu"] == ["ornith"] and stand["letzte_suche"] == 1000.0
|
||||||
|
|
||||||
|
stand["kandidaten"]["weg"] = _kandidat("weg", quelle="discover")
|
||||||
|
radar._suche_anwenden(stand, [], {"watchlist": True, "discover": False}, 2000.0) # Entdeckung offline
|
||||||
|
assert "weg" in stand["kandidaten"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bestanden_bleibt_liegen_durchgefallen_wird_geloescht(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
for kid in ("gut", "schlecht"):
|
||||||
|
ordner = radar.RADAR_DIR / kid
|
||||||
|
ordner.mkdir()
|
||||||
|
(ordner / "m.gguf").write_bytes(b"x")
|
||||||
|
stand["kandidaten"][kid] = _kandidat(kid, status="wartet", ordner=str(ordner), erster_start=1.0)
|
||||||
|
test = radar._abschluss(stand, "gut", "bestanden", "Bestanden: versteht Bilder (heute nicht).",
|
||||||
|
werte={"tiefe": {"tg_tps": 95.0}}, vergleich={"Bilder": {"urteil": "besser"}})
|
||||||
|
radar._abschluss(stand, "schlecht", "durchgefallen", "Durchgefallen: Werkzeuge 3/6 (heute 6/6).")
|
||||||
|
assert stand["kandidaten"]["gut"]["status"] == "bestanden" and (radar.RADAR_DIR / "gut").exists()
|
||||||
|
assert "wartet auf deine Entscheidung" in stand["kandidaten"]["gut"]["begruendung"]
|
||||||
|
assert stand["kandidaten"]["schlecht"]["status"] == "durchgefallen" and not (radar.RADAR_DIR / "schlecht").exists()
|
||||||
|
assert [t["ergebnis"] for t in stand["tests"]] == ["bestanden", "durchgefallen"]
|
||||||
|
assert set(test) == set(radar.TEST_FELDER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_abbrueche_zaehlen_nur_mit_schuld_des_kandidaten(zustand):
|
||||||
|
ordner = radar.RADAR_DIR / "zaeh"
|
||||||
|
ordner.mkdir()
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["zaeh"] = _kandidat("zaeh", status="wartet", ordner=str(ordner), erster_start=1.0)
|
||||||
|
radar._abschluss(stand, "zaeh", "abgebrochen", "Port 5899 ist belegt.", zaehlt=False)
|
||||||
|
assert stand["kandidaten"]["zaeh"]["status"] == "wartet" and not stand["kandidaten"]["zaeh"].get("versuche")
|
||||||
|
for _ in range(radar.VERSUCHE_MAX):
|
||||||
|
radar._abschluss(stand, "zaeh", "abgebrochen", "Das Nachtfenster endete um 02:30, bevor der Test fertig war.")
|
||||||
|
k = stand["kandidaten"]["zaeh"]
|
||||||
|
assert k["status"] == "durchgefallen" and k["begruendung"].startswith(f"{radar.VERSUCHE_MAX}-mal abgebrochen")
|
||||||
|
assert not ordner.exists()
|
||||||
|
assert {t["ergebnis"] for t in stand["tests"]} == {"abgebrochen"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_vergleichswerte_bleibt_der_kandidat_getestet(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["g"] = _kandidat("g", status="wartet", erster_start=1.0)
|
||||||
|
assert radar._abschluss(stand, "g", None, "", werte={"tiefe": {"tg_tps": 80.0}}) is None
|
||||||
|
assert stand["kandidaten"]["g"]["status"] == "getestet" and stand["kandidaten"]["g"]["messung"]
|
||||||
|
assert stand["tests"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_verwerfen_und_uebernehmen_pruefen_den_status(zustand):
|
||||||
|
ordner = radar.RADAR_DIR / "b"
|
||||||
|
ordner.mkdir()
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"b": _kandidat("b", status="bestanden", ordner=str(ordner)),
|
||||||
|
"u": _kandidat("u", status="uebernommen"), "n": _kandidat("n")}
|
||||||
|
radar._speichere(stand)
|
||||||
|
assert radar.uebernehmen("n")["status"] == 409 # nur bestandene
|
||||||
|
assert radar.uebernehmen("gibt-es-nicht")["status"] == 404
|
||||||
|
assert radar.verwerfen("u")["status"] == 409 # schon übernommen
|
||||||
|
antwort = radar.verwerfen("b")
|
||||||
|
assert antwort["ok"] and antwort["kandidat"]["status"] == "verworfen"
|
||||||
|
assert not ordner.exists()
|
||||||
|
assert radar._lade()["kandidaten"]["b"]["verworfen_von"] == "nutzer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebersicht_hat_die_felder_der_oberflaeche(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"a": _kandidat("a", status="wartet", erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||||
|
"b": _kandidat("b", rang=1)}
|
||||||
|
radar._abschluss(stand, "a", "bestanden", "Bestanden: Prefill bei 13k 18 % schneller.",
|
||||||
|
zeit=_zeit("2026-09-29T01:40:00").timestamp())
|
||||||
|
radar._speichere(stand)
|
||||||
|
u = radar.uebersicht(_zeit("2026-10-01T12:00:00"))
|
||||||
|
assert set(u) == {"naechster_test", "letzter_test", "kandidaten", "tests"}
|
||||||
|
assert set(u["kandidaten"][0]) == set(radar.KANDIDAT_FELDER)
|
||||||
|
assert [k["status"] for k in u["kandidaten"]] == ["wartet", "bestanden"]
|
||||||
|
assert u["naechster_test"] == "2026-10-06T00:30:00+02:00" # die Woche ab dem 29.09. ist belegt
|
||||||
|
assert u["letzter_test"].startswith("2026-09-29T01:40")
|
||||||
|
assert set(u["tests"][0]) == set(radar.TEST_FELDER)
|
||||||
|
|
||||||
|
|
||||||
|
def _uebernahme_vorbereiten(zustand, monkeypatch, rolle: str, alt: dict) -> tuple[list, dict]:
|
||||||
|
"""Bestandenen Kandidaten „o“ anlegen und llama-swap/Hirn-Wechsel durch Protokoll-Attrappen ersetzen."""
|
||||||
|
modelle = zustand / "models"
|
||||||
|
modelle.mkdir()
|
||||||
|
monkeypatch.setattr(radar, "MODELS_DIR", modelle)
|
||||||
|
ordner = radar.RADAR_DIR / "o"
|
||||||
|
ordner.mkdir()
|
||||||
|
(ordner / "o-Q4_K_M.gguf").write_bytes(b"x")
|
||||||
|
(ordner / "mmproj-F16.gguf").write_bytes(b"x")
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["o"] = _kandidat("o", rolle=rolle, status="bestanden", ordner=str(ordner))
|
||||||
|
radar._speichere(stand)
|
||||||
|
aufrufe, cfg = [], {"models": {}}
|
||||||
|
|
||||||
|
def eintragen(pfad, **kw):
|
||||||
|
aufrufe.append(("eintragen", pfad, kw["role"], kw["set_alias"]))
|
||||||
|
cfg["models"]["o"] = {"cmd": "vorlage"}
|
||||||
|
return "o"
|
||||||
|
|
||||||
|
from services import agent
|
||||||
|
monkeypatch.setattr(radar, "_heutige_modelle", lambda: {rolle: alt})
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "register_model", eintragen)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "read_config", lambda: cfg)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "write_config", lambda c: aufrufe.append(("schreiben",)))
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "set_role", lambda mid, r: aufrufe.append(("rolle", mid, r)) or True)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "add_role", lambda mid, r: aufrufe.append(("dazu", mid, r)) or True)
|
||||||
|
monkeypatch.setattr(radar, "_steward_neu_starten", lambda: aufrufe.append(("steward",)) or None)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "set_ttl", lambda mid, ttl: aufrufe.append(("ttl", mid, ttl)) or True)
|
||||||
|
monkeypatch.setattr(agent, "set_agent_brain", lambda mid: aufrufe.append(("hirn", mid)) or {"ok": True})
|
||||||
|
monkeypatch.setattr(radar, "STEWARD_WARMSET", zustand / "warmset.conf")
|
||||||
|
return aufrufe, cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebernehmen_hirn_nutzt_den_hirn_wechsel_und_nimmt_fast_mit(zustand, monkeypatch):
|
||||||
|
aufrufe, cfg = _uebernahme_vorbereiten(zustand, monkeypatch, "hirn",
|
||||||
|
{"name": "Qwen3.6-35B-A3B", "aliases": ["hermes", "fast"], "ttl": 0})
|
||||||
|
(zustand / "warmset.conf").write_text('[Service]\nEnvironment="MC_WARMSET=Qwen3-Embedding-0.6B Qwen3.6-35B-A3B"\n',
|
||||||
|
encoding="utf-8")
|
||||||
|
antwort = radar.uebernehmen("o")
|
||||||
|
assert antwort["ok"] and antwort["modell_id"] == "o"
|
||||||
|
assert [a[0] for a in aufrufe] == ["eintragen", "schreiben", "hirn", "dazu", "steward"]
|
||||||
|
assert aufrufe[0][2:] == ("hermes", False) and aufrufe[3] == ("dazu", "o", "fast")
|
||||||
|
befehl = str(cfg["models"]["o"]["cmd"])
|
||||||
|
assert "--parallel 2" in befehl and "-ctk q8_0" in befehl and "--mmproj" in befehl
|
||||||
|
assert (zustand / "models" / "o-GGUF" / "o-Q4_K_M.gguf").exists() and not (radar.RADAR_DIR / "o").exists()
|
||||||
|
k = radar._lade()["kandidaten"]["o"]
|
||||||
|
assert k["status"] == "uebernommen" and "vorher Qwen3.6-35B-A3B" in k["begruendung"]
|
||||||
|
# Der Steward hält danach das neue Hirn warm, nicht mehr das alte — ohne Handgriff.
|
||||||
|
assert 'MC_WARMSET=Qwen3-Embedding-0.6B o"' in (zustand / "warmset.conf").read_text(encoding="utf-8")
|
||||||
|
assert antwort["text"] is None and "Noch zu tun" not in k["begruendung"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebernehmen_coder_nimmt_heavy_mit(zustand, monkeypatch):
|
||||||
|
aufrufe, cfg = _uebernahme_vorbereiten(zustand, monkeypatch, "coder",
|
||||||
|
{"name": "Qwen3.8-27B", "aliases": ["coder", "heavy"], "ttl": 5400})
|
||||||
|
antwort = radar.uebernehmen("o")
|
||||||
|
assert antwort["ok"] and antwort["text"] is None
|
||||||
|
assert aufrufe[2:] == [("rolle", "o", "coder"), ("dazu", "o", "heavy"), ("ttl", "o", 5400)]
|
||||||
|
assert "--parallel 1" in str(cfg["models"]["o"]["cmd"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_betriebsbefehl_uebernimmt_die_getesteten_flags():
|
||||||
|
k = _kandidat("f", rolle="coder", ctx=65536, draft={"typ": "draft-mtp", "n_max": 2, "datei": None}, flags=["-ub", "512"])
|
||||||
|
befehl = radar.betriebs_befehl("coder", {"gguf": "/srv/models/F-GGUF/f.gguf", "mmproj": "/srv/models/F-GGUF/mm.gguf",
|
||||||
|
"draft": None}, k)
|
||||||
|
assert "-c 65536" in befehl and "--parallel 1" in befehl and "-ctk q8_0 -ctv q8_0" in befehl
|
||||||
|
assert "--mmproj /srv/models/F-GGUF/mm.gguf" in befehl and "--load-mode none" in befehl
|
||||||
|
assert befehl.endswith("--spec-type draft-mtp --spec-draft-n-max 2 -ub 512")
|
||||||
|
assert "--parallel 2" in radar.betriebs_befehl("hirn", {"gguf": "x.gguf", "mmproj": None, "draft": None},
|
||||||
|
_kandidat("h"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Prüfstand: Urteil, Programmieraufgaben, Bild ----------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def ps():
|
||||||
|
return radar._pruefstand()
|
||||||
|
|
||||||
|
|
||||||
|
BILD_GUT = {"direkt": 1.0, "gefunden": ["Wort ZEBRA", "Zahl 4711"], "agentisch": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _werte(rolle: str, decode: float = 100.0, prefill: float = 13.0, werkzeuge: int = 6, code: int = 8,
|
||||||
|
bild: dict | None = None, deutsch: bool = True) -> dict:
|
||||||
|
werte = {"rolle": rolle, "tiefe": {"tg_tps": decode, "pp_s": prefill}, "werkzeuge": {"ok": werkzeuge, "von": 6},
|
||||||
|
"bild": bild}
|
||||||
|
if rolle == "hirn":
|
||||||
|
werte.update(deutsch={"ok": deutsch}, json={"ok": True})
|
||||||
|
else:
|
||||||
|
werte["code"] = {"ok": code, "von": 10}
|
||||||
|
return werte
|
||||||
|
|
||||||
|
|
||||||
|
def test_urteil_hirn_mit_bildern_besteht_ohne_verschlechterung(ps):
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", decode=95.0, bild=BILD_GUT), _werte("hirn", decode=100.8))
|
||||||
|
assert u["ergebnis"] == "bestanden"
|
||||||
|
assert u["zusammenfassung"] == "Bestanden: versteht Bilder (heute nicht)."
|
||||||
|
assert u["vergleich"]["Decode bei 13k"]["urteil"] == "gleich" # −6 % liegt in der Toleranz
|
||||||
|
assert list(u["vergleich"]) == ["Decode bei 13k", "Prefill bei 13k", "Werkzeug-Aufrufe", "Deutsch", "JSON",
|
||||||
|
"Bilder"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_urteil_hirn_faellt_bei_schwaeche_oder_ohne_vorteil_durch(ps):
|
||||||
|
basis = _werte("hirn", decode=100.8)
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", werkzeuge=5, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "Werkzeuge 5/6 (heute 6/6)" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", decode=70.0, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "Decode bei 13k 70 statt 100,8 t/s" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", bild=BILD_GUT), _werte("hirn", bild=BILD_GUT))
|
||||||
|
assert u["zusammenfassung"] == "Durchgefallen: kein Vorteil gegenüber dem heutigen Modell."
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", decode=130.0), basis) # schneller, aber blind
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "versteht keine Bilder" in u["zusammenfassung"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_urteil_coder_zaehlt_programmieraufgaben_und_bilder(ps):
|
||||||
|
basis = _werte("coder", decode=30.4, prefill=41.2, code=8, bild=BILD_GUT)
|
||||||
|
u = ps.urteil("coder", _werte("coder", decode=45.0, prefill=12.0, code=9, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "bestanden" and "Programmieraufgaben 9/10 statt 8/10" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("coder", _werte("coder", decode=45.0, prefill=12.0, code=7, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "Programmieraufgaben 7/10 (heute 8/10)" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("coder", _werte("coder", code=9), basis) # der heutige Coder kann Bilder
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "versteht keine Bilder" in u["zusammenfassung"]
|
||||||
|
|
||||||
|
|
||||||
|
REFERENZ = {
|
||||||
|
"palindrom": "def ist_palindrom(text):\n z = [c.lower() for c in text if c.isalnum()]\n return z == z[::-1]\n",
|
||||||
|
"roemisch": (
|
||||||
|
"def roemisch(zahl):\n"
|
||||||
|
" if not isinstance(zahl, int) or not 1 <= zahl <= 3999:\n"
|
||||||
|
" raise ValueError(zahl)\n"
|
||||||
|
" out = ''\n"
|
||||||
|
" for wert, zeichen in ((1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'), (90, 'XC'),\n"
|
||||||
|
" (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I')):\n"
|
||||||
|
" while zahl >= wert:\n"
|
||||||
|
" out += zeichen\n"
|
||||||
|
" zahl -= wert\n"
|
||||||
|
" return out\n"),
|
||||||
|
"intervalle": (
|
||||||
|
"def zusammenfassen(intervalle):\n"
|
||||||
|
" out = []\n"
|
||||||
|
" for a, b in sorted(intervalle):\n"
|
||||||
|
" if out and a <= out[-1][1]:\n"
|
||||||
|
" out[-1][1] = max(out[-1][1], b)\n"
|
||||||
|
" else:\n"
|
||||||
|
" out.append([a, b])\n"
|
||||||
|
" return out\n"),
|
||||||
|
"flach": (
|
||||||
|
"def flach(liste):\n"
|
||||||
|
" out = []\n"
|
||||||
|
" for x in liste:\n"
|
||||||
|
" out.extend(flach(x) if isinstance(x, list) else [x])\n"
|
||||||
|
" return out\n"),
|
||||||
|
"iban": (
|
||||||
|
"def iban_gueltig(iban):\n"
|
||||||
|
" s = iban.replace(' ', '').upper()\n"
|
||||||
|
" if not 15 <= len(s) <= 34 or not s.isalnum() or not s.isascii():\n"
|
||||||
|
" return False\n"
|
||||||
|
" return int(''.join(str(int(c, 36)) for c in s[4:] + s[:4])) % 97 == 1\n"),
|
||||||
|
"lru": (
|
||||||
|
"from collections import OrderedDict\n"
|
||||||
|
"class LRUCache:\n"
|
||||||
|
" def __init__(self, kapazitaet):\n"
|
||||||
|
" self.k, self.d = kapazitaet, OrderedDict()\n"
|
||||||
|
" def get(self, schluessel):\n"
|
||||||
|
" if schluessel not in self.d:\n"
|
||||||
|
" return None\n"
|
||||||
|
" self.d.move_to_end(schluessel)\n"
|
||||||
|
" return self.d[schluessel]\n"
|
||||||
|
" def put(self, schluessel, wert):\n"
|
||||||
|
" self.d[schluessel] = wert\n"
|
||||||
|
" self.d.move_to_end(schluessel)\n"
|
||||||
|
" if len(self.d) > self.k:\n"
|
||||||
|
" self.d.popitem(last=False)\n"),
|
||||||
|
"klammern": (
|
||||||
|
"def klammern_ok(text):\n"
|
||||||
|
" paare, stapel = {')': '(', ']': '[', '}': '{'}, []\n"
|
||||||
|
" for c in text:\n"
|
||||||
|
" if c in '([{':\n"
|
||||||
|
" stapel.append(c)\n"
|
||||||
|
" elif c in paare and (not stapel or stapel.pop() != paare[c]):\n"
|
||||||
|
" return False\n"
|
||||||
|
" return not stapel\n"),
|
||||||
|
"wege": (
|
||||||
|
"import heapq\n"
|
||||||
|
"def kuerzeste_wege(graph, start):\n"
|
||||||
|
" dist, heap = {start: 0}, [(0, start)]\n"
|
||||||
|
" while heap:\n"
|
||||||
|
" d, u = heapq.heappop(heap)\n"
|
||||||
|
" if d > dist[u]:\n"
|
||||||
|
" continue\n"
|
||||||
|
" for v, w in graph.get(u, {}).items():\n"
|
||||||
|
" if v not in dist or d + w < dist[v]:\n"
|
||||||
|
" dist[v] = d + w\n"
|
||||||
|
" heapq.heappush(heap, (d + w, v))\n"
|
||||||
|
" return dist\n"),
|
||||||
|
"median": (
|
||||||
|
"def median(zahlen):\n"
|
||||||
|
" if not zahlen:\n"
|
||||||
|
" raise ValueError('leer')\n"
|
||||||
|
" s, n = sorted(zahlen), len(zahlen)\n"
|
||||||
|
" return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2\n"),
|
||||||
|
"csv": (
|
||||||
|
"def spaltensumme(csv_text, spalte):\n"
|
||||||
|
" zeilen = [z for z in csv_text.splitlines() if z.strip()]\n"
|
||||||
|
" kopf = zeilen[0].split(';')\n"
|
||||||
|
" if spalte not in kopf:\n"
|
||||||
|
" raise KeyError(spalte)\n"
|
||||||
|
" i, summe = kopf.index(spalte), 0.0\n"
|
||||||
|
" for z in zeilen[1:]:\n"
|
||||||
|
" zellen = z.split(';')\n"
|
||||||
|
" wert = zellen[i].strip() if i < len(zellen) else ''\n"
|
||||||
|
" if wert:\n"
|
||||||
|
" summe += float(wert.replace('.', '').replace(',', '.'))\n"
|
||||||
|
" return summe\n"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_referenzloesungen_bestehen_alle_programmieraufgaben(ps):
|
||||||
|
assert {a["id"] for a in ps.AUFGABEN_CODE} == set(REFERENZ)
|
||||||
|
for aufgabe in ps.AUFGABEN_CODE:
|
||||||
|
ok, grund = ps.fuehre_aufgabe_aus(REFERENZ[aufgabe["id"]], aufgabe["tests"])
|
||||||
|
assert ok, f"{aufgabe['id']}: {grund}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_falsche_verbotene_und_endlose_loesungen_fallen_durch(ps):
|
||||||
|
tests = next(a["tests"] for a in ps.AUFGABEN_CODE if a["id"] == "median")
|
||||||
|
ok, _ = ps.fuehre_aufgabe_aus("def median(z):\n z.sort()\n return z[len(z) // 2]\n", tests)
|
||||||
|
assert not ok
|
||||||
|
ok, grund = ps.fuehre_aufgabe_aus("import os\ndef median(z):\n return os.getpid()\n", tests)
|
||||||
|
assert not ok and grund.startswith("unerlaubter Aufruf")
|
||||||
|
ok, grund = ps.fuehre_aufgabe_aus("def median(z):\n while True:\n pass\n", tests, zeitlimit=2)
|
||||||
|
assert not ok and "Zeitlimit" in grund
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_aus_antwort_nimmt_den_loesungsblock(ps):
|
||||||
|
antwort = "<think>kurz nachdenken</think>Hier:\n```python\ndef f():\n return 1\n```\nAufruf:\n```python\nf()\n```"
|
||||||
|
assert ps.code_aus_antwort(antwort) == "def f():\n return 1\n"
|
||||||
|
assert ps.code_aus_antwort("def g(): return 2") == "def g(): return 2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bild_ist_ein_gueltiges_png_mit_rotem_kreis(ps):
|
||||||
|
png = ps.bild_png()
|
||||||
|
assert png.startswith(b"\x89PNG\r\n\x1a\n")
|
||||||
|
breite, hoehe = struct.unpack(">II", png[16:24])
|
||||||
|
assert (breite, hoehe) == (448, 300)
|
||||||
|
laenge = struct.unpack(">I", png[33:37])[0]
|
||||||
|
roh = zlib.decompress(png[41:41 + laenge])
|
||||||
|
zeile = 1 + breite * 3
|
||||||
|
assert roh[95 * zeile + 1 + 80 * 3:95 * zeile + 1 + 81 * 3] == b"\xdc\x14\x14" # Mitte des Kreises
|
||||||
|
beschreibung = "Ein roter Kreis, ein blaues Quadrat, ein grünes Dreieck und darunter der Text ZEBRA 4711."
|
||||||
|
assert ps.bild_merkmale(beschreibung) == list(ps.BILD_MERKMALE)
|
||||||
|
assert ps.bild_data_url().startswith("data:image/png;base64,")
|
||||||
|
|
||||||
|
|
||||||
|
def test_werkzeug_treffer_liest_json_argumente(ps):
|
||||||
|
aufruf = [{"function": {"name": "send_telegram", "arguments": json.dumps({"text": "Prüfstand läuft."})}}]
|
||||||
|
assert ps.trifft(aufruf, [("send_telegram", r"pr(ü|ue)fstand")])
|
||||||
|
assert not ps.trifft(aufruf, [("terminal", r".*")])
|
||||||
|
assert len(ps.WERKZEUGE_HIRN) >= 90 # Lucy hat ~100 Werkzeuge
|
||||||
|
assert ps.tiefen_prompt("ab12").startswith("Messlauf ab12.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_role_behaelt_die_uebrigen_aliase(monkeypatch):
|
||||||
|
"""add_role ohne Attrappe: coder bleibt, heavy wandert vom alten Coder herüber."""
|
||||||
|
from services import llamaswap
|
||||||
|
cfg = {"models": {"alt": {"cmd": "x", "aliases": ["coder", "heavy"]}, "neu": {"cmd": "y", "aliases": ["coder"]}}}
|
||||||
|
monkeypatch.setattr(llamaswap, "read_config", lambda: cfg)
|
||||||
|
monkeypatch.setattr(llamaswap, "write_config", lambda c: None)
|
||||||
|
assert llamaswap.add_role("neu", "heavy") is True
|
||||||
|
assert cfg["models"]["neu"]["aliases"] == ["coder", "heavy"]
|
||||||
|
assert cfg["models"]["alt"]["aliases"] == ["coder"]
|
||||||
|
assert llamaswap.add_role("gibt-es-nicht", "heavy") is False
|
||||||
+63
-167
@@ -1,22 +1,23 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Prüfstand für Hirn-Kandidaten (17.09.2026). Kandidat läuft standalone auf :5899 — der Live-Stack bleibt unberührt —,
|
"""Prüfstand für Hirn-Kandidaten (17.09.2026), seit 09/2026 eine dünne Hülle um pruefstand.py.
|
||||||
Baselines laufen über llama-swap :8080. Je Betriebsart: Kurz- und 13k-Tiefenmessung (Prefill/Decode/Draft-Akzeptanz),
|
|
||||||
Tool-Calling direkt (3 Aufgaben, Denken an), Deutsch- und JSON-Probe (Denken aus) und der Tool-Smoke durch den ECHTEN
|
Die Messungen selbst (Tempo, 13k-Tiefe, Werkzeuge, Deutsch/JSON, Bild-Probe, Server auf :5899) liegen
|
||||||
Hermes-Agenten (`hermes chat --provider pruefstand -m kandidat`; Provider `pruefstand` -> http://127.0.0.1:5899/v1 muss in
|
im Modul — dasselbe nutzt das Modell-Radar nachts. Dieses Skript bleibt der Handbetrieb: Kandidaten
|
||||||
`providers:` der Hermes-Config stehen). Aufruf auf der Box: python3 pruefstand-hirn.py [baseline] [nemo] [u38] [u36]
|
mit festen Pfaden gegen die Live-Modelle (llama-swap :8080) messen, optional mit dem Tool-Smoke durch
|
||||||
(Kandidaten-Pfade unten anpassen). Ergebnis: ~/pruefstand-<datum>.json + Log auf stdout. Lehren: Denk-Modelle brauchen
|
den ECHTEN Hermes-Agenten (`hermes chat --provider pruefstand -m kandidat`; der Provider `pruefstand`
|
||||||
für Tool-Aufrufe >=1500 Tokens Budget, /no_think wirkt bei Qwen 3.6 nicht (chat_template_kwargs.enable_thinking=false);
|
-> http://127.0.0.1:5899/v1 muss in `providers:` der Hermes-Config stehen).
|
||||||
der erste Lauf nach dem Laden ist ~40 % langsamer (Warmlauf verwerfen)."""
|
|
||||||
|
Aufruf auf der Box: python3 pruefstand-hirn.py [baseline] [nemo] [u38] [u36] (Pfade unten anpassen).
|
||||||
|
Ergebnis: ~/pruefstand-<datum>.json + Log auf stdout.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
BIN = "/opt/llamacpp-vulkan/llama-server"; PORT = 5899
|
import pruefstand as ps # liegt daneben; beim Skriptaufruf steht dieser Ordner auf sys.path
|
||||||
BASE = ["-c", "65536", "-ngl", "999", "-fa", "on", "--load-mode", "none", "--jinja", "--parallel", "1", "-ctk", "q8_0", "-ctv", "q8_0"]
|
|
||||||
M = "/srv/models"
|
M = "/srv/models"
|
||||||
DFLASH_36 = f"{M}/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf"
|
DFLASH_36 = f"{M}/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf"
|
||||||
DFLASH2_38 = f"{M}/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf"
|
DFLASH2_38 = f"{M}/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf"
|
||||||
@@ -24,170 +25,65 @@ NEMO = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/NVIDIA-Nemotron-3.5-Lightning-3
|
|||||||
NEMO_MTP = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/mtp-NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q8_0.gguf"
|
NEMO_MTP = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/mtp-NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q8_0.gguf"
|
||||||
U38 = f"{M}/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf"
|
U38 = f"{M}/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf"
|
||||||
U36 = f"{M}/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
|
U36 = f"{M}/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
|
||||||
HERMES = os.path.expanduser("~/.local/bin/hermes")
|
OUT = os.path.expanduser(f"~/pruefstand-{time.strftime('%Y-%m-%d')}.json")
|
||||||
OUT = os.path.expanduser("~/pruefstand-2026-09-17.json")
|
RESULTS: list[dict] = []
|
||||||
RESULTS = []
|
|
||||||
|
|
||||||
|
|
||||||
def log(*a):
|
def save() -> None:
|
||||||
print(*a, flush=True)
|
with open(OUT, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(RESULTS, f, indent=1, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def post(url, payload, timeout=900):
|
def probes(basis: str, modell: str, label: str, with_hermes: bool, hermes_args: list[str], bild: bool = False) -> None:
|
||||||
r = urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"})
|
werte = {"label": label, **ps.pruefe("hirn", basis, modell, bild=bild)}
|
||||||
return json.loads(urllib.request.urlopen(r, timeout=timeout).read())
|
|
||||||
|
|
||||||
|
|
||||||
def chat(base, model, messages, max_tokens=200, tools=None, think=True):
|
|
||||||
p = {"model": model, "temperature": 0, "max_tokens": max_tokens, "messages": messages}
|
|
||||||
if not think:
|
|
||||||
p["chat_template_kwargs"] = {"enable_thinking": False}
|
|
||||||
if tools:
|
|
||||||
p["tools"] = tools
|
|
||||||
p["tool_choice"] = "auto"
|
|
||||||
t0 = time.time()
|
|
||||||
j = post(f"{base}/v1/chat/completions", p)
|
|
||||||
j["_wall"] = time.time() - t0
|
|
||||||
return j
|
|
||||||
|
|
||||||
|
|
||||||
PARA = ("Notiz {i}: Die Box läuft mit llama-swap auf Port 8080, das Hirn ist ein Mixture-of-Experts-Modell mit drei Milliarden "
|
|
||||||
"aktiven Parametern, der Coder ein dichtes 27B-Modell mit Draft-Beschleunigung. Lucy hört über Parakeet, spricht über "
|
|
||||||
"pocket-tts und meldet sich per Telegram. Der Sonntags-Job aktualisiert Router, Engine und Agent und rollt bei Rot zurück. ")
|
|
||||||
DEPTH_PROMPT = "".join(PARA.format(i=i) for i in range(130)) + \
|
|
||||||
"\n\nFrage: Fasse in drei Sätzen zusammen, was in den Notizen steht, und nenne die Nummer der letzten Notiz."
|
|
||||||
|
|
||||||
TOOLS = [
|
|
||||||
{"type": "function", "function": {"name": "terminal", "description": "Führt einen Shell-Befehl auf der Box aus und gibt stdout zurück.",
|
|
||||||
"parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}},
|
|
||||||
{"type": "function", "function": {"name": "read_file", "description": "Liest eine Datei und gibt den Inhalt zurück.",
|
|
||||||
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
|
|
||||||
{"type": "function", "function": {"name": "send_telegram", "description": "Schickt dem Nutzer eine Telegram-Nachricht.",
|
|
||||||
"parameters": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}},
|
|
||||||
]
|
|
||||||
TOOL_TASKS = [
|
|
||||||
("Wie viel freier Speicher ist auf /srv/models? Nutze das passende Werkzeug.", "terminal", r"df"),
|
|
||||||
("Lies die Datei /etc/hostname und sag mir, wie die Box heißt.", "read_file", r"/etc/hostname"),
|
|
||||||
("Schick mir per Telegram die Nachricht: Prüfstand läuft.", "send_telegram", r"Pr.fstand"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def timings(j):
|
|
||||||
t = j.get("timings") or {}
|
|
||||||
acc = (t.get("draft_n_accepted", 0) / t["draft_n"]) if t.get("draft_n") else None
|
|
||||||
return {"pp_n": t.get("prompt_n"), "pp_s": round((t.get("prompt_ms") or 0) / 1000, 1), "pp_tps": round(t.get("prompt_per_second") or 0),
|
|
||||||
"tg_n": t.get("predicted_n"), "tg_tps": round(t.get("predicted_per_second") or 0, 1), "acc": None if acc is None else round(acc, 2)}
|
|
||||||
|
|
||||||
|
|
||||||
def save():
|
|
||||||
json.dump(RESULTS, open(OUT, "w"), indent=1, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def probes(base, model, label, with_hermes, hermes_args):
|
|
||||||
res = {"label": label}
|
|
||||||
chat(base, model, [{"role": "user", "content": "Sag kurz hallo."}], 32) # Warmlauf
|
|
||||||
j = chat(base, model, [{"role": "user", "content": "Erkläre in fünf Sätzen, was ein Mixture-of-Experts-Modell ist."}], 200)
|
|
||||||
res["kurz"] = timings(j); log(f" kurz : {res['kurz']}")
|
|
||||||
j = chat(base, model, [{"role": "user", "content": DEPTH_PROMPT}], 160)
|
|
||||||
res["tiefe"] = timings(j); log(f" 13k : {res['tiefe']}")
|
|
||||||
tc = []
|
|
||||||
for q, want_fn, want_arg in TOOL_TASKS:
|
|
||||||
try:
|
|
||||||
j = chat(base, model, [{"role": "user", "content": q}], 1500, TOOLS)
|
|
||||||
msg = j["choices"][0]["message"]
|
|
||||||
calls = msg.get("tool_calls") or []
|
|
||||||
ok = any(c["function"]["name"] == want_fn and re.search(want_arg, c["function"].get("arguments", "")) for c in calls)
|
|
||||||
raw = "" if calls else (msg.get("content") or "")[:120].replace("\n", " ")
|
|
||||||
tc.append({"task": want_fn, "ok": ok, "calls": [(c["function"]["name"], c["function"].get("arguments", "")[:80]) for c in calls], "raw": raw})
|
|
||||||
except Exception as e:
|
|
||||||
tc.append({"task": want_fn, "ok": False, "error": str(e)[:120]})
|
|
||||||
res["tools_direkt"] = tc
|
|
||||||
log(f" tools: {sum(1 for t in tc if t['ok'])}/3 " + "; ".join(f"{t['task']}={'✓' if t['ok'] else '✗'}" for t in tc))
|
|
||||||
for t in tc:
|
|
||||||
if not t["ok"]:
|
|
||||||
log(f" {t['task']}: calls={t.get('calls')} raw={t.get('raw', '')!r} {t.get('error', '')}")
|
|
||||||
j = chat(base, model, [{"role": "user", "content": "Antworte auf Deutsch in genau drei Sätzen: Warum braucht ein Heimserver ein Backup?"}], 300, think=False)
|
|
||||||
m = j["choices"][0]["message"]
|
|
||||||
txt = (m.get("content") or "").strip() or ("[nur reasoning] " + (m.get("reasoning_content") or "")[:200])
|
|
||||||
res["deutsch"] = txt[:400]; log(f" de : {txt[:160]!r}")
|
|
||||||
j = chat(base, model, [{"role": "user", "content": 'Antworte NUR mit JSON, ohne Erklärung: {"modell": "<dein Name>", "ok": true}'}], 120, think=False)
|
|
||||||
m = j["choices"][0]["message"]
|
|
||||||
txt = (m.get("content") or "").strip()
|
|
||||||
try:
|
|
||||||
json.loads(txt[txt.find("{"): txt.rfind("}") + 1]); js = True
|
|
||||||
except Exception:
|
|
||||||
js = False
|
|
||||||
res["json_ok"] = js; log(f" json : {'✓' if js else '✗'} {txt[:80]!r}")
|
|
||||||
if with_hermes:
|
if with_hermes:
|
||||||
hz = []
|
werte["hermes"] = ps.hermes_smoke(hermes_args)
|
||||||
for q, want in [("Führe im Terminal exakt den Befehl echo pruefstand-ok aus und gib mir nur dessen Ausgabe zurück.", "pruefstand-ok"),
|
RESULTS.append(werte)
|
||||||
("Welche Verzeichnisse liegen direkt unter /srv/models? Nenne mir drei davon.", "GGUF")]:
|
save()
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
p = subprocess.run([HERMES, "chat", *hermes_args, "-Q", "--oneshot", "--max-turns", "6", "-q", q],
|
|
||||||
capture_output=True, text=True, timeout=900)
|
|
||||||
out = (p.stdout + p.stderr)[-600:]
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
out = "TIMEOUT"
|
|
||||||
ok = want.lower() in out.lower()
|
|
||||||
hz.append({"ok": ok, "s": round(time.time() - t0), "tail": out[-160:].replace("\n", " ")})
|
|
||||||
log(f" hermes: {'✓' if ok else '✗'} {round(time.time() - t0)} s {out[-120:].replace(chr(10), ' ')!r}")
|
|
||||||
res["hermes"] = hz
|
|
||||||
RESULTS.append(res); save()
|
|
||||||
|
|
||||||
|
|
||||||
def start_server(model, extra):
|
def candidate(label: str, model: str, extra: list[str], with_hermes: bool, mmproj: str | None = None) -> None:
|
||||||
cmd = [BIN, "-m", model, "--host", "127.0.0.1", "--port", str(PORT), *BASE, *extra]
|
ps.log(f"\n=== {label}")
|
||||||
logf = open("/tmp/pruefstand-server.log", "w")
|
ps.log(f" {os.path.basename(model)} {' '.join(extra)}")
|
||||||
p = subprocess.Popen(cmd, stdout=logf, stderr=subprocess.STDOUT)
|
|
||||||
t0 = time.time()
|
|
||||||
for _ in range(150):
|
|
||||||
time.sleep(2)
|
|
||||||
if p.poll() is not None:
|
|
||||||
tail = open("/tmp/pruefstand-server.log", errors="replace").read()[-500:]
|
|
||||||
return None, f"LOAD-CRASH rc={p.returncode}: {tail.strip()[-300:]}"
|
|
||||||
try:
|
|
||||||
if b"ok" in urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=2).read():
|
|
||||||
return p, round(time.time() - t0)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
p.kill()
|
|
||||||
return None, "TIMEOUT beim Laden"
|
|
||||||
|
|
||||||
|
|
||||||
def candidate(label, model, extra, with_hermes):
|
|
||||||
log(f"\n=== {label}"); log(f" {os.path.basename(model)} {' '.join(extra)}")
|
|
||||||
if not os.path.exists(model):
|
|
||||||
log(" FEHLT: Datei nicht da"); RESULTS.append({"label": label, "fehler": "Datei fehlt"}); save(); return
|
|
||||||
p, info = start_server(model, extra)
|
|
||||||
if p is None:
|
|
||||||
log(f" {info}"); RESULTS.append({"label": label, "fehler": info}); save(); return
|
|
||||||
log(f" geladen in {info} s")
|
|
||||||
try:
|
try:
|
||||||
probes(f"http://127.0.0.1:{PORT}", "kandidat", label, with_hermes, ["--provider", "pruefstand", "-m", "kandidat"])
|
server = ps.starte_server(model, mmproj=mmproj, flags=extra, ctx=ps.CTX_STANDARD)
|
||||||
|
except ps.ServerFehler as e:
|
||||||
|
ps.log(f" {e}")
|
||||||
|
RESULTS.append({"label": label, "fehler": str(e)[:300]})
|
||||||
|
save()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
probes(ps.KANDIDAT_URL, "kandidat", label, with_hermes, ["--provider", "pruefstand", "-m", "kandidat"],
|
||||||
|
bild=bool(mmproj))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f" FEHLER in Proben: {e}"); RESULTS.append({"label": label, "fehler": str(e)[:200]}); save()
|
ps.log(f" FEHLER in Proben: {e}")
|
||||||
|
RESULTS.append({"label": label, "fehler": str(e)[:200]})
|
||||||
|
save()
|
||||||
finally:
|
finally:
|
||||||
p.terminate()
|
ps.stoppe_server(server)
|
||||||
try:
|
|
||||||
p.wait(timeout=60)
|
|
||||||
except Exception:
|
|
||||||
p.kill()
|
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
which = set(sys.argv[1:]) or {"baseline", "nemo", "u38", "u36"}
|
def main() -> None:
|
||||||
if "baseline" in which:
|
which = set(sys.argv[1:]) or {"baseline", "nemo", "u38", "u36"}
|
||||||
log("\n=== BASELINE fast (live, llama-swap)"); probes("http://127.0.0.1:8080", "fast", "fast-live", True, ["-m", "fast"])
|
if "baseline" in which:
|
||||||
log("\n=== BASELINE coder = Qwen3.8-27B+DFlash2 (live)"); probes("http://127.0.0.1:8080", "coder", "coder-live", True, ["-m", "coder"])
|
ps.log("\n=== BASELINE fast (live, llama-swap)")
|
||||||
if "nemo" in which:
|
probes(ps.SWAP_URL, "fast", "fast-live", True, ["-m", "fast"])
|
||||||
candidate("Nemotron 3.5 Lightning · MTP n3", NEMO, ["--spec-type", "draft-mtp", "--spec-draft-model", NEMO_MTP, "--spec-draft-n-max", "3"], True)
|
ps.log("\n=== BASELINE coder (live, llama-swap)")
|
||||||
candidate("Nemotron 3.5 Lightning · ohne Spec", NEMO, [], False)
|
probes(ps.SWAP_URL, "coder", "coder-live", True, ["-m", "coder"], bild=True)
|
||||||
if "u38" in which:
|
if "nemo" in which:
|
||||||
candidate("Qwen3.8-27B-Uncensored · MTP fused n2", U38, ["--spec-type", "draft-mtp", "--spec-draft-n-max", "2"], True)
|
candidate("Nemotron 3.5 Lightning · MTP n3", NEMO,
|
||||||
candidate("Qwen3.8-27B-Uncensored · DFlash2 (z-lab) n3", U38, ["--spec-type", "draft-dflash", "--spec-draft-model", DFLASH2_38, "--spec-draft-n-max", "3"], False)
|
ps.draft_flags(NEMO_MTP, "draft-mtp", 3), True)
|
||||||
candidate("Qwen3.8-27B-Uncensored · ohne Spec", U38, [], False)
|
candidate("Nemotron 3.5 Lightning · ohne Spec", NEMO, [], False)
|
||||||
if "u36" in which:
|
if "u38" in which:
|
||||||
candidate("Qwen3.6-35B-A3B-Uncensored-Aggressive · DFlash (giocom)", U36, ["--spec-type", "draft-dflash", "--spec-draft-model", DFLASH_36], True)
|
candidate("Qwen3.8-27B · MTP fused n2", U38, ps.draft_flags(None, "draft-mtp", 2), True)
|
||||||
candidate("Qwen3.6-35B-A3B-Uncensored-Aggressive · ohne Spec", U36, [], False)
|
candidate("Qwen3.8-27B · DFlash2 (z-lab) n3", U38, ps.draft_flags(DFLASH2_38, "draft-dflash", 3), False)
|
||||||
log("\nPRUEFSTAND_DONE")
|
candidate("Qwen3.8-27B · ohne Spec", U38, [], False)
|
||||||
|
if "u36" in which:
|
||||||
|
candidate("Qwen3.6-35B-A3B · DFlash (giocom)", U36, ps.draft_flags(DFLASH_36, "draft-dflash"), True)
|
||||||
|
candidate("Qwen3.6-35B-A3B · ohne Spec", U36, [], False)
|
||||||
|
ps.log("\nPRUEFSTAND_DONE")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,11 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
||||||
|
# Das Modell-Radar (09/2026) ist die erste Unit, die deploy.sh selbst einspielt: Sie gehört ganz dem
|
||||||
|
# Repo, auf der Box wird an ihr nichts von Hand gedreht. Die übrigen Units bleiben wie bisher Handarbeit.
|
||||||
|
install -m 644 deploy/mc2-radar.service deploy/mc2-radar.timer "$HOME/.config/systemd/user/"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now mc2-radar.timer
|
||||||
|
|
||||||
# 4.5 Hermes Skills synchronisieren
|
# 4.5 Hermes Skills synchronisieren
|
||||||
echo "Synchronisiere Hermes Skills..."
|
echo "Synchronisiere Hermes Skills..."
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# systemd-USER-Unit für das Modell-Radar (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
|
||||||
|
# Sucht höchstens einmal am Tag nach neuen Modellen für Hirn und Coder und testet nachts im Fenster
|
||||||
|
# 00:30–02:30 höchstens einen neuen Kandidaten pro Woche (backend/radar_lauf.py, services/radar.py).
|
||||||
|
# Um 03:00 braucht der NerdQuiz-Nachtlauf das Hirn: Der Lauf hört um 02:30 selbst auf, um 02:35 zieht
|
||||||
|
# seine Notbremse, und RuntimeMaxSec beendet ihn spätestens 02:40 samt llama-server (control-group).
|
||||||
|
# Type=simple statt oneshot: Bei oneshot greift RuntimeMaxSec nicht.
|
||||||
|
# Sudo-frei wie alle MC2-Units: ~/.config/systemd/user/ + systemctl --user.
|
||||||
|
# Ablage: ~/.config/systemd/user/mc2-radar.service (+ mc2-radar.timer) ; dann:
|
||||||
|
# systemctl --user daemon-reload && systemctl --user enable --now mc2-radar.timer
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=MC2 Modell-Radar (Suche + nächtlicher Prüfstand 00:30–02:30)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/mission-control-v2/backend
|
||||||
|
ExecStart=%h/mission-control-v2/backend/.venv/bin/python radar_lauf.py
|
||||||
|
Environment=PYTHONPATH=%h/mission-control-v2
|
||||||
|
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
|
||||||
|
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
|
||||||
|
Environment=MC_MODELS_DIR=/srv/models
|
||||||
|
Environment=MC_LOCAL_TZ=Europe/Berlin
|
||||||
|
# Kein Nice=: der Kandidaten-Server erbt die Priorität — gemessen wird gegen das Live-Modell.
|
||||||
|
RuntimeMaxSec=2h10min
|
||||||
|
KillMode=control-group
|
||||||
|
TimeoutStopSec=60
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nächtliches Modell-Radar (00:30, Test-Fenster bis 02:30)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 00:30:00
|
||||||
|
Persistent=true
|
||||||
|
AccuracySec=1min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"_hinweis": "Merkliste des Modell-Radars (backend/services/radar.py). Einträge hier werden vor den Funden der Hugging-Face-Entdeckung getestet, in dieser Reihenfolge. Felder: name, rolle (hirn|coder), repo, quant (Standard Q4_K_M), eng (true = passt nur knapp neben das Warm-Set), notiz (erscheint in der Begründung), ueberspringen (Grund; Eintrag wird nicht getestet), optional draft {repo, datei, typ, n_max} und flags (zusätzliche llama-server-Flags). Getestet wird nur, was ein mmproj hat und neben das Warm-Set passt (27 GB + Kandidat inkl. KV-Cache <= 115 GB).",
|
||||||
|
"kandidaten": [
|
||||||
|
{
|
||||||
|
"name": "Ornith-1.5-35B-A3B",
|
||||||
|
"rolle": "hirn",
|
||||||
|
"repo": "ornith-ai/Ornith-1.5-35B-A3B-GGUF",
|
||||||
|
"quant": "Q4_K_M",
|
||||||
|
"notiz": "MoE mit 3B aktiven Parametern (Architektur qwen35moe), mmproj im Repo."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Qwen3.8-Flash-Next",
|
||||||
|
"rolle": "coder",
|
||||||
|
"repo": "unsloth/Qwen3.8-Flash-Next-GGUF",
|
||||||
|
"quant": "UD-IQ3_XXS",
|
||||||
|
"eng": true,
|
||||||
|
"notiz": "UD-IQ3_XXS ist ca. 82 GB groß und passt nur knapp neben das Warm-Set (UD-IQ4_XS mit 94 GB passt nicht)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "K2-Horizon-MoVA-36B-A4B",
|
||||||
|
"rolle": "hirn",
|
||||||
|
"repo": "IFM/K2-Horizon-MoVA-36B-A4B-GGUF",
|
||||||
|
"quant": "Q4_K_M",
|
||||||
|
"ueberspringen": "läuft noch nicht im offiziellen llama.cpp."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{a as e,c as t,i as n,s as r,t as i,u as a}from"./button-Dd8hfusv.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-CgRgLCFz.js";var p=r();a();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`dialog`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`dialog-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,r),t[3]=r,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`dialog-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(t){let r=(0,p.c)(15),a,o,s,l;r[0]===t?(a=r[1],o=r[2],s=r[3],l=r[4]):({className:o,children:a,showCloseButton:l,...s}=t,r[0]=t,r[1]=a,r[2]=o,r[3]=s,r[4]=l);let d=l===void 0||l,f;r[5]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,m.jsx)(_,{}),r[5]=f):f=r[5];let h;r[6]===o?h=r[7]:(h=n(`fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95`,o),r[6]=o,r[7]=h);let v;r[8]===d?v=r[9]:(v=d&&(0,m.jsx)(c,{"data-slot":`dialog-close`,asChild:!0,children:(0,m.jsxs)(i,{variant:`ghost`,className:`absolute top-2 right-2`,size:`icon-sm`,children:[(0,m.jsx)(e,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),r[8]=d,r[9]=v);let y;return r[10]!==a||r[11]!==s||r[12]!==h||r[13]!==v?(y=(0,m.jsxs)(g,{children:[f,(0,m.jsxs)(u,{"data-slot":`dialog-content`,className:h,...s,children:[a,v]})]}),r[10]=a,r[11]=s,r[12]=h,r[13]=v,r[14]=y):y=r[14],y}function y(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`flex flex-col gap-2`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`dialog-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(14),r,a,o,s;t[0]===e?(r=t[1],a=t[2],o=t[3],s=t[4]):({className:a,showCloseButton:s,children:r,...o}=e,t[0]=e,t[1]=r,t[2]=a,t[3]=o,t[4]=s);let l=s!==void 0&&s,u;t[5]===a?u=t[6]:(u=n(`-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end`,a),t[5]=a,t[6]=u);let d;t[7]===l?d=t[8]:(d=l&&(0,m.jsx)(c,{asChild:!0,children:(0,m.jsx)(i,{variant:`outline`,children:`Schließen`})}),t[7]=l,t[8]=d);let f;return t[9]!==r||t[10]!==o||t[11]!==u||t[12]!==d?(f=(0,m.jsxs)(`div`,{"data-slot":`dialog-footer`,className:u,...o,children:[r,d]}),t[9]=r,t[10]=o,t[11]=u,t[12]=d,t[13]=f):f=t[13],f}function x(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`font-heading text-base leading-none font-medium`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`dialog-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function S(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`dialog-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function C(e){let t=(0,p.c)(26),{offen:n,titel:r,text:a,knopf:o,gefahr:s,onJa:c,onNein:l,children:u}=e,d;t[0]===l?d=t[1]:(d=e=>!e&&l(),t[0]=l,t[1]=d);let f;t[2]===r?f=t[3]:(f=(0,m.jsx)(x,{className:`schild text-xl`,children:r}),t[2]=r,t[3]=f);let g;t[4]===a?g=t[5]:(g=(0,m.jsx)(S,{className:`text-[15px] leading-relaxed text-text-2`,children:a}),t[4]=a,t[5]=g);let _;t[6]!==f||t[7]!==g?(_=(0,m.jsxs)(y,{children:[f,g]}),t[6]=f,t[7]=g,t[8]=_):_=t[8];let C;t[9]===l?C=t[10]:(C=(0,m.jsx)(i,{variant:`outline`,onClick:l,children:`Abbrechen`}),t[9]=l,t[10]=C);let w=s?`gefahr`:`default`,T;t[11]!==o||t[12]!==c||t[13]!==w?(T=(0,m.jsx)(i,{variant:w,onClick:c,children:o}),t[11]=o,t[12]=c,t[13]=w,t[14]=T):T=t[14];let E;t[15]!==C||t[16]!==T?(E=(0,m.jsxs)(b,{className:`gap-2 sm:gap-2`,children:[C,T]}),t[15]=C,t[16]=T,t[17]=E):E=t[17];let D;t[18]!==u||t[19]!==_||t[20]!==E?(D=(0,m.jsxs)(v,{className:`border-linie bg-panel sm:max-w-lg`,children:[_,u,E]}),t[18]=u,t[19]=_,t[20]=E,t[21]=D):D=t[21];let O;return t[22]!==n||t[23]!==d||t[24]!==D?(O=(0,m.jsx)(h,{open:n,onOpenChange:d,children:D}),t[22]=n,t[23]=d,t[24]=D,t[25]=O):O=t[25],O}export{C as t};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{F as a,O as o,_ as s,k as c,u as l}from"./index-B6MevgIX.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=a(),_=l(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=s(v);async function C(e){x(null);try{let t=await o(`/api/maintenance/restart`,{service:e});t.ok?c(`erfolg`,`${e} startet neu.`):c(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){c(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste};
|
import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{A as a,L as o,f as s,j as c,y as l}from"./index-ZVRs0IGR.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=o(),_=s(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=l(v);async function C(e){x(null);try{let t=await a(`/api/maintenance/restart`,{service:e});t.ok?c(`erfolg`,`${e} startet neu.`):c(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){c(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste};
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{D as i,F as a,N as o,O as s,P as c,k as l}from"./index-B6MevgIX.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=a(),_=c({queryKey:[`geheimnisse`],queryFn:()=>i(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await s(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),l(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){l(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(o,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
|
import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{A as i,F as a,I as o,L as s,j as c,k as l}from"./index-ZVRs0IGR.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=s(),_=o({queryKey:[`geheimnisse`],queryFn:()=>l(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await i(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),c(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){c(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(a,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{c as e,s as t}from"./button-Dd8hfusv.js";import{M as n,N as r,j as i}from"./index-B6MevgIX.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue};
|
import{c as e,s as t}from"./button-Dd8hfusv.js";import{F as n,N as r,P as i}from"./index-ZVRs0IGR.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue};
|
||||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,9 +7,9 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>MC2 · Box-Wart</title>
|
<title>MC2 · Box-Wart</title>
|
||||||
<script type="module" crossorigin src="/assets/index-B6MevgIX.js"></script>
|
<script type="module" crossorigin src="/assets/index-ZVRs0IGR.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/button-Dd8hfusv.js">
|
<link rel="modulepreload" crossorigin href="/assets/button-Dd8hfusv.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D4C10235.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C3amg33Y.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -83,3 +83,20 @@ export function rolleVon(m: Modell): "hirn" | "coder" | null {
|
|||||||
if (a.includes("coder")) return "coder"
|
if (a.includes("coder")) return "coder"
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Modell-Radar ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Status eines Radar-Kandidaten in Worten fürs Cockpit. */
|
||||||
|
export const RADAR_STATUS_TEXT: Record<string, string> = {
|
||||||
|
neu: "Neu", wartet: "Wartet", getestet: "Gemessen", bestanden: "Bestanden",
|
||||||
|
durchgefallen: "Durchgefallen", uebernommen: "Übernommen", verworfen: "Verworfen",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messwert des Radar-Vergleichs lesbar machen (Zahlen deutsch, eine Nachkommastelle). */
|
||||||
|
export function messwert(wert: unknown, einheit = ""): string {
|
||||||
|
if (wert == null || wert === "") return "–"
|
||||||
|
const text = typeof wert === "number"
|
||||||
|
? wert.toLocaleString("de-DE", { maximumFractionDigits: wert < 10 ? 1 : 0 })
|
||||||
|
: String(wert)
|
||||||
|
return einheit ? `${text} ${einheit}` : text
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Search } from "lucide-react"
|
import { Search } from "lucide-react"
|
||||||
|
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||||
import { Panel } from "@/components/cockpit/Panel"
|
import { Panel } from "@/components/cockpit/Panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, useRadarSuche, useStart,
|
useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, useRadarSuche, useStart,
|
||||||
} from "@/lib/abfragen"
|
} from "@/lib/abfragen"
|
||||||
import { rolleVon } from "@/lib/anzeige"
|
import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
|
||||||
import { useLetzteMetrik } from "@/lib/strom"
|
import { useLetzteMetrik } from "@/lib/strom"
|
||||||
import type { Modell, Nutzung, RadarKandidat } from "@/lib/typen"
|
import type { Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
|
||||||
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
|
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
|
||||||
import { ModellSuche } from "./ModellSuche"
|
import { ModellSuche } from "./ModellSuche"
|
||||||
|
|
||||||
@@ -122,39 +123,94 @@ function NutzungsPanel({ nutzung }: { nutzung: Nutzung | undefined }) {
|
|||||||
const STATUS_ART: Record<string, ChipArt> = {
|
const STATUS_ART: Record<string, ChipArt> = {
|
||||||
bestanden: "gruen", durchgefallen: "rot", wartet: "cyan", neu: "cyan", getestet: "grau", uebernommen: "gruen", verworfen: "grau",
|
bestanden: "gruen", durchgefallen: "rot", wartet: "cyan", neu: "cyan", getestet: "grau", uebernommen: "gruen", verworfen: "grau",
|
||||||
}
|
}
|
||||||
|
const URTEIL_FARBE: Record<string, string> = { besser: "text-gruen", schlechter: "text-rot-text", gleich: "text-text-2" }
|
||||||
|
|
||||||
function Kandidat({ k }: { k: RadarKandidat }) {
|
type Vergleichszeile = { kandidat: unknown; baseline: unknown; einheit?: string; urteil?: string }
|
||||||
|
|
||||||
|
/** Die Messwerte hinter dem Urteil: Kandidat gegen das heutige Modell der Rolle. */
|
||||||
|
function Vergleich({ test }: { test: RadarTest }) {
|
||||||
|
const zeilen = Object.entries(test.vergleich ?? {}) as [string, Vergleichszeile][]
|
||||||
|
if (zeilen.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div role="table" aria-label="Messwerte gegen das heutige Modell" className="rounded-lg border border-linie bg-erhaben/50 px-3 py-2 text-sm">
|
||||||
|
<div role="row" className="schild grid grid-cols-[minmax(0,1fr)_auto_auto] gap-x-4 pb-1 text-xs text-text-3">
|
||||||
|
<span role="columnheader">Messwert</span>
|
||||||
|
<span role="columnheader" className="text-right">Kandidat</span>
|
||||||
|
<span role="columnheader" className="text-right">Heute</span>
|
||||||
|
</div>
|
||||||
|
{zeilen.map(([name, z]) => (
|
||||||
|
<div key={name} role="row" className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-x-4 border-t border-linie py-1">
|
||||||
|
<span role="rowheader" className="text-text-2">{name}</span>
|
||||||
|
<span role="cell" className={`ziffern text-right ${URTEIL_FARBE[z.urteil ?? ""] ?? "text-text-2"}`}>
|
||||||
|
{messwert(z.kandidat, z.einheit)}
|
||||||
|
</span>
|
||||||
|
<span role="cell" className="ziffern text-right text-text-3">{messwert(z.baseline, z.einheit)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | undefined; heute: string | undefined }) {
|
||||||
const aktion = useRadarAktion()
|
const aktion = useRadarAktion()
|
||||||
const offen = ["bestanden", "wartet", "neu"].includes(k.status)
|
const [frage, setFrage] = useState<null | "uebernehmen" | "verwerfen">(null)
|
||||||
|
const offen = ["bestanden", "wartet", "neu", "getestet"].includes(k.status)
|
||||||
|
const hirn = k.rolle === "hirn"
|
||||||
return (
|
return (
|
||||||
<li className="flex flex-col gap-2 border-t border-linie py-3.5">
|
<li className="flex flex-col gap-2 border-t border-linie py-3.5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="ziffern text-[15px] font-medium">{k.name}</span>
|
<span className="ziffern text-[15px] font-medium">{k.name}</span>
|
||||||
<Chip art="cyan">{k.rolle === "hirn" ? "fürs Hirn" : "fürs Coden"}</Chip>
|
<Chip art="cyan">{hirn ? "fürs Hirn" : "fürs Coden"}</Chip>
|
||||||
<Chip art={k.eng ? "bernstein" : k.passt ? "gruen" : "rot"}>{k.eng ? "passt knapp" : k.passt ? "passt" : "passt nicht"}</Chip>
|
<Chip art={k.eng ? "bernstein" : k.passt ? "gruen" : "rot"}>{k.eng ? "passt knapp" : k.passt ? "passt" : "passt nicht"}</Chip>
|
||||||
<Chip art={STATUS_ART[k.status] ?? "grau"}>{k.status}</Chip>
|
<Chip art={STATUS_ART[k.status] ?? "grau"}>{RADAR_STATUS_TEXT[k.status] ?? k.status}</Chip>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-2">
|
<p className="text-sm text-text-2">
|
||||||
{k.groesse_gb != null ? `${k.groesse_gb.toLocaleString("de-DE")} GB. ` : ""}
|
{k.groesse_gb != null ? `${k.groesse_gb.toLocaleString("de-DE")} GB. ` : ""}
|
||||||
{k.begruendung}
|
{k.begruendung}
|
||||||
</p>
|
</p>
|
||||||
|
{test && ["bestanden", "durchgefallen"].includes(k.status) && <Vergleich test={test} />}
|
||||||
{offen && (
|
{offen && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{k.status === "bestanden" && (
|
{k.status === "bestanden" && (
|
||||||
<Button size="sm" disabled={aktion.isPending} onClick={() => aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })}>
|
<Button size="sm" disabled={aktion.isPending} onClick={() => setFrage("uebernehmen")}>
|
||||||
Übernehmen
|
Übernehmen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" disabled={aktion.isPending} onClick={() => aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })}>
|
<Button variant="outline" size="sm" disabled={aktion.isPending} onClick={() => setFrage("verwerfen")}>
|
||||||
Verwerfen
|
Verwerfen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<Bestaetigen
|
||||||
|
offen={frage === "uebernehmen"}
|
||||||
|
titel={`${k.name} übernehmen?`}
|
||||||
|
text={hirn
|
||||||
|
? `${k.name} wird Lucys Hirn${heute ? ` und löst ${heute} ab` : ""}. NerdQuiz und OpenChamber nutzen ab dann ebenfalls das neue Modell. Hermes startet dafür kurz neu, Lucy ist etwa eine Minute nicht erreichbar. Das alte Modell bleibt auf der Platte.`
|
||||||
|
: `${k.name} wird der Coder${heute ? ` und löst ${heute} ab` : ""}. OpenChamber plant und baut ab dann damit. Das alte Modell bleibt auf der Platte.`}
|
||||||
|
knopf="Übernehmen"
|
||||||
|
onNein={() => setFrage(null)}
|
||||||
|
onJa={() => {
|
||||||
|
aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })
|
||||||
|
setFrage(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bestaetigen
|
||||||
|
offen={frage === "verwerfen"}
|
||||||
|
gefahr
|
||||||
|
titel={`${k.name} verwerfen?`}
|
||||||
|
text="Die heruntergeladenen Dateien werden gelöscht, und das Radar testet dieses Modell nicht noch einmal."
|
||||||
|
knopf="Verwerfen"
|
||||||
|
onNein={() => setFrage(null)}
|
||||||
|
onJa={() => {
|
||||||
|
aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })
|
||||||
|
setFrage(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RadarPanel() {
|
function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | undefined }) {
|
||||||
const radar = useRadar()
|
const radar = useRadar()
|
||||||
const suche = useRadarSuche()
|
const suche = useRadarSuche()
|
||||||
if (radar.isError) {
|
if (radar.isError) {
|
||||||
@@ -181,7 +237,14 @@ function RadarPanel() {
|
|||||||
<p className="text-[15px] text-text-2">Nichts Besseres in Sicht.</p>
|
<p className="text-[15px] text-text-2">Nichts Besseres in Sicht.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="m-0 flex list-none flex-col p-0">
|
<ul className="m-0 flex list-none flex-col p-0">
|
||||||
{d.kandidaten.map((k) => <Kandidat key={k.id} k={k} />)}
|
{d.kandidaten.map((k) => (
|
||||||
|
<Kandidat
|
||||||
|
key={k.id}
|
||||||
|
k={k}
|
||||||
|
test={d.tests.find((t) => t.kandidat === k.id)}
|
||||||
|
heute={k.rolle === "hirn" ? hirn : coder}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{d && d.tests.length > 0 && (
|
{d && d.tests.length > 0 && (
|
||||||
@@ -223,7 +286,7 @@ export function ModelleSeite() {
|
|||||||
</section>
|
</section>
|
||||||
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
|
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
|
||||||
<NutzungsPanel nutzung={nutzung.data} />
|
<NutzungsPanel nutzung={nutzung.data} />
|
||||||
<RadarPanel />
|
<RadarPanel hirn={hirn?.name} coder={coder?.name} />
|
||||||
</div>
|
</div>
|
||||||
<Panel
|
<Panel
|
||||||
titel="Weitere Einträge"
|
titel="Weitere Einträge"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Warnpanel } from "@/components/cockpit/Warnpanel"
|
|||||||
import { Zaehlwerk } from "@/components/cockpit/Zaehlwerk"
|
import { Zaehlwerk } from "@/components/cockpit/Zaehlwerk"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useRadar, useStart } from "@/lib/abfragen"
|
import { useRadar, useStart } from "@/lib/abfragen"
|
||||||
import { temperaturFarbe } from "@/lib/anzeige"
|
import { RADAR_STATUS_TEXT, temperaturFarbe } from "@/lib/anzeige"
|
||||||
import { useLetzteMetrik } from "@/lib/strom"
|
import { useLetzteMetrik } from "@/lib/strom"
|
||||||
import type { Radar, Start } from "@/lib/typen"
|
import type { Radar, Start } from "@/lib/typen"
|
||||||
import { gb, gbText } from "@/lib/zeit"
|
import { gb, gbText } from "@/lib/zeit"
|
||||||
@@ -74,7 +74,7 @@ function RadarKasten({ radar }: { radar: Radar | undefined }) {
|
|||||||
<span className="flex flex-wrap items-center gap-2 text-[15px]">
|
<span className="flex flex-wrap items-center gap-2 text-[15px]">
|
||||||
<span className="ziffern">{vorn.name}</span>
|
<span className="ziffern">{vorn.name}</span>
|
||||||
<span className="text-text-2">fürs {vorn.rolle === "hirn" ? "Hirn" : "Coden"}</span>
|
<span className="text-text-2">fürs {vorn.rolle === "hirn" ? "Hirn" : "Coden"}</span>
|
||||||
<Chip art={RADAR_ART[vorn.status as keyof typeof RADAR_ART] ?? "grau"}>{vorn.status}</Chip>
|
<Chip art={RADAR_ART[vorn.status as keyof typeof RADAR_ART] ?? "grau"}>{RADAR_STATUS_TEXT[vorn.status] ?? vorn.status}</Chip>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[15px] text-text-2">Nichts Besseres in Sicht.</span>
|
<span className="text-[15px] text-text-2">Nichts Besseres in Sicht.</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user