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:
co-authored by
Claude Opus 5.5
parent
c31ef827fc
commit
44f33c31d3
+43
-1
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user