diff --git a/backend/radar_lauf.py b/backend/radar_lauf.py index 40f36b2..3e91f64 100644 --- a/backend/radar_lauf.py +++ b/backend/radar_lauf.py @@ -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 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. + +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 @@ -108,5 +111,29 @@ def main() -> int: 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__": - sys.exit(main()) + sys.exit(nur_suche() if "--nur-suche" in sys.argv[1:] else main()) diff --git a/backend/routers/models.py b/backend/routers/models.py index f3500bd..45fed2e 100644 --- a/backend/routers/models.py +++ b/backend/routers/models.py @@ -209,21 +209,8 @@ def unload_model(model_id: str) -> dict: @router.post("/models/{model_id}/load") def load_model(model_id: str) -> dict: - import httpx - from config import LLAMA_SWAP_URL - 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)) + """Lädt ein Modell. Kommt es nicht zustande, steht ok: false mit dem Grund in `detail` (services/llamaswap).""" + return llamaswap.lade_modell(model_id) @router.delete("/models/{model_id}") diff --git a/backend/routers/radar.py b/backend/routers/radar.py index 867d9b8..c1d9c14 100644 --- a/backend/routers/radar.py +++ b/backend/routers/radar.py @@ -2,7 +2,7 @@ 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/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) @@ -29,9 +29,8 @@ def radar_stand() -> dict: @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} + """Startet die Suche als Auftrag (sie kann Minuten dauern) und antwortet sofort mit der Auftrags-ID.""" + return radar.suche_starten() @router.post("/radar/{kandidat_id}/uebernehmen") diff --git a/backend/services/llamaswap.py b/backend/services/llamaswap.py index b48809a..eb30ac1 100644 --- a/backend/services/llamaswap.py +++ b/backend/services/llamaswap.py @@ -659,3 +659,104 @@ def get_running_models() -> list[str]: except Exception: log.warning("get_running_models fehlgeschlagen", exc_info=True) 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)}"} diff --git a/backend/services/radar.py b/backend/services/radar.py index 3e23d11..9fd6aad 100644 --- a/backend/services/radar.py +++ b/backend/services/radar.py @@ -52,7 +52,7 @@ from config import HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR from kern.zeit import LOCAL_TZ 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 try: @@ -795,6 +795,26 @@ def suche() -> dict: 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 --------------------------------------------------------------------------------- _PS = None diff --git a/backend/services/waechter.py b/backend/services/waechter.py index e57fe27..80c441b 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -386,17 +386,52 @@ def _hermes_kopf() -> dict[str, str]: 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]: """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) engine_ok = llamaswap.engine_reachable() proben.append(("kern:engine", "Der Motor antwortet nicht", "llama-swap reagiert nicht. Ohne ihn laufen keine Modelle.", engine_ok)) - if engine_ok: - 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)) + hirn = _hirn_befund() if engine_ok else None proben.append(("kern:hermes", "Hermes antwortet nicht", "Der Agent-Dienst reagiert nicht. Telegram und Lucys Werkzeuge gehen gerade nicht.", _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"))) proben.append(("kern:gateway", "Der Modell-Gateway antwortet nicht", "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]) - for (i, t, x, ok) in proben if not ok] + 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] + return befunde + ([hirn] if hirn else []) def pruefe_platte() -> list[Befund]: diff --git a/backend/tests/test_hirn_und_laden.py b/backend/tests/test_hirn_und_laden.py new file mode 100644 index 0000000..e84d56f --- /dev/null +++ b/backend/tests/test_hirn_und_laden.py @@ -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"} diff --git a/backend/tests/test_radar_suche_auftrag.py b/backend/tests/test_radar_suche_auftrag.py new file mode 100644 index 0000000..2050ae9 --- /dev/null +++ b/backend/tests/test_radar_suche_auftrag.py @@ -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") diff --git a/backend/tests/test_waechter.py b/backend/tests/test_waechter.py index 2c4be1b..b027d10 100644 --- a/backend/tests/test_waechter.py +++ b/backend/tests/test_waechter.py @@ -169,7 +169,7 @@ def test_ausblenden_gilt_bis_zum_naechsten_lauf(monkeypatch, tmp_path): def test_schlafende_spracherkennung_ist_kein_kernbefund(monkeypatch): """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, "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, "_systemctl_show", lambda name, system: {"ActiveState": "inactive", "UnitFileState": "disabled"}) diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 29e94e7..c8ae245 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -250,10 +250,11 @@ export const useRadarAktion = () => }, ) +/** „Jetzt suchen“: Die Box startet die Suche als Auftrag (Gruppe „radar“) und antwortet sofort. */ export const useRadarSuche = () => useAktion(() => post("/api/radar/suche"), { - erfolg: () => "Radar sucht nach neuen Modellen.", - neuLaden: ["radar"], + erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Das Radar sucht jetzt nach neuen Modellen.", + neuLaden: ["jobs"], }) /** Wer liefert die Seite aus, und gibt es die zweite Instanz? (ändert sich nur mit einem Deploy) */ diff --git a/frontend/src/views/Modelle.tsx b/frontend/src/views/Modelle.tsx index a706399..3fd8e87 100644 --- a/frontend/src/views/Modelle.tsx +++ b/frontend/src/views/Modelle.tsx @@ -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 { Search } from "lucide-react" import { Auftraege } from "@/components/cockpit/Auftraege" @@ -10,10 +11,11 @@ import { useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, useRadarSuche, useStart, } 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 { useLetzteMetrik } from "@/lib/strom" import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen" +import { letzteMeldung } from "@/lib/wartung" import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit" 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 }) { const radar = useRadar() 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) { // 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 @@ -263,15 +280,21 @@ function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | id="radar" className="scroll-mt-4" rechts={ - } >

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)}.` : ""} + {sucht ? " Sucht gerade bei Hugging Face. Das dauert ein paar Minuten; den Stand zeigt der Auftrag oben unter „Aufträge“." : ""}

+ {!sucht && auftrag?.state === "failed" && ( +

+ Die letzte Suche ist gescheitert: {letzteMeldung(auftrag) ?? "ohne Meldung"} +

+ )} {!d ? (

Wird gelesen …

) : d.kandidaten.length === 0 ? (