box-wart: Hirn gezielt pruefen, Laden meldet das echte Ergebnis, Radar-Suche als Auftrag

- Waechter (pruefe_kern): prueft jetzt gezielt das Modell mit der Rolle hermes und seinen
  Zustand in llama-swap (/running, v257 listet auch ladende Modelle), statt das Hirn fuer
  bereit zu halten, sobald irgendein Modell laeuft. Ein Ladevorgang ist kein Ausfall, aber
  nur bis 10 Minuten (MC_WAECHTER_HIRN_LADEN_S), sonst bliebe ein immer neu startendes Hirn
  unbemerkt. Sonst die ueblichen Takte (FAIL_AFTER); Knopf "Protokoll des Motors".
- llamaswap: modell_zustaende() und hirn_zustand(); lade_modell() meldet ok false mit
  deutschem Grund und der Meldung der Engine, "laedt noch" nach der Wartezeit ist kein Fehler,
  ein geladenes Modell ohne Chat (embed) zaehlt als geladen. POST /api/models/{id}/load
  reicht das Ergebnis durch (Router duenn).
- Radar "Jetzt suchen": startet die Suche als Auftrag (Job-Engine, Gruppe radar, hoechstens
  einer, radar_lauf.py --nur-suche) und antwortet sofort mit der Auftrags-ID. Die Ansicht
  zeigt "Sucht ..." bis der Auftrag fertig ist, liest danach das Radar neu und nennt eine
  gescheiterte Suche; die Auftragskarte zeigt den Stand.
- Tests: Hirn-Zustaende und Frist, Laden gegen eine llama-swap-Attrappe, Radar-Auftrag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 20:39:01 +02:00
co-authored by Claude Opus 5.5
parent 40d2840b16
commit 93f6613146
11 changed files with 462 additions and 35 deletions
+28 -1
View File
@@ -12,6 +12,9 @@ Nachtlauf das Hirn braucht. Ablauf:
Die Messungen prüfen die Frist selbst. Hängt trotzdem etwas, zieht fünf Minuten nach der Frist die 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 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. (RuntimeMaxSec) ist die letzte Sicherung. Ist nichts fällig, endet der Lauf sofort mit Code 0.
Mit --nur-suche (seit 24.09.2026) nur Schritt 1, jederzeit: So startet der Knopf „Jetzt suchen“ die Suche als
Auftrag (services/radar.suche_starten). Getestet wird dabei nichts.
""" """
import logging import logging
@@ -108,5 +111,29 @@ def main() -> int:
bremse.cancel() bremse.cancel()
def nur_suche() -> int:
"""„Jetzt suchen“ aus der Oberfläche: nur die Suche, kein Test. Die letzte Zeile sagt das Ergebnis in Worten —
die Auftragskarte zeigt sie. Code 1, wenn die Suche scheitert oder keine Quelle erreichbar war."""
# Das Protokoll des Auftrags zeigt die Oberfläche: ohne eine Zeile je Hugging-Face-Abruf (samt signierter
# Download-Adressen), nur die Schritte des Radars und das Ergebnis.
logging.getLogger("httpx").setLevel(logging.WARNING)
try:
ergebnis = radar.suche()
except Exception as exc:
log.exception("Radar: Suche fehlgeschlagen")
print(f"Die Suche ist gescheitert: {exc.__class__.__name__}: {exc}", flush=True)
return 1
neu = ergebnis.get("neu") or []
quellen = ergebnis.get("quellen") or {}
teile = [f"{len(neu)} neu ({', '.join(neu)})" if neu else "nichts Neues",
f"{ergebnis.get('wartend', 0)} warten auf den Nachttest"]
if not quellen.get("discover", True):
teile.append("Hugging Face war nicht erreichbar")
if not quellen.get("watchlist", True):
teile.append("die Merkliste war nicht lesbar")
print(f"Suche fertig: {', '.join(teile)}.", flush=True)
return 0 if any(quellen.values()) else 1
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(nur_suche() if "--nur-suche" in sys.argv[1:] else main())
+2 -15
View File
@@ -209,21 +209,8 @@ def unload_model(model_id: str) -> dict:
@router.post("/models/{model_id}/load") @router.post("/models/{model_id}/load")
def load_model(model_id: str) -> dict: def load_model(model_id: str) -> dict:
import httpx """Lädt ein Modell. Kommt es nicht zustande, steht ok: false mit dem Grund in `detail` (services/llamaswap)."""
from config import LLAMA_SWAP_URL return llamaswap.lade_modell(model_id)
try:
# Trigger load by sending a lightweight completion request.
body = {
"model": model_id,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1
}
# High timeout because model loading might take time
with httpx.Client(timeout=60.0) as c:
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=body)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, str(exc))
@router.delete("/models/{model_id}") @router.delete("/models/{model_id}")
+3 -4
View File
@@ -2,7 +2,7 @@
Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026). Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026).
GET /api/radar Nächster und letzter Test, Kandidaten, Test-Verlauf GET /api/radar Nächster und letzter Test, Kandidaten, Test-Verlauf
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung) 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}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen) POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
@@ -29,9 +29,8 @@ def radar_stand() -> dict:
@router.post("/radar/suche") @router.post("/radar/suche")
def radar_suche() -> dict: def radar_suche() -> dict:
"""Sucht sofort (Netz: Hugging Face) und liefert danach den neuen Stand.""" """Startet die Suche als Auftrag (sie kann Minuten dauern) und antwortet sofort mit der Auftrags-ID."""
ergebnis = radar.suche() return radar.suche_starten()
return {**radar.uebersicht(), "suche": ergebnis}
@router.post("/radar/{kandidat_id}/uebernehmen") @router.post("/radar/{kandidat_id}/uebernehmen")
+101
View File
@@ -659,3 +659,104 @@ def get_running_models() -> list[str]:
except Exception: except Exception:
log.warning("get_running_models fehlgeschlagen", exc_info=True) log.warning("get_running_models fehlgeschlagen", exc_info=True)
return [] return []
def modell_zustaende() -> dict[str, str] | None:
"""Zustand je Modell aus /running: Name → "ready" | "starting" | "stopping". Wer fehlt, ist nicht geladen.
llama-swap v257 listet dort jedes Modell, das weder „stopped“ noch „shutdown“ ist (internal/router/base.go,
RunningModels) — also auch eines, das gerade lädt. Ältere Fassungen lieferten nur Namen; die gelten als bereit.
None = /running nicht lesbar."""
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{LLAMA_SWAP_URL}/running")
if r.status_code != 200:
return None
eintraege = r.json().get("running") or []
except Exception:
log.warning("modell_zustaende: /running nicht lesbar", exc_info=True)
return None
zustaende: dict[str, str] = {}
for x in eintraege:
if isinstance(x, dict):
if x.get("model"):
zustaende[str(x["model"])] = str(x.get("state") or "ready")
elif x:
zustaende[str(x)] = "ready"
return zustaende
def hirn_zustand() -> dict:
"""Wie steht Lucys Hirn (Modell mit dem Alias/der Rolle „hermes“) in llama-swap? Gezielt dieses eine Modell —
bis 24.09.2026 galt das Hirn als bereit, sobald IRGENDEIN Modell lief (ein abgestürztes Hirn neben einem
geladenen Coder blieb unbemerkt).
Rückgabe {"modell": Name oder None, "zustand": "bereit" | "laedt" | "fehlt" | "unbekannt"};
„unbekannt“ heißt: /running antwortet nicht. Trägt kein Modell die Rolle, ist modell None und zustand "fehlt"."""
name = brain_model_name()
zustaende = modell_zustaende()
if zustaende is None:
return {"modell": name, "zustand": "unbekannt"}
zustand = zustaende.get(name or "")
if zustand == "ready":
return {"modell": name, "zustand": "bereit"}
if zustand == "starting":
return {"modell": name, "zustand": "laedt"}
return {"modell": name, "zustand": "fehlt"}
# Wie lange „Jetzt laden“ auf die Engine wartet. Große Modelle brauchen eine Weile (Lesen + Hochladen in den Speicher);
# was danach noch lädt, meldet lade_modell als „lädt noch“ statt als Fehler.
LADEN_WARTE_S = float(os.environ.get("MC_LADEN_WARTE_S", "90"))
def _engine_grund(r: httpx.Response) -> str:
"""Die Fehlermeldung der Engine aus ihrer Antwort (OpenAI-Format {"error": {"message"}}, {"error": "…"},
{"detail": "…"} oder reiner Text), auf eine Zeile gekürzt."""
text = ""
try:
daten = r.json()
except ValueError:
daten = None
if isinstance(daten, dict):
fehler = daten.get("error")
if isinstance(fehler, dict):
text = str(fehler.get("message") or "")
elif fehler:
text = str(fehler)
elif daten.get("detail"):
text = str(daten["detail"])
text = " ".join((text or r.text or "").split())
return text[:200] or "ohne Begründung"
def lade_modell(model_id: str, warte_s: float | None = None) -> dict:
"""Ein Modell laden und das ECHTE Ergebnis melden (seit 24.09.2026 — vorher hieß jede Antwort der Engine „ok“).
llama-swap lädt ein Modell mit der ersten Anfrage; dafür reicht eine Mini-Anfrage mit einem Token. Ob das Modell
danach wirklich geladen ist, entscheidet /running — so zählen auch Modelle, die gar nicht chatten (embed,
reranker), und ein Fehler der Anfrage selbst macht ein geladenes Modell nicht zum Fehlschlag.
Rückgabe: {"ok": True, "text": …} (bei "laedt": True lädt es nach der Wartezeit noch) oder
{"ok": False, "detail": deutscher Grund, mit der Meldung der Engine}."""
warte = LADEN_WARTE_S if warte_s is None else warte_s
anfrage = {"model": model_id, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
try:
with httpx.Client(timeout=warte) as c:
r = c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=anfrage)
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: Die Engine (llama-swap) ist nicht "
f"erreichbar ({exc.__class__.__name__})."}
except httpx.TimeoutException:
if (modell_zustaende() or {}).get(model_id) == "starting":
return {"ok": True, "laedt": True,
"text": f"{model_id} lädt noch. Große Modelle brauchen etwas; der Stand erscheint gleich unter Modelle."}
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: Die Engine hat nach {warte:.0f} Sekunden "
"nicht geantwortet, und das Modell lädt auch nicht."}
except httpx.HTTPError as exc:
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: {exc.__class__.__name__}: {exc}"[:300]}
if r.status_code == 200:
return {"ok": True, "text": f"{model_id} ist geladen."}
zustand = (modell_zustaende() or {}).get(model_id)
if zustand == "ready":
return {"ok": True, "text": f"{model_id} ist geladen."}
if zustand == "starting":
return {"ok": True, "laedt": True, "text": f"{model_id} lädt noch."}
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden. Die Engine meldet (HTTP {r.status_code}): "
f"{_engine_grund(r)}"}
+21 -1
View File
@@ -52,7 +52,7 @@ from config import HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR
from kern.zeit import LOCAL_TZ from kern.zeit import LOCAL_TZ
from ruamel.yaml.scalarstring import LiteralScalarString from ruamel.yaml.scalarstring import LiteralScalarString
from services import discover, geheimnisse, hf, llamaswap from services import discover, geheimnisse, hf, jobengine, llamaswap
from services.fit import extract_active_params_b, extract_params_b from services.fit import extract_active_params_b, extract_params_b
try: try:
@@ -795,6 +795,26 @@ def suche() -> dict:
return ergebnis return ergebnis
SUCHE_ZEITLIMIT_S = 20 * 60
_LAUF_SKRIPT = Path(__file__).resolve().parents[1] / "radar_lauf.py"
def suche_starten() -> dict:
"""„Jetzt suchen“ als Auftrag (seit 24.09.2026): Die Suche fragt Hugging Face je Fund ab und kann Minuten dauern —
synchron hielt sie die Anfrage der Oberfläche so lange fest. Jetzt läuft sie in der Job-Engine (Gruppe „radar“,
höchstens eine zugleich, radar_lauf.py --nur-suche); die Antwort kommt sofort mit der Auftrags-ID, den Stand
zeigt die Auftragskarte. Läuft schon eine Suche, kommt deren ID zurück."""
job_id, laeuft = jobengine.start_job_exklusiv(
"radar", [sys.executable, str(_LAUF_SKRIPT), "--nur-suche"], "Modell-Radar: Suche nach neuen Modellen",
zeitlimit_s=SUCHE_ZEITLIMIT_S)
if job_id is None:
return {"ok": True, "job_id": (laeuft or {}).get("id"), "laeuft_schon": True,
"text": "Die Suche läuft schon. Den Stand zeigt der Auftrag unter „Aufträge“."}
return {"ok": True, "job_id": job_id,
"text": "Das Radar sucht jetzt nach neuen Modellen. Das dauert ein paar Minuten; den Stand zeigt der "
"Auftrag unter „Aufträge“."}
# --- Test --------------------------------------------------------------------------------- # --- Test ---------------------------------------------------------------------------------
_PS = None _PS = None
+43 -7
View File
@@ -386,17 +386,52 @@ def _hermes_kopf() -> dict[str, str]:
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {} return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
# Lucys Hirn gezielt (seit 24.09.2026): Ein Ladevorgang ist kein Ausfall — aber nur bis zu dieser Frist. llama-swap
# bricht einen Start nach healthCheckTimeout (300 s) selbst ab; wer danach immer noch „lädt“, hängt oder startet
# immer wieder neu (dann wechseln sich „lädt“ und „fehlt“ ab, und ohne Frist käme nie ein Hinweis zustande).
HIRN_LADEN_MAX_S = int(os.environ.get("MC_WAECHTER_HIRN_LADEN_S", "600"))
_hirn: dict[str, float | None] = {"fehlt_seit": None, "geprueft": None}
def _hirn_befund(jetzt: float | None = None) -> Befund | None:
"""Ist Lucys Hirn (Rolle hermes) geladen? Bis 24.09.2026 galt es als bereit, sobald irgendein Modell lief —
ein abgestürztes Hirn neben einem geladenen Coder blieb so unbemerkt. Lädt es gerade, ist das kein Befund;
alles andere zählt wie jeder Befund erst nach FAIL_AFTER Takten."""
jetzt = time.monotonic() if jetzt is None else jetzt
zuletzt, _hirn["geprueft"] = _hirn["geprueft"], jetzt
if zuletzt is None or jetzt - zuletzt > 5 * 60:
_hirn["fehlt_seit"] = None # lange nicht geprüft (Update, Motor weg): die Frist beginnt neu
st = llamaswap.hirn_zustand()
name, zustand = st.get("modell"), st.get("zustand")
if zustand == "bereit":
_hirn["fehlt_seit"] = None
return None
if _hirn["fehlt_seit"] is None:
_hirn["fehlt_seit"] = jetzt
dauer = jetzt - _hirn["fehlt_seit"]
if zustand == "laedt" and dauer < HIRN_LADEN_MAX_S:
return None
if not name:
text = "Kein Modell trägt die Rolle „hermes“. Ohne sie hat Lucy kein Hirn; die Rolle vergibt die Modelle-Seite."
elif zustand == "laedt":
text = (f"{name} lädt seit über {int(dauer // 60)} Minuten und wird nicht fertig. Vermutlich startet es immer "
"wieder neu; das Protokoll des Motors sagt, warum.")
elif zustand == "unbekannt":
text = "Der Motor sagt nicht, welche Modelle laufen (llama-swap /running antwortet nicht)."
else:
text = f"{name} läuft nicht. Der Re-Warm-Wächter lädt es nach; das Protokoll des Motors sagt, warum es fehlt."
return Befund(id="kern:hirn", stufe="rot",
titel="Lucys Hirn lädt nicht fertig" if zustand == "laedt" else "Lucys Hirn ist nicht geladen",
text=text, quelle="hirn", aktionen=[_aktion("protokoll", "Protokoll des Motors", dienst="llama-swap")])
def pruefe_kern() -> list[Befund]: def pruefe_kern() -> list[Befund]:
"""HTTP-Proben: läuft der Dienst UND antwortet er? (Der Dienst-Check sieht nur systemd.)""" """HTTP-Proben: läuft der Dienst UND antwortet er? (Der Dienst-Check sieht nur systemd.)"""
proben: list[tuple[str, str, str, bool]] = [] # (id, Titel, Text, ok) proben: list[tuple[str, str, str, bool]] = [] # (id, Titel, Text, ok)
engine_ok = llamaswap.engine_reachable() engine_ok = llamaswap.engine_reachable()
proben.append(("kern:engine", "Der Motor antwortet nicht", proben.append(("kern:engine", "Der Motor antwortet nicht",
"llama-swap reagiert nicht. Ohne ihn laufen keine Modelle.", engine_ok)) "llama-swap reagiert nicht. Ohne ihn laufen keine Modelle.", engine_ok))
if engine_ok: hirn = _hirn_befund() if engine_ok else None
st = llamaswap.brain_status()
hirn_ok = bool(st.get("ready")) or bool(llamaswap.get_running_models())
proben.append(("kern:hirn", "Lucys Hirn ist nicht geladen",
"Das Hirn-Modell lädt nicht. Der Re-Warm-Wächter versucht es weiter.", hirn_ok))
proben.append(("kern:hermes", "Hermes antwortet nicht", proben.append(("kern:hermes", "Hermes antwortet nicht",
"Der Agent-Dienst reagiert nicht. Telegram und Lucys Werkzeuge gehen gerade nicht.", "Der Agent-Dienst reagiert nicht. Telegram und Lucys Werkzeuge gehen gerade nicht.",
_reach(HERMES_API_URL, "/v1/models", _hermes_kopf()))) _reach(HERMES_API_URL, "/v1/models", _hermes_kopf())))
@@ -409,8 +444,9 @@ def pruefe_kern() -> list[Befund]:
"Die Oberfläche und Lucys Sprach-Schnittstellen hängen.", _reach(MC2_URL, "/api/health"))) "Die Oberfläche und Lucys Sprach-Schnittstellen hängen.", _reach(MC2_URL, "/api/health")))
proben.append(("kern:gateway", "Der Modell-Gateway antwortet nicht", proben.append(("kern:gateway", "Der Modell-Gateway antwortet nicht",
"Anfragen von Lucy und OpenChamber an die Modelle hängen.", _reach(GATEWAY_URL, "/gw/health"))) "Anfragen von Lucy und OpenChamber an die Modelle hängen.", _reach(GATEWAY_URL, "/gw/health")))
return [Befund(id=i, stufe="rot", titel=t, text=x, quelle=i.split(":", 1)[1]) befunde = [Befund(id=i, stufe="rot", titel=t, text=x, quelle=i.split(":", 1)[1])
for (i, t, x, ok) in proben if not ok] for (i, t, x, ok) in proben if not ok]
return befunde + ([hirn] if hirn else [])
def pruefe_platte() -> list[Befund]: def pruefe_platte() -> list[Befund]:
+156
View File
@@ -0,0 +1,156 @@
"""Lucys Hirn gezielt prüfen (Wächter, 24.09.2026) und „Jetzt laden“ mit echtem Ergebnis.
Bis 24.09. galt das Hirn als bereit, sobald irgendein Modell lief, und POST /api/models/{id}/load meldete „ok“,
egal was die Engine sagte. llama-swap wird hier durch httpx.MockTransport ersetzt (Format von /running wie in
llama-swap v257: jedes Modell, das nicht „stopped“ ist, mit seinem Zustand)."""
import httpx
import pytest
from services import llamaswap, waechter
_ECHTER_CLIENT = httpx.Client
def _engine(monkeypatch, handler) -> None:
"""llama-swap durch eine Attrappe ersetzen (alle httpx.Client in services.llamaswap)."""
monkeypatch.setattr(llamaswap.httpx, "Client", lambda *a, **kw: _ECHTER_CLIENT(
transport=httpx.MockTransport(handler), timeout=kw.get("timeout")))
def _running(*eintraege) -> httpx.Response:
return httpx.Response(200, json={"running": list(eintraege)})
# --- Zustände aus /running ------------------------------------------------------------------------
def test_modell_zustaende_liest_objekte_und_alte_namenslisten(monkeypatch):
_engine(monkeypatch, lambda req: _running({"model": "Hirn", "state": "starting"},
{"model": "Coder", "state": "ready"}, "Alt"))
assert llamaswap.modell_zustaende() == {"Hirn": "starting", "Coder": "ready", "Alt": "ready"}
_engine(monkeypatch, lambda req: httpx.Response(500))
assert llamaswap.modell_zustaende() is None
@pytest.mark.parametrize("running, zustand", [
([{"model": "Hirn", "state": "ready"}], "bereit"),
([{"model": "Hirn", "state": "starting"}], "laedt"),
([{"model": "Hirn", "state": "stopping"}], "fehlt"),
([{"model": "Coder", "state": "ready"}], "fehlt"), # der alte Fehler: Coder läuft, Hirn ist weg
(None, "unbekannt"),
])
def test_hirn_zustand_prueft_gezielt_das_hirn(monkeypatch, running, zustand):
monkeypatch.setattr(llamaswap, "brain_model_name", lambda: "Hirn")
monkeypatch.setattr(llamaswap, "modell_zustaende",
lambda: None if running is None else {e["model"]: e["state"] for e in running})
assert llamaswap.hirn_zustand() == {"modell": "Hirn", "zustand": zustand}
# --- Wächter ------------------------------------------------------------------------------------------
@pytest.fixture
def hirn(monkeypatch):
"""Der Wächter sieht den Zustand, den der Test vorgibt; seine Frist beginnt frisch."""
zustand = {"modell": "Qwen3.6-35B-A3B", "zustand": "bereit"}
monkeypatch.setattr(waechter, "_hirn", {"fehlt_seit": None, "geprueft": None})
monkeypatch.setattr(waechter.llamaswap, "hirn_zustand", lambda: dict(zustand))
return zustand
def test_abgestuerztes_hirn_neben_geladenem_coder_ist_ein_befund(monkeypatch, hirn):
monkeypatch.setattr(waechter.llamaswap, "engine_reachable", lambda: True)
monkeypatch.setattr(waechter.llamaswap, "get_running_models", lambda: ["Qwen3.8-27B"]) # Coder läuft
monkeypatch.setattr(waechter, "_reach", lambda url, pfad, kopf=None: True)
monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"ActiveState": "active"})
hirn["zustand"] = "fehlt"
befunde = {b.id: b for b in waechter.pruefe_kern()}
assert befunde["kern:hirn"].titel == "Lucys Hirn ist nicht geladen"
assert "Qwen3.6-35B-A3B läuft nicht" in befunde["kern:hirn"].text
assert befunde["kern:hirn"].aktionen == [{"id": "protokoll", "label": "Protokoll des Motors", "dienst": "llama-swap"}]
assert not befunde["kern:hirn"].sofort # zählt erst nach den üblichen Takten
def test_laden_ist_kein_ausfall_bis_zur_frist(monkeypatch, hirn):
monkeypatch.setattr(waechter, "HIRN_LADEN_MAX_S", 600)
hirn["zustand"] = "laedt"
assert waechter._hirn_befund(jetzt=1000.0) is None
assert waechter._hirn_befund(jetzt=1060.0) is None
# Startet es immer wieder neu, wechseln sich „fehlt“ und „lädt“ ab: „fehlt“ ist ein Befund, und nach der Frist
# ist es auch „lädt“ — sonst käme nie ein Hinweis zustande.
hirn["zustand"] = "fehlt"
assert waechter._hirn_befund(jetzt=1120.0).titel == "Lucys Hirn ist nicht geladen"
hirn["zustand"] = "laedt"
for jetzt in range(1180, 1600, 60): # Takt wie im Betrieb: jede Minute
assert waechter._hirn_befund(jetzt=float(jetzt)) is None
b = waechter._hirn_befund(jetzt=1600.0)
assert b.titel == "Lucys Hirn lädt nicht fertig" and "seit über 10 Minuten" in b.text
hirn["zustand"] = "bereit"
assert waechter._hirn_befund(jetzt=1660.0) is None
hirn["zustand"] = "laedt" # nach „bereit“ beginnt die Frist neu
assert waechter._hirn_befund(jetzt=1720.0) is None
def test_lange_pause_setzt_die_frist_zurueck(hirn):
"""Während eines Updates prüft der Wächter den Kern nicht. Danach darf das Hirn wieder in Ruhe laden."""
hirn["zustand"] = "laedt"
assert waechter._hirn_befund(jetzt=0.0) is None
assert waechter._hirn_befund(jetzt=5000.0) is None
def test_ohne_hirn_rolle_ist_es_ein_befund(hirn):
hirn.update(modell=None, zustand="fehlt")
assert "Kein Modell trägt die Rolle „hermes“" in waechter._hirn_befund(jetzt=0.0).text
# --- Jetzt laden -----------------------------------------------------------------------------------------
def _laden(monkeypatch, antwort, running=()) -> dict:
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path == "/running":
return _running(*running)
if isinstance(antwort, Exception):
raise antwort
return antwort
_engine(monkeypatch, handler)
return llamaswap.lade_modell("Qwen3.8-27B", warte_s=5)
def test_laden_meldet_den_fehler_der_engine(monkeypatch):
ergebnis = _laden(monkeypatch, httpx.Response(502, text="upstream command exited prematurely but successfully"))
assert ergebnis["ok"] is False
assert ergebnis["detail"] == ("Qwen3.8-27B ließ sich nicht laden. Die Engine meldet (HTTP 502): "
"upstream command exited prematurely but successfully")
ergebnis = _laden(monkeypatch, httpx.Response(404, json={"error": {"message": "model not found"}}))
assert ergebnis["ok"] is False and ergebnis["detail"].endswith("(HTTP 404): model not found")
def test_laden_gelingt(monkeypatch):
assert _laden(monkeypatch, httpx.Response(200, json={"choices": []})) == {"ok": True, "text": "Qwen3.8-27B ist geladen."}
# Ein Modell, das nicht chattet (embed), antwortet mit Fehler — geladen ist es trotzdem.
ergebnis = _laden(monkeypatch, httpx.Response(501, text="no chat"), running=[{"model": "Qwen3.8-27B", "state": "ready"}])
assert ergebnis["ok"] is True
def test_laden_nach_der_wartezeit(monkeypatch):
zeitueber = httpx.ReadTimeout("zu langsam")
ergebnis = _laden(monkeypatch, zeitueber, running=[{"model": "Qwen3.8-27B", "state": "starting"}])
assert ergebnis["ok"] is True and ergebnis["laedt"] is True
ergebnis = _laden(monkeypatch, zeitueber)
assert ergebnis["ok"] is False and "nach 5 Sekunden nicht geantwortet" in ergebnis["detail"]
ergebnis = _laden(monkeypatch, httpx.ConnectError("refused"))
assert ergebnis["ok"] is False and "nicht erreichbar (ConnectError)" in ergebnis["detail"]
# Unter Windows scheitert ein Verbindungsaufbau zu einem toten Port als Zeitüberschreitung — das ist
# „nicht erreichbar“, nicht „lädt noch“.
ergebnis = _laden(monkeypatch, httpx.ConnectTimeout("timed out"))
assert ergebnis["ok"] is False and "nicht erreichbar (ConnectTimeout)" in ergebnis["detail"]
def test_route_reicht_das_ergebnis_durch(monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routers import models
monkeypatch.setattr(models.llamaswap, "lade_modell", lambda mid: {"ok": False, "detail": f"{mid}: kaputt"})
app = FastAPI()
app.include_router(models.router)
antwort = TestClient(app).post("/api/models/Qwen3.8-27B/load")
assert antwort.status_code == 200 and antwort.json() == {"ok": False, "detail": "Qwen3.8-27B: kaputt"}
+77
View File
@@ -0,0 +1,77 @@
"""„Jetzt suchen“ im Radar als Auftrag (24.09.2026): Die Route antwortet sofort mit einer Auftrags-ID, gesucht wird
in radar_lauf.py --nur-suche. Vorher lief die Suche synchron in der Anfrage und konnte Minuten dauern."""
import sys
import pytest
import radar_lauf
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routers import radar as radar_router
from services import radar
@pytest.fixture
def auftraege(monkeypatch):
"""Job-Engine-Attrappe: merkt sich den Start; läuft schon einer, kommt der zurück."""
stand: dict = {"gestartet": [], "laeuft": None}
def start_job_exklusiv(gruppe, args, label, **kw):
if stand["laeuft"]:
return None, stand["laeuft"]
stand["gestartet"].append({"gruppe": gruppe, "args": args, "label": label, **kw})
return "job123", None
monkeypatch.setattr(radar.jobengine, "start_job_exklusiv", start_job_exklusiv)
return stand
def test_suche_startet_einen_auftrag(auftraege):
ergebnis = radar.suche_starten()
assert ergebnis["ok"] is True and ergebnis["job_id"] == "job123" and "Aufträge" in ergebnis["text"]
(start,) = auftraege["gestartet"]
assert start["gruppe"] == "radar" and start["zeitlimit_s"] == radar.SUCHE_ZEITLIMIT_S
assert start["args"][0] == sys.executable
assert start["args"][1].endswith("radar_lauf.py") and start["args"][2:] == ["--nur-suche"]
def test_laufende_suche_wird_nicht_doppelt_gestartet(auftraege):
auftraege["laeuft"] = {"id": "job-alt", "state": "running"}
ergebnis = radar.suche_starten()
assert (ergebnis["job_id"], ergebnis["laeuft_schon"]) == ("job-alt", True)
assert auftraege["gestartet"] == []
def test_route_antwortet_sofort_ohne_selbst_zu_suchen(auftraege, monkeypatch):
def nicht_hier():
raise AssertionError("Die Route darf nicht selbst suchen.")
monkeypatch.setattr(radar, "suche", nicht_hier)
app = FastAPI()
app.include_router(radar_router.router)
antwort = TestClient(app).post("/api/radar/suche")
assert antwort.status_code == 200 and antwort.json()["job_id"] == "job123"
@pytest.mark.parametrize("ergebnis, code, zeile", [
({"neu": ["Ornith 1.5"], "wartend": 2, "quellen": {"watchlist": True, "discover": True}}, 0,
"Suche fertig: 1 neu (Ornith 1.5), 2 warten auf den Nachttest."),
({"neu": [], "wartend": 1, "quellen": {"watchlist": True, "discover": False}}, 0,
"Suche fertig: nichts Neues, 1 warten auf den Nachttest, Hugging Face war nicht erreichbar."),
({"neu": [], "wartend": 0, "quellen": {"watchlist": False, "discover": False}}, 1,
("Suche fertig: nichts Neues, 0 warten auf den Nachttest, Hugging Face war nicht erreichbar, "
"die Merkliste war nicht lesbar.")),
])
def test_nur_suche_sagt_das_ergebnis_in_der_letzten_zeile(monkeypatch, capsys, ergebnis, code, zeile):
monkeypatch.setattr(radar_lauf.radar, "suche", lambda: ergebnis)
assert radar_lauf.nur_suche() == code
assert capsys.readouterr().out.strip().splitlines()[-1] == zeile
def test_nur_suche_bei_absturz(monkeypatch, capsys):
def kaputt():
raise OSError("Platte voll")
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")
+1 -1
View File
@@ -169,7 +169,7 @@ def test_ausblenden_gilt_bis_zum_naechsten_lauf(monkeypatch, tmp_path):
def test_schlafende_spracherkennung_ist_kein_kernbefund(monkeypatch): def test_schlafende_spracherkennung_ist_kein_kernbefund(monkeypatch):
"""24.09.2026: Die Spracherkennung schläft bis zur Android-App — kein roter Hinweis mehr.""" """24.09.2026: Die Spracherkennung schläft bis zur Android-App — kein roter Hinweis mehr."""
monkeypatch.setattr(waechter.llamaswap, "engine_reachable", lambda: True) monkeypatch.setattr(waechter.llamaswap, "engine_reachable", lambda: True)
monkeypatch.setattr(waechter.llamaswap, "brain_status", lambda: {"ready": True}) monkeypatch.setattr(waechter.llamaswap, "hirn_zustand", lambda: {"modell": "Hirn", "zustand": "bereit"})
monkeypatch.setattr(waechter, "_reach", lambda url, pfad, kopf=None: "8650" not in url) monkeypatch.setattr(waechter, "_reach", lambda url, pfad, kopf=None: "8650" not in url)
monkeypatch.setattr(waechter, "_systemctl_show", monkeypatch.setattr(waechter, "_systemctl_show",
lambda name, system: {"ActiveState": "inactive", "UnitFileState": "disabled"}) lambda name, system: {"ActiveState": "inactive", "UnitFileState": "disabled"})
+3 -2
View File
@@ -250,10 +250,11 @@ export const useRadarAktion = () =>
}, },
) )
/** „Jetzt suchen“: Die Box startet die Suche als Auftrag (Gruppe „radar“) und antwortet sofort. */
export const useRadarSuche = () => export const useRadarSuche = () =>
useAktion(() => post<AktionsErgebnis>("/api/radar/suche"), { useAktion(() => post<AktionsErgebnis>("/api/radar/suche"), {
erfolg: () => "Radar sucht nach neuen Modellen.", erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Das Radar sucht jetzt nach neuen Modellen.",
neuLaden: ["radar"], neuLaden: ["jobs"],
}) })
/** Wer liefert die Seite aus, und gibt es die zweite Instanz? (ändert sich nur mit einem Deploy) */ /** Wer liefert die Seite aus, und gibt es die zweite Instanz? (ändert sich nur mit einem Deploy) */
+27 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { useLocation } from "@tanstack/react-router" import { useLocation } from "@tanstack/react-router"
import { Search } from "lucide-react" import { Search } from "lucide-react"
import { Auftraege } from "@/components/cockpit/Auftraege" import { Auftraege } from "@/components/cockpit/Auftraege"
@@ -10,10 +11,11 @@ import {
useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion,
useRadarSuche, useStart, useRadarSuche, useStart,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige" import { laeuftNoch, messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
import { ApiFehler } from "@/lib/api" import { ApiFehler } from "@/lib/api"
import { useLetzteMetrik } from "@/lib/strom" import { useLetzteMetrik } from "@/lib/strom"
import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen" import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
import { letzteMeldung } from "@/lib/wartung"
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit" import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
import { ModellSuche } from "./ModellSuche" import { ModellSuche } from "./ModellSuche"
@@ -243,6 +245,21 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | undefined }) { function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | undefined }) {
const radar = useRadar() const radar = useRadar()
const suche = useRadarSuche() const suche = useRadarSuche()
const jobs = useJobs()
const qc = useQueryClient()
// „Jetzt suchen“ läuft als Auftrag (Gruppe „radar“); der jüngste sagt, ob gerade gesucht wird. Bis die Liste
// der Aufträge den eben gestarteten kennt, gilt er als laufend (sonst sprang der Knopf kurz zurück).
const liste = jobs.data?.jobs
const auftrag = (liste ?? []).filter((j) => j.group === "radar").at(-1)
const gestartet = suche.data?.job_id
const nochNichtGelistet = !!gestartet && !!liste && !liste.some((j) => j.id === gestartet)
const sucht = suche.isPending || nochNichtGelistet || (auftrag !== undefined && laeuftNoch(auftrag))
// Ist die Suche eben fertig geworden, das Radar gleich neu lesen (sonst erst nach fünf Minuten).
const suchteEben = useRef(sucht)
useEffect(() => {
if (suchteEben.current && !sucht) qc.invalidateQueries({ queryKey: ["radar"] })
suchteEben.current = sucht
}, [sucht, qc])
if (radar.isError && !radar.data) { if (radar.isError && !radar.data) {
// 404 heißt: Diese Box hat kein Radar. Alles andere ist ein Fehler, kein fehlendes Radar. // 404 heißt: Diese Box hat kein Radar. Alles andere ist ein Fehler, kein fehlendes Radar.
const fehlt = radar.error instanceof ApiFehler && radar.error.status === 404 const fehlt = radar.error instanceof ApiFehler && radar.error.status === 404
@@ -263,15 +280,21 @@ function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string |
id="radar" id="radar"
className="scroll-mt-4" className="scroll-mt-4"
rechts={ rechts={
<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={suche.isPending}> <Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={sucht}>
{suche.isPending ? "Sucht …" : "Jetzt suchen"} {sucht ? "Sucht …" : "Jetzt suchen"}
</Button> </Button>
} }
> >
<p className="-mt-2 text-sm text-text-2"> <p className="-mt-2 text-sm text-text-2">
Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche. Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche.
{d?.naechster_test ? ` Nächster Test: ${flugplanZeit(d.naechster_test)}.` : ""} {d?.naechster_test ? ` Nächster Test: ${flugplanZeit(d.naechster_test)}.` : ""}
{sucht ? " Sucht gerade bei Hugging Face. Das dauert ein paar Minuten; den Stand zeigt der Auftrag oben unter „Aufträge“." : ""}
</p> </p>
{!sucht && auftrag?.state === "failed" && (
<p role="alert" className="text-sm text-rot-text">
Die letzte Suche ist gescheitert: {letzteMeldung(auftrag) ?? "ohne Meldung"}
</p>
)}
{!d ? ( {!d ? (
<p className="text-[15px] text-text-2">Wird gelesen …</p> <p className="text-[15px] text-text-2">Wird gelesen …</p>
) : d.kandidaten.length === 0 ? ( ) : d.kandidaten.length === 0 ? (