diff --git a/backend/radar_lauf.py b/backend/radar_lauf.py
index 3e91f64..1e91ca6 100644
--- a/backend/radar_lauf.py
+++ b/backend/radar_lauf.py
@@ -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())
diff --git a/backend/routers/radar.py b/backend/routers/radar.py
index c1d9c14..c5f6293 100644
--- a/backend/routers/radar.py
+++ b/backend/routers/radar.py
@@ -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))
diff --git a/backend/services/radar.py b/backend/services/radar.py
index 9fd6aad..dcf7fd5 100644
--- a/backend/services/radar.py
+++ b/backend/services/radar.py
@@ -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"):
diff --git a/backend/tests/test_radar.py b/backend/tests/test_radar.py
index 24d9c0f..ef1043f 100644
--- a/backend/tests/test_radar.py
+++ b/backend/tests/test_radar.py
@@ -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")
diff --git a/backend/tests/test_radar_suche_auftrag.py b/backend/tests/test_radar_suche_auftrag.py
index 2050ae9..74bebe6 100644
--- a/backend/tests/test_radar_suche_auftrag.py
+++ b/backend/tests/test_radar_suche_auftrag.py
@@ -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"]
diff --git a/docs/RADAR.md b/docs/RADAR.md
index ce81af6..542ebe0 100644
--- a/docs/RADAR.md
+++ b/docs/RADAR.md
@@ -21,6 +21,13 @@ Merkliste `deploy/radar-watchlist.json`. Zustand: `/srv/models/mc2-radar.json`,
- Zwei Rollen: Hirn (Alias `hermes`) und Coder (Alias `coder`), jede mit Bild-Zwilling (`vision` bzw. `coder-bild`).
- Tests nur nachts 00:30–02:30, höchstens ein neuer Kandidat pro Woche. Um 03:00 braucht der NerdQuiz-Nachtlauf das
Hirn.
+- **Jetzt testen** (seit 25.09.2026, User-Wunsch): Auf der Modelle-Seite hat jeder wartende Kandidat den Knopf, wenn er
+ samt heutigem Coder neben das Warm-Set passt (Bedarf + 29 GB Coder ≤ 88 GB, also ~59 GB) und es nicht zwischen 00:00
+ und 03:30 ist. Grund: Tagsüber kann jederzeit der Coder laden; der Test-Wächter stoppt den Kandidaten dann, aber
+ erst nach bis zu zehn Sekunden — so lange müssen alle drei in den Speicher passen. Sonst steht der Grund beim
+ Kandidaten, und er wartet auf die Nacht. Der Test läuft als Auftrag (`radar_lauf.py --test $m){s.length=o;break}f=new Promise(jp.bind(f)),s.push(f)}}}if(0
+
{k.testbar_grund}
+ )} {offen && (