radar: „Jetzt testen“ – Kandidaten auf Knopfdruck prüfen, wenn der Speicher reicht

User 25.09.: „Warum kann ich Modell-Tests nicht selbst anstoßen, sondern muss
immer auf die Box warten?“ Nachts testet das Radar weiter selbst. Tagsüber jetzt
auch per Knopf – aber nur, wenn Kandidat, Warm-Set UND der heutige Coder
zusammen unter die 115-GB-Grenze passen (der Test-Wächter stoppt den Kandidaten
erst bis zu 10 s, nachdem der Coder zu laden beginnt), und nicht 00:00–03:30.
Sonst steht der Grund beim Kandidaten. Test als Auftrag (radar_lauf.py --test),
höchstens 2 h, dieselben Leitplanken wie nachts; jedes Ergebnis kommt als Meldung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 13:16:39 +02:00
co-authored by Claude Opus 5.5
parent c31ef827fc
commit 44f33c31d3
31 changed files with 278 additions and 36 deletions
+43 -1
View File
@@ -135,5 +135,47 @@ def nur_suche() -> int:
return 0 if any(quellen.values()) else 1
def melde_ergebnis(test: dict) -> None:
"""Nach „Jetzt testen“ kommt jedes Ergebnis als Meldung — der Commander hat den Test selbst angestoßen."""
if test.get("ergebnis") == "bestanden":
melde_bestanden(test)
return
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
text = f"Test von {k.get('name') or test.get('kandidat')}: {str(test.get('zusammenfassung') or test.get('ergebnis'))[:400]}"
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 zum Test ging nicht raus.", exc_info=True)
def test_auf_knopfdruck(kid: str) -> int:
"""„Jetzt testen“ (seit 25.09.2026, services/radar.test_starten): ein Kandidat, jetzt, mit eigener Frist von
TAGTEST_H Stunden. Dieselben Leitplanken wie nachts: nur Warm-Set neben dem Kandidaten, Test-Wächter, Notbremse.
Ob der Kandidat tagsüber überhaupt darf (Speicher mit Coder, Sperrzeit), hat test_starten geprüft; hier noch
einmal, falls zwischen Knopf und Start Zeit verging."""
k = radar.lade_stand()["kandidaten"].get(kid)
testbar, grund = radar.jetzt_testbar(k or {})
if not k or not testbar:
print(f"Kein Test: {grund or 'den Kandidaten gibt es nicht mehr'}.", flush=True)
return 1
frist = time.time() + radar.TAGTEST_H * 3600
signal.signal(signal.SIGTERM, _beim_beenden)
bremse = _notbremse(frist)
try:
test = radar.teste(kid, frist, auf_knopfdruck=True)
if test:
print(f"{k.get('name')}: {test.get('ergebnis')} – {test.get('zusammenfassung')}", flush=True)
melde_ergebnis(test)
return 0
finally:
bremse.cancel()
if __name__ == "__main__":
sys.exit(nur_suche() if "--nur-suche" in sys.argv[1:] else main())
argumente = sys.argv[1:]
if "--nur-suche" in argumente:
sys.exit(nur_suche())
if "--test" in argumente and argumente.index("--test") + 1 < len(argumente):
sys.exit(test_auf_knopfdruck(argumente[argumente.index("--test") + 1]))
sys.exit(main())
+9 -1
View File
@@ -5,8 +5,9 @@ Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026).
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung) — als Auftrag, antwortet sofort
POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
POST /api/radar/{id}/testen Jetzt testen (seit 25.09.2026; nur wenn er samt Coder in den Speicher passt)
Dünn: die Logik liegt in services/radar.py. Getestet wird nur nachts (backend/radar_lauf.py).
Dünn: die Logik liegt in services/radar.py. Getestet wird nachts (backend/radar_lauf.py) oder auf Knopfdruck.
"""
from fastapi import APIRouter, HTTPException
@@ -38,6 +39,13 @@ def radar_uebernehmen(kandidat_id: str) -> dict:
return _antwort(radar.uebernehmen(kandidat_id))
@router.post("/radar/{kandidat_id}/testen")
def radar_testen(kandidat_id: str) -> dict:
"""„Jetzt testen“ (seit 25.09.2026): nur, wenn der Kandidat samt Coder neben das Warm-Set passt und nicht in
der Nacht-Sperre — sonst 409 mit Grund. Antwortet sofort mit der Auftrags-ID."""
return _antwort(radar.test_starten(kandidat_id))
@router.post("/radar/{kandidat_id}/verwerfen")
def radar_verwerfen(kandidat_id: str) -> dict:
return _antwort(radar.verwerfen(kandidat_id))
+64 -6
View File
@@ -86,6 +86,10 @@ MIN_PARAMS_B = float(os.environ.get("MC_RADAR_MIN_PARAMS_B", "20"))
HIRN_MAX_AKTIV_B = float(os.environ.get("MC_RADAR_HIRN_MAX_AKTIV_B", "12")) # Verdikt: dichte Modelle nie als Hirn
ABGELEHNT_TAGE = 30
TESTS_MAX = 100
# „Jetzt testen“ (User 25.09.2026: „Warum kann ich Modell-Tests nicht selbst anstoßen?“): so lange darf ein Test auf
# Knopfdruck dauern, und in dieser Zeit ist er gesperrt (Nachtlauf des Radars, um 03:00 NerdQuiz).
TAGTEST_H = float(os.environ.get("MC_RADAR_TAGTEST_H", "2"))
KNOPF_GESPERRT = (os.environ.get("MC_RADAR_KNOPF_SPERRE_VON", "00:00"), os.environ.get("MC_RADAR_KNOPF_SPERRE_BIS", "03:30"))
STORE_PATH = Path(os.environ.get("MC_RADAR_STORE", str(MODELS_DIR / "mc2-radar.json")))
BASELINE_PATH = Path(os.environ.get("MC_RADAR_BASELINE", str(MODELS_DIR / "mc2-radar-baseline.json")))
@@ -1191,9 +1195,11 @@ def _draft_text(wahl: dict) -> str:
f" (13k: {'; '.join(tempi)})." if len(tempi) > 1 else ".")
def teste(kid: str, frist: float) -> dict | None:
def teste(kid: str, frist: float, auf_knopfdruck: bool = False) -> dict | None:
"""Einen Kandidaten prüfen: Download → Baseline → nur Warm-Set in llama-swap → Kandidat auf :5899 →
Urteil. Gibt den Test-Eintrag zurück (None, wenn nur gemessen wurde oder es den Kandidaten nicht gibt)."""
Urteil. Gibt den Test-Eintrag zurück (None, wenn nur gemessen wurde oder es den Kandidaten nicht gibt).
auf_knopfdruck: gestartet mit „Jetzt testen“ (test_starten) statt im Nachtlauf — nur der Text zum Zeitende
unterscheidet sich; die Leitplanken (Warm-Set, Test-Wächter, Frist) sind dieselben."""
ps = _pruefstand()
k = _lade()["kandidaten"].get(kid)
if not k or k.get("status") not in ("neu", "wartet"): # inzwischen verworfen o. Ä.
@@ -1226,7 +1232,8 @@ def teste(kid: str, frist: float) -> dict | None:
werte["baseline"] = {"modell": (basis.get("werte") or {}).get("modell"), "gemessen": _iso(basis.get("zeit"))}
ergebnis, satz = lauf["ergebnis"], lauf["zusammenfassung"] + _draft_text(wahl)
except ps.Zeitende:
satz = f"Das Nachtfenster endete um {FENSTER_ENDE}, bevor der Test fertig war."
satz = (f"Die {TAGTEST_H:g} Stunden für den Test auf Knopfdruck waren um, bevor er fertig war."
if auf_knopfdruck else f"Das Nachtfenster endete um {FENSTER_ENDE}, bevor der Test fertig war.")
except ps.ServerFehler as e:
if "unknown model architecture" in str(e).lower():
ergebnis, satz = "durchgefallen", f"Durchgefallen: läuft nicht im offiziellen llama.cpp ({str(e)[:160]})."
@@ -1446,8 +1453,59 @@ def _tausche_ein(k: dict) -> dict:
return {"modell_id": modell_id, "pfad": str(ziel), "alt": (alt or {}).get("name"), "hinweis": hinweis}
def _oeffentlich(k: dict) -> dict:
return {f: k.get(f) for f in KANDIDAT_FELDER}
def _oeffentlich(k: dict, zeit: datetime | None = None) -> dict:
testbar, grund = jetzt_testbar(k, zeit)
return {**{f: k.get(f) for f in KANDIDAT_FELDER}, "testbar_jetzt": testbar, "testbar_grund": grund}
def _in_knopf_sperre(zeit: datetime) -> bool:
von, bis = (uhrzeit(*(int(x) for x in t.split(":"))) for t in KNOPF_GESPERRT)
uhr = zeit.astimezone(LOCAL_TZ).time()
return von <= uhr < bis if von <= bis else (uhr >= von or uhr < bis)
def jetzt_testbar(k: dict, zeit: datetime | None = None) -> tuple[bool, str | None]:
"""Darf dieser Kandidat jetzt mit „Jetzt testen“ geprüft werden — und wenn nicht, warum?
Tagsüber kann jederzeit der Coder geladen werden (OpenChamber). Der Test-Wächter stoppt den Kandidaten dann zwar
(_Nachtwaechter), aber erst nach bis zu zehn Sekunden; so lange stünden Warm-Set, Kandidat UND Coder im Speicher.
Also nur, wenn alle drei zusammen unter die Speichergrenze passen. Größere testet das Radar nachts."""
if k.get("status") not in ("neu", "wartet"):
return False, None
bedarf = float(k.get("bedarf_gb") or 0)
frei = SPEICHER_GRENZE_GB - WARMSET_GB - CODER_HEUTE_GB
if not k.get("passt") or not bedarf:
return False, "Ob er in den Speicher passt, ist nicht bekannt — das Radar testet ihn nachts."
if bedarf > frei:
return False, (f"Zu groß für einen Test tagsüber: braucht ~{_gb(bedarf)} GB, neben Warm-Set und Coder sind "
f"{_gb(frei)} GB frei. Das Radar testet ihn nachts.")
if _in_knopf_sperre(zeit or jetzt()):
return False, (f"Zwischen {KNOPF_GESPERRT[0]} und {KNOPF_GESPERRT[1]} testet das Radar selbst, und um 03:00 "
"braucht NerdQuiz das Hirn — danach geht es auf Knopfdruck.")
return True, None
def test_starten(kid: str, zeit: datetime | None = None) -> dict:
"""„Jetzt testen“: den Test als Auftrag starten (Gruppe „radar“, höchstens einer zugleich; radar_lauf.py --test).
Antwortet sofort; den Stand zeigen Auftragskarte und Kandidat („Läuft gerade seit …“)."""
stand = _lade()
k = stand["kandidaten"].get(kid)
if not k:
return {"ok": False, "status": 404, "detail": "Diesen Kandidaten gibt es nicht."}
testbar, grund = jetzt_testbar(k, zeit)
if not testbar:
return {"ok": False, "status": 409, "detail": grund or f"{k.get('name')} lässt sich nicht testen "
f"(Status: {k.get('status')})."}
if _aktiver_lauf(stand):
return {"ok": False, "status": 409, "detail": "Es läuft schon ein Test des Radars."}
job_id, laeuft = jobengine.start_job_exklusiv(
"radar", [sys.executable, str(_LAUF_SKRIPT), "--test", kid], f"Modell-Radar: Test von {k.get('name')}",
zeitlimit_s=int(TAGTEST_H * 3600) + 900)
if job_id is None:
return {"ok": False, "status": 409,
"detail": f"Das Radar ist gerade beschäftigt: {(laeuft or {}).get('label') or 'ein Auftrag'}."}
return {"ok": True, "job_id": job_id,
"text": f"Der Test von {k.get('name')} läuft. Er dauert je nach Download etwa eine halbe bis zwei Stunden; "
"das Ergebnis kommt als Meldung."}
def uebernehmen(kid: str) -> dict:
@@ -1523,7 +1581,7 @@ def uebersicht(zeit: datetime | None = None) -> dict:
kandidaten = sorted(stand["kandidaten"].values(),
key=lambda k: (_REIHENFOLGE.get(k.get("status"), 9), 0 if k.get("quelle") == "watchlist" else 1,
k.get("rang", 999)))
oeffentlich = [_oeffentlich(k) for k in kandidaten]
oeffentlich = [_oeffentlich(k, zeit) for k in kandidaten]
if (lauf := _aktiver_lauf(stand)): # den laufenden Test in seiner Begründung zeigen (nicht gespeichert)
for k in oeffentlich:
if k["id"] == lauf.get("kandidat"):
+2 -1
View File
@@ -336,7 +336,8 @@ def test_uebersicht_hat_die_felder_der_oberflaeche(zustand):
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)
# Dazu seit 25.09.2026, ob „Jetzt testen“ geht und warum nicht.
assert set(u["kandidaten"][0]) == set(radar.KANDIDAT_FELDER) | {"testbar_jetzt", "testbar_grund"}
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")
+52
View File
@@ -2,6 +2,7 @@
in radar_lauf.py --nur-suche. Vorher lief die Suche synchron in der Anfrage und konnte Minuten dauern."""
import sys
from datetime import datetime
import pytest
import radar_lauf
@@ -75,3 +76,54 @@ def test_nur_suche_bei_absturz(monkeypatch, capsys):
monkeypatch.setattr(radar_lauf.radar, "suche", kaputt)
assert radar_lauf.nur_suche() == 1
assert capsys.readouterr().out.strip().endswith("Die Suche ist gescheitert: OSError: Platte voll")
# --- „Jetzt testen“ (seit 25.09.2026) ------------------------------------------------------------------------
@pytest.fixture
def radarstand(monkeypatch, tmp_path):
monkeypatch.setattr(radar, "STORE_PATH", tmp_path / "mc2-radar.json")
monkeypatch.setattr(radar, "RADAR_DIR", tmp_path / "radar")
(tmp_path / "radar").mkdir()
stand = radar._leer()
# Zahlen vom 25.09.: Ornith (~27 GB mit KV-Cache) passt tagsüber, Flash-Next (84,6 GB) nur nachts.
stand["kandidaten"]["ornith"] = {"id": "ornith", "name": "Ornith-1.5-35B-A3B", "rolle": "hirn", "status": "neu",
"passt": True, "eng": False, "bedarf_gb": 27.4, "groesse_gb": 22.6}
stand["kandidaten"]["flash"] = {"id": "flash", "name": "Qwen3.8-Flash-Next", "rolle": "coder", "status": "wartet",
"passt": True, "eng": True, "bedarf_gb": 84.6, "groesse_gb": 82.9}
stand["kandidaten"]["alt"] = {"id": "alt", "name": "Alt", "rolle": "hirn", "status": "durchgefallen",
"passt": True, "bedarf_gb": 20.0}
radar._speichere(stand)
return stand
MITTAG = datetime(2026, 9, 25, 13, 0, tzinfo=radar.LOCAL_TZ)
NACHT = datetime(2026, 9, 26, 1, 15, tzinfo=radar.LOCAL_TZ)
def test_jetzt_testbar_nur_wenn_auch_der_coder_noch_passt(radarstand):
k = radarstand["kandidaten"]
assert radar.jetzt_testbar(k["ornith"], MITTAG) == (True, None)
testbar, grund = radar.jetzt_testbar(k["flash"], MITTAG)
assert not testbar and grund.startswith("Zu groß für einen Test tagsüber: braucht ~84,6 GB")
assert radar.jetzt_testbar(k["alt"], MITTAG) == (False, None) # schon beurteilt
testbar, grund = radar.jetzt_testbar(k["ornith"], NACHT)
assert not testbar and "testet das Radar selbst" in grund
oeffentlich = {x["id"]: x for x in radar.uebersicht(MITTAG)["kandidaten"]}
assert oeffentlich["ornith"]["testbar_jetzt"] is True and oeffentlich["flash"]["testbar_jetzt"] is False
def test_jetzt_testen_startet_einen_auftrag(radarstand, auftraege, monkeypatch):
monkeypatch.setattr(radar, "jetzt", lambda: MITTAG)
ergebnis = radar.test_starten("ornith")
assert ergebnis["ok"] and ergebnis["job_id"] == "job123"
start = auftraege["gestartet"][0]
assert start["gruppe"] == "radar" and start["args"][1:] == [str(radar._LAUF_SKRIPT), "--test", "ornith"]
assert start["label"] == "Modell-Radar: Test von Ornith-1.5-35B-A3B"
zu_gross = radar.test_starten("flash")
assert zu_gross["ok"] is False and zu_gross["status"] == 409 and "Zu groß" in zu_gross["detail"]
assert radar.test_starten("gibt-es-nicht")["status"] == 404
app = FastAPI()
app.include_router(radar_router.router)
antwort = TestClient(app).post("/api/radar/flash/testen")
assert antwort.status_code == 409 and "Zu groß" in antwort.json()["detail"]