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:
co-authored by
Claude Opus 5.5
parent
40d2840b16
commit
93f6613146
+28
-1
@@ -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())
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)}"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user