Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d46adfd86 | ||
|
|
177c9a311c | ||
|
|
4b226d5d22 | ||
|
|
ed6948429a | ||
|
|
7135042752 | ||
|
|
790de19f1a | ||
|
|
f3af2adec3 | ||
|
|
bfb07a92c3 | ||
|
|
907289d7dc | ||
|
|
3d2c9549fb | ||
|
|
12dadfe6ef |
+26
-24
@@ -4,6 +4,10 @@ Mission Control 2.0 — dünner FastAPI-Einstieg.
|
|||||||
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
|
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
|
||||||
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
|
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
|
||||||
Server (proxyt /api hierher), daher CORS für localhost offen.
|
Server (proxyt /api hierher), daher CORS für localhost offen.
|
||||||
|
|
||||||
|
Seit dem Box-Wart-Umbau (09/2026) ist MC2 nur noch Updater, Wächter und Modell-Radar.
|
||||||
|
Ideen, Wissen, Chronik, Skills, Verbinden und die Konsole sind raus; die Schnittstellen,
|
||||||
|
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge), bleiben.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -15,31 +19,27 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from routers import (
|
from routers import (
|
||||||
agent,
|
agent,
|
||||||
chronik,
|
boxwart,
|
||||||
connect,
|
|
||||||
console,
|
|
||||||
events,
|
events,
|
||||||
gateway_proxy,
|
gateway_proxy,
|
||||||
health,
|
health,
|
||||||
hermes_ui,
|
hermes_ui,
|
||||||
ideen,
|
|
||||||
maintenance,
|
maintenance,
|
||||||
models,
|
models,
|
||||||
|
radar,
|
||||||
routing,
|
routing,
|
||||||
skills,
|
|
||||||
system,
|
system,
|
||||||
voice,
|
voice,
|
||||||
wissen,
|
|
||||||
zeitmaschine,
|
zeitmaschine,
|
||||||
)
|
)
|
||||||
from routers import reminders as reminders_router
|
from routers import reminders as reminders_router
|
||||||
from services import metrics_history, reminders, sentry, warmer
|
from services import metrics_history, reminders, warmer
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||||
@@ -53,8 +53,9 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
|
"""Hintergrund-Tasks an den App-Lebenszyklus binden. Der Wächter läuft NICHT hier,
|
||||||
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
|
sondern im mc2-steward (eigener Prozess, steward.py) — ein totes MC2 könnte sich
|
||||||
|
sonst nicht selbst melden."""
|
||||||
tasks: list[asyncio.Task[Any]] = []
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
if warmer.ENABLED:
|
if warmer.ENABLED:
|
||||||
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
||||||
@@ -62,11 +63,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
||||||
warmer.INTERVAL,
|
warmer.INTERVAL,
|
||||||
)
|
)
|
||||||
if sentry.ENABLED:
|
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen und
|
||||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
# Messverlauf gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
|
||||||
|
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||||
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
# 24-h-Metrik-Verlauf: 10-s-Sampler, Ringpuffer, persistiert.
|
||||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||||
|
else:
|
||||||
|
log.info("Probelauf: Erinnerungen und Messverlauf bleiben beim echten MC2.")
|
||||||
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
||||||
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
||||||
app.state.gw_client = httpx.AsyncClient(
|
app.state.gw_client = httpx.AsyncClient(
|
||||||
@@ -103,11 +107,12 @@ async def no_cache(
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
|
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
||||||
|
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||||
app.include_router(models.router)
|
app.include_router(models.router)
|
||||||
app.include_router(routing.router)
|
app.include_router(routing.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
|
|
||||||
app.include_router(connect.router)
|
|
||||||
app.include_router(agent.router)
|
app.include_router(agent.router)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
voice.router
|
voice.router
|
||||||
@@ -125,18 +130,11 @@ if V1_UPSTREAM:
|
|||||||
else:
|
else:
|
||||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||||
app.include_router(maintenance.router)
|
app.include_router(maintenance.router)
|
||||||
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
|
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
|
||||||
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
|
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
|
||||||
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
|
||||||
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
|
||||||
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
|
|
||||||
app.include_router(skills.router) # Skills & Jobs Dashboard
|
|
||||||
app.include_router(
|
|
||||||
console.router
|
|
||||||
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
|
|
||||||
app.include_router(
|
app.include_router(
|
||||||
hermes_ui.router
|
hermes_ui.router
|
||||||
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
) # Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
||||||
|
|
||||||
|
|
||||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||||
@@ -147,6 +145,10 @@ if FRONTEND_DIST.exists():
|
|||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
def spa(full_path: str):
|
def spa(full_path: str):
|
||||||
|
# Unbekannte /api-Pfade sind ein echter 404, keine Startseite: Sonst bekommt die
|
||||||
|
# Oberfläche HTML statt JSON und stürzt ab (erster Probelauf 23.09., /api/radar).
|
||||||
|
if full_path == "api" or full_path.startswith("api/"):
|
||||||
|
raise HTTPException(status_code=404, detail="Diese Schnittstelle gibt es nicht.")
|
||||||
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
||||||
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Nächtlicher Lauf des Modell-Radars (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
|
||||||
|
|
||||||
|
Warum ein eigener Einstieg: Der Test lädt ein bis zu ~85 GB großes Modell neben Lucys Hirn. Das
|
||||||
|
darf nur nachts passieren und muss um 02:30 sicher vorbei sein, weil um 03:00 der NerdQuiz-
|
||||||
|
Nachtlauf das Hirn braucht. Ablauf:
|
||||||
|
|
||||||
|
1. Suchen — höchstens einmal am Tag (Merkliste + Entdeckung).
|
||||||
|
2. Offene Urteile nachholen („getestet“: damals fehlte die Vergleichsmessung).
|
||||||
|
3. Testen — nur im Fenster 00:30–02:30 und höchstens ein neuer Kandidat pro Woche.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from services import radar
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||||
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("radar_lauf")
|
||||||
|
|
||||||
|
NOTBREMSE_S = int(os.environ.get("MC_RADAR_NOTBREMSE_S", "300"))
|
||||||
|
|
||||||
|
|
||||||
|
def _beim_beenden(signum, _frame) -> None:
|
||||||
|
"""SIGTERM (systemd stop, RuntimeMaxSec) → normal auslaufen, damit der Kandidaten-Server gestoppt wird."""
|
||||||
|
raise SystemExit(f"Signal {signum}")
|
||||||
|
|
||||||
|
|
||||||
|
def _notbremse(frist: float) -> threading.Timer:
|
||||||
|
def ziehen() -> None:
|
||||||
|
log.error("Radar: Notbremse – %s s nach der Frist läuft noch etwas, der Lauf wird hart beendet.", NOTBREMSE_S)
|
||||||
|
try:
|
||||||
|
radar.notstopp()
|
||||||
|
finally:
|
||||||
|
os._exit(3)
|
||||||
|
|
||||||
|
bremse = threading.Timer(max(frist - time.time(), 0) + NOTBREMSE_S, ziehen)
|
||||||
|
bremse.daemon = True
|
||||||
|
bremse.start()
|
||||||
|
return bremse
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFY = Path(__file__).resolve().parents[1] / "deploy" / "notify.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def melde_bestanden(test: dict) -> None:
|
||||||
|
"""Ein bestandener Kandidat wartet auf eine Entscheidung — einmal Bescheid geben, sonst liegt er
|
||||||
|
unbemerkt herum (Lehre der Pins vom September). notify.sh sammelt Nachtmeldungen für den Morgen."""
|
||||||
|
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
|
||||||
|
rolle = "das Hirn" if test.get("rolle") == "hirn" else "den Coder"
|
||||||
|
text = (f"{k.get('name') or test.get('kandidat')} hat den Nachttest für {rolle} bestanden. "
|
||||||
|
f"{str(test.get('zusammenfassung') or '')[:300]} "
|
||||||
|
"In MC2 unter Modelle kannst du ihn übernehmen oder verwerfen.")
|
||||||
|
try:
|
||||||
|
subprocess.run(["bash", str(NOTIFY), "-s", "[Modell-Radar]", text], timeout=120, check=False,
|
||||||
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
log.warning("Radar: Meldung über den bestandenen Kandidaten ging nicht raus.", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
beginn = radar.jetzt()
|
||||||
|
if radar.suche_faellig(beginn):
|
||||||
|
try:
|
||||||
|
ergebnis = radar.suche()
|
||||||
|
log.info("Radar: Suche – %s neu, %s warten", len(ergebnis.get("neu") or []), ergebnis.get("wartend"))
|
||||||
|
except Exception:
|
||||||
|
log.warning("Radar: Suche fehlgeschlagen", exc_info=True)
|
||||||
|
else:
|
||||||
|
log.info("Radar: heute schon gesucht.")
|
||||||
|
|
||||||
|
if not radar.im_fenster(radar.jetzt()):
|
||||||
|
log.info("Radar: außerhalb des Test-Fensters %s–%s, nichts zu testen.", radar.FENSTER_START,
|
||||||
|
radar.FENSTER_ENDE)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
frist = radar.fenster_ende(radar.jetzt()).timestamp()
|
||||||
|
signal.signal(signal.SIGTERM, _beim_beenden)
|
||||||
|
bremse = _notbremse(frist)
|
||||||
|
try:
|
||||||
|
if (nachgeholt := radar.nachurteilen(frist)):
|
||||||
|
log.info("Radar: %s offene Urteile nachgeholt.", nachgeholt)
|
||||||
|
plan = radar.plane_test(radar.lade_stand(), radar.jetzt())
|
||||||
|
if not plan.get("kandidat"):
|
||||||
|
log.info("Radar: nichts fällig (%s).", plan.get("grund"))
|
||||||
|
return 0
|
||||||
|
log.info("Radar: teste %s bis spätestens %s.", plan.get("grund"), radar.FENSTER_ENDE)
|
||||||
|
test = radar.teste(plan["kandidat"], frist)
|
||||||
|
if test:
|
||||||
|
log.info("Radar: %s – %s", test.get("ergebnis"), test.get("zusammenfassung"))
|
||||||
|
if test.get("ergebnis") == "bestanden":
|
||||||
|
melde_bestanden(test)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
bremse.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm braucht.
|
||||||
|
|
||||||
|
GET /api/start Zustand, Hinweise, Warnlampen, Box-Werte, Updates, Flugplan
|
||||||
|
GET /api/hinweise Hinweise + Verlauf des Wächters
|
||||||
|
POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen
|
||||||
|
GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde)
|
||||||
|
GET /api/zeitplan Heute gelaufen / demnächst geplant
|
||||||
|
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
|
||||||
|
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
|
||||||
|
|
||||||
|
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
from config import MODELS_DIR
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from services import (
|
||||||
|
backup,
|
||||||
|
llamaswap,
|
||||||
|
maintenance,
|
||||||
|
modell_nutzung,
|
||||||
|
system,
|
||||||
|
update_verlauf,
|
||||||
|
waechter,
|
||||||
|
zeitplan,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["boxwart"])
|
||||||
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
|
|
||||||
|
_updates_lock = threading.Lock()
|
||||||
|
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False}
|
||||||
|
UPDATES_CACHE_S = 600
|
||||||
|
|
||||||
|
|
||||||
|
def _updates_holen() -> None:
|
||||||
|
try:
|
||||||
|
daten = maintenance.updates()
|
||||||
|
except Exception:
|
||||||
|
daten = None
|
||||||
|
with _updates_lock:
|
||||||
|
if daten is not None or _updates_cache["daten"] is None:
|
||||||
|
_updates_cache["daten"] = daten or {}
|
||||||
|
_updates_cache["ts"] = time.time()
|
||||||
|
_updates_cache["laeuft"] = False
|
||||||
|
|
||||||
|
|
||||||
|
def _updates_gecacht() -> dict:
|
||||||
|
"""maintenance.updates() fragt GitHub, apt und Hugging Face und braucht nach einem Neustart
|
||||||
|
gern zehn Sekunden und mehr. Der Start wartet darauf nie: Er bekommt den letzten Stand (oder
|
||||||
|
zunächst nichts), frisch geholt wird im Hintergrund."""
|
||||||
|
with _updates_lock:
|
||||||
|
veraltet = _updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
|
||||||
|
if veraltet and not _updates_cache["laeuft"]:
|
||||||
|
_updates_cache["laeuft"] = True
|
||||||
|
threading.Thread(target=_updates_holen, name="updates-holen", daemon=True).start()
|
||||||
|
return _updates_cache["daten"] or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _bausteine(u: dict) -> list[dict]:
|
||||||
|
"""Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft."""
|
||||||
|
liste = []
|
||||||
|
if u.get("os"):
|
||||||
|
liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"})
|
||||||
|
if u.get("engine"):
|
||||||
|
liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"})
|
||||||
|
if u.get("swap"):
|
||||||
|
liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"})
|
||||||
|
for c in u.get("components") or []:
|
||||||
|
if c.get("key") == "hermes_agent" and c.get("update"):
|
||||||
|
liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"})
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
def _zeit_kurz(ts: float | None) -> str:
|
||||||
|
if not ts:
|
||||||
|
return "–"
|
||||||
|
dt = datetime.fromtimestamp(ts, LOCAL_TZ)
|
||||||
|
heute = datetime.now(LOCAL_TZ).date()
|
||||||
|
if dt.date() == heute:
|
||||||
|
return f"heute {dt:%H:%M}"
|
||||||
|
if (heute - dt.date()).days == 1:
|
||||||
|
return f"gestern {dt:%H:%M}"
|
||||||
|
return f"{dt:%d.%m. %H:%M}"
|
||||||
|
|
||||||
|
|
||||||
|
def _lampen(stand: dict, u: dict) -> list[dict]:
|
||||||
|
ids = {h.get("id", "") for h in stand["hinweise"]}
|
||||||
|
versionen = system.check_versions_cached()
|
||||||
|
|
||||||
|
def lampe(lid: str, label: str, zustand: str, wert: str) -> dict:
|
||||||
|
return {"id": lid, "label": label, "zustand": zustand, "wert": wert}
|
||||||
|
|
||||||
|
engine_ok = llamaswap.engine_reachable()
|
||||||
|
build = (versionen.get("engine") or {}).get("version_text", "")
|
||||||
|
lampen = [lampe("motor", "Motor", "ok" if engine_ok else "fehler", build if engine_ok else "antwortet nicht")]
|
||||||
|
|
||||||
|
hirn = llamaswap.brain_status() if engine_ok else {}
|
||||||
|
lampen.append(lampe("hirn", "Hirn", "ok" if hirn.get("ready") else "fehler",
|
||||||
|
"geladen" if hirn.get("ready") else "lädt nicht"))
|
||||||
|
|
||||||
|
laufend = set(llamaswap.get_running_models()) if engine_ok else set()
|
||||||
|
coder = next((m for m in llamaswap.list_models() if "coder" in (m.get("aliases") or [])), None)
|
||||||
|
coder_an = bool(coder and coder.get("name") in laufend)
|
||||||
|
lampen.append(lampe("coder", "Coder", "ok" if coder_an else "aus", "geladen" if coder_an else "auf Abruf"))
|
||||||
|
|
||||||
|
hermes_weg = any(i in ids for i in ("kern:hermes", "dienst:hermes-gateway"))
|
||||||
|
lampen.append(lampe("hermes", "Hermes", "fehler" if hermes_weg else "ok",
|
||||||
|
"antwortet nicht" if hermes_weg else "läuft"))
|
||||||
|
|
||||||
|
job_hinweise = [h for h in stand["hinweise"] if h.get("id", "").startswith(("job:", "timer:"))]
|
||||||
|
rot = any(h.get("stufe") == "rot" for h in job_hinweise)
|
||||||
|
lampen.append(lampe("jobs", "Jobs", "fehler" if rot else ("warn" if job_hinweise else "ok"),
|
||||||
|
f"{len(job_hinweise)} Hinweis{'e' if len(job_hinweise) != 1 else ''}"
|
||||||
|
if job_hinweise else "alles gelaufen"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
sicherungen = backup.list_backups()
|
||||||
|
except Exception:
|
||||||
|
sicherungen = []
|
||||||
|
letzte = max((s.get("mtime") or s.get("ts") or 0 for s in sicherungen), default=0)
|
||||||
|
zu_alt = not letzte or time.time() - letzte > 36 * 3600
|
||||||
|
lampen.append(lampe("sicherung", "Sicherung", "warn" if zu_alt else "ok", _zeit_kurz(letzte)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
platte = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".").percent
|
||||||
|
except Exception:
|
||||||
|
platte = None
|
||||||
|
zustand = "ok" if platte is None or platte < waechter.DISK_GELB_PCT else (
|
||||||
|
"warn" if platte < waechter.DISK_ROT_PCT else "fehler")
|
||||||
|
lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–"))
|
||||||
|
|
||||||
|
n = len(_bausteine(u))
|
||||||
|
fest = len(update_verlauf.festgehalten())
|
||||||
|
if fest:
|
||||||
|
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
|
||||||
|
elif _updates_cache["daten"] is None:
|
||||||
|
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
|
||||||
|
else:
|
||||||
|
lampen.append(lampe("updates", "Updates", "info" if n else "ok", f"{n} bereit" if n else "aktuell"))
|
||||||
|
return lampen
|
||||||
|
|
||||||
|
|
||||||
|
def _box() -> dict:
|
||||||
|
p = system.metrik_punkt()
|
||||||
|
try:
|
||||||
|
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".")
|
||||||
|
platte = {"used": du.used, "total": du.total, "percent": du.percent}
|
||||||
|
except Exception:
|
||||||
|
platte = None
|
||||||
|
return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"),
|
||||||
|
"temp_cpu": p.get("temp_cpu"), "temp_gpu": p.get("temp_gpu"),
|
||||||
|
"platte": platte, "uptime_s": p.get("uptime_s")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/start")
|
||||||
|
def start() -> dict:
|
||||||
|
stand = waechter.lese_stand()
|
||||||
|
u = _updates_gecacht()
|
||||||
|
rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot")
|
||||||
|
anzahl = len(stand["hinweise"])
|
||||||
|
wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60
|
||||||
|
return {
|
||||||
|
"zustand": {
|
||||||
|
"stufe": "rot" if rot else ("gelb" if anzahl else "ok"),
|
||||||
|
"anzahl": anzahl,
|
||||||
|
"waechter_wach": wach,
|
||||||
|
"stand": stand["stand"],
|
||||||
|
"update_laeuft": stand["update_laeuft"],
|
||||||
|
},
|
||||||
|
"hinweise": stand["hinweise"],
|
||||||
|
"verlauf": stand["verlauf"][:20],
|
||||||
|
"lampen": _lampen(stand, u),
|
||||||
|
"box": _box(),
|
||||||
|
"updates": {"bausteine": _bausteine(u), "modelle": u.get("model_list") or [],
|
||||||
|
"festgehalten": update_verlauf.festgehalten(),
|
||||||
|
"gelesen": _updates_cache["daten"] is not None},
|
||||||
|
"flugplan": zeitplan.flugplan(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hinweise")
|
||||||
|
def hinweise() -> dict:
|
||||||
|
return waechter.lese_stand()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}")
|
||||||
|
def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
|
||||||
|
ergebnis = waechter.fuehre_aktion_aus(hinweis_id, aktion_id)
|
||||||
|
if ergebnis.get("detail") and not ergebnis.get("ok"):
|
||||||
|
raise HTTPException(status_code=409, detail=ergebnis["detail"])
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/modelle/nutzung")
|
||||||
|
def nutzung() -> dict:
|
||||||
|
return modell_nutzung.nutzung()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/zeitplan")
|
||||||
|
def zeitplan_route() -> dict:
|
||||||
|
return zeitplan.flugplan()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/updates/verlauf")
|
||||||
|
def updates_verlauf(anzahl: int = 8) -> dict:
|
||||||
|
"""Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box)."""
|
||||||
|
return {"laeufe": update_verlauf.laeufe(max(1, min(anzahl, 30)))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/updates/festgehalten/{baustein}/freigeben")
|
||||||
|
def festgehalten_freigeben(baustein: str) -> dict:
|
||||||
|
if not update_verlauf.freigeben(baustein):
|
||||||
|
raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.")
|
||||||
|
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
|
|
||||||
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
|
|
||||||
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons — alle
|
|
||||||
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import announce
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class ChronikItem(BaseModel):
|
|
||||||
id: int
|
|
||||||
ts: float
|
|
||||||
subject: str
|
|
||||||
text: str
|
|
||||||
source: str
|
|
||||||
priority: str
|
|
||||||
|
|
||||||
|
|
||||||
class ChronikResponse(BaseModel):
|
|
||||||
items: list[ChronikItem]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chronik", response_model=ChronikResponse)
|
|
||||||
def chronik(limit: int = 150) -> dict:
|
|
||||||
return {"items": announce.list_recent(limit)}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets für den Gateway."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from services.connect import DEFAULT_HOST, build_snippets, check_health
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/connect")
|
|
||||||
def connect(host: str = DEFAULT_HOST) -> dict:
|
|
||||||
return build_snippets(host=host)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/connect/health")
|
|
||||||
def connect_health() -> dict:
|
|
||||||
"""Live-Status der drei Leitungen (Gateway, Gedächtnis, Desktop-Gateway) für den Verbinden-Tab."""
|
|
||||||
return check_health()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""
|
|
||||||
ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
|
|
||||||
|
|
||||||
Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
|
|
||||||
den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
|
|
||||||
+ WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
|
|
||||||
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
|
|
||||||
wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
|
|
||||||
Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import websockets
|
|
||||||
from config import BOX_CONSOLE_UPSTREAM
|
|
||||||
from fastapi import APIRouter, Request, WebSocket
|
|
||||||
from starlette.responses import Response
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _register(base: str, upstream: str) -> None:
|
|
||||||
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
|
|
||||||
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
|
|
||||||
|
|
||||||
@router.websocket(f"/{base}/ws")
|
|
||||||
async def _proxy_ws(ws: WebSocket) -> None:
|
|
||||||
await ws.accept(subprotocol="tty")
|
|
||||||
try:
|
|
||||||
async with websockets.connect(
|
|
||||||
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
|
|
||||||
open_timeout=10, max_size=None, ping_interval=None,
|
|
||||||
) as up:
|
|
||||||
async def c2u() -> None:
|
|
||||||
while True:
|
|
||||||
msg = await ws.receive()
|
|
||||||
if msg.get("type") == "websocket.disconnect":
|
|
||||||
return
|
|
||||||
if (t := msg.get("text")) is not None:
|
|
||||||
await up.send(t)
|
|
||||||
elif (b := msg.get("bytes")) is not None:
|
|
||||||
await up.send(b)
|
|
||||||
|
|
||||||
async def u2c() -> None:
|
|
||||||
async for m in up:
|
|
||||||
if isinstance(m, (bytes, bytearray)):
|
|
||||||
await ws.send_bytes(bytes(m))
|
|
||||||
else:
|
|
||||||
await ws.send_text(m)
|
|
||||||
|
|
||||||
_done, pending = await asyncio.wait(
|
|
||||||
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
except Exception:
|
|
||||||
log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
await ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _proxy_http(request: Request, path: str = "") -> Response:
|
|
||||||
url = f"{upstream}/{base}/{path}"
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
||||||
r = await c.request(request.method, url, content=await request.body())
|
|
||||||
return Response(content=r.content, status_code=r.status_code,
|
|
||||||
media_type=r.headers.get("content-type"))
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
|
|
||||||
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
|
|
||||||
media_type="text/plain")
|
|
||||||
|
|
||||||
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
|
|
||||||
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
|
|
||||||
name=f"{base}_path")
|
|
||||||
|
|
||||||
|
|
||||||
# Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem
|
|
||||||
# Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.)
|
|
||||||
_register("console", BOX_CONSOLE_UPSTREAM)
|
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
|
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
|
||||||
|
|
||||||
Zwei Endpunkte, ein Sammler:
|
GET /api/stream (v3-Umbau P4, 28.08.2026) — zwei Ereignisarten:
|
||||||
|
|
||||||
GET /api/stream (v3-Umbau P4, 28.08.2026) — der aktuelle Kanal. Zwei Ereignisarten:
|
|
||||||
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
|
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
|
||||||
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
|
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
|
||||||
|
|
||||||
GET /api/events — der alte Kanal, nur `invalidate`. Bleibt EINE Fassung lang stehen,
|
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen.
|
||||||
weil ein Browser-Tab nach einem Deploy noch das vorige Bündel halten kann und dieses
|
|
||||||
nur `/api/events` kennt. Danach entfernen.
|
|
||||||
|
|
||||||
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
|
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
|
||||||
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
|
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
|
||||||
@@ -56,15 +52,9 @@ def _fingerprints() -> dict[str, object]:
|
|||||||
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
|
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
|
||||||
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
|
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
|
||||||
fp: dict[str, object] = {}
|
fp: dict[str, object] = {}
|
||||||
try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
|
try: # Wächter-Stand (mc2-steward schreibt ihn jede Minute): Hinweise → Startseite neu laden
|
||||||
from services import announce
|
from services import waechter
|
||||||
fp["chronik"] = announce.list_after(None)["latest"]
|
fp["start"] = _mtime(waechter.STORE_PATH)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts
|
|
||||||
from services import ideen
|
|
||||||
d = ideen.list_queue()
|
|
||||||
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
|
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
|
||||||
@@ -133,14 +123,6 @@ _KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
|||||||
|
|
||||||
@router.get("/stream")
|
@router.get("/stream")
|
||||||
async def stream(request: Request) -> StreamingResponse:
|
async def stream(request: Request) -> StreamingResponse:
|
||||||
"""Der aktuelle Kanal: Anstöße UND Messpunkte."""
|
"""Anstöße UND Messpunkte."""
|
||||||
return StreamingResponse(_strom(request, mit_metrik=True),
|
return StreamingResponse(_strom(request, mit_metrik=True),
|
||||||
media_type="text/event-stream", headers=_KOPF)
|
media_type="text/event-stream", headers=_KOPF)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events")
|
|
||||||
async def events(request: Request) -> StreamingResponse:
|
|
||||||
"""Alt-Kanal ohne Messpunkte. Nur für Browser-Tabs, die noch ein Bündel von vor
|
|
||||||
dem 28.08.2026 halten. Mit der übernächsten Fassung entfernen."""
|
|
||||||
return StreamingResponse(_strom(request, mit_metrik=False),
|
|
||||||
media_type="text/event-stream", headers=_KOPF)
|
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRE
|
|||||||
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
|
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
|
||||||
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
|
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
|
||||||
|
|
||||||
Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
|
Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin
|
||||||
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
|
durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
||||||
MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
|
||||||
|
|
||||||
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
|
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
|
||||||
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
|
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
|
||||||
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
|
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
|
||||||
`:9119/...` weiter.
|
`:9119/...` weiter.
|
||||||
|
|
||||||
|
Seit Hermes v0.21 hat das Dashboard eine eigene Anmeldung: `/` leitet auf `/login?next=…` um,
|
||||||
|
und die Anmeldeseite schickt an `/auth/password-login`. Beide Pfade sind absolut und kennen
|
||||||
|
das Präfix nicht — MC2 reichte die Umleitung unverändert weiter, und der Knopf „Hermes-GUI
|
||||||
|
öffnen“ landete auf MC2s eigener „Diese Seite gibt es nicht“ (gefunden 23.09.2026). Deshalb
|
||||||
|
schreibt der Proxy Umleitungen und diese Pfade im HTML auf `/hermes-ui/…` um.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -91,6 +96,24 @@ async def _proxy_ws(ws: WebSocket, path: str) -> None:
|
|||||||
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
|
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
|
||||||
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
|
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
|
||||||
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
|
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
|
||||||
|
# Absolute Anmelde-Pfade in der (server-gerenderten) Login-Seite: "/auth/…", "/login", "/logout".
|
||||||
|
_ANMELDE_PFADE = re.compile(rb'(["\'(=])/(auth|login|logout)(?=[/?"\')\s])')
|
||||||
|
|
||||||
|
|
||||||
|
def praefixiere_ort(ort: str) -> str:
|
||||||
|
"""Umleitungsziel unter /hermes-ui/ holen. Fremde Ziele (http…, //host) bleiben."""
|
||||||
|
if ort.startswith("/") and not ort.startswith(("//", f"/{_BASE}/")):
|
||||||
|
return f"/{_BASE}{ort}"
|
||||||
|
return ort
|
||||||
|
|
||||||
|
|
||||||
|
def praefixiere_html(body: bytes) -> bytes:
|
||||||
|
"""Basis-Pfad der SPA setzen und die absoluten Anmelde-Pfade unter /hermes-ui/ legen."""
|
||||||
|
if _BASE_PATH_RE.search(body):
|
||||||
|
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
||||||
|
elif b"<head>" in body:
|
||||||
|
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
||||||
|
return _ANMELDE_PFADE.sub(rb"\1/" + _BASE.encode() + rb"/\2", body)
|
||||||
|
|
||||||
|
|
||||||
async def _proxy_http(request: Request, path: str = "") -> Response:
|
async def _proxy_http(request: Request, path: str = "") -> Response:
|
||||||
@@ -113,12 +136,18 @@ async def _proxy_http(request: Request, path: str = "") -> Response:
|
|||||||
)
|
)
|
||||||
body = r.content
|
body = r.content
|
||||||
if "text/html" in r.headers.get("content-type", "").lower():
|
if "text/html" in r.headers.get("content-type", "").lower():
|
||||||
if _BASE_PATH_RE.search(body):
|
body = praefixiere_html(body)
|
||||||
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
resp = Response(content=body, status_code=r.status_code)
|
||||||
elif b"<head>" in body:
|
# multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
|
||||||
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
# dürfen nicht zu einer zusammenfallen.
|
||||||
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
|
for k, v in r.headers.multi_items():
|
||||||
return Response(content=body, status_code=r.status_code, headers=resp_headers)
|
kl = k.lower()
|
||||||
|
if kl in _DROP:
|
||||||
|
continue
|
||||||
|
if kl == "location":
|
||||||
|
v = praefixiere_ort(v)
|
||||||
|
resp.raw_headers.append((kl.encode("latin-1"), v.encode("latin-1")))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.get(f"/{_BASE}")
|
@router.get(f"/{_BASE}")
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
"""Ideen-Queue-Endpoints — dünner REST-Layer über services/ideen.py (natives Hermes-Kanban).
|
|
||||||
LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuch."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from services import ideen
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
class IdeeIn(BaseModel):
|
|
||||||
titel: str
|
|
||||||
notiz: str = ""
|
|
||||||
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Agentic IDE
|
|
||||||
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
|
|
||||||
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
|
|
||||||
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskIn(BaseModel):
|
|
||||||
id: str
|
|
||||||
|
|
||||||
|
|
||||||
class AntwortIn(BaseModel):
|
|
||||||
id: str
|
|
||||||
antwort: str
|
|
||||||
|
|
||||||
|
|
||||||
class ArchivAlleIn(BaseModel):
|
|
||||||
# Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts.
|
|
||||||
projekt: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class EinordnenIn(BaseModel):
|
|
||||||
text: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ideen")
|
|
||||||
def list_queue() -> dict:
|
|
||||||
return ideen.list_queue()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen")
|
|
||||||
def add_idea(body: IdeeIn) -> dict:
|
|
||||||
res = ideen.add_idea(body.titel, body.notiz, welt=body.welt, tiefe=body.tiefe,
|
|
||||||
ziel=body.ziel, art=body.art)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/antwort")
|
|
||||||
def answer(body: AntwortIn) -> dict:
|
|
||||||
res = ideen.answer_task(body.id, body.antwort)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Antwort konnte nicht gesendet werden."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class PrioIn(BaseModel):
|
|
||||||
id: str
|
|
||||||
richtung: str # hoch | runter
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/stopp")
|
|
||||||
def stop(body: TaskIn) -> dict:
|
|
||||||
res = ideen.stop_task(body.id)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Stoppen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/retry")
|
|
||||||
def retry(body: TaskIn) -> dict:
|
|
||||||
res = ideen.retry_task(body.id)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Neuer Versuch fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/prio")
|
|
||||||
def prio(body: PrioIn) -> dict:
|
|
||||||
res = ideen.prio_task(body.id, body.richtung)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Prio-Änderung fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class WeiterIn(BaseModel):
|
|
||||||
id: str
|
|
||||||
ziel: str = "" # "lxc" | "docker" (leer = Box entscheidet)
|
|
||||||
tiefe: str = "" # "simpel" | "gruendlich" (leer = Box schätzt selbst)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/weiterfuehren")
|
|
||||||
def weiterfuehren(body: WeiterIn) -> dict:
|
|
||||||
"""„Konzept gefällt mir" → IDE-Projekt-Karte mit dem fertigen Konzept als Vorlage."""
|
|
||||||
res = ideen.projekt_aus_idee(body.id, ziel=body.ziel, tiefe=body.tiefe)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Projekt konnte nicht angelegt werden."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class NachschaerfenIn(BaseModel):
|
|
||||||
id: str
|
|
||||||
hinweis: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/nachschaerfen")
|
|
||||||
def nachschaerfen(body: NachschaerfenIn) -> dict:
|
|
||||||
"""„Punkt X passt nicht" → Überarbeitungs-Runde am bestehenden Konzept."""
|
|
||||||
res = ideen.konzept_ueberarbeiten(body.id, body.hinweis)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Überarbeitung konnte nicht gestartet werden."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/archivieren")
|
|
||||||
def archive(body: TaskIn) -> dict:
|
|
||||||
res = ideen.archive_task(body.id)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/einordnen")
|
|
||||||
def einordnen(body: EinordnenIn) -> dict:
|
|
||||||
"""Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false`
|
|
||||||
und die Oberfläche behält ihre Voreinstellung."""
|
|
||||||
return ideen.einordnen(body.text)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ideen/archivieren-alle")
|
|
||||||
def archive_alle(body: ArchivAlleIn) -> dict:
|
|
||||||
"""Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an."""
|
|
||||||
res = ideen.archive_done(body.projekt)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ideen/{id}/log")
|
|
||||||
def get_log(id: str) -> dict:
|
|
||||||
return ideen.log_of(id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ideen/{id}/ergebnis")
|
|
||||||
def get_ergebnis(id: str) -> dict:
|
|
||||||
return ideen.ergebnis_of(id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ideen/{id}/konzept")
|
|
||||||
def get_konzept(id: str) -> dict:
|
|
||||||
return ideen.konzept_of(id)
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
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/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
|
||||||
|
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
|
||||||
|
|
||||||
|
Dünn: die Logik liegt in services/radar.py. Getestet wird nur nachts (backend/radar_lauf.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from services import radar
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["radar"])
|
||||||
|
|
||||||
|
|
||||||
|
def _antwort(ergebnis: dict) -> dict:
|
||||||
|
if not ergebnis.get("ok"):
|
||||||
|
raise HTTPException(status_code=int(ergebnis.get("status") or 409),
|
||||||
|
detail=ergebnis.get("detail") or "Das ging nicht.")
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/radar")
|
||||||
|
def radar_stand() -> dict:
|
||||||
|
return radar.uebersicht()
|
||||||
|
|
||||||
|
|
||||||
|
@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}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/radar/{kandidat_id}/uebernehmen")
|
||||||
|
def radar_uebernehmen(kandidat_id: str) -> dict:
|
||||||
|
return _antwort(radar.uebernehmen(kandidat_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/radar/{kandidat_id}/verwerfen")
|
||||||
|
def radar_verwerfen(kandidat_id: str) -> dict:
|
||||||
|
return _antwort(radar.verwerfen(kandidat_id))
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
import psutil
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
|
|
||||||
def _hermes_name(ordner: str) -> str:
|
|
||||||
"""Repo-Ordnername -> Hermes-Skillname.
|
|
||||||
|
|
||||||
deploy/deploy.sh kopiert `deploy/skills/<name>` nach `~/.hermes/skills/<name mit _>`
|
|
||||||
(`tr '-' '_'`), weil Hermes keine Bindestriche in Skillnamen mag. Ohne dieselbe
|
|
||||||
Umschreibung hier bekam Hermes den Ordnernamen MIT Bindestrich und fand den Skill
|
|
||||||
nicht - betroffen waren 7 der 12 Skills (betrieb-playbook, konzept-fliessband,
|
|
||||||
llm-wiki, morning-report, pc-pfad-cache, projekt-start, trend-radar): der Startknopf
|
|
||||||
meldete Erfolg, ausgefuehrt wurde nichts. deploy.sh bleibt die Quelle der Wahrheit.
|
|
||||||
"""
|
|
||||||
return ordner.replace("-", "_")
|
|
||||||
|
|
||||||
|
|
||||||
class RunSkillRequest(BaseModel):
|
|
||||||
skill_name: str
|
|
||||||
|
|
||||||
@router.get("/skills")
|
|
||||||
def list_skills():
|
|
||||||
"""Listet alle verfügbaren Skills aus dem deploy/skills Verzeichnis auf."""
|
|
||||||
# Pfad relativ zu dieser Datei (backend/routers/skills.py)
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
repo_root = os.path.dirname(os.path.dirname(current_dir))
|
|
||||||
skills_dir = os.path.join(repo_root, "deploy", "skills")
|
|
||||||
skills = []
|
|
||||||
|
|
||||||
# Check currently running hermes skill processes
|
|
||||||
running_skills = set()
|
|
||||||
for p in psutil.process_iter(['name', 'cmdline']):
|
|
||||||
try:
|
|
||||||
cmdline = p.info.get('cmdline')
|
|
||||||
if cmdline and any("hermes" in arg for arg in cmdline):
|
|
||||||
for arg in cmdline:
|
|
||||||
if "Führe den " in arg and " Skill aus" in arg:
|
|
||||||
# Extract skill name from "Führe den 'skill_name' Skill aus"
|
|
||||||
import re
|
|
||||||
match = re.search(r"Führe den '(.+?)' Skill aus", arg)
|
|
||||||
if match:
|
|
||||||
running_skills.add(match.group(1))
|
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
|
|
||||||
for entry in os.scandir(skills_dir):
|
|
||||||
if entry.is_dir():
|
|
||||||
# Suche nach SKILL.md für Metadaten
|
|
||||||
skill_md_path = os.path.join(entry.path, "SKILL.md")
|
|
||||||
description = ""
|
|
||||||
if os.path.exists(skill_md_path):
|
|
||||||
try:
|
|
||||||
with open(skill_md_path, "r", encoding="utf-8") as f:
|
|
||||||
# Lese erste Zeilen für Description (YAML Frontmatter)
|
|
||||||
lines = f.readlines()[:10]
|
|
||||||
for line in lines:
|
|
||||||
if "description:" in line.lower():
|
|
||||||
description = line.split(":", 1)[1].strip().strip('"\'')
|
|
||||||
break
|
|
||||||
elif "author:" in line.lower() and not description:
|
|
||||||
description = line.strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
skills.append({
|
|
||||||
"name": entry.name,
|
|
||||||
# Der Name, unter dem Hermes den Skill kennt - auch fuer den Laufend-
|
|
||||||
# Vergleich, weil im Prozess-cmdline genau dieser Name steht.
|
|
||||||
"hermes_name": _hermes_name(entry.name),
|
|
||||||
"description": description or "Keine Beschreibung verfügbar.",
|
|
||||||
"running": _hermes_name(entry.name) in running_skills
|
|
||||||
})
|
|
||||||
return {"skills": skills}
|
|
||||||
|
|
||||||
@router.post("/skills/run")
|
|
||||||
def run_skill(req: RunSkillRequest):
|
|
||||||
"""Startet einen autonomen Skill via Hermes im Hintergrund."""
|
|
||||||
hermes_bin = os.path.expanduser("~/.local/bin/hermes")
|
|
||||||
if not os.path.exists(hermes_bin):
|
|
||||||
# Fallback falls lokal (auf Windows) entwickelt wird
|
|
||||||
return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"}
|
|
||||||
|
|
||||||
# Entspricht: hermes -z "Führe den 'X' Skill aus" chat - mit dem Namen, den Hermes
|
|
||||||
# nach dem Deploy-Sync wirklich kennt (Bindestriche -> Unterstriche, s. _hermes_name).
|
|
||||||
skill = _hermes_name(req.skill_name)
|
|
||||||
cmd = [hermes_bin, "-z", f"Führe den '{skill}' Skill aus", "chat"]
|
|
||||||
try:
|
|
||||||
log_path = os.path.expanduser(f"~/.hermes/logs/skill_{skill.replace('/', '_')}.log")
|
|
||||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
||||||
with open(log_path, "a", encoding="utf-8") as f:
|
|
||||||
f.write(f"\n--- Starting Skill: {skill} ---\n")
|
|
||||||
subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT)
|
|
||||||
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
|
|
||||||
except Exception as e:
|
|
||||||
return {"ok": False, "err": str(e)}
|
|
||||||
@@ -94,10 +94,10 @@ def services() -> dict:
|
|||||||
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||||
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||||
"ok": _voice_reachable(), "scope": "user"},
|
"ok": _voice_reachable(), "scope": "user"},
|
||||||
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
|
# Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte
|
||||||
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
|
# sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus.
|
||||||
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
|
{"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "",
|
||||||
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
|
"ok": _user_unit_active("lucy-stimme"), "scope": "user"},
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"services": rows,
|
"services": rows,
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares
|
|
||||||
Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron
|
|
||||||
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
|
||||||
|
|
||||||
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
|
|
||||||
|
|
||||||
|
|
||||||
def _available() -> bool:
|
|
||||||
return VAULT.is_dir()
|
|
||||||
|
|
||||||
|
|
||||||
def _title_of(p: Path) -> str:
|
|
||||||
"""Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename."""
|
|
||||||
try:
|
|
||||||
with p.open(encoding="utf-8", errors="replace") as fh:
|
|
||||||
for line in fh:
|
|
||||||
s = line.strip()
|
|
||||||
if s:
|
|
||||||
return s.lstrip("# ").strip()[:160]
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return p.stem
|
|
||||||
|
|
||||||
|
|
||||||
class VaultFile(BaseModel):
|
|
||||||
path: str
|
|
||||||
name: str
|
|
||||||
dir: str
|
|
||||||
title: str
|
|
||||||
mtime: float
|
|
||||||
neu: bool
|
|
||||||
|
|
||||||
|
|
||||||
class WissenListResponse(BaseModel):
|
|
||||||
available: bool
|
|
||||||
files: list[VaultFile]
|
|
||||||
|
|
||||||
|
|
||||||
class WissenFileResponse(BaseModel):
|
|
||||||
path: str
|
|
||||||
content: str
|
|
||||||
mtime: float
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wissen", response_model=WissenListResponse)
|
|
||||||
def list_vault() -> dict:
|
|
||||||
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
|
|
||||||
if not _available():
|
|
||||||
return {"available": False, "files": []}
|
|
||||||
files = []
|
|
||||||
now = time.time()
|
|
||||||
for p in sorted(VAULT.rglob("*.md")):
|
|
||||||
if ".git" in p.parts or "traeume" in p.parts:
|
|
||||||
continue
|
|
||||||
rel = p.relative_to(VAULT).as_posix()
|
|
||||||
st = p.stat()
|
|
||||||
files.append({
|
|
||||||
"path": rel,
|
|
||||||
"name": p.stem,
|
|
||||||
"dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "",
|
|
||||||
"title": _title_of(p),
|
|
||||||
"mtime": st.st_mtime,
|
|
||||||
"neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht"
|
|
||||||
})
|
|
||||||
files.sort(key=lambda f: f["mtime"], reverse=True)
|
|
||||||
return {"available": True, "files": files}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wissen/datei", response_model=WissenFileResponse)
|
|
||||||
def read_file(pfad: str) -> dict:
|
|
||||||
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
|
|
||||||
if not _available():
|
|
||||||
raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).")
|
|
||||||
try:
|
|
||||||
target = (VAULT / pfad).resolve()
|
|
||||||
if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file():
|
|
||||||
raise HTTPException(404, "Notiz nicht gefunden.")
|
|
||||||
return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000],
|
|
||||||
"mtime": target.stat().st_mtime}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except (ValueError, OSError):
|
|
||||||
raise HTTPException(404, "Notiz nicht lesbar.")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wissen/graph")
|
|
||||||
def graph_vault() -> dict:
|
|
||||||
"""Obsidian-artiger Graph des Vaults (Nodes = Dateien, Edges = Wiki-Links)."""
|
|
||||||
if not _available():
|
|
||||||
return {"nodes": [], "edges": []}
|
|
||||||
|
|
||||||
nodes = []
|
|
||||||
edges = []
|
|
||||||
# Für schnelle Link-Auflösung (case-insensitive Name -> relativer Pfad als ID)
|
|
||||||
name_to_id = {}
|
|
||||||
|
|
||||||
# 1. Alle Nodes sammeln
|
|
||||||
for p in VAULT.rglob("*.md"):
|
|
||||||
if ".git" in p.parts or "traeume" in p.parts:
|
|
||||||
continue
|
|
||||||
rel = p.relative_to(VAULT).as_posix()
|
|
||||||
name_to_id[p.stem.lower()] = rel
|
|
||||||
|
|
||||||
# Kategorie aus dem Ordner ableiten
|
|
||||||
cat = "knowledge"
|
|
||||||
parent = p.parent.name if p.parent != VAULT else ""
|
|
||||||
if parent == "traeume":
|
|
||||||
cat = "events"
|
|
||||||
elif parent == "muster":
|
|
||||||
cat = "rules"
|
|
||||||
elif parent == "skill-kandidaten":
|
|
||||||
cat = "identity"
|
|
||||||
|
|
||||||
nodes.append({
|
|
||||||
"id": rel,
|
|
||||||
"content": p.stem, # stem ist oft kürzer/prägnanter als der Titel
|
|
||||||
"category": cat,
|
|
||||||
"source": "wiki"
|
|
||||||
})
|
|
||||||
|
|
||||||
# 2. Edges extrahieren (Wiki-Links [[Name]])
|
|
||||||
link_rx = re.compile(r"\[\[([^\]]+)\]\]")
|
|
||||||
for n in nodes:
|
|
||||||
try:
|
|
||||||
target = VAULT / n["id"]
|
|
||||||
content = target.read_text(encoding="utf-8", errors="replace")
|
|
||||||
# Set, um mehrfache Links auf dieselbe Datei zu entduplizieren
|
|
||||||
found_links = {m.group(1).strip().lower() for m in link_rx.finditer(content)}
|
|
||||||
|
|
||||||
for link in found_links:
|
|
||||||
target_id = name_to_id.get(link)
|
|
||||||
if target_id and target_id != n["id"]:
|
|
||||||
edges.append({
|
|
||||||
"source": n["id"],
|
|
||||||
"target": target_id,
|
|
||||||
"weight": 1.0
|
|
||||||
})
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"nodes": nodes, "edges": edges}
|
|
||||||
@@ -70,9 +70,11 @@ def list_backups() -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
out = []
|
out = []
|
||||||
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
|
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
|
||||||
|
st = p.stat()
|
||||||
out.append({
|
out.append({
|
||||||
"snapshot": _ts(p),
|
"snapshot": _ts(p),
|
||||||
"file": p.name,
|
"file": p.name,
|
||||||
"size_mb": round(p.stat().st_size / 1_000_000, 2),
|
"size_mb": round(st.st_size / 1_000_000, 2),
|
||||||
|
"mtime": st.st_mtime, # für die Sicherungs-Lampe im Cockpit (Alter der letzten)
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
"""
|
|
||||||
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
|
|
||||||
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
|
|
||||||
(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1).
|
|
||||||
|
|
||||||
Gedächtnis ist hier bewusst KEINE eigene Leitung mehr: bis August 2026 gab es dafür einen
|
|
||||||
Memory-MCP-Server gegen MC2s /api/memory; seit dessen Ablösung (docs/wissen/VERDIKTE.md,
|
|
||||||
07.08.2026) führt Hermes sein Gedächtnis
|
|
||||||
selbst — es hängt am Agenten, nicht am Gateway. check_health() prüft es weiterhin (Leitung 2),
|
|
||||||
es ist nur nichts mehr zu konfigurieren.
|
|
||||||
|
|
||||||
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
|
|
||||||
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, PORT, V1_UPSTREAM
|
|
||||||
|
|
||||||
DEFAULT_HOST = "192.168.178.151"
|
|
||||||
|
|
||||||
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
|
|
||||||
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
|
|
||||||
PRIMARY_MODEL = "coder"
|
|
||||||
|
|
||||||
|
|
||||||
def _caps(tools: bool = True, images: bool = False) -> dict:
|
|
||||||
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
|
|
||||||
return {"tools": tools, "images": images, "parallel_tool_calls": False,
|
|
||||||
"prompt_cache_key": False, "chat_completions": True,
|
|
||||||
"interleaved_reasoning": False, "max_tokens_parameter": False}
|
|
||||||
|
|
||||||
|
|
||||||
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
|
|
||||||
IDE_MODELS = [
|
|
||||||
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
|
|
||||||
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
|
|
||||||
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _gw(host: str) -> str:
|
|
||||||
# Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort
|
|
||||||
# nur noch roh an den eigenständigen mc2-gateway (:9010) durch.
|
|
||||||
return f"http://{host}:{PORT}/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def build_snippets(host: str = DEFAULT_HOST) -> dict:
|
|
||||||
gw = _gw(host)
|
|
||||||
mc_url = f"http://{host}:{PORT}"
|
|
||||||
|
|
||||||
# Kilo Code = aktiver Nachfolger der Roo/Cline-Linie (Roo im April 2026 eingestellt).
|
|
||||||
# Gleiche Provider-Settings-Struktur; Multi-Agent kommt aus dem Tool (Orchestrator-Modus).
|
|
||||||
kilo = json.dumps({
|
|
||||||
"apiProvider": "openai",
|
|
||||||
"openAiBaseUrl": gw,
|
|
||||||
"openAiApiKey": "local",
|
|
||||||
"openAiModelId": PRIMARY_MODEL,
|
|
||||||
}, indent=2)
|
|
||||||
|
|
||||||
# Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
|
|
||||||
# Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
|
|
||||||
hermes_desktop = (
|
|
||||||
f"# Hermes Desktop — Remote-IDE auf der Box\n"\
|
|
||||||
f"#\n"\
|
|
||||||
f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
|
|
||||||
f"# (MC2-Proxy auf dem Gateway)\n"\
|
|
||||||
f"#\n"\
|
|
||||||
f"# 2. Session-Token: auf der Box liegen in\n"\
|
|
||||||
f"# ~/.hermes/desktop-gateway-token\n"\
|
|
||||||
f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
|
|
||||||
f"#\n"\
|
|
||||||
f"# 3. Token im Browser-Login einfügen — fertig.\n"\
|
|
||||||
f"#\n"\
|
|
||||||
f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
|
|
||||||
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
|
|
||||||
# (Anthropic ⇄ OpenAI) als Aufsatz. Die env-Vars sind Claude Codes echte Schnittstelle.
|
|
||||||
claude_code = (
|
|
||||||
f"# Claude Code spricht das Anthropic-Format — der Gateway ist OpenAI-kompatibel ({gw})\n"
|
|
||||||
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
|
|
||||||
f"# • claude-code-router (leichtgewichtig, npm)\n"
|
|
||||||
f"# • oder LiteLLM mit /v1/messages-Bridge\n"
|
|
||||||
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n"
|
|
||||||
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
|
|
||||||
f"\n"
|
|
||||||
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
|
|
||||||
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
|
|
||||||
f'export ANTHROPIC_MODEL="coder"'
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"host": host,
|
|
||||||
"gateway_url": gw,
|
|
||||||
"mc_url": mc_url,
|
|
||||||
# Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop
|
|
||||||
# (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus),
|
|
||||||
# Claude Code (starke Sessions).
|
|
||||||
# Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal
|
|
||||||
# oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie.
|
|
||||||
"tools": {
|
|
||||||
"hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
|
|
||||||
"note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
|
|
||||||
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
|
|
||||||
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
|
|
||||||
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
|
|
||||||
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
|
|
||||||
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
|
|
||||||
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
|
|
||||||
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway."},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def check_health() -> dict:
|
|
||||||
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
|
|
||||||
Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
|
|
||||||
sonst llama-swap direkt), Leitung 2 = Natives Hermes-Gedächtnis (hermes-gateway),
|
|
||||||
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
|
|
||||||
gateway = {"ok": False, "detail": "nicht erreichbar"}
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=3.0) as c:
|
|
||||||
r = c.get(f"{V1_UPSTREAM or LLAMA_SWAP_URL}/v1/models")
|
|
||||||
if r.status_code == 200:
|
|
||||||
n = len(r.json().get("data", []))
|
|
||||||
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
|
||||||
else:
|
|
||||||
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=3.0) as c:
|
|
||||||
# Hermes Gateway stellt das native Gedächtnis & Agent-Loop bereit
|
|
||||||
r = c.get("http://127.0.0.1:8642/health")
|
|
||||||
if r.status_code in (200, 404, 401):
|
|
||||||
memory = {"ok": True, "detail": "Hermes-Nativ bereit"}
|
|
||||||
else:
|
|
||||||
memory = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=3.0) as c:
|
|
||||||
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
|
|
||||||
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
|
||||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -474,6 +474,21 @@ def set_role(model_id: str, role: str | None) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def add_role(model_id: str, role: str) -> bool:
|
||||||
|
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
|
||||||
|
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
|
||||||
|
cfg = read_config()
|
||||||
|
models = cfg.get("models") or {}
|
||||||
|
rolle = (role or "").strip().lower()
|
||||||
|
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
|
||||||
|
return False
|
||||||
|
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle]
|
||||||
|
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg
|
||||||
|
models[model_id]["aliases"] = bisher + [rolle]
|
||||||
|
write_config(cfg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def set_ctx(model_id: str, ctx: int) -> bool:
|
def set_ctx(model_id: str, ctx: int) -> bool:
|
||||||
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
import psutil
|
||||||
@@ -19,8 +20,11 @@ from services import catalog, discover, jobengine, llamaswap, system
|
|||||||
|
|
||||||
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
||||||
SYSTEM_SERVICES = {"llama-swap"}
|
SYSTEM_SERVICES = {"llama-swap"}
|
||||||
|
# projekte-sync/mc2-backup sind Einmal-Dienste hinter Timern: „restart“ heißt dort „jetzt
|
||||||
|
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py).
|
||||||
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
|
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
|
||||||
"hermes-gateway", "hermes-builtin-ui", "voice-service"}
|
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
|
||||||
|
"projekte-sync", "mc2-backup", "mc2-radar"}
|
||||||
|
|
||||||
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
||||||
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
@@ -510,6 +514,16 @@ def _summarize_hermes_commits(commits: list[dict]) -> dict:
|
|||||||
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
|
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
|
||||||
|
|
||||||
|
|
||||||
|
def _hermes_version(path: str) -> str | None:
|
||||||
|
"""Versionsnummer aus der pyproject.toml des Hermes-Checkouts (z. B. „0.21.3“)."""
|
||||||
|
try:
|
||||||
|
text = (Path(path) / "pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
def hermes_update_details() -> dict:
|
def hermes_update_details() -> dict:
|
||||||
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
|
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
|
||||||
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
|
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
|
||||||
@@ -533,6 +547,7 @@ def hermes_update_details() -> dict:
|
|||||||
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
|
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
|
||||||
info["commits"] = commits
|
info["commits"] = commits
|
||||||
info["behind"] = len(commits)
|
info["behind"] = len(commits)
|
||||||
|
info["installed_version"] = _hermes_version(path)
|
||||||
if commits:
|
if commits:
|
||||||
info.update(_summarize_hermes_commits(commits))
|
info.update(_summarize_hermes_commits(commits))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026)
|
||||||
|
|
||||||
|
llama-swap protokolliert jede Anfrage mit Absender-IP im Journal. Daraus zählen wir je
|
||||||
|
Absender und Tag die Chat-Anfragen, je Stunde der letzten 24 h, und wann welches Modell
|
||||||
|
zuletzt geladen wurde ("Health check passed").
|
||||||
|
|
||||||
|
Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25
|
||||||
|
Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben,
|
||||||
|
bevor jemand nachsieht (gemessen am 23.09.2026).
|
||||||
|
|
||||||
|
Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles,
|
||||||
|
was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
CACHE_S = 600
|
||||||
|
TAGE = 7
|
||||||
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
|
|
||||||
|
_STANDARD_NAMEN = {
|
||||||
|
"127.0.0.1": "Lucy und OpenChamber über MC2",
|
||||||
|
"::1": "Lucy und OpenChamber über MC2",
|
||||||
|
"192.168.178.28": "NerdQuiz auf Arcane",
|
||||||
|
"192.168.178.22": "NerdQuiz-Tests vom PC",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)')
|
||||||
|
_GELADEN = re.compile(r"<([^>]+)> Health check passed")
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cache: dict = {"ts": 0.0, "daten": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _namen() -> dict[str, str]:
|
||||||
|
namen = dict(_STANDARD_NAMEN)
|
||||||
|
for paar in os.environ.get("MC_NUTZER_NAMEN", "").split(";"):
|
||||||
|
if "=" in paar:
|
||||||
|
ip, name = paar.split("=", 1)
|
||||||
|
namen[ip.strip()] = name.strip()
|
||||||
|
return namen
|
||||||
|
|
||||||
|
|
||||||
|
def _journal(seit: str) -> list[str]:
|
||||||
|
"""Nur die Zeilen, die uns interessieren — llama-swap loggt sonst ~6.000 Status-Abfragen
|
||||||
|
am Tag. Ohne systemd (Windows-Dev) leer."""
|
||||||
|
cmd = (f"journalctl -u llama-swap --since '{seit}' -o short-iso --no-pager 2>/dev/null"
|
||||||
|
" | grep -E 'POST /v1/|Health check passed'")
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=60)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return out.stdout.splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict:
|
||||||
|
"""Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Stunde, Modell."""
|
||||||
|
je_absender: Counter = Counter()
|
||||||
|
je_tag: dict[str, Counter] = defaultdict(Counter)
|
||||||
|
stunden: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender → Anzahl (letzte 24 h)
|
||||||
|
geladen: dict[str, str] = {}
|
||||||
|
grenze_24h = jetzt - timedelta(hours=24)
|
||||||
|
for z in zeilen:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(z.split(" ", 1)[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=LOCAL_TZ)
|
||||||
|
if (m := _GELADEN.search(z)):
|
||||||
|
geladen[m.group(1)] = ts.isoformat()
|
||||||
|
continue
|
||||||
|
m = _ANFRAGE.search(z)
|
||||||
|
if not m or not m.group(2).startswith("/v1/chat/completions"):
|
||||||
|
continue
|
||||||
|
wer = namen.get(m.group(1), m.group(1))
|
||||||
|
je_absender[wer] += 1
|
||||||
|
je_tag[ts.date().isoformat()][wer] += 1
|
||||||
|
if ts >= grenze_24h:
|
||||||
|
stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
|
||||||
|
return {
|
||||||
|
"tage": TAGE,
|
||||||
|
"absender": [{"name": n, "anfragen": c} for n, c in je_absender.most_common()],
|
||||||
|
"je_tag": {tag: dict(c) for tag, c in sorted(je_tag.items())},
|
||||||
|
"letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden.get(f"{h:02d}", {}))}
|
||||||
|
for h in range(24)],
|
||||||
|
"zuletzt_geladen": geladen,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def nutzung() -> dict:
|
||||||
|
"""Gecachte Auswertung der letzten TAGE Tage (10 min)."""
|
||||||
|
with _lock:
|
||||||
|
if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S:
|
||||||
|
return _cache["daten"]
|
||||||
|
jetzt = datetime.now(LOCAL_TZ)
|
||||||
|
seit = (jetzt - timedelta(days=TAGE)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
daten = werte_aus(_journal(seit), jetzt, _namen())
|
||||||
|
with _lock:
|
||||||
|
_cache.update(ts=time.time(), daten=daten)
|
||||||
|
return daten
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,164 +0,0 @@
|
|||||||
"""
|
|
||||||
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
|
|
||||||
|
|
||||||
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway,
|
|
||||||
Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten
|
|
||||||
(services/announce.py → Lucy spricht es) und via notify.sh (Telegram).
|
|
||||||
|
|
||||||
Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in
|
|
||||||
Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick
|
|
||||||
nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert.
|
|
||||||
|
|
||||||
Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend
|
|
||||||
(wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import psutil
|
|
||||||
from config import HERMES_API_KEY, HERMES_API_URL, MODELS_DIR, VOICE_SERVICE_URL
|
|
||||||
|
|
||||||
from services import announce, llamaswap
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0"
|
|
||||||
INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks
|
|
||||||
START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen
|
|
||||||
FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min)
|
|
||||||
REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h
|
|
||||||
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
|
|
||||||
|
|
||||||
|
|
||||||
def _reach(url: str, path: str = "/health", headers: dict[str, str] | None = None) -> bool:
|
|
||||||
"""Antwortet der Dienst ueberhaupt? `< 500` ist bewusst nachsichtig: Die Frage ist
|
|
||||||
"laeuft er", nicht "darf ich rein" — ein 401 beweist, dass jemand zuhoert.
|
|
||||||
|
|
||||||
`headers` gibt es seit dem 28.08.2026: Die Hermes-Probe schlug ohne API-Schluessel an
|
|
||||||
und erzeugte dabei 720 `rejected invalid API key`-Warnungen pro Tag in dessen Log
|
|
||||||
(gemessen: 30/Stunde seit dem 27.08. 15:05). Das Urteil war richtig, der Laerm nicht —
|
|
||||||
und er haette einen echten Auth-Fehler unter sich begraben."""
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=5.0) as c:
|
|
||||||
return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _hermes_kopf() -> dict[str, str]:
|
|
||||||
"""Bearer-Kopf fuer die Hermes-Platform, falls ein Schluessel konfiguriert ist."""
|
|
||||||
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _check_engine() -> bool:
|
|
||||||
return llamaswap.engine_reachable()
|
|
||||||
|
|
||||||
|
|
||||||
def _check_brain() -> bool:
|
|
||||||
"""Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL —
|
|
||||||
Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt."""
|
|
||||||
st = llamaswap.brain_status()
|
|
||||||
if st.get("ready"):
|
|
||||||
return True
|
|
||||||
return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt
|
|
||||||
|
|
||||||
|
|
||||||
def _check_disk() -> bool:
|
|
||||||
try:
|
|
||||||
return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT
|
|
||||||
except Exception:
|
|
||||||
return True # kein Messwert ≠ Alarm
|
|
||||||
|
|
||||||
|
|
||||||
# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache).
|
|
||||||
CHECKS: dict[str, tuple] = {
|
|
||||||
"engine": (_check_engine,
|
|
||||||
"Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.",
|
|
||||||
"Die Modell-Engine ist wieder da."),
|
|
||||||
"brain": (_check_brain,
|
|
||||||
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
|
|
||||||
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
|
|
||||||
"hermes": (lambda: _reach(HERMES_API_URL, "/v1/models", _hermes_kopf()),
|
|
||||||
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
|
|
||||||
"Der Agent-Dienst läuft wieder."),
|
|
||||||
"voice": (lambda: _reach(VOICE_SERVICE_URL),
|
|
||||||
"Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.",
|
|
||||||
"Der Hör-Dienst läuft wieder."),
|
|
||||||
"disk": (_check_disk,
|
|
||||||
f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.",
|
|
||||||
"Die Platte hat wieder genug Luft."),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Steward-Modus (UMBAU v3 P2): Läuft der Wächter als EIGENER Prozess (mc2-steward),
|
|
||||||
# beobachtet er zusätzlich das Steuerpult selbst und den mc2-gateway — genau die zwei
|
|
||||||
# Ausfälle, die der alte In-Process-Wächter prinzipbedingt nie melden konnte (er starb mit).
|
|
||||||
if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
|
|
||||||
_MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
|
|
||||||
_GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
|
|
||||||
CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"),
|
|
||||||
"Das Steuerpult ist ausgefallen — Dashboard und Briefkasten gehen gerade nicht.",
|
|
||||||
"Das Steuerpult ist wieder da.")
|
|
||||||
CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"),
|
|
||||||
"Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.",
|
|
||||||
"Der Modell-Gateway läuft wieder.")
|
|
||||||
|
|
||||||
|
|
||||||
class _Watch:
|
|
||||||
__slots__ = ("alert_ts", "alerted", "fails")
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.fails = 0 # Fehl-Ticks in Folge
|
|
||||||
self.alerted = False # Alarm ist raus, Entwarnung steht aus
|
|
||||||
self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S)
|
|
||||||
|
|
||||||
|
|
||||||
_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS}
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_telegram(subject: str, text: str) -> None:
|
|
||||||
"""Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag
|
|
||||||
legt der Wächter selbst ab."""
|
|
||||||
announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)")
|
|
||||||
|
|
||||||
|
|
||||||
def _tick() -> None:
|
|
||||||
now = time.time()
|
|
||||||
for name, (check, fail_msg, ok_msg) in CHECKS.items():
|
|
||||||
w = _watches[name]
|
|
||||||
try:
|
|
||||||
ok = bool(check())
|
|
||||||
except Exception:
|
|
||||||
ok = False
|
|
||||||
if ok:
|
|
||||||
w.fails = 0
|
|
||||||
if w.alerted:
|
|
||||||
w.alerted = False
|
|
||||||
announce.add(ok_msg, subject="[Box wieder ok]", source="sentry")
|
|
||||||
_notify_telegram("[Box wieder ok]", ok_msg)
|
|
||||||
continue
|
|
||||||
w.fails += 1
|
|
||||||
due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S)
|
|
||||||
if due:
|
|
||||||
prefix = "" if not w.alerted else "Immer noch: "
|
|
||||||
w.alerted = True
|
|
||||||
w.alert_ts = now
|
|
||||||
announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry")
|
|
||||||
_notify_telegram("[Box-Problem]", prefix + fail_msg)
|
|
||||||
log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails)
|
|
||||||
|
|
||||||
|
|
||||||
async def sentry_loop() -> None:
|
|
||||||
"""Endlos-Schleife (Hintergrund-Task im MC2-Lifespan)."""
|
|
||||||
await asyncio.sleep(START_DELAY)
|
|
||||||
log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(_tick)
|
|
||||||
except Exception:
|
|
||||||
log.debug("sentry: Tick fehlgeschlagen", exc_info=True)
|
|
||||||
await asyncio.sleep(INTERVAL)
|
|
||||||
+18
-25
@@ -8,6 +8,7 @@ der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Byt
|
|||||||
|
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -127,33 +128,25 @@ def find_hermes_agent_git() -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def get_engine_version() -> dict:
|
def get_engine_version() -> dict:
|
||||||
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
|
"""Build-Nummer der laufenden Engine (offizieller Vulkan-Build unter /opt/llamacpp-vulkan).
|
||||||
git_info = get_git_info(engine_path)
|
|
||||||
if git_info:
|
|
||||||
return {**git_info, "type": "git"}
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
os.path.join(engine_path, "llama-server"),
|
|
||||||
os.path.join(engine_path, "bin", "llama-server"),
|
|
||||||
"/usr/local/bin/llama-server",
|
|
||||||
"/usr/bin/llama-server",
|
|
||||||
"llama-server"
|
|
||||||
]
|
|
||||||
|
|
||||||
for binary in candidates:
|
|
||||||
if binary != "llama-server" and not os.path.exists(binary):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
|
|
||||||
output = (res.stdout or "").strip() or (res.stderr or "").strip()
|
|
||||||
if output:
|
|
||||||
lines = output.splitlines()
|
|
||||||
ver = lines[0] if lines else "unknown"
|
|
||||||
return {"version_text": ver, "type": "binary"}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
Bis 09/2026 stand hier /opt/llamacpp als Standard — ein verwaister Ordner aus der ROCm-Zeit
|
||||||
|
mit eigenem git-Stand. MC2 zeigte deshalb „1 (1ec44d1)“, während Build 11057 lief. Die
|
||||||
|
Binary braucht ihre .so-Dateien aus dem eigenen Ordner, daher LD_LIBRARY_PATH."""
|
||||||
|
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
|
||||||
|
binary = os.path.join(engine_path, "llama-server")
|
||||||
|
if not os.path.exists(binary):
|
||||||
return {"type": "unknown"}
|
return {"type": "unknown"}
|
||||||
|
try:
|
||||||
|
env = dict(os.environ, LD_LIBRARY_PATH=engine_path)
|
||||||
|
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20, env=env)
|
||||||
|
text = (res.stdout or "") + (res.stderr or "")
|
||||||
|
except Exception:
|
||||||
|
return {"type": "unknown"}
|
||||||
|
if (m := re.search(r"\bbuild\s+(\d{3,})\b", text)):
|
||||||
|
return {"type": "binary", "build": int(m.group(1)), "version_text": f"b{m.group(1)}"}
|
||||||
|
zeile = next((z for z in text.splitlines() if z.startswith("version")), "")
|
||||||
|
return {"type": "binary", "version_text": zeile or "unbekannt"}
|
||||||
|
|
||||||
|
|
||||||
_VERSION_CACHE = {"ts": 0.0, "data": {}}
|
_VERSION_CACHE = {"ts": 0.0, "data": {}}
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""
|
||||||
|
Update-Verlauf und festgehaltene Bausteine (Box-Wart, Umbau 09/2026).
|
||||||
|
|
||||||
|
Was ein Update-Lauf wirklich gebracht hat, steht nicht im Hermes-Cron-Status — der sagt nur
|
||||||
|
„Skript lief". Die Wahrheit ist das, was deploy/autoupdate.sh am Ende meldet: eine Zeile je
|
||||||
|
Baustein („255 → 256 eingespielt", „zurückgerollt + GEPINNT" …). notify.sh schreibt jede
|
||||||
|
Meldung mit Zeitstempel in ~/mc2-notify.log; der Verlauf wird von dort gelesen, nur lesend.
|
||||||
|
|
||||||
|
Das Pin-Register (/srv/models/mc2-pins.json) führt autoupdate.sh: Besteht ein Update den Check
|
||||||
|
nicht, wird zurückgerollt und der Baustein festgehalten — künftige Sonntage überspringen ihn.
|
||||||
|
Vom 06. bis 17.09.2026 hat das niemand gesehen, die Box bekam elf Tage keine Updates. Darum
|
||||||
|
zeigt der Wächter jeden festgehaltenen Baustein als Hinweis, und hier lässt er sich freigeben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
|
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
|
||||||
|
PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json"))
|
||||||
|
LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate
|
||||||
|
LAUF_LUECKE_S = 45 * 60 # Meldungen mit größerem Abstand gehören zu verschiedenen Läufen
|
||||||
|
|
||||||
|
NAMEN = {"swap": "llama-swap", "engine": "Motor (llama.cpp)", "hermes": "Hermes"}
|
||||||
|
FREIGEGEBEN_TEXT = "Freigegeben. Der nächste Sonntags-Lauf versucht das Update erneut."
|
||||||
|
|
||||||
|
# So heißen die Bausteine in den Meldungen von autoupdate.sh → kurzer Name fürs Cockpit.
|
||||||
|
_BAUSTEIN_IM_TEXT = (
|
||||||
|
(re.compile(r"Router \(llama-swap\)"), "llama-swap"),
|
||||||
|
(re.compile(r"Engine \(llama\.cpp\)"), "Motor"),
|
||||||
|
(re.compile(r"Hermes(-Agent)?\b"), "Hermes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
|
||||||
|
_EINTRAG = re.compile(
|
||||||
|
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
||||||
|
r"(?:OK telegram|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
|
||||||
|
|
||||||
|
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
|
||||||
|
_UPDATE_MELDUNG = re.compile(
|
||||||
|
r"^(KRITISCH: )?(Router \(llama-swap\)|Engine \(llama\.cpp\)|Hermes-Agent|Hermes)[ -](aktualisiert|Update)"
|
||||||
|
r"|^Auto-Update abgebrochen|^Sonntags-Update")
|
||||||
|
_NEUSTART = re.compile(r"^Die Box startet jetzt neu|^Neustart (NICHT|verschoben|FEHLGESCHLAGEN)")
|
||||||
|
|
||||||
|
# Einordnung einer Ergebniszeile. Reihenfolge zählt: „zurückgerollt + GEPINNT“ ist ein Rückbau,
|
||||||
|
# „Check fehlgeschlagen“ ist offen und kein Stack-Schaden.
|
||||||
|
_EINORDNUNG = (
|
||||||
|
("fehler", re.compile(r"kritisch", re.IGNORECASE)),
|
||||||
|
("zurueckgerollt", re.compile(r"zurückgerollt", re.IGNORECASE)),
|
||||||
|
("festgehalten", re.compile(r"gepinnt", re.IGNORECASE)),
|
||||||
|
("offen", re.compile(r"wartet|unklar|check fehlgeschlagen|job-start|nicht ausgefuehrt|verschoben",
|
||||||
|
re.IGNORECASE)),
|
||||||
|
("fehler", re.compile(r"fehlgeschlagen|abgebrochen|ausgefallen|mit fehler", re.IGNORECASE)),
|
||||||
|
("neustart", re.compile(r"startet jetzt neu", re.IGNORECASE)),
|
||||||
|
("eingespielt", re.compile(r"eingespielt|aktualisiert|repariert", re.IGNORECASE)),
|
||||||
|
("aktuell", re.compile(r"aktuell", re.IGNORECASE)),
|
||||||
|
)
|
||||||
|
STUFE_DER_ZEILE = {"fehler": "rot", "zurueckgerollt": "gelb", "festgehalten": "gelb", "offen": "gelb",
|
||||||
|
"neustart": "info", "eingespielt": "gruen", "aktuell": "grau"}
|
||||||
|
_RANG = {"rot": 4, "gelb": 3, "gruen": 2, "info": 1, "grau": 0}
|
||||||
|
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_cache: dict = {"schluessel": None, "laeufe": []}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Protokoll lesen ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def eintraege(text: str) -> list[tuple[datetime, str]]:
|
||||||
|
"""Protokoll → (Zeitpunkt, Nachricht). Zeilen ohne Zeitstempel gehören zur Nachricht davor
|
||||||
|
(mehrzeilige Meldungen wie die Wochen-Zusammenfassung oder die Nachrichten-Übersicht)."""
|
||||||
|
ergebnis: list[tuple[datetime, str]] = []
|
||||||
|
for zeile in text.splitlines():
|
||||||
|
if m := _EINTRAG.match(zeile):
|
||||||
|
try:
|
||||||
|
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
ergebnis.append((zeit, m.group(2)))
|
||||||
|
elif ergebnis:
|
||||||
|
zeit, bisher = ergebnis[-1]
|
||||||
|
ergebnis[-1] = (zeit, f"{bisher}\n{zeile}")
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _einordnen(text: str) -> str:
|
||||||
|
return next((art for art, muster in _EINORDNUNG if muster.search(text)), "offen")
|
||||||
|
|
||||||
|
|
||||||
|
def _baustein_aus(text: str) -> str:
|
||||||
|
return next((name for muster, name in _BAUSTEIN_IM_TEXT if muster.search(text)), "Update-Lauf")
|
||||||
|
|
||||||
|
|
||||||
|
def _lesbar(text: str) -> str:
|
||||||
|
"""Meldungstext fürs Cockpit: Fachwörter der Skripte in Alltagssprache."""
|
||||||
|
text = re.sub(r"\s*\+\s*GEPINNT", " und festgehalten", text)
|
||||||
|
text = re.sub(r"gepinnt, übersprungen", "festgehalten, übersprungen", text, flags=re.IGNORECASE)
|
||||||
|
text = text.replace("GEPINNT", "festgehalten").replace("FEHLGESCHLAGEN", "fehlgeschlagen")
|
||||||
|
text = re.sub(r"\b1 Commits\b", "1 Änderung", text)
|
||||||
|
text = re.sub(r"\b(\d+) Commits\b", r"\1 Änderungen", text)
|
||||||
|
# Nur der erste Satz: der Rest der Meldungen ist Begleittext für Telegram.
|
||||||
|
return re.split(r"(?<=[.!])\s", text.strip(), maxsplit=1)[0][:240]
|
||||||
|
|
||||||
|
|
||||||
|
def _zeile(baustein: str, text: str) -> dict:
|
||||||
|
ergebnis = _einordnen(text)
|
||||||
|
return {"baustein": baustein, "ergebnis": ergebnis, "stufe": STUFE_DER_ZEILE[ergebnis], "text": _lesbar(text)}
|
||||||
|
|
||||||
|
|
||||||
|
def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
|
||||||
|
zusammenfassung = next((t for _, t in reversed(gruppe) if _ZUSAMMENFASSUNG.search(t)), None)
|
||||||
|
zeilen: list[dict] = []
|
||||||
|
if zusammenfassung:
|
||||||
|
for z in zusammenfassung.splitlines()[1:]:
|
||||||
|
if m := re.match(r"^\s*•\s*(.+?):\s*(.+)$", z):
|
||||||
|
zeilen.append(_zeile(_baustein_aus(m.group(1)), m.group(2)))
|
||||||
|
for _, text in gruppe:
|
||||||
|
erste = text.splitlines()[0] if text else ""
|
||||||
|
if _NEUSTART.search(erste):
|
||||||
|
zeilen.append(_zeile("Neustart", erste))
|
||||||
|
elif erste.startswith(("Sonntags-Update", "Auto-Update abgebrochen")):
|
||||||
|
zeilen.append(_zeile("Update-Lauf", erste))
|
||||||
|
elif not zusammenfassung and _UPDATE_MELDUNG.search(erste):
|
||||||
|
# Ohne Abschlussmeldung (Lauf abgebrochen) sind die Einzelmeldungen alles, was es gibt.
|
||||||
|
zeilen.append(_zeile(_baustein_aus(erste), re.sub(r"^KRITISCH:\s*", "Kritisch: ", erste)))
|
||||||
|
|
||||||
|
stufe = max((z["stufe"] for z in zeilen), key=lambda s: _RANG[s], default="grau")
|
||||||
|
arten = {z["ergebnis"] for z in zeilen}
|
||||||
|
if stufe == "rot":
|
||||||
|
titel = "Fehler"
|
||||||
|
elif stufe == "gelb":
|
||||||
|
titel = ("Zurückgerollt" if "zurueckgerollt" in arten
|
||||||
|
else "Festgehalten" if "festgehalten" in arten else "Unvollständig")
|
||||||
|
elif stufe == "gruen":
|
||||||
|
titel = "Eingespielt"
|
||||||
|
elif stufe == "info":
|
||||||
|
titel = "Neu gestartet"
|
||||||
|
else:
|
||||||
|
titel = "Alles aktuell"
|
||||||
|
start, ende = gruppe[0][0], gruppe[-1][0]
|
||||||
|
sonntag = start.weekday() == 6 and 4 <= start.hour < 6
|
||||||
|
return {"start": start.isoformat(), "ende": ende.isoformat(),
|
||||||
|
"anlass": "Updates am Sonntag" if sonntag else "Update von Hand",
|
||||||
|
"stufe": stufe, "titel": titel, "zeilen": zeilen}
|
||||||
|
|
||||||
|
|
||||||
|
def laeufe_aus(text: str) -> list[dict]:
|
||||||
|
"""Alle Update-Läufe im Protokolltext, neueste zuerst."""
|
||||||
|
relevant = [(z, t) for z, t in eintraege(text)
|
||||||
|
if _ZUSAMMENFASSUNG.search(t) or _UPDATE_MELDUNG.search(t.splitlines()[0] if t else "")
|
||||||
|
or _NEUSTART.search(t.splitlines()[0] if t else "")]
|
||||||
|
gruppen: list[list[tuple[datetime, str]]] = []
|
||||||
|
for eintrag in relevant:
|
||||||
|
if gruppen and (eintrag[0] - gruppen[-1][-1][0]).total_seconds() <= LAUF_LUECKE_S:
|
||||||
|
gruppen[-1].append(eintrag)
|
||||||
|
else:
|
||||||
|
gruppen.append([eintrag])
|
||||||
|
# Ein Neustart allein ist kein Update-Lauf (z. B. von Hand ausgelöst).
|
||||||
|
laeufe = [_lauf(g) for g in gruppen if any(not _NEUSTART.search(t.splitlines()[0]) for _, t in g)]
|
||||||
|
return list(reversed(laeufe))
|
||||||
|
|
||||||
|
|
||||||
|
def _protokoll_ende() -> str:
|
||||||
|
try:
|
||||||
|
with NOTIFY_LOG.open("rb") as f:
|
||||||
|
f.seek(0, os.SEEK_END)
|
||||||
|
groesse = f.tell()
|
||||||
|
f.seek(max(0, groesse - LESE_MAX))
|
||||||
|
roh = f.read()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
text = roh.decode("utf-8", errors="replace")
|
||||||
|
if groesse > LESE_MAX:
|
||||||
|
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def laeufe(anzahl: int = 8) -> list[dict]:
|
||||||
|
"""Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn das Protokoll wuchs."""
|
||||||
|
try:
|
||||||
|
st = NOTIFY_LOG.stat()
|
||||||
|
schluessel = (st.st_mtime_ns, st.st_size)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
with _cache_lock:
|
||||||
|
if _cache["schluessel"] != schluessel:
|
||||||
|
_cache["laeufe"] = laeufe_aus(_protokoll_ende())
|
||||||
|
_cache["schluessel"] = schluessel
|
||||||
|
return _cache["laeufe"][:anzahl]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Festgehaltene Bausteine (Pin-Register von autoupdate.sh) -----------------------
|
||||||
|
|
||||||
|
def _pins_lesen() -> dict:
|
||||||
|
try:
|
||||||
|
daten = json.loads(PINS_FILE.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return daten if isinstance(daten, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def festgehalten() -> list[dict]:
|
||||||
|
"""Bausteine, die der Sonntags-Lauf nach einem gescheiterten Update überspringt."""
|
||||||
|
ergebnis = []
|
||||||
|
for baustein, eintrag in _pins_lesen().items():
|
||||||
|
if not (isinstance(eintrag, dict) and eintrag.get("pinned")):
|
||||||
|
continue
|
||||||
|
datum = str(eintrag.get("datum") or "")
|
||||||
|
m = re.fullmatch(r"\d{4}-(\d{2})-(\d{2})", datum)
|
||||||
|
seit = f"{m.group(2)}.{m.group(1)}." if m else (datum or "?")
|
||||||
|
ergebnis.append({"baustein": baustein, "name": NAMEN.get(baustein, baustein),
|
||||||
|
"version": str(eintrag.get("version") or "?"), "seit": seit,
|
||||||
|
"grund": _lesbar(str(eintrag.get("grund") or ""))})
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def freigeben(baustein: str) -> bool:
|
||||||
|
"""Pin lösen (atomar schreiben). False, wenn der Baustein gar nicht festgehalten war."""
|
||||||
|
daten = _pins_lesen()
|
||||||
|
if baustein not in daten:
|
||||||
|
return False
|
||||||
|
daten.pop(baustein)
|
||||||
|
tmp = PINS_FILE.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, PINS_FILE)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,572 @@
|
|||||||
|
"""
|
||||||
|
Wächter der Box (Box-Wart, Umbau 09/2026) — löst den alten Health-Wächter (sentry.py) ab.
|
||||||
|
|
||||||
|
Der alte Wächter kannte nur sieben Dienste per HTTP. Dass projekte-sync seit dem 07.09.
|
||||||
|
stündlich scheiterte und der Nachrichten-Job jeden Morgen Werkzeugfehler warf, hat er nie
|
||||||
|
gesehen. Dieser Wächter schaut deshalb breiter hin:
|
||||||
|
|
||||||
|
• Dienste systemd-Zustand der Kern-Dienste (System + User)
|
||||||
|
• Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung)
|
||||||
|
• Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log)
|
||||||
|
• Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP
|
||||||
|
• Platte Füllstand des Modell-Laufwerks
|
||||||
|
• Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält
|
||||||
|
|
||||||
|
Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und
|
||||||
|
Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst
|
||||||
|
(ActiveState=failed) wird neu gestartet, höchstens AUTO_MAX_PRO_STUNDE-mal pro Stunde. Ein
|
||||||
|
bewusst gestoppter Dienst (inactive) bleibt aus und wird nur gemeldet. Rote Hinweise gehen
|
||||||
|
zusätzlich an Telegram und in Lucys Briefkasten.
|
||||||
|
|
||||||
|
Läuft im mc2-steward (eigener Prozess, übersteht MC2-Neustarts) und ist dort der EINZIGE
|
||||||
|
Schreiber von STORE_PATH. MC2 liest den Stand nur (lese_stand()) und führt Knopf-Aktionen
|
||||||
|
selbst aus (fuehre_aktion_aus()); der nächste Takt sieht das Ergebnis.
|
||||||
|
|
||||||
|
Während eines Updates (autoupdate.sh, update-engine.sh, update-swap.sh, hermes update)
|
||||||
|
hält er still: keine neuen Dienst-Hinweise, keine Selbstreparatur — Neustarts gehören dort
|
||||||
|
zum Ablauf.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import psutil
|
||||||
|
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, MODELS_DIR, VOICE_SERVICE_URL
|
||||||
|
|
||||||
|
from services import announce, llamaswap, maintenance, update_verlauf
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# MC_SENTRY_ENABLED bleibt als Rückfall-Schalter gültig: Die bestehenden Units setzen ihn.
|
||||||
|
ENABLED = os.environ.get("MC_WAECHTER_ENABLED", os.environ.get("MC_SENTRY_ENABLED", "1")) != "0"
|
||||||
|
INTERVAL = int(os.environ.get("MC_WAECHTER_INTERVAL", "60")) # Sekunden zwischen Takten
|
||||||
|
START_DELAY = int(os.environ.get("MC_WAECHTER_START_DELAY", "60")) # Dienste nach Boot setzen lassen
|
||||||
|
FAIL_AFTER = int(os.environ.get("MC_WAECHTER_FAIL_AFTER", "2")) # Takte bis zum Hinweis
|
||||||
|
REMIND_S = int(os.environ.get("MC_WAECHTER_REMIND_S", "21600")) # Telegram-Erinnerung: 6 h
|
||||||
|
AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2"))
|
||||||
|
DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90"))
|
||||||
|
DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80"))
|
||||||
|
STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(MODELS_DIR / "mc2-waechter.json")))
|
||||||
|
VERLAUF_MAX = 200
|
||||||
|
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber
|
||||||
|
# nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter.
|
||||||
|
TROCKEN = os.environ.get("MC_WAECHTER_TROCKEN", "") == "1"
|
||||||
|
|
||||||
|
# Im Steward-Modus beobachtet er auch MC2 selbst und den Gateway (wie der alte Wächter).
|
||||||
|
WATCH_MC2 = os.environ.get("MC_SENTRY_WATCH_MC2", os.environ.get("MC_WAECHTER_WATCH_MC2", "")) == "1"
|
||||||
|
MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
|
||||||
|
GATEWAY_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
|
||||||
|
|
||||||
|
# Dienst → (System-Dienst?, wichtig?, Anzeigename). Wichtig = rot, sonst gelb.
|
||||||
|
DIENSTE: dict[str, tuple[bool, bool, str]] = {
|
||||||
|
"llama-swap": (True, True, "Motor (llama-swap)"),
|
||||||
|
"hermes-gateway": (False, True, "Hermes"),
|
||||||
|
"mc2-gateway": (False, True, "Modell-Gateway"),
|
||||||
|
"mission-control-2": (False, True, "MC2"),
|
||||||
|
"voice-service": (False, False, "Hör-Dienst"),
|
||||||
|
"lucy-stimme": (False, False, "Lucys Stimme"),
|
||||||
|
"hermes-builtin-ui": (False, False, "Hermes-Dashboard"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Einmal-Dienste hinter Timern: Name → (Anzeigename, wichtig?)
|
||||||
|
TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
|
||||||
|
"projekte-sync": ("Projekte-Abgleich", False),
|
||||||
|
"mc2-backup": ("Sicherung", True),
|
||||||
|
"mc2-radar": ("Modell-Radar", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
|
||||||
|
HERMES_EXEC_DB = HERMES_HOME / "cron" / "executions.db"
|
||||||
|
HERMES_ERRORS_LOG = HERMES_HOME / "logs" / "errors.log"
|
||||||
|
|
||||||
|
# Prozesse, an denen ein laufendes Update zu erkennen ist.
|
||||||
|
_UPDATE_PROZESSE = re.compile(r"autoupdate\.sh|update-engine\.sh|update-swap\.sh|hermes(\s+\S+)*\s+update\b")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Befund:
|
||||||
|
"""Ein aktuell festgestelltes Problem. Wird nach FAIL_AFTER Takten zum Hinweis."""
|
||||||
|
id: str
|
||||||
|
stufe: str # "rot" | "gelb"
|
||||||
|
titel: str
|
||||||
|
text: str
|
||||||
|
quelle: str
|
||||||
|
aktionen: list[dict] = field(default_factory=list)
|
||||||
|
auto: str | None = None # Name der Selbstreparatur, falls erlaubt
|
||||||
|
sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde)
|
||||||
|
|
||||||
|
|
||||||
|
def _aktion(aid: str, label: str, **extra: str) -> dict:
|
||||||
|
return {"id": aid, "label": label, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Rohdaten -------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _systemctl_show(name: str, system: bool) -> dict[str, str]:
|
||||||
|
"""Zustand einer Unit. Auf Windows (Dev) oder ohne systemd: leeres Dict (harmlos)."""
|
||||||
|
cmd = ["systemctl"] if system else ["systemctl", "--user"]
|
||||||
|
cmd += ["show", name, "-p",
|
||||||
|
"LoadState,ActiveState,SubState,Result,ExecMainStatus,InactiveExitTimestamp"]
|
||||||
|
try:
|
||||||
|
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return dict(line.split("=", 1) for line in out.splitlines() if "=" in line)
|
||||||
|
|
||||||
|
|
||||||
|
def _journal_fehlerzeile(name: str, system: bool = False) -> str:
|
||||||
|
"""Die aussagekräftigste Fehlerzeile aus den letzten Journal-Zeilen einer Unit."""
|
||||||
|
cmd = ["journalctl"] + ([] if system else ["--user"]) + ["-u", name, "-n", "40", "-o", "cat", "--no-pager"]
|
||||||
|
try:
|
||||||
|
zeilen = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return waehle_fehlerzeile(zeilen)
|
||||||
|
|
||||||
|
|
||||||
|
def waehle_fehlerzeile(zeilen: list[str]) -> str:
|
||||||
|
"""Die Zeile mit der eigentlichen Ursache. Zuerst die Meldungen des Skripts selbst
|
||||||
|
(projekte-sync markiert Fehler mit „!“), erst dann allgemeine Fehlerwörter — und nie die
|
||||||
|
systemd-Rahmenzeilen („Failed to start …“, „Main process exited …“): Die sagen nur,
|
||||||
|
DASS es scheiterte, nicht warum (so stand es im ersten Probelauf am 23.09.)."""
|
||||||
|
rahmen = re.compile(r"Failed to start|Main process exited|Failed with result|Consumed .* CPU time")
|
||||||
|
stufen = (
|
||||||
|
re.compile(r"(^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+)?\s*!|fehlgeschlagen|ABBRUCH)", re.IGNORECASE),
|
||||||
|
re.compile(r"(error|failed|fatal|traceback)", re.IGNORECASE),
|
||||||
|
)
|
||||||
|
for muster in stufen:
|
||||||
|
for zeile in reversed(zeilen):
|
||||||
|
if muster.search(zeile) and not rahmen.search(zeile):
|
||||||
|
# Zeitstempel "2026-09-23 21:02:46 " des Skript-Logs abschneiden
|
||||||
|
return re.sub(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+", "", zeile).strip()[:300]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _reach(url: str, path: str, headers: dict[str, str] | None = None) -> bool:
|
||||||
|
"""Antwortet der Dienst? < 500 genügt — ein 401 beweist, dass jemand zuhört."""
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=5.0) as c:
|
||||||
|
return c.get(f"{url}{path}", headers=headers or {}).status_code < 500
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _update_laeuft() -> bool:
|
||||||
|
"""Läuft gerade ein Update? Dann gehören Neustarts zum Ablauf und sind kein Befund."""
|
||||||
|
try:
|
||||||
|
for p in psutil.process_iter(["cmdline"]):
|
||||||
|
cmd = " ".join(p.info.get("cmdline") or [])
|
||||||
|
if cmd and _UPDATE_PROZESSE.search(cmd):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Prüfungen ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def pruefe_dienste() -> list[Befund]:
|
||||||
|
befunde: list[Befund] = []
|
||||||
|
for name, (system, wichtig, anzeige) in DIENSTE.items():
|
||||||
|
z = _systemctl_show(name, system)
|
||||||
|
if not z or z.get("LoadState") in ("not-found", ""):
|
||||||
|
continue # nicht installiert oder kein systemd (Dev)
|
||||||
|
zustand = z.get("ActiveState", "")
|
||||||
|
if zustand in ("active", "activating", "reloading", "deactivating"):
|
||||||
|
continue
|
||||||
|
stufe = "rot" if wichtig else "gelb"
|
||||||
|
aktionen = [_aktion("neustart", "Neu starten", dienst=name), _aktion("protokoll", "Protokoll", dienst=name)]
|
||||||
|
if zustand == "failed":
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist abgestürzt",
|
||||||
|
text=_journal_fehlerzeile(name, system) or f"Die Unit {name} steht auf „failed“.",
|
||||||
|
quelle=name, aktionen=aktionen, auto="neustart"))
|
||||||
|
else: # inactive: vermutlich bewusst gestoppt → nur melden, nicht eigenmächtig starten
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"dienst:{name}", stufe=stufe, titel=f"{anzeige} ist gestoppt",
|
||||||
|
text=f"Die Unit {name} läuft nicht. Sie wurde vermutlich angehalten.",
|
||||||
|
quelle=name, aktionen=[_aktion("neustart", "Starten", dienst=name), aktionen[1]]))
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_timer_dienste() -> list[Befund]:
|
||||||
|
befunde: list[Befund] = []
|
||||||
|
for name, (anzeige, wichtig) in TIMER_DIENSTE.items():
|
||||||
|
z = _systemctl_show(name, False)
|
||||||
|
if not z or z.get("LoadState") in ("not-found", ""):
|
||||||
|
continue
|
||||||
|
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
|
||||||
|
continue
|
||||||
|
grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}."
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",
|
||||||
|
titel=f"{anzeige} scheitert beim letzten Lauf", text=grund, quelle=name,
|
||||||
|
aktionen=[_aktion("protokoll", "Protokoll", dienst=name),
|
||||||
|
_aktion("neustart", "Jetzt erneut laufen lassen", dienst=name)],
|
||||||
|
sofort=True))
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def _hermes_jobs() -> list[dict]:
|
||||||
|
try:
|
||||||
|
daten = json.loads(HERMES_JOBS_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
jobs = daten.get("jobs", daten) if isinstance(daten, dict) else daten
|
||||||
|
if isinstance(jobs, dict):
|
||||||
|
jobs = list(jobs.values())
|
||||||
|
return [j for j in jobs if isinstance(j, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _letzter_lauf(job_id: str) -> dict | None:
|
||||||
|
"""Letzter Lauf eines Jobs aus executions.db (nur lesend geöffnet)."""
|
||||||
|
if not HERMES_EXEC_DB.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(f"file:{HERMES_EXEC_DB}?mode=ro", uri=True, timeout=2)
|
||||||
|
try:
|
||||||
|
row = con.execute(
|
||||||
|
"select status, started_at, finished_at, error from executions "
|
||||||
|
"where job_id=? order by started_at desc limit 1", (job_id,)).fetchone()
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
return None
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {"status": row[0], "started_at": row[1], "finished_at": row[2], "error": row[3]}
|
||||||
|
|
||||||
|
|
||||||
|
def werkzeugfehler_im_lauf(zeilen: list[str], job_id: str, start_iso: str) -> tuple[int, str]:
|
||||||
|
"""Zählt 'Tool X returned error'-Zeilen eines Laufs in errors.log. Hermes markiert jede
|
||||||
|
Zeile mit [cron_<job>_<JJJJMMTT>_<HHMMSS>]; der Tag des Laufstarts reicht zur Zuordnung."""
|
||||||
|
try:
|
||||||
|
tag = datetime.fromisoformat(start_iso).strftime("%Y%m%d")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0, ""
|
||||||
|
marke = f"[cron_{job_id}_{tag}_"
|
||||||
|
treffer = [z for z in zeilen if marke in z and "returned error" in z]
|
||||||
|
if not treffer:
|
||||||
|
return 0, ""
|
||||||
|
m = re.search(r"Tool (\S+) returned error[^:]*:\s*(.*)$", treffer[0])
|
||||||
|
beispiel = ""
|
||||||
|
if m:
|
||||||
|
beispiel = f"{m.group(1)}: {m.group(2)}"
|
||||||
|
if (fm := re.search(r'"error":\s*"([^"]+)', m.group(2))):
|
||||||
|
beispiel = f"{m.group(1)}: {fm.group(1)}"
|
||||||
|
return len(treffer), beispiel[:220]
|
||||||
|
|
||||||
|
|
||||||
|
def _errors_log_ende(max_bytes: int = 400_000) -> list[str]:
|
||||||
|
try:
|
||||||
|
with HERMES_ERRORS_LOG.open("rb") as f:
|
||||||
|
f.seek(0, os.SEEK_END)
|
||||||
|
f.seek(max(0, f.tell() - max_bytes))
|
||||||
|
return f.read().decode("utf-8", "replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_hermes_jobs() -> list[Befund]:
|
||||||
|
befunde: list[Befund] = []
|
||||||
|
jobs = _hermes_jobs()
|
||||||
|
if not jobs:
|
||||||
|
return befunde
|
||||||
|
log_zeilen = _errors_log_ende()
|
||||||
|
jetzt = time.time()
|
||||||
|
for job in jobs:
|
||||||
|
if not job.get("enabled", True):
|
||||||
|
continue
|
||||||
|
jid, name = str(job.get("id", "")), str(job.get("name", "Job"))
|
||||||
|
wichtig = "update" in name.lower()
|
||||||
|
status = job.get("last_status")
|
||||||
|
if status not in (None, "ok", "success") or int(job.get("failure_streak") or 0) > 0:
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"job:{jid}", stufe="rot" if wichtig else "gelb",
|
||||||
|
titel=f"Job „{name}“ ist fehlgeschlagen",
|
||||||
|
text=str(job.get("last_error") or "Der letzte Lauf endete mit einem Fehler.")[:300],
|
||||||
|
quelle=f"hermes-cron:{jid}",
|
||||||
|
aktionen=[_aktion("job-wiederholen", "Erneut ausführen", job=jid),
|
||||||
|
_aktion("protokoll", "Protokoll", job=jid)],
|
||||||
|
sofort=True))
|
||||||
|
continue
|
||||||
|
if job.get("no_agent"):
|
||||||
|
continue # Skript-Jobs haben keine Werkzeuge
|
||||||
|
lauf = _letzter_lauf(jid)
|
||||||
|
if not lauf or not lauf.get("started_at"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
alter = jetzt - datetime.fromisoformat(lauf["started_at"]).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if alter > 26 * 3600:
|
||||||
|
continue # nur der aktuelle Lauf zählt
|
||||||
|
anzahl, beispiel = werkzeugfehler_im_lauf(log_zeilen, jid, lauf["started_at"])
|
||||||
|
if anzahl:
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"job:{jid}:werkzeug", stufe="gelb",
|
||||||
|
titel=f"Job „{name}“: {anzahl} Werkzeugfehler im letzten Lauf",
|
||||||
|
text=beispiel or "Einzelne Werkzeuge meldeten Fehler, der Lauf selbst kam durch.",
|
||||||
|
quelle=f"hermes-cron:{jid}",
|
||||||
|
aktionen=[_aktion("protokoll", "Protokoll", job=jid)],
|
||||||
|
sofort=True))
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def _hermes_kopf() -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {HERMES_API_KEY}"} if HERMES_API_KEY else {}
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
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())))
|
||||||
|
proben.append(("kern:hoeren", "Der Hör-Dienst antwortet nicht",
|
||||||
|
"Spracheingabe über Lucy-Desktop kann hängen.", _reach(VOICE_SERVICE_URL, "/health")))
|
||||||
|
if WATCH_MC2:
|
||||||
|
proben.append(("kern:mc2", "MC2 antwortet nicht",
|
||||||
|
"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]
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_platte() -> list[Befund]:
|
||||||
|
try:
|
||||||
|
pct = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if pct >= DISK_ROT_PCT:
|
||||||
|
stufe = "rot"
|
||||||
|
elif pct >= DISK_GELB_PCT:
|
||||||
|
stufe = "gelb"
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
return [Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll",
|
||||||
|
text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.",
|
||||||
|
quelle="platte", sofort=True)]
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_festgehalten() -> list[Befund]:
|
||||||
|
"""Festgehaltene Bausteine (Pin-Register von autoupdate.sh). Vom 06. bis 17.09.2026 hielt
|
||||||
|
die Box Motor und Hermes fest, ohne dass es jemand sah — elf Tage ohne Updates."""
|
||||||
|
return [Befund(id=f"pin:{p['baustein']}", stufe="gelb",
|
||||||
|
titel=f"{p['name']} bekommt keine Updates mehr",
|
||||||
|
text=(f"Seit {p['seit']} auf {p['version']} festgehalten: {p['grund']}. "
|
||||||
|
"Der Sonntags-Lauf überspringt ihn, bis du ihn freigibst."),
|
||||||
|
quelle="updates", sofort=True,
|
||||||
|
aktionen=[_aktion("freigeben", "Freigeben", baustein=p["baustein"])])
|
||||||
|
for p in update_verlauf.festgehalten()]
|
||||||
|
|
||||||
|
|
||||||
|
PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
|
||||||
|
pruefe_festgehalten)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zustand, Takt, Selbstreparatur -----------------------------------------------
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_stand: dict = {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
def _lade() -> None:
|
||||||
|
global _stand
|
||||||
|
try:
|
||||||
|
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(daten.get("hinweise"), dict):
|
||||||
|
_stand = {**_stand, **daten}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _speichere() -> None:
|
||||||
|
try:
|
||||||
|
tmp = STORE_PATH.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(_stand, ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(STORE_PATH)
|
||||||
|
except OSError:
|
||||||
|
log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _verlauf(art: str, hinweis_id: str, text: str) -> None:
|
||||||
|
_stand["verlauf"].append({"ts": time.time(), "art": art, "id": hinweis_id, "text": text})
|
||||||
|
del _stand["verlauf"][:-VERLAUF_MAX]
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram(betreff: str, text: str) -> None:
|
||||||
|
if TROCKEN:
|
||||||
|
log.info("waechter (trocken): würde melden %s %s", betreff, text)
|
||||||
|
return
|
||||||
|
announce.add(text, subject=betreff, source="waechter")
|
||||||
|
announce.notify_telegram(betreff, text + " (Diese Meldung kam auch an Lucy.)")
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_erlaubt(schluessel: str, jetzt: float) -> bool:
|
||||||
|
versuche = [t for t in _stand["auto"].get(schluessel, []) if jetzt - t < 3600]
|
||||||
|
_stand["auto"][schluessel] = versuche
|
||||||
|
return len(versuche) < AUTO_MAX_PRO_STUNDE
|
||||||
|
|
||||||
|
|
||||||
|
def _selbst_beheben(b: Befund, jetzt: float) -> None:
|
||||||
|
if b.auto != "neustart" or not _auto_erlaubt(b.id, jetzt):
|
||||||
|
return
|
||||||
|
if TROCKEN:
|
||||||
|
_verlauf("auto", b.id, f"{b.titel}: würde automatisch neu starten (Trockenlauf)")
|
||||||
|
_stand["auto"][b.id].append(jetzt)
|
||||||
|
return
|
||||||
|
ergebnis = maintenance.restart_service(b.quelle)
|
||||||
|
_stand["auto"][b.id].append(jetzt)
|
||||||
|
ok = bool(ergebnis.get("ok", ergebnis.get("returncode", 1) == 0))
|
||||||
|
_verlauf("auto", b.id, f"{b.titel}: automatisch neu gestartet" + ("" if ok else " (ohne Erfolg)"))
|
||||||
|
log.warning("waechter: %s → Neustart von %s (%s)", b.id, b.quelle, "ok" if ok else "fehlgeschlagen")
|
||||||
|
|
||||||
|
|
||||||
|
def takt() -> None:
|
||||||
|
"""Ein Prüfdurchlauf: Befunde sammeln, Hinweise führen, Einfaches selbst beheben."""
|
||||||
|
jetzt = time.time()
|
||||||
|
update = _update_laeuft()
|
||||||
|
befunde: list[Befund] = []
|
||||||
|
for pruefung in PRUEFUNGEN:
|
||||||
|
if update and pruefung in (pruefe_dienste, pruefe_kern):
|
||||||
|
continue # während eines Updates sind Neustarts normal
|
||||||
|
try:
|
||||||
|
befunde += pruefung()
|
||||||
|
except Exception:
|
||||||
|
log.debug("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True)
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
hinweise: dict = _stand["hinweise"]
|
||||||
|
kandidaten: dict = _stand["kandidaten"]
|
||||||
|
aktuell = {b.id for b in befunde}
|
||||||
|
|
||||||
|
for b in befunde:
|
||||||
|
k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt})
|
||||||
|
k["takte"] += 1
|
||||||
|
if not update and b.auto:
|
||||||
|
_selbst_beheben(b, jetzt)
|
||||||
|
if not (b.sofort or k["takte"] >= FAIL_AFTER):
|
||||||
|
continue
|
||||||
|
h = hinweise.get(b.id)
|
||||||
|
neu = h is None
|
||||||
|
h = h or {"id": b.id, "seit": k["erstmals"], "gemeldet": 0.0}
|
||||||
|
h.update(stufe=b.stufe, titel=b.titel, text=b.text, quelle=b.quelle,
|
||||||
|
aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"])
|
||||||
|
hinweise[b.id] = h
|
||||||
|
if neu:
|
||||||
|
_verlauf("neu", b.id, b.titel)
|
||||||
|
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
|
||||||
|
vorsatz = "" if neu else "Immer noch: "
|
||||||
|
_telegram("[Box-Problem]", f"{vorsatz}{b.titel}. {b.text}")
|
||||||
|
h["gemeldet"] = jetzt
|
||||||
|
|
||||||
|
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
|
||||||
|
# Kern-Hinweise stehen (sie wurden in diesem Takt gar nicht geprüft).
|
||||||
|
for hid in list(hinweise):
|
||||||
|
if hid in aktuell:
|
||||||
|
continue
|
||||||
|
if update and hid.startswith(("dienst:", "kern:")):
|
||||||
|
continue
|
||||||
|
h = hinweise.pop(hid)
|
||||||
|
_verlauf("erledigt", hid, h.get("titel", hid))
|
||||||
|
if h.get("stufe") == "rot" and h.get("gemeldet"):
|
||||||
|
_telegram("[Box wieder ok]", f"Erledigt: {h.get('titel', hid)}.")
|
||||||
|
for kid in list(kandidaten):
|
||||||
|
if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))):
|
||||||
|
kandidaten.pop(kid)
|
||||||
|
|
||||||
|
_stand["stand"] = jetzt
|
||||||
|
_stand["update_laeuft"] = update
|
||||||
|
_speichere()
|
||||||
|
|
||||||
|
|
||||||
|
async def waechter_loop() -> None:
|
||||||
|
"""Endlos-Schleife im mc2-steward."""
|
||||||
|
_lade()
|
||||||
|
await asyncio.sleep(START_DELAY)
|
||||||
|
log.info("waechter: aktiv (Takt %ss, Hinweis nach %s Takten, Selbstreparatur max. %s/h)",
|
||||||
|
INTERVAL, FAIL_AFTER, AUTO_MAX_PRO_STUNDE)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(takt)
|
||||||
|
except Exception:
|
||||||
|
log.warning("waechter: Takt fehlgeschlagen", exc_info=True)
|
||||||
|
await asyncio.sleep(INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Lesen und Knöpfe (MC2-Prozess) ------------------------------------------------
|
||||||
|
|
||||||
|
def lese_stand() -> dict:
|
||||||
|
"""Stand für die Oberfläche: Hinweise (rot zuerst, dann nach Beginn) + Verlauf."""
|
||||||
|
try:
|
||||||
|
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
daten = {}
|
||||||
|
hinweise = sorted((daten.get("hinweise") or {}).values(),
|
||||||
|
key=lambda h: (h.get("stufe") != "rot", h.get("seit", 0)))
|
||||||
|
for h in hinweise:
|
||||||
|
h.pop("gemeldet", None)
|
||||||
|
return {
|
||||||
|
"hinweise": hinweise,
|
||||||
|
"verlauf": list(reversed((daten.get("verlauf") or [])[-50:])),
|
||||||
|
"stand": daten.get("stand"),
|
||||||
|
"update_laeuft": bool(daten.get("update_laeuft")),
|
||||||
|
"aktiv": bool(daten),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_protokoll(job_id: str) -> dict:
|
||||||
|
marke = f"[cron_{job_id}_"
|
||||||
|
zeilen = [z for z in _errors_log_ende() if marke in z][-60:]
|
||||||
|
return {"ok": True, "text": "\n".join(zeilen) or "Keine Fehlerzeilen zu diesem Job gefunden."}
|
||||||
|
|
||||||
|
|
||||||
|
def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
|
||||||
|
"""Knopf eines Hinweises ausführen. Nur Aktionen, die der Hinweis selbst anbietet."""
|
||||||
|
stand = lese_stand()
|
||||||
|
hinweis = next((h for h in stand["hinweise"] if h.get("id") == hinweis_id), None)
|
||||||
|
if hinweis is None:
|
||||||
|
return {"ok": False, "detail": "Diesen Hinweis gibt es nicht mehr."}
|
||||||
|
aktion = next((a for a in hinweis.get("aktionen", []) if a.get("id") == aktion_id), None)
|
||||||
|
if aktion is None:
|
||||||
|
return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."}
|
||||||
|
if aktion_id == "neustart":
|
||||||
|
return maintenance.restart_service(aktion["dienst"])
|
||||||
|
if aktion_id == "protokoll":
|
||||||
|
if aktion.get("job"):
|
||||||
|
return _job_protokoll(aktion["job"])
|
||||||
|
return maintenance.logs(aktion["dienst"], lines=120)
|
||||||
|
if aktion_id == "freigeben":
|
||||||
|
if not update_verlauf.freigeben(aktion["baustein"]):
|
||||||
|
return {"ok": False, "detail": "Der Baustein war schon freigegeben."}
|
||||||
|
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||||
|
if aktion_id == "job-wiederholen":
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["hermes", "cron", "run", aktion["job"]],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
return {"ok": r.returncode == 0,
|
||||||
|
"text": (r.stdout or r.stderr).strip()[:500] or "Job läuft beim nächsten Takt."}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "detail": f"hermes cron run ging nicht: {e}"}
|
||||||
|
return {"ok": False, "detail": f"Unbekannte Aktion {aktion_id}."}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Flugplan der Box (Box-Wart, Umbau 09/2026): was lief heute, was kommt als Nächstes.
|
||||||
|
|
||||||
|
Quellen: die Hermes-Cron-Jobs (jobs.json: nächster/letzter Lauf, Status), die systemd-
|
||||||
|
Timer (Sicherung) und die Modell-Nutzung (NerdQuiz-Nachtlauf, erkannt an den nächtlichen
|
||||||
|
Anfragen). Alles nur lesend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from services import modell_nutzung, waechter
|
||||||
|
|
||||||
|
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar"}
|
||||||
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(wert) -> datetime | None:
|
||||||
|
"""ISO-Zeit; ohne Offset gilt MC_LOCAL_TZ (Projektregel, nie raten)."""
|
||||||
|
if not wert:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def _timer() -> list[dict]:
|
||||||
|
"""systemd-Timer als JSON (systemd ≥ 256). Ohne systemd: leer."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["systemctl", "--user", "list-timers", "--all", "--no-pager", "--output=json"],
|
||||||
|
capture_output=True, text=True, timeout=10).stdout
|
||||||
|
daten = json.loads(out or "[]")
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
ergebnis = []
|
||||||
|
for t in daten if isinstance(daten, list) else []:
|
||||||
|
name = TIMER.get(t.get("unit", ""))
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
def _us(v):
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(int(v) / 1_000_000, LOCAL_TZ) if v else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
ergebnis.append({"name": name, "naechster": _us(t.get("next")), "letzter": _us(t.get("last"))})
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _nerdquiz_nacht(nutzung: dict) -> dict | None:
|
||||||
|
"""Der NerdQuiz-Nachtlauf, erkannt an Anfragen zwischen 01 und 06 Uhr in den letzten 24 h."""
|
||||||
|
stunden = [s for s in nutzung.get("letzte_24h", []) if "01" <= s["stunde"] <= "06"]
|
||||||
|
anfragen = sum(v for s in stunden for n, v in s["je_absender"].items() if "NerdQuiz" in n)
|
||||||
|
if not anfragen:
|
||||||
|
return None
|
||||||
|
erste = next((s["stunde"] for s in stunden
|
||||||
|
if any("NerdQuiz" in n for n in s["je_absender"])), "03")
|
||||||
|
heute = datetime.now(LOCAL_TZ).replace(hour=int(erste), minute=0, second=0, microsecond=0)
|
||||||
|
return {"zeit": heute, "titel": "NerdQuiz-Nachtlauf", "text": f"{anfragen} Anfragen ans Hirn",
|
||||||
|
"status": "erledigt"}
|
||||||
|
|
||||||
|
|
||||||
|
def flugplan() -> dict:
|
||||||
|
"""Heute Gelaufenes und die nächsten geplanten Läufe, je zeitlich sortiert."""
|
||||||
|
jetzt = datetime.now(LOCAL_TZ)
|
||||||
|
heute_start = jetzt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
gelaufen: list[dict] = []
|
||||||
|
geplant: list[dict] = []
|
||||||
|
|
||||||
|
for job in waechter._hermes_jobs():
|
||||||
|
if not job.get("enabled", True):
|
||||||
|
continue
|
||||||
|
name = str(job.get("name", "Job"))
|
||||||
|
letzter, naechster = _parse_iso(job.get("last_run_at")), _parse_iso(job.get("next_run_at"))
|
||||||
|
if letzter and letzter >= heute_start:
|
||||||
|
ok = job.get("last_status") in (None, "ok", "success")
|
||||||
|
gelaufen.append({"zeit": letzter, "titel": name, "text": "",
|
||||||
|
"status": "erledigt" if ok else "fehler"})
|
||||||
|
if naechster and naechster > jetzt:
|
||||||
|
geplant.append({"zeit": naechster, "titel": name, "text": "", "status": "geplant"})
|
||||||
|
|
||||||
|
for t in _timer():
|
||||||
|
if t["letzter"] and t["letzter"] >= heute_start:
|
||||||
|
gelaufen.append({"zeit": t["letzter"], "titel": t["name"], "text": "", "status": "erledigt"})
|
||||||
|
if t["naechster"] and t["naechster"] > jetzt:
|
||||||
|
geplant.append({"zeit": t["naechster"], "titel": t["name"], "text": "", "status": "geplant"})
|
||||||
|
|
||||||
|
if (nq := _nerdquiz_nacht(modell_nutzung.nutzung())):
|
||||||
|
gelaufen.append(nq)
|
||||||
|
|
||||||
|
def _aus(eintraege: list[dict]) -> list[dict]:
|
||||||
|
return [{**e, "zeit": e["zeit"].isoformat()} for e in sorted(eintraege, key=lambda e: e["zeit"])]
|
||||||
|
|
||||||
|
grenze = jetzt + timedelta(days=8)
|
||||||
|
return {"gelaufen": _aus(gelaufen), "geplant": _aus([g for g in geplant if g["zeit"] <= grenze])}
|
||||||
+8
-7
@@ -1,15 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2).
|
MC2-Steward — die Wächter-Loops als EIGENER Prozess (UMBAU v3, P2).
|
||||||
|
|
||||||
Bisher hingen Re-Warm-Wächter und Health-Wächter (sentry) am
|
Bisher hingen Re-Warm-Wächter und Health-Wächter am
|
||||||
Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss
|
Lebenszyklus des Steuerpult-Webservers (app.py-Lifespan): jeder MC2-Neustart riss
|
||||||
den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte
|
den Wächtern Timing und Flanken-Gedächtnis weg — und ein TOTES Steuerpult konnte
|
||||||
sich prinzipbedingt nicht selbst melden. Hier laufen DIESELBEN Loops (unveränderte
|
sich prinzipbedingt nicht selbst melden. Hier laufen die Loops als eigener
|
||||||
Module) als eigener Mini-Dienst (mc2-steward.service, Restart=always):
|
Mini-Dienst (mc2-steward.service, Restart=always):
|
||||||
|
|
||||||
• warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den
|
• warmer.rewarm_loop — Warm-Set nachladen (+ Config-Watch ersetzt den
|
||||||
In-Process-Nudge aus llamaswap.write_config)
|
In-Process-Nudge aus llamaswap.write_config)
|
||||||
• sentry.sentry_loop — Health-Flanken → Briefkasten (HTTP an MC2) + Telegram;
|
• waechter.waechter_loop — Box-Wart-Wächter (seit 09/2026, löst sentry ab): Dienste,
|
||||||
|
Timer, Hermes-Jobs, Kern, Platte → Hinweise + Selbstreparatur;
|
||||||
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
|
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
|
||||||
|
|
||||||
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
|
Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung
|
||||||
@@ -30,7 +31,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from config import CONFIG_PATH
|
from config import CONFIG_PATH
|
||||||
from services import sentry, warmer
|
from services import waechter, warmer
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||||
@@ -65,8 +66,8 @@ async def main() -> None:
|
|||||||
tasks.append(asyncio.create_task(config_watch()))
|
tasks.append(asyncio.create_task(config_watch()))
|
||||||
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
|
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
|
||||||
warmer.INTERVAL, CONFIG_WATCH_S)
|
warmer.INTERVAL, CONFIG_WATCH_S)
|
||||||
if sentry.ENABLED:
|
if waechter.ENABLED:
|
||||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
tasks.append(asyncio.create_task(waechter.waechter_loop()))
|
||||||
if not tasks:
|
if not tasks:
|
||||||
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
|
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Backend-Module importieren sich gegenseitig ohne Paketpräfix (from config import …) —
|
||||||
|
für die Tests liegt deshalb backend/ auf dem Suchpfad, genau wie beim Start der Dienste."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Tests für die Pfad-Umschreibung des Hermes-Dashboard-Proxys (Anmeldung seit Hermes v0.21)."""
|
||||||
|
|
||||||
|
from routers import hermes_ui
|
||||||
|
|
||||||
|
|
||||||
|
def test_umleitung_auf_login_bekommt_praefix():
|
||||||
|
assert hermes_ui.praefixiere_ort("/login?next=%2F") == "/hermes-ui/login?next=%2F"
|
||||||
|
assert hermes_ui.praefixiere_ort("/") == "/hermes-ui/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremde_oder_schon_praefixierte_ziele_bleiben():
|
||||||
|
assert hermes_ui.praefixiere_ort("/hermes-ui/sessions") == "/hermes-ui/sessions"
|
||||||
|
assert hermes_ui.praefixiere_ort("https://example.org/x") == "https://example.org/x"
|
||||||
|
assert hermes_ui.praefixiere_ort("//evil.example/x") == "//evil.example/x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anmeldeseite_schickt_an_praefixierten_pfad():
|
||||||
|
html = (b'<html><head><title>Login</title></head><body>'
|
||||||
|
b'<form action="/auth/password-login" method="post"></form>'
|
||||||
|
b'<script>fetch("/auth/password-login",{method:"POST"});location.href="/login?x=1"</script>'
|
||||||
|
b'</body></html>')
|
||||||
|
neu = hermes_ui.praefixiere_html(html)
|
||||||
|
assert b'action="/hermes-ui/auth/password-login"' in neu
|
||||||
|
assert b'fetch("/hermes-ui/auth/password-login"' in neu
|
||||||
|
assert b'"/hermes-ui/login?x=1"' in neu
|
||||||
|
assert b'window.__HERMES_BASE_PATH__="/hermes-ui"' in neu
|
||||||
|
|
||||||
|
|
||||||
|
def test_andere_absolute_pfade_bleiben_unberuehrt():
|
||||||
|
html = b'<head></head><a href="/assets/x.js">x</a><a href="/authors">y</a>'
|
||||||
|
neu = hermes_ui.praefixiere_html(html)
|
||||||
|
assert b'href="/assets/x.js"' in neu
|
||||||
|
assert b'href="/authors"' in neu
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23.09.2026)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from services import modell_nutzung
|
||||||
|
|
||||||
|
ZEILEN = [
|
||||||
|
('2026-09-23T03:12:44+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
|
||||||
|
'"POST /v1/chat/completions HTTP/1.1" 200 10254 "Go-http-client/1.1" 1.2s'),
|
||||||
|
('2026-09-23T03:12:50+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
|
||||||
|
'"POST /v1/chat/completions HTTP/1.1" 200 9000 "Go-http-client/1.1" 1.1s'),
|
||||||
|
('2026-09-23T07:01:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
|
||||||
|
'"POST /v1/chat/completions HTTP/1.1" 200 800 "python-httpx/0.28.1" 3s'),
|
||||||
|
('2026-09-23T07:02:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 '
|
||||||
|
'"POST /v1/embeddings HTTP/1.1" 200 800 "python-httpx/0.28.1" 20ms'),
|
||||||
|
"2026-09-17T19:59:00+02:00 box llama-swap[1]: [INFO] <Qwen3.8-27B> Health check passed on http://localhost:5805/health",
|
||||||
|
('2026-09-10T03:00:00+02:00 box llama-swap[1]: [INFO] Request 192.168.178.28 '
|
||||||
|
'"POST /v1/chat/completions HTTP/1.1" 200 1 "Go-http-client/1.1" 1s'),
|
||||||
|
]
|
||||||
|
|
||||||
|
NAMEN = {"127.0.0.1": "Lucy und OpenChamber über MC2", "192.168.178.28": "NerdQuiz auf Arcane"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehlt_nur_chat_anfragen_je_absender():
|
||||||
|
d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
|
||||||
|
assert d["absender"] == [
|
||||||
|
{"name": "NerdQuiz auf Arcane", "anfragen": 3},
|
||||||
|
{"name": "Lucy und OpenChamber über MC2", "anfragen": 1},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_letzte_24h_je_stunde_und_zuletzt_geladen():
|
||||||
|
d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
|
||||||
|
stunden = {s["stunde"]: s["je_absender"] for s in d["letzte_24h"]}
|
||||||
|
assert stunden["03"] == {"NerdQuiz auf Arcane": 2} # der Lauf vom 10.09. liegt außerhalb
|
||||||
|
assert stunden["07"] == {"Lucy und OpenChamber über MC2": 1}
|
||||||
|
assert len(d["letzte_24h"]) == 24
|
||||||
|
assert d["zuletzt_geladen"]["Qwen3.8-27B"].startswith("2026-09-17T19:59")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_ip_bleibt_als_ip_stehen():
|
||||||
|
zeile = ('2026-09-23T10:00:00+02:00 box llama-swap[1]: [INFO] Request 10.0.0.9 '
|
||||||
|
'"POST /v1/chat/completions HTTP/1.1" 200 1 "curl/8" 1s')
|
||||||
|
d = modell_nutzung.werte_aus([zeile], datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
|
||||||
|
assert d["absender"] == [{"name": "10.0.0.9", "anfragen": 1}]
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
"""Tests für das Modell-Radar: reine Logik (Zeitfenster, Wochengrenze, Filter, Speicher-Rechnung,
|
||||||
|
Zustandsübergänge) und die Urteile und Programmieraufgaben des Prüfstands — ohne Netz und ohne Box.
|
||||||
|
Zahlen wie auf der Box am 23.09.2026 (Ornith 21,7 GB + mmproj, Flash-Next UD-IQ3_XXS 82 GB)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
import zlib
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from services import radar
|
||||||
|
|
||||||
|
BERLIN = radar.LOCAL_TZ
|
||||||
|
|
||||||
|
|
||||||
|
def _zeit(text: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(text).replace(tzinfo=BERLIN)
|
||||||
|
|
||||||
|
|
||||||
|
def _kandidat(kid: str, rolle: str = "hirn", status: str = "neu", quelle: str = "watchlist", rang: int = 0,
|
||||||
|
**extra) -> dict:
|
||||||
|
return {"id": kid, "name": kid, "rolle": rolle, "repo": f"org/{kid}-GGUF", "quant": "Q4_K_M", "quelle": quelle,
|
||||||
|
"rang": rang, "datei": f"{kid}-Q4_K_M.gguf", "dateien": [[f"{kid}-Q4_K_M.gguf", 100]],
|
||||||
|
"mmproj": ["mmproj-F16.gguf", 10], "draft": None, "flags": [], "groesse_gb": 21.7, "passt": True,
|
||||||
|
"eng": False, "ctx": 131072, "status": status, "begruendung": "", **extra}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def zustand(monkeypatch, tmp_path):
|
||||||
|
"""Radar-Zustand und Radar-Ordner in einem Temp-Ordner statt unter /srv/models."""
|
||||||
|
monkeypatch.setattr(radar, "STORE_PATH", tmp_path / "mc2-radar.json")
|
||||||
|
monkeypatch.setattr(radar, "RADAR_DIR", tmp_path / "radar")
|
||||||
|
(tmp_path / "radar").mkdir()
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zeitfenster und Wochengrenze ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_zeitfenster_nur_von_0030_bis_0230():
|
||||||
|
assert not radar.im_fenster(_zeit("2026-09-29T00:29:59"))
|
||||||
|
assert radar.im_fenster(_zeit("2026-09-29T00:30:00"))
|
||||||
|
assert radar.im_fenster(_zeit("2026-09-29T02:29:59"))
|
||||||
|
assert not radar.im_fenster(_zeit("2026-09-29T02:30:00"))
|
||||||
|
assert not radar.im_fenster(_zeit("2026-09-29T14:00:00"))
|
||||||
|
assert radar.fenster_ende(_zeit("2026-09-29T00:45:00")) == _zeit("2026-09-29T02:30:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fensterende_am_umstellungstag_nie_nach_0300():
|
||||||
|
# 28.03.2027: 02:00 springt auf 03:00, 02:30 gibt es nicht. Lieber früher enden als in den NerdQuiz-Lauf.
|
||||||
|
ende = radar.fenster_ende(_zeit("2027-03-28T00:45:00"))
|
||||||
|
assert ende.timestamp() <= datetime(2027, 3, 28, 3, 0, tzinfo=BERLIN).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def test_naechster_start_ist_die_naechste_nacht():
|
||||||
|
assert radar.naechster_start(_zeit("2026-09-29T00:10:00")) == _zeit("2026-09-29T00:30:00")
|
||||||
|
assert radar.naechster_start(_zeit("2026-09-29T01:00:00")) == _zeit("2026-09-30T00:30:00")
|
||||||
|
assert radar.naechster_start(_zeit("2026-09-29T14:00:00")) == _zeit("2026-09-30T00:30:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wochengrenze_ein_neuer_kandidat_pro_woche():
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"a": _kandidat("a", status="bestanden",
|
||||||
|
erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||||
|
"b": _kandidat("b")}
|
||||||
|
assert radar.wochen_frei_ab(stand, date(2026, 10, 3)) == date(2026, 10, 6)
|
||||||
|
assert radar.wochen_frei_ab(stand, date(2026, 10, 6)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_testet_nur_im_fenster_mit_freier_woche_und_setzt_angefangene_fort():
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"a": _kandidat("a", status="bestanden",
|
||||||
|
erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||||
|
"b": _kandidat("b", rang=1)}
|
||||||
|
radar._ordne(stand)
|
||||||
|
assert stand["kandidaten"]["b"]["status"] == "wartet"
|
||||||
|
|
||||||
|
plan = radar.plane_test(stand, _zeit("2026-10-03T00:31:00"))
|
||||||
|
assert plan["kandidat"] is None and "06.10." in plan["grund"]
|
||||||
|
assert radar.plane_test(stand, _zeit("2026-10-06T00:31:00"))["kandidat"] == "b"
|
||||||
|
assert "außerhalb" in radar.plane_test(stand, _zeit("2026-10-06T14:00:00"))["grund"]
|
||||||
|
assert "Minuten" in radar.plane_test(stand, _zeit("2026-10-06T02:15:00"))["grund"]
|
||||||
|
|
||||||
|
# angefangen (z. B. Download unterbrochen): darf in der nächsten Nacht weiter, die Woche gilt ab dem 1. Start
|
||||||
|
stand["kandidaten"]["b"]["erster_start"] = _zeit("2026-10-06T00:31:00").timestamp()
|
||||||
|
assert radar.plane_test(stand, _zeit("2026-10-07T00:31:00"))["kandidat"] == "b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufender_test_blockiert_zweiten_lauf_und_zeigt_sich(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["b"] = _kandidat("b", status="wartet", erster_start=time.time())
|
||||||
|
stand["lauf"] = {"pid": os.getpid(), "kandidat": "b", "schritt": "test", "seit": time.time(),
|
||||||
|
"frist": time.time() + 3600}
|
||||||
|
assert radar.plane_test(stand, _zeit("2026-10-06T00:40:00"))["grund"] == "es läuft schon ein Test"
|
||||||
|
radar._speichere(stand)
|
||||||
|
assert radar.uebersicht()["kandidaten"][0]["begruendung"].startswith("Läuft gerade seit")
|
||||||
|
stand["lauf"]["pid"] = 999_999_999 # Prozess tot → Lauf gilt als beendet
|
||||||
|
assert radar._aktiver_lauf(stand) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Namen, Filter, Dateien, Speicher -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_namen_und_staemme():
|
||||||
|
assert radar.anzeigename("bartowski/Qwen_Qwen3.6-35B-A3B-GGUF") == "Qwen3.6-35B-A3B"
|
||||||
|
assert radar.anzeigename("unsloth/Qwen3.8-Flash-Next-GGUF") == "Qwen3.8-Flash-Next"
|
||||||
|
assert radar.stamm("Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf") == "qwen3.8-flash-next"
|
||||||
|
assert radar.stamm("Qwen3.6-35B-A3B") == radar.stamm("unsloth/Qwen3.6-35B-A3B-GGUF")
|
||||||
|
assert radar.generation("Qwen3.8-27B") == ("qwen", 3.8)
|
||||||
|
assert radar.kandidat_id("hirn", "ornith-ai/Ornith-1.5-35B-A3B-GGUF", "Q4_K_M") == \
|
||||||
|
"hirn-ornith-ai-ornith-1-5-35b-a3b-gguf-q4-k-m"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entdeckung_filtert_alte_kleine_bekannte_und_dichte_hirne():
|
||||||
|
heute = {"hirn": "Qwen3.6-35B-A3B", "coder": "Qwen3.8-27B"}
|
||||||
|
bekannt = {"qwen3.6-35b-a3b", "qwen3.8-27b"}
|
||||||
|
|
||||||
|
def fund(repo, params, aktiv=None, kuratiert=False):
|
||||||
|
return {"repo": repo, "params_b": params, "caps": {"active_b": aktiv}, "curated": kuratiert}
|
||||||
|
|
||||||
|
def grund(m, rolle):
|
||||||
|
return radar.entdeckung_ungeeignet(m, rolle, bekannt, heute)
|
||||||
|
|
||||||
|
assert "Katalog" in grund(fund("unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF", 30, 3, kuratiert=True), "coder")
|
||||||
|
assert "auf der Box" in grund(fund("bartowski/Qwen_Qwen3.6-35B-A3B-GGUF", 35, 3), "hirn")
|
||||||
|
assert "zu klein" in grund(fund("lmstudio-community/Qwen2.5-Coder-14B-Instruct-GGUF", 14), "coder")
|
||||||
|
assert "dichte" in grund(fund("bartowski/Hermes-3-Llama-3.1-70B-GGUF", 70), "hirn")
|
||||||
|
assert "ältere Generation" in grund(fund("bartowski/Qwen_Qwen3.5-35B-A3B-GGUF", 35, 3), "hirn")
|
||||||
|
assert grund(fund("unsloth/Qwen-AgentWorld-35B-A3B-GGUF", 35, 3), "hirn") is None
|
||||||
|
|
||||||
|
|
||||||
|
BAUM_FLASH = [
|
||||||
|
{"type": "directory", "path": "UD-IQ3_XXS"},
|
||||||
|
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf", "size": 10_000_000},
|
||||||
|
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00002-of-00003.gguf", "size": 49_570_000_000},
|
||||||
|
{"path": "UD-IQ3_XXS/Qwen3.8-Flash-Next-UD-IQ3_XXS-00003-of-00003.gguf", "lfs": {"size": 32_380_000_000}},
|
||||||
|
{"path": "UD-IQ4_XS/Qwen3.8-Flash-Next-UD-IQ4_XS-00001-of-00003.gguf", "size": 10_000_000},
|
||||||
|
{"path": "MTP/mtp-Qwen3.8-Flash-Next-Q8_0.gguf", "size": 4_140_000_000},
|
||||||
|
{"path": "mmproj-BF16.gguf", "size": 910_000_000},
|
||||||
|
{"path": "mmproj-F16.gguf", "size": 900_000_000},
|
||||||
|
]
|
||||||
|
KOPF_FLASH = {"general.architecture": "qwen4exp", "qwen4exp.block_count": 48, "qwen4exp.attention.head_count": 24,
|
||||||
|
"qwen4exp.attention.head_count_kv": 2, "qwen4exp.attention.key_length": 256,
|
||||||
|
"qwen4exp.attention.value_length": 256, "qwen4exp.full_attention_interval": 4}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dateiauswahl_nimmt_alle_teile_des_quants_und_das_f16_mmproj():
|
||||||
|
wahl = radar.waehle_dateien(BAUM_FLASH, "UD-IQ3_XXS")
|
||||||
|
assert [p.rsplit("-", 3)[-3] for p, _ in wahl["dateien"]] == ["00001", "00002", "00003"]
|
||||||
|
assert all(p.startswith("UD-IQ3_XXS/") for p, _ in wahl["dateien"])
|
||||||
|
assert wahl["mmproj"] == ["mmproj-F16.gguf", 900_000_000]
|
||||||
|
assert radar.waehle_dateien(BAUM_FLASH, "Q4_K_M") is None
|
||||||
|
assert radar.waehle_dateien(BAUM_FLASH, "Q8_0") is None # der MTP-Kopf ist kein Modell
|
||||||
|
ornith = [{"path": "Ornith-1.5-35B-Q4_K_M.gguf", "size": 21_710_000_000},
|
||||||
|
{"path": "Ornith-1.5-35B-Q8_0.gguf", "size": 37_800_000_000},
|
||||||
|
{"path": "mmproj-Ornith-1.5-35B-BF16.gguf", "size": 900_000_000}]
|
||||||
|
wahl = radar.waehle_dateien(ornith, "Q4_K_M")
|
||||||
|
assert wahl["dateien"] == [["Ornith-1.5-35B-Q4_K_M.gguf", 21_710_000_000]]
|
||||||
|
assert wahl["mmproj"][0] == "mmproj-Ornith-1.5-35B-BF16.gguf"
|
||||||
|
|
||||||
|
|
||||||
|
def _gguf_kopf_bytes(werte: dict) -> bytes:
|
||||||
|
"""Minimaler GGUF-Kopf wie von llama.cpp: Architektur-Schlüssel, danach der Tokenizer (abgeschnitten)."""
|
||||||
|
def s(text: str) -> bytes:
|
||||||
|
return struct.pack("<Q", len(text.encode())) + text.encode()
|
||||||
|
|
||||||
|
teile = [b"GGUF", struct.pack("<I", 3), struct.pack("<Q", 0), struct.pack("<Q", len(werte) + 1)]
|
||||||
|
for k, v in werte.items():
|
||||||
|
if isinstance(v, str):
|
||||||
|
teile += [s(k), struct.pack("<I", 8), s(v)]
|
||||||
|
elif isinstance(v, list):
|
||||||
|
teile += [s(k), struct.pack("<I", 9), struct.pack("<I", 5), struct.pack("<Q", len(v)),
|
||||||
|
struct.pack(f"<{len(v)}i", *v)]
|
||||||
|
else:
|
||||||
|
teile += [s(k), struct.pack("<I", 4), struct.pack("<I", v)]
|
||||||
|
teile += [s("tokenizer.ggml.tokens"), struct.pack("<I", 9)]
|
||||||
|
return b"".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kv_cache_aus_dem_gguf_kopf_kennt_hybrid_modelle():
|
||||||
|
daten = _gguf_kopf_bytes({"general.architecture": "qwen35moe", "qwen35moe.block_count": 41,
|
||||||
|
"qwen35moe.attention.head_count": 16, "qwen35moe.attention.head_count_kv": 2,
|
||||||
|
"qwen35moe.rope.dimension_sections": [11, 11, 10, 0],
|
||||||
|
"qwen35moe.attention.key_length": 256, "qwen35moe.attention.value_length": 256,
|
||||||
|
"qwen35moe.full_attention_interval": 4})
|
||||||
|
kopf = radar.gguf_kopf_aus_bytes(daten)
|
||||||
|
assert kopf["general.architecture"] == "qwen35moe" and kopf["qwen35moe.block_count"] == 41
|
||||||
|
# 11 von 41 Schichten mit KV-Cache × 2 KV-Köpfe × (256 + 256) × 1,0625 Byte (q8_0)
|
||||||
|
assert radar.kv_je_token_gb(kopf) == pytest.approx(11 * 2 * 512 * 1.0625 / 1e9)
|
||||||
|
assert radar.kv_je_token_gb(kopf) * 131072 == pytest.approx(1.57, abs=0.01)
|
||||||
|
ohne_hybrid = {k: v for k, v in kopf.items() if not k.endswith("full_attention_interval")}
|
||||||
|
assert radar.kv_je_token_gb(ohne_hybrid) == pytest.approx(41 * 2 * 512 * 1.0625 / 1e9)
|
||||||
|
assert radar.gguf_kopf_aus_bytes(b"kein gguf") is None
|
||||||
|
assert radar.gguf_kopf_aus_bytes(daten[:20]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_speicher_passung_nach_der_115_gb_regel():
|
||||||
|
budget = radar.SPEICHER_GRENZE_GB - radar.WARMSET_GB
|
||||||
|
p = radar.speicher_passung("hirn", 22.6, 11968 / 1e9) # Ornith: bequem, voller Kontext
|
||||||
|
assert p["passt"] and not p["eng"] and p["ctx"] == 131072
|
||||||
|
p = radar.speicher_passung("coder", 82.9, 13056 / 1e9, eng_markiert=True) # Flash-Next: knapp
|
||||||
|
assert p["passt"] and p["eng"] and p["bedarf_gb"] <= budget
|
||||||
|
p = radar.speicher_passung("coder", 82.9, 52224 / 1e9) # großer KV-Cache → halber Kontext
|
||||||
|
assert p["passt"] and p["eng"] and p["ctx"] == 65536
|
||||||
|
p = radar.speicher_passung("coder", 94.0, 13056 / 1e9) # UD-IQ4_XS passt nicht
|
||||||
|
assert not p["passt"] and p["text"].startswith("Passt nicht")
|
||||||
|
p = radar.speicher_passung("hirn", 85.0, 1e-6) # Test ja, im Betrieb neben dem Coder knapp
|
||||||
|
assert p["passt"] and p["eng"] and "Im Betrieb" in p["text"]
|
||||||
|
assert radar.platte_nach_download(265, 1900, 82) == pytest.approx(18.26, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bewertung_ohne_netz(monkeypatch):
|
||||||
|
monkeypatch.setattr(radar, "_hf_baum", lambda repo: BAUM_FLASH)
|
||||||
|
monkeypatch.setattr(radar, "_gguf_kopf", lambda repo, datei: KOPF_FLASH)
|
||||||
|
monkeypatch.setattr(radar, "engine_kennt", lambda arch: True)
|
||||||
|
fund = {"name": "Qwen3.8-Flash-Next", "rolle": "coder", "repo": "unsloth/Qwen3.8-Flash-Next-GGUF",
|
||||||
|
"quant": "UD-IQ3_XXS", "eng": True, "quelle": "watchlist", "rang": 1}
|
||||||
|
k = radar._bewerte(fund)
|
||||||
|
assert k["tauglich"] and k["passt"] and k["eng"] and k["ctx"] == 131072
|
||||||
|
assert k["datei"] == "Qwen3.8-Flash-Next-UD-IQ3_XXS-00001-of-00003.gguf"
|
||||||
|
assert k["groesse_gb"] == pytest.approx(82.9, abs=0.05)
|
||||||
|
assert k["begruendung"].startswith("Aus der Merkliste. Passt knapp")
|
||||||
|
|
||||||
|
monkeypatch.setattr(radar, "engine_kennt", lambda arch: False)
|
||||||
|
k = radar._bewerte(fund)
|
||||||
|
assert not k["tauglich"] and "qwen4exp" in k["begruendung"]
|
||||||
|
|
||||||
|
# Kopf nicht lesbar: die grobe Schätzung sagt „passt nicht“ — das darf nicht zum Verwerfen führen
|
||||||
|
monkeypatch.setattr(radar, "_gguf_kopf", lambda repo, datei: None)
|
||||||
|
k = radar._bewerte(fund)
|
||||||
|
assert k["netzfehler"] and not k["tauglich"] and k["begruendung"].startswith("Speicherbedarf unklar")
|
||||||
|
|
||||||
|
|
||||||
|
def test_merkliste_im_repo():
|
||||||
|
funde, ok = radar._merkliste()
|
||||||
|
assert ok
|
||||||
|
nach_name = {f["name"]: f for f in funde}
|
||||||
|
assert nach_name["Ornith-1.5-35B-A3B"]["rolle"] == "hirn"
|
||||||
|
assert nach_name["Qwen3.8-Flash-Next"]["quant"] == "UD-IQ3_XXS" and nach_name["Qwen3.8-Flash-Next"]["eng"] is True
|
||||||
|
k2 = radar._bewerte(nach_name["K2-Horizon-MoVA-36B-A4B"]) # übersprungen, ganz ohne Netz
|
||||||
|
assert not k2["tauglich"] and k2["begruendung"].startswith("Übersprungen") and "llama.cpp" in k2["begruendung"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zustandsübergänge ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_suche_uebernimmt_merkliste_und_laesst_geschichte_in_ruhe():
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"alt": _kandidat("alt", status="durchgefallen", quelle="discover"),
|
||||||
|
"weg": _kandidat("weg", quelle="discover"),
|
||||||
|
"nein": _kandidat("nein", status="verworfen", verworfen_von="nutzer")}
|
||||||
|
bewertet = [
|
||||||
|
{**_kandidat("ornith", rang=0), "tauglich": True, "begruendung": "Aus der Merkliste. Passt."},
|
||||||
|
{**_kandidat("k2", rang=2), "tauglich": False, "passt": False,
|
||||||
|
"begruendung": "Übersprungen: läuft noch nicht im offiziellen llama.cpp."},
|
||||||
|
{**_kandidat("alt", quelle="discover"), "tauglich": True},
|
||||||
|
{**_kandidat("nein"), "tauglich": True},
|
||||||
|
{**_kandidat("klein", quelle="discover"), "tauglich": False, "begruendung": "Kein GGUF mit Q4_K_M im Repo."},
|
||||||
|
]
|
||||||
|
ergebnis = radar._suche_anwenden(stand, bewertet, {"watchlist": True, "discover": True}, 1000.0)
|
||||||
|
k = stand["kandidaten"]
|
||||||
|
assert k["ornith"]["status"] == "wartet" # Kopf der Warteschlange
|
||||||
|
assert k["k2"]["status"] == "verworfen" and k["k2"]["verworfen_von"] == "radar"
|
||||||
|
assert k["alt"]["status"] == "durchgefallen" # Geschichte bleibt
|
||||||
|
assert k["nein"]["status"] == "verworfen" and k["nein"]["verworfen_von"] == "nutzer"
|
||||||
|
assert "weg" not in k and "klein" not in k
|
||||||
|
assert "klein" in stand["abgelehnt"]
|
||||||
|
assert ergebnis["neu"] == ["ornith"] and stand["letzte_suche"] == 1000.0
|
||||||
|
|
||||||
|
stand["kandidaten"]["weg"] = _kandidat("weg", quelle="discover")
|
||||||
|
radar._suche_anwenden(stand, [], {"watchlist": True, "discover": False}, 2000.0) # Entdeckung offline
|
||||||
|
assert "weg" in stand["kandidaten"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bestanden_bleibt_liegen_durchgefallen_wird_geloescht(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
for kid in ("gut", "schlecht"):
|
||||||
|
ordner = radar.RADAR_DIR / kid
|
||||||
|
ordner.mkdir()
|
||||||
|
(ordner / "m.gguf").write_bytes(b"x")
|
||||||
|
stand["kandidaten"][kid] = _kandidat(kid, status="wartet", ordner=str(ordner), erster_start=1.0)
|
||||||
|
test = radar._abschluss(stand, "gut", "bestanden", "Bestanden: versteht Bilder (heute nicht).",
|
||||||
|
werte={"tiefe": {"tg_tps": 95.0}}, vergleich={"Bilder": {"urteil": "besser"}})
|
||||||
|
radar._abschluss(stand, "schlecht", "durchgefallen", "Durchgefallen: Werkzeuge 3/6 (heute 6/6).")
|
||||||
|
assert stand["kandidaten"]["gut"]["status"] == "bestanden" and (radar.RADAR_DIR / "gut").exists()
|
||||||
|
assert "wartet auf deine Entscheidung" in stand["kandidaten"]["gut"]["begruendung"]
|
||||||
|
assert stand["kandidaten"]["schlecht"]["status"] == "durchgefallen" and not (radar.RADAR_DIR / "schlecht").exists()
|
||||||
|
assert [t["ergebnis"] for t in stand["tests"]] == ["bestanden", "durchgefallen"]
|
||||||
|
assert set(test) == set(radar.TEST_FELDER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_abbrueche_zaehlen_nur_mit_schuld_des_kandidaten(zustand):
|
||||||
|
ordner = radar.RADAR_DIR / "zaeh"
|
||||||
|
ordner.mkdir()
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["zaeh"] = _kandidat("zaeh", status="wartet", ordner=str(ordner), erster_start=1.0)
|
||||||
|
radar._abschluss(stand, "zaeh", "abgebrochen", "Port 5899 ist belegt.", zaehlt=False)
|
||||||
|
assert stand["kandidaten"]["zaeh"]["status"] == "wartet" and not stand["kandidaten"]["zaeh"].get("versuche")
|
||||||
|
for _ in range(radar.VERSUCHE_MAX):
|
||||||
|
radar._abschluss(stand, "zaeh", "abgebrochen", "Das Nachtfenster endete um 02:30, bevor der Test fertig war.")
|
||||||
|
k = stand["kandidaten"]["zaeh"]
|
||||||
|
assert k["status"] == "durchgefallen" and k["begruendung"].startswith(f"{radar.VERSUCHE_MAX}-mal abgebrochen")
|
||||||
|
assert not ordner.exists()
|
||||||
|
assert {t["ergebnis"] for t in stand["tests"]} == {"abgebrochen"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_vergleichswerte_bleibt_der_kandidat_getestet(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["g"] = _kandidat("g", status="wartet", erster_start=1.0)
|
||||||
|
assert radar._abschluss(stand, "g", None, "", werte={"tiefe": {"tg_tps": 80.0}}) is None
|
||||||
|
assert stand["kandidaten"]["g"]["status"] == "getestet" and stand["kandidaten"]["g"]["messung"]
|
||||||
|
assert stand["tests"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_verwerfen_und_uebernehmen_pruefen_den_status(zustand):
|
||||||
|
ordner = radar.RADAR_DIR / "b"
|
||||||
|
ordner.mkdir()
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"b": _kandidat("b", status="bestanden", ordner=str(ordner)),
|
||||||
|
"u": _kandidat("u", status="uebernommen"), "n": _kandidat("n")}
|
||||||
|
radar._speichere(stand)
|
||||||
|
assert radar.uebernehmen("n")["status"] == 409 # nur bestandene
|
||||||
|
assert radar.uebernehmen("gibt-es-nicht")["status"] == 404
|
||||||
|
assert radar.verwerfen("u")["status"] == 409 # schon übernommen
|
||||||
|
antwort = radar.verwerfen("b")
|
||||||
|
assert antwort["ok"] and antwort["kandidat"]["status"] == "verworfen"
|
||||||
|
assert not ordner.exists()
|
||||||
|
assert radar._lade()["kandidaten"]["b"]["verworfen_von"] == "nutzer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebersicht_hat_die_felder_der_oberflaeche(zustand):
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"] = {"a": _kandidat("a", status="wartet", erster_start=_zeit("2026-09-29T00:31:00").timestamp()),
|
||||||
|
"b": _kandidat("b", rang=1)}
|
||||||
|
radar._abschluss(stand, "a", "bestanden", "Bestanden: Prefill bei 13k 18 % schneller.",
|
||||||
|
zeit=_zeit("2026-09-29T01:40:00").timestamp())
|
||||||
|
radar._speichere(stand)
|
||||||
|
u = radar.uebersicht(_zeit("2026-10-01T12:00:00"))
|
||||||
|
assert set(u) == {"naechster_test", "letzter_test", "kandidaten", "tests"}
|
||||||
|
assert set(u["kandidaten"][0]) == set(radar.KANDIDAT_FELDER)
|
||||||
|
assert [k["status"] for k in u["kandidaten"]] == ["wartet", "bestanden"]
|
||||||
|
assert u["naechster_test"] == "2026-10-06T00:30:00+02:00" # die Woche ab dem 29.09. ist belegt
|
||||||
|
assert u["letzter_test"].startswith("2026-09-29T01:40")
|
||||||
|
assert set(u["tests"][0]) == set(radar.TEST_FELDER)
|
||||||
|
|
||||||
|
|
||||||
|
def _uebernahme_vorbereiten(zustand, monkeypatch, rolle: str, alt: dict) -> tuple[list, dict]:
|
||||||
|
"""Bestandenen Kandidaten „o“ anlegen und llama-swap/Hirn-Wechsel durch Protokoll-Attrappen ersetzen."""
|
||||||
|
modelle = zustand / "models"
|
||||||
|
modelle.mkdir()
|
||||||
|
monkeypatch.setattr(radar, "MODELS_DIR", modelle)
|
||||||
|
ordner = radar.RADAR_DIR / "o"
|
||||||
|
ordner.mkdir()
|
||||||
|
(ordner / "o-Q4_K_M.gguf").write_bytes(b"x")
|
||||||
|
(ordner / "mmproj-F16.gguf").write_bytes(b"x")
|
||||||
|
stand = radar._leer()
|
||||||
|
stand["kandidaten"]["o"] = _kandidat("o", rolle=rolle, status="bestanden", ordner=str(ordner))
|
||||||
|
radar._speichere(stand)
|
||||||
|
aufrufe, cfg = [], {"models": {}}
|
||||||
|
|
||||||
|
def eintragen(pfad, **kw):
|
||||||
|
aufrufe.append(("eintragen", pfad, kw["role"], kw["set_alias"]))
|
||||||
|
cfg["models"]["o"] = {"cmd": "vorlage"}
|
||||||
|
return "o"
|
||||||
|
|
||||||
|
from services import agent
|
||||||
|
monkeypatch.setattr(radar, "_heutige_modelle", lambda: {rolle: alt})
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "register_model", eintragen)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "read_config", lambda: cfg)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "write_config", lambda c: aufrufe.append(("schreiben",)))
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "set_role", lambda mid, r: aufrufe.append(("rolle", mid, r)) or True)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "add_role", lambda mid, r: aufrufe.append(("dazu", mid, r)) or True)
|
||||||
|
monkeypatch.setattr(radar, "_steward_neu_starten", lambda: aufrufe.append(("steward",)) or None)
|
||||||
|
monkeypatch.setattr(radar.llamaswap, "set_ttl", lambda mid, ttl: aufrufe.append(("ttl", mid, ttl)) or True)
|
||||||
|
monkeypatch.setattr(agent, "set_agent_brain", lambda mid: aufrufe.append(("hirn", mid)) or {"ok": True})
|
||||||
|
monkeypatch.setattr(radar, "STEWARD_WARMSET", zustand / "warmset.conf")
|
||||||
|
return aufrufe, cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebernehmen_hirn_nutzt_den_hirn_wechsel_und_nimmt_fast_mit(zustand, monkeypatch):
|
||||||
|
aufrufe, cfg = _uebernahme_vorbereiten(zustand, monkeypatch, "hirn",
|
||||||
|
{"name": "Qwen3.6-35B-A3B", "aliases": ["hermes", "fast"], "ttl": 0})
|
||||||
|
(zustand / "warmset.conf").write_text('[Service]\nEnvironment="MC_WARMSET=Qwen3-Embedding-0.6B Qwen3.6-35B-A3B"\n',
|
||||||
|
encoding="utf-8")
|
||||||
|
antwort = radar.uebernehmen("o")
|
||||||
|
assert antwort["ok"] and antwort["modell_id"] == "o"
|
||||||
|
assert [a[0] for a in aufrufe] == ["eintragen", "schreiben", "hirn", "dazu", "steward"]
|
||||||
|
assert aufrufe[0][2:] == ("hermes", False) and aufrufe[3] == ("dazu", "o", "fast")
|
||||||
|
befehl = str(cfg["models"]["o"]["cmd"])
|
||||||
|
assert "--parallel 2" in befehl and "-ctk q8_0" in befehl and "--mmproj" in befehl
|
||||||
|
assert (zustand / "models" / "o-GGUF" / "o-Q4_K_M.gguf").exists() and not (radar.RADAR_DIR / "o").exists()
|
||||||
|
k = radar._lade()["kandidaten"]["o"]
|
||||||
|
assert k["status"] == "uebernommen" and "vorher Qwen3.6-35B-A3B" in k["begruendung"]
|
||||||
|
# Der Steward hält danach das neue Hirn warm, nicht mehr das alte — ohne Handgriff.
|
||||||
|
assert 'MC_WARMSET=Qwen3-Embedding-0.6B o"' in (zustand / "warmset.conf").read_text(encoding="utf-8")
|
||||||
|
assert antwort["text"] is None and "Noch zu tun" not in k["begruendung"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_uebernehmen_coder_nimmt_heavy_mit(zustand, monkeypatch):
|
||||||
|
aufrufe, cfg = _uebernahme_vorbereiten(zustand, monkeypatch, "coder",
|
||||||
|
{"name": "Qwen3.8-27B", "aliases": ["coder", "heavy"], "ttl": 5400})
|
||||||
|
antwort = radar.uebernehmen("o")
|
||||||
|
assert antwort["ok"] and antwort["text"] is None
|
||||||
|
assert aufrufe[2:] == [("rolle", "o", "coder"), ("dazu", "o", "heavy"), ("ttl", "o", 5400)]
|
||||||
|
assert "--parallel 1" in str(cfg["models"]["o"]["cmd"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_betriebsbefehl_uebernimmt_die_getesteten_flags():
|
||||||
|
k = _kandidat("f", rolle="coder", ctx=65536, draft={"typ": "draft-mtp", "n_max": 2, "datei": None}, flags=["-ub", "512"])
|
||||||
|
befehl = radar.betriebs_befehl("coder", {"gguf": "/srv/models/F-GGUF/f.gguf", "mmproj": "/srv/models/F-GGUF/mm.gguf",
|
||||||
|
"draft": None}, k)
|
||||||
|
assert "-c 65536" in befehl and "--parallel 1" in befehl and "-ctk q8_0 -ctv q8_0" in befehl
|
||||||
|
assert "--mmproj /srv/models/F-GGUF/mm.gguf" in befehl and "--load-mode none" in befehl
|
||||||
|
assert befehl.endswith("--spec-type draft-mtp --spec-draft-n-max 2 -ub 512")
|
||||||
|
assert "--parallel 2" in radar.betriebs_befehl("hirn", {"gguf": "x.gguf", "mmproj": None, "draft": None},
|
||||||
|
_kandidat("h"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Prüfstand: Urteil, Programmieraufgaben, Bild ----------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def ps():
|
||||||
|
return radar._pruefstand()
|
||||||
|
|
||||||
|
|
||||||
|
BILD_GUT = {"direkt": 1.0, "gefunden": ["Wort ZEBRA", "Zahl 4711"], "agentisch": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _werte(rolle: str, decode: float = 100.0, prefill: float = 13.0, werkzeuge: int = 6, code: int = 8,
|
||||||
|
bild: dict | None = None, deutsch: bool = True) -> dict:
|
||||||
|
werte = {"rolle": rolle, "tiefe": {"tg_tps": decode, "pp_s": prefill}, "werkzeuge": {"ok": werkzeuge, "von": 6},
|
||||||
|
"bild": bild}
|
||||||
|
if rolle == "hirn":
|
||||||
|
werte.update(deutsch={"ok": deutsch}, json={"ok": True})
|
||||||
|
else:
|
||||||
|
werte["code"] = {"ok": code, "von": 10}
|
||||||
|
return werte
|
||||||
|
|
||||||
|
|
||||||
|
def test_urteil_hirn_mit_bildern_besteht_ohne_verschlechterung(ps):
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", decode=95.0, bild=BILD_GUT), _werte("hirn", decode=100.8))
|
||||||
|
assert u["ergebnis"] == "bestanden"
|
||||||
|
assert u["zusammenfassung"] == "Bestanden: versteht Bilder (heute nicht)."
|
||||||
|
assert u["vergleich"]["Decode bei 13k"]["urteil"] == "gleich" # −6 % liegt in der Toleranz
|
||||||
|
assert list(u["vergleich"]) == ["Decode bei 13k", "Prefill bei 13k", "Werkzeug-Aufrufe", "Deutsch", "JSON",
|
||||||
|
"Bilder"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_urteil_hirn_faellt_bei_schwaeche_oder_ohne_vorteil_durch(ps):
|
||||||
|
basis = _werte("hirn", decode=100.8)
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", werkzeuge=5, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "Werkzeuge 5/6 (heute 6/6)" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", decode=70.0, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "Decode bei 13k 70 statt 100,8 t/s" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", bild=BILD_GUT), _werte("hirn", bild=BILD_GUT))
|
||||||
|
assert u["zusammenfassung"] == "Durchgefallen: kein Vorteil gegenüber dem heutigen Modell."
|
||||||
|
u = ps.urteil("hirn", _werte("hirn", decode=130.0), basis) # schneller, aber blind
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "versteht keine Bilder" in u["zusammenfassung"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_urteil_coder_zaehlt_programmieraufgaben_und_bilder(ps):
|
||||||
|
basis = _werte("coder", decode=30.4, prefill=41.2, code=8, bild=BILD_GUT)
|
||||||
|
u = ps.urteil("coder", _werte("coder", decode=45.0, prefill=12.0, code=9, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "bestanden" and "Programmieraufgaben 9/10 statt 8/10" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("coder", _werte("coder", decode=45.0, prefill=12.0, code=7, bild=BILD_GUT), basis)
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "Programmieraufgaben 7/10 (heute 8/10)" in u["zusammenfassung"]
|
||||||
|
u = ps.urteil("coder", _werte("coder", code=9), basis) # der heutige Coder kann Bilder
|
||||||
|
assert u["ergebnis"] == "durchgefallen" and "versteht keine Bilder" in u["zusammenfassung"]
|
||||||
|
|
||||||
|
|
||||||
|
REFERENZ = {
|
||||||
|
"palindrom": "def ist_palindrom(text):\n z = [c.lower() for c in text if c.isalnum()]\n return z == z[::-1]\n",
|
||||||
|
"roemisch": (
|
||||||
|
"def roemisch(zahl):\n"
|
||||||
|
" if not isinstance(zahl, int) or not 1 <= zahl <= 3999:\n"
|
||||||
|
" raise ValueError(zahl)\n"
|
||||||
|
" out = ''\n"
|
||||||
|
" for wert, zeichen in ((1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'), (90, 'XC'),\n"
|
||||||
|
" (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I')):\n"
|
||||||
|
" while zahl >= wert:\n"
|
||||||
|
" out += zeichen\n"
|
||||||
|
" zahl -= wert\n"
|
||||||
|
" return out\n"),
|
||||||
|
"intervalle": (
|
||||||
|
"def zusammenfassen(intervalle):\n"
|
||||||
|
" out = []\n"
|
||||||
|
" for a, b in sorted(intervalle):\n"
|
||||||
|
" if out and a <= out[-1][1]:\n"
|
||||||
|
" out[-1][1] = max(out[-1][1], b)\n"
|
||||||
|
" else:\n"
|
||||||
|
" out.append([a, b])\n"
|
||||||
|
" return out\n"),
|
||||||
|
"flach": (
|
||||||
|
"def flach(liste):\n"
|
||||||
|
" out = []\n"
|
||||||
|
" for x in liste:\n"
|
||||||
|
" out.extend(flach(x) if isinstance(x, list) else [x])\n"
|
||||||
|
" return out\n"),
|
||||||
|
"iban": (
|
||||||
|
"def iban_gueltig(iban):\n"
|
||||||
|
" s = iban.replace(' ', '').upper()\n"
|
||||||
|
" if not 15 <= len(s) <= 34 or not s.isalnum() or not s.isascii():\n"
|
||||||
|
" return False\n"
|
||||||
|
" return int(''.join(str(int(c, 36)) for c in s[4:] + s[:4])) % 97 == 1\n"),
|
||||||
|
"lru": (
|
||||||
|
"from collections import OrderedDict\n"
|
||||||
|
"class LRUCache:\n"
|
||||||
|
" def __init__(self, kapazitaet):\n"
|
||||||
|
" self.k, self.d = kapazitaet, OrderedDict()\n"
|
||||||
|
" def get(self, schluessel):\n"
|
||||||
|
" if schluessel not in self.d:\n"
|
||||||
|
" return None\n"
|
||||||
|
" self.d.move_to_end(schluessel)\n"
|
||||||
|
" return self.d[schluessel]\n"
|
||||||
|
" def put(self, schluessel, wert):\n"
|
||||||
|
" self.d[schluessel] = wert\n"
|
||||||
|
" self.d.move_to_end(schluessel)\n"
|
||||||
|
" if len(self.d) > self.k:\n"
|
||||||
|
" self.d.popitem(last=False)\n"),
|
||||||
|
"klammern": (
|
||||||
|
"def klammern_ok(text):\n"
|
||||||
|
" paare, stapel = {')': '(', ']': '[', '}': '{'}, []\n"
|
||||||
|
" for c in text:\n"
|
||||||
|
" if c in '([{':\n"
|
||||||
|
" stapel.append(c)\n"
|
||||||
|
" elif c in paare and (not stapel or stapel.pop() != paare[c]):\n"
|
||||||
|
" return False\n"
|
||||||
|
" return not stapel\n"),
|
||||||
|
"wege": (
|
||||||
|
"import heapq\n"
|
||||||
|
"def kuerzeste_wege(graph, start):\n"
|
||||||
|
" dist, heap = {start: 0}, [(0, start)]\n"
|
||||||
|
" while heap:\n"
|
||||||
|
" d, u = heapq.heappop(heap)\n"
|
||||||
|
" if d > dist[u]:\n"
|
||||||
|
" continue\n"
|
||||||
|
" for v, w in graph.get(u, {}).items():\n"
|
||||||
|
" if v not in dist or d + w < dist[v]:\n"
|
||||||
|
" dist[v] = d + w\n"
|
||||||
|
" heapq.heappush(heap, (d + w, v))\n"
|
||||||
|
" return dist\n"),
|
||||||
|
"median": (
|
||||||
|
"def median(zahlen):\n"
|
||||||
|
" if not zahlen:\n"
|
||||||
|
" raise ValueError('leer')\n"
|
||||||
|
" s, n = sorted(zahlen), len(zahlen)\n"
|
||||||
|
" return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2\n"),
|
||||||
|
"csv": (
|
||||||
|
"def spaltensumme(csv_text, spalte):\n"
|
||||||
|
" zeilen = [z for z in csv_text.splitlines() if z.strip()]\n"
|
||||||
|
" kopf = zeilen[0].split(';')\n"
|
||||||
|
" if spalte not in kopf:\n"
|
||||||
|
" raise KeyError(spalte)\n"
|
||||||
|
" i, summe = kopf.index(spalte), 0.0\n"
|
||||||
|
" for z in zeilen[1:]:\n"
|
||||||
|
" zellen = z.split(';')\n"
|
||||||
|
" wert = zellen[i].strip() if i < len(zellen) else ''\n"
|
||||||
|
" if wert:\n"
|
||||||
|
" summe += float(wert.replace('.', '').replace(',', '.'))\n"
|
||||||
|
" return summe\n"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_referenzloesungen_bestehen_alle_programmieraufgaben(ps):
|
||||||
|
assert {a["id"] for a in ps.AUFGABEN_CODE} == set(REFERENZ)
|
||||||
|
for aufgabe in ps.AUFGABEN_CODE:
|
||||||
|
ok, grund = ps.fuehre_aufgabe_aus(REFERENZ[aufgabe["id"]], aufgabe["tests"])
|
||||||
|
assert ok, f"{aufgabe['id']}: {grund}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_falsche_verbotene_und_endlose_loesungen_fallen_durch(ps):
|
||||||
|
tests = next(a["tests"] for a in ps.AUFGABEN_CODE if a["id"] == "median")
|
||||||
|
ok, _ = ps.fuehre_aufgabe_aus("def median(z):\n z.sort()\n return z[len(z) // 2]\n", tests)
|
||||||
|
assert not ok
|
||||||
|
ok, grund = ps.fuehre_aufgabe_aus("import os\ndef median(z):\n return os.getpid()\n", tests)
|
||||||
|
assert not ok and grund.startswith("unerlaubter Aufruf")
|
||||||
|
ok, grund = ps.fuehre_aufgabe_aus("def median(z):\n while True:\n pass\n", tests, zeitlimit=2)
|
||||||
|
assert not ok and "Zeitlimit" in grund
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_aus_antwort_nimmt_den_loesungsblock(ps):
|
||||||
|
antwort = "<think>kurz nachdenken</think>Hier:\n```python\ndef f():\n return 1\n```\nAufruf:\n```python\nf()\n```"
|
||||||
|
assert ps.code_aus_antwort(antwort) == "def f():\n return 1\n"
|
||||||
|
assert ps.code_aus_antwort("def g(): return 2") == "def g(): return 2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bild_ist_ein_gueltiges_png_mit_rotem_kreis(ps):
|
||||||
|
png = ps.bild_png()
|
||||||
|
assert png.startswith(b"\x89PNG\r\n\x1a\n")
|
||||||
|
breite, hoehe = struct.unpack(">II", png[16:24])
|
||||||
|
assert (breite, hoehe) == (448, 300)
|
||||||
|
laenge = struct.unpack(">I", png[33:37])[0]
|
||||||
|
roh = zlib.decompress(png[41:41 + laenge])
|
||||||
|
zeile = 1 + breite * 3
|
||||||
|
assert roh[95 * zeile + 1 + 80 * 3:95 * zeile + 1 + 81 * 3] == b"\xdc\x14\x14" # Mitte des Kreises
|
||||||
|
beschreibung = "Ein roter Kreis, ein blaues Quadrat, ein grünes Dreieck und darunter der Text ZEBRA 4711."
|
||||||
|
assert ps.bild_merkmale(beschreibung) == list(ps.BILD_MERKMALE)
|
||||||
|
assert ps.bild_data_url().startswith("data:image/png;base64,")
|
||||||
|
|
||||||
|
|
||||||
|
def test_werkzeug_treffer_liest_json_argumente(ps):
|
||||||
|
aufruf = [{"function": {"name": "send_telegram", "arguments": json.dumps({"text": "Prüfstand läuft."})}}]
|
||||||
|
assert ps.trifft(aufruf, [("send_telegram", r"pr(ü|ue)fstand")])
|
||||||
|
assert not ps.trifft(aufruf, [("terminal", r".*")])
|
||||||
|
assert len(ps.WERKZEUGE_HIRN) >= 90 # Lucy hat ~100 Werkzeuge
|
||||||
|
assert ps.tiefen_prompt("ab12").startswith("Messlauf ab12.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_role_behaelt_die_uebrigen_aliase(monkeypatch):
|
||||||
|
"""add_role ohne Attrappe: coder bleibt, heavy wandert vom alten Coder herüber."""
|
||||||
|
from services import llamaswap
|
||||||
|
cfg = {"models": {"alt": {"cmd": "x", "aliases": ["coder", "heavy"]}, "neu": {"cmd": "y", "aliases": ["coder"]}}}
|
||||||
|
monkeypatch.setattr(llamaswap, "read_config", lambda: cfg)
|
||||||
|
monkeypatch.setattr(llamaswap, "write_config", lambda c: None)
|
||||||
|
assert llamaswap.add_role("neu", "heavy") is True
|
||||||
|
assert cfg["models"]["neu"]["aliases"] == ["coder", "heavy"]
|
||||||
|
assert cfg["models"]["alt"]["aliases"] == ["coder"]
|
||||||
|
assert llamaswap.add_role("gibt-es-nicht", "heavy") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_baseline_misst_neu_wenn_sich_die_proben_geaendert_haben(ps, tmp_path, monkeypatch):
|
||||||
|
"""Eine Baseline aus einem älteren Prüfstand ist nicht vergleichbar (23.09.: Ablenker geändert)."""
|
||||||
|
cache = tmp_path / "baseline.json"
|
||||||
|
cache.write_text(json.dumps({"hirn": {"kennung": "k", "version": ps.PRUEFSTAND_VERSION - 1,
|
||||||
|
"zeit": time.time(), "werte": {"alt": True}}}), encoding="utf-8")
|
||||||
|
gemessen = []
|
||||||
|
monkeypatch.setattr(ps, "pruefe", lambda *a, **kw: gemessen.append(1) or {"neu": True})
|
||||||
|
assert ps.baseline("hirn", str(cache), kennung="k", protokoll=lambda *_: None)["werte"] == {"neu": True}
|
||||||
|
assert gemessen == [1]
|
||||||
|
# Gleiche Version und Kennung: aus dem Cache, keine neue Messung.
|
||||||
|
assert ps.baseline("hirn", str(cache), kennung="k", protokoll=lambda *_: None)["werte"] == {"neu": True}
|
||||||
|
assert gemessen == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kein_ablenker_klingt_nach_plattenplatz(ps):
|
||||||
|
namen = {w["function"]["name"] for w in ps.WERKZEUGE_HIRN}
|
||||||
|
assert "system_speicher" not in namen and "system_arbeitsspeicher" in namen
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Update-Verlauf aus dem Meldeprotokoll und festgehaltene Bausteine (Box-Wart 09/2026)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from services import update_verlauf
|
||||||
|
|
||||||
|
# Echte Formate aus ~/mc2-notify.log der Box (gekürzt), inklusive fremder Meldungen dazwischen.
|
||||||
|
PROTOKOLL = """\
|
||||||
|
2026-08-14 18:53:58 OK telegram: Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken.
|
||||||
|
2026-08-23 04:31:15 QUEUED für Morgen-Digest: KRITISCH: Engine (llama.cpp)-Update auf 10588 UND Rollback fehlgeschlagen — Stack möglicherweise kaputt.
|
||||||
|
2026-08-23 07:04:17 OK telegram: 📰 DAILY NEWS 23.08.2026
|
||||||
|
|
||||||
|
• **Eine Nachricht** (Quelle, 21. August 2026)
|
||||||
|
Text der Nachricht.
|
||||||
|
2026-09-04 14:07:27 OK telegram: Router (llama-swap) aktualisiert: 252 → 253. Stack-Check grün, alles läuft.
|
||||||
|
2026-09-04 14:09:30 OK telegram: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
• Router (llama-swap): 252 → 253 eingespielt, Stack-Check grün.
|
||||||
|
• Engine (llama.cpp): 10733 → 10797 eingespielt, Stack-Check grün.
|
||||||
|
• Hermes-Agent: 1 Commits eingespielt, Gehirn-Check grün.
|
||||||
|
2026-09-04 14:26:29 OK telegram: Die Box startet jetzt neu — Kernel 7.0.0-31 ist eingespielt.
|
||||||
|
2026-09-06 04:31:16 QUEUED für Morgen-Digest: Engine (llama.cpp) aktualisiert: 10797 → 10819. Stack-Check grün, alles läuft.
|
||||||
|
2026-09-06 04:55:53 QUEUED für Morgen-Digest: Hermes-Update fehlgeschlagen (Gehirn-Check rot). Automatisch zurückgerollt auf 63279301b — läuft wieder. Hermes ist jetzt GEPINNT.
|
||||||
|
2026-09-06 04:55:53 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
• Router (llama-swap): aktuell (253).
|
||||||
|
• Engine (llama.cpp): 10797 → 10819 eingespielt, Stack-Check grün.
|
||||||
|
• Hermes-Agent: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf 63279301b.
|
||||||
|
2026-09-10 12:00:00 OK telegram: Die Box startet jetzt neu — von Hand.
|
||||||
|
2026-09-13 04:31:07 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
• Router (llama-swap): 253 → 255 eingespielt, Stack-Check grün.
|
||||||
|
• Engine (llama.cpp): 10936 FEHLGESCHLAGEN → zurückgerollt auf 10819 + GEPINNT.
|
||||||
|
• Hermes-Agent: gepinnt, übersprungen.
|
||||||
|
2026-09-13 04:31:07 QUEUED für Morgen-Digest: Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 ). Sie ist ein paar Minuten weg.
|
||||||
|
2026-09-20 04:30:39 QUEUED für Morgen-Digest: Router (llama-swap) aktualisiert: 255 → 256. Stack-Check grün, alles läuft.
|
||||||
|
2026-09-20 04:35:33 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
• Router (llama-swap): 255 → 256 eingespielt, Stack-Check grün.
|
||||||
|
• Engine (llama.cpp): 11026 → 11057 eingespielt, Stack-Check grün.
|
||||||
|
• Hermes-Agent: 1861 Commits eingespielt, Gehirn-Check grün.
|
||||||
|
2026-09-27 04:31:00 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||||
|
• Router (llama-swap): aktuell (256).
|
||||||
|
• Engine (llama.cpp): aktuell (11057).
|
||||||
|
• Hermes-Agent: aktuell.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_laeufe_neueste_zuerst_und_ohne_fremde_meldungen():
|
||||||
|
laeufe = update_verlauf.laeufe_aus(PROTOKOLL)
|
||||||
|
# 27.09., 20.09., 13.09., 06.09., 04.09., 23.08. — der Neustart vom 10.09. allein ist kein Lauf.
|
||||||
|
assert [lauf["start"][:10] for lauf in laeufe] == [
|
||||||
|
"2026-09-27", "2026-09-20", "2026-09-13", "2026-09-06", "2026-09-04", "2026-08-23"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruener_sonntag_mit_einer_zeile_je_baustein():
|
||||||
|
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[1]
|
||||||
|
assert lauf["anlass"] == "Updates am Sonntag"
|
||||||
|
assert (lauf["stufe"], lauf["titel"]) == ("gruen", "Eingespielt")
|
||||||
|
assert [(z["baustein"], z["ergebnis"]) for z in lauf["zeilen"]] == [
|
||||||
|
("llama-swap", "eingespielt"), ("Motor", "eingespielt"), ("Hermes", "eingespielt")]
|
||||||
|
assert lauf["zeilen"][2]["text"] == "1861 Änderungen eingespielt, Gehirn-Check grün."
|
||||||
|
|
||||||
|
|
||||||
|
def test_rueckbau_und_festhalten_werden_gelb_und_lesbar():
|
||||||
|
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[2] # 13.09.
|
||||||
|
assert (lauf["stufe"], lauf["titel"]) == ("gelb", "Zurückgerollt")
|
||||||
|
zeilen = {z["baustein"]: z for z in lauf["zeilen"]}
|
||||||
|
assert zeilen["Motor"]["ergebnis"] == "zurueckgerollt"
|
||||||
|
assert zeilen["Motor"]["text"] == "10936 fehlgeschlagen → zurückgerollt auf 10819 und festgehalten."
|
||||||
|
assert zeilen["Hermes"]["ergebnis"] == "festgehalten"
|
||||||
|
assert zeilen["Neustart"]["ergebnis"] == "neustart"
|
||||||
|
assert zeilen["Neustart"]["text"] == "Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 )."
|
||||||
|
|
||||||
|
|
||||||
|
def test_abgebrochener_lauf_ohne_zusammenfassung_ist_rot():
|
||||||
|
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[-1] # 23.08.
|
||||||
|
assert (lauf["stufe"], lauf["titel"]) == ("rot", "Fehler")
|
||||||
|
assert lauf["zeilen"][0]["baustein"] == "Motor"
|
||||||
|
assert lauf["zeilen"][0]["text"].startswith("Kritisch: Engine (llama.cpp)-Update auf 10588")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lauf_von_hand_mit_neustart_danach():
|
||||||
|
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[4] # 04.09. nachmittags
|
||||||
|
assert lauf["anlass"] == "Update von Hand"
|
||||||
|
assert lauf["titel"] == "Eingespielt"
|
||||||
|
assert lauf["zeilen"][-1]["baustein"] == "Neustart"
|
||||||
|
assert lauf["zeilen"][2]["text"] == "1 Änderung eingespielt, Gehirn-Check grün."
|
||||||
|
|
||||||
|
|
||||||
|
def test_alles_aktuell_ist_grau():
|
||||||
|
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[0]
|
||||||
|
assert (lauf["stufe"], lauf["titel"]) == ("grau", "Alles aktuell")
|
||||||
|
|
||||||
|
|
||||||
|
def test_laeufe_liest_die_datei_und_merkt_sich_den_stand(tmp_path, monkeypatch):
|
||||||
|
datei = tmp_path / "mc2-notify.log"
|
||||||
|
datei.write_text(PROTOKOLL, encoding="utf-8")
|
||||||
|
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", datei)
|
||||||
|
monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []})
|
||||||
|
assert len(update_verlauf.laeufe(3)) == 3
|
||||||
|
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", tmp_path / "gibt-es-nicht.log")
|
||||||
|
assert update_verlauf.laeufe() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_festgehalten_und_freigeben(tmp_path, monkeypatch):
|
||||||
|
pins = tmp_path / "mc2-pins.json"
|
||||||
|
pins.write_text(json.dumps({
|
||||||
|
"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13",
|
||||||
|
"grund": "Update auf 10936 zerschoss den Stack-Check; Rollback grün"},
|
||||||
|
"hermes": {"pinned": True, "version": "63279301b", "datum": "2026-09-06", "grund": "x"},
|
||||||
|
"swap": {"pinned": False},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
monkeypatch.setattr(update_verlauf, "PINS_FILE", pins)
|
||||||
|
|
||||||
|
fest = update_verlauf.festgehalten()
|
||||||
|
assert [(p["baustein"], p["seit"]) for p in fest] == [("engine", "13.09."), ("hermes", "06.09.")]
|
||||||
|
assert fest[0]["name"] == "Motor (llama.cpp)"
|
||||||
|
|
||||||
|
assert update_verlauf.freigeben("engine") is True
|
||||||
|
assert update_verlauf.freigeben("engine") is False
|
||||||
|
rest = json.loads(pins.read_text(encoding="utf-8"))
|
||||||
|
assert set(rest) == {"hermes", "swap"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_waechter_meldet_festgehaltene_bausteine(tmp_path, monkeypatch):
|
||||||
|
from services import waechter
|
||||||
|
|
||||||
|
pins = tmp_path / "mc2-pins.json"
|
||||||
|
pins.write_text(json.dumps({"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13",
|
||||||
|
"grund": "Update zerschoss den Stack-Check"}}), encoding="utf-8")
|
||||||
|
monkeypatch.setattr(update_verlauf, "PINS_FILE", pins)
|
||||||
|
befunde = waechter.pruefe_festgehalten()
|
||||||
|
assert len(befunde) == 1
|
||||||
|
b = befunde[0]
|
||||||
|
assert (b.id, b.stufe, b.sofort) == ("pin:engine", "gelb", True)
|
||||||
|
assert "Seit 13.09. auf 10819 festgehalten" in b.text
|
||||||
|
assert b.aktionen[0]["id"] == "freigeben" and b.aktionen[0]["baustein"] == "engine"
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Tests für die reinen Auswertungen des Wächters — mit echten Zeilen von der Box (23.09.2026)."""
|
||||||
|
|
||||||
|
from services import waechter
|
||||||
|
|
||||||
|
PROJEKTE_SYNC_LOG = [
|
||||||
|
"2026-09-23 21:02:44 === Sync-Lauf (14 Repos gemeldet) ===",
|
||||||
|
"2026-09-23 21:02:45 = NerdQuiz-next: aktuell (a1b2c3d)",
|
||||||
|
"2026-09-23 21:02:46 ! TTT2-Toolbox_ClaudeEdition: Pull fehlgeschlagen — fatal: no such ref was fetched",
|
||||||
|
"2026-09-23 21:02:46 === Ende (Fehler: 1) ===",
|
||||||
|
]
|
||||||
|
|
||||||
|
ERRORS_LOG = [
|
||||||
|
('2026-09-23 07:02:41,584 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
|
||||||
|
'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md: '
|
||||||
|
'/tmp/news-text.md exists"}'),
|
||||||
|
('2026-09-23 07:02:58,516 WARNING [cron_195e479e8e30_20260923_070017] agent.tool_executor: '
|
||||||
|
'Tool write_file returned error (0.00s): {"error": "Refusing to overwrite /tmp/news-text.md"}'),
|
||||||
|
"2026-09-23 07:03:39,596 WARNING gateway.mirror: Mirror: no session found for telegram:1 thread=None",
|
||||||
|
('2026-09-22 07:01:10,000 WARNING [cron_195e479e8e30_20260922_070017] agent.tool_executor: '
|
||||||
|
'Tool web_extract returned error (0.10s): {"error": "timeout"}'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlerzeile_nimmt_die_letzte_fehlerzeile_ohne_zeitstempel():
|
||||||
|
zeile = waechter.waehle_fehlerzeile(PROJEKTE_SYNC_LOG)
|
||||||
|
assert zeile.startswith("! TTT2-Toolbox_ClaudeEdition: Pull fehlgeschlagen")
|
||||||
|
assert "2026-09-23" not in zeile
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlerzeile_uebergeht_systemd_rahmenzeilen():
|
||||||
|
"""Echtes Journal vom 23.09.: systemd schreibt NACH der Skriptzeile noch „Failed to start …“."""
|
||||||
|
zeilen = PROJEKTE_SYNC_LOG + [
|
||||||
|
"projekte-sync.service: Main process exited, code=exited, status=1/FAILURE",
|
||||||
|
"projekte-sync.service: Failed with result 'exit-code'.",
|
||||||
|
"Failed to start projekte-sync.service - Projekte-Sync — haelt ~/projekte mit Gitea deckungsgleich.",
|
||||||
|
]
|
||||||
|
assert waechter.waehle_fehlerzeile(zeilen).startswith("! TTT2-Toolbox_ClaudeEdition")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlerzeile_leer_wenn_nichts_nach_fehler_aussieht():
|
||||||
|
assert waechter.waehle_fehlerzeile(["alles gut", "=== Ende (Fehler: 0) ==="]) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_werkzeugfehler_zaehlt_nur_den_lauf_des_tages():
|
||||||
|
anzahl, beispiel = waechter.werkzeugfehler_im_lauf(
|
||||||
|
ERRORS_LOG, "195e479e8e30", "2026-09-23T07:00:17.675474+02:00")
|
||||||
|
assert anzahl == 2
|
||||||
|
assert beispiel.startswith("write_file: Refusing to overwrite /tmp/news-text.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_werkzeugfehler_null_fuer_anderen_job_oder_kaputtes_datum():
|
||||||
|
assert waechter.werkzeugfehler_im_lauf(ERRORS_LOG, "ca7d4ed207c2", "2026-09-23T08:00:00+02:00") == (0, "")
|
||||||
|
assert waechter.werkzeugfehler_im_lauf(ERRORS_LOG, "195e479e8e30", "kein-datum") == (0, "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_befunde_werden_erst_nach_fail_after_takten_zum_hinweis(monkeypatch, tmp_path):
|
||||||
|
"""Flankenlogik: ein kurzer Aussetzer (1 Takt) erzeugt keinen Hinweis, ein dauerhafter schon.
|
||||||
|
Rote Hinweise gehen an Telegram, Erledigtes verschwindet und landet im Verlauf."""
|
||||||
|
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json")
|
||||||
|
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
|
||||||
|
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
|
||||||
|
meldungen: list[str] = []
|
||||||
|
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(betreff))
|
||||||
|
defekt = [True]
|
||||||
|
|
||||||
|
def pruefung():
|
||||||
|
if not defekt[0]:
|
||||||
|
return []
|
||||||
|
return [waechter.Befund(id="kern:engine", stufe="rot", titel="Der Motor antwortet nicht",
|
||||||
|
text="…", quelle="engine")]
|
||||||
|
|
||||||
|
monkeypatch.setattr(waechter, "PRUEFUNGEN", (pruefung,))
|
||||||
|
monkeypatch.setattr(waechter, "FAIL_AFTER", 2)
|
||||||
|
|
||||||
|
waechter.takt()
|
||||||
|
assert waechter._stand["hinweise"] == {} # erster Takt: nur Kandidat
|
||||||
|
waechter.takt()
|
||||||
|
assert "kern:engine" in waechter._stand["hinweise"]
|
||||||
|
assert meldungen == ["[Box-Problem]"]
|
||||||
|
|
||||||
|
defekt[0] = False
|
||||||
|
waechter.takt()
|
||||||
|
assert waechter._stand["hinweise"] == {}
|
||||||
|
assert meldungen == ["[Box-Problem]", "[Box wieder ok]"]
|
||||||
|
arten = [v["art"] for v in waechter._stand["verlauf"]]
|
||||||
|
assert arten == ["neu", "erledigt"]
|
||||||
|
|
||||||
|
stand = waechter.lese_stand()
|
||||||
|
assert stand["aktiv"] is True and stand["hinweise"] == []
|
||||||
+58
-162
@@ -1,22 +1,23 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Prüfstand für Hirn-Kandidaten (17.09.2026). Kandidat läuft standalone auf :5899 — der Live-Stack bleibt unberührt —,
|
"""Prüfstand für Hirn-Kandidaten (17.09.2026), seit 09/2026 eine dünne Hülle um pruefstand.py.
|
||||||
Baselines laufen über llama-swap :8080. Je Betriebsart: Kurz- und 13k-Tiefenmessung (Prefill/Decode/Draft-Akzeptanz),
|
|
||||||
Tool-Calling direkt (3 Aufgaben, Denken an), Deutsch- und JSON-Probe (Denken aus) und der Tool-Smoke durch den ECHTEN
|
Die Messungen selbst (Tempo, 13k-Tiefe, Werkzeuge, Deutsch/JSON, Bild-Probe, Server auf :5899) liegen
|
||||||
Hermes-Agenten (`hermes chat --provider pruefstand -m kandidat`; Provider `pruefstand` -> http://127.0.0.1:5899/v1 muss in
|
im Modul — dasselbe nutzt das Modell-Radar nachts. Dieses Skript bleibt der Handbetrieb: Kandidaten
|
||||||
`providers:` der Hermes-Config stehen). Aufruf auf der Box: python3 pruefstand-hirn.py [baseline] [nemo] [u38] [u36]
|
mit festen Pfaden gegen die Live-Modelle (llama-swap :8080) messen, optional mit dem Tool-Smoke durch
|
||||||
(Kandidaten-Pfade unten anpassen). Ergebnis: ~/pruefstand-<datum>.json + Log auf stdout. Lehren: Denk-Modelle brauchen
|
den ECHTEN Hermes-Agenten (`hermes chat --provider pruefstand -m kandidat`; der Provider `pruefstand`
|
||||||
für Tool-Aufrufe >=1500 Tokens Budget, /no_think wirkt bei Qwen 3.6 nicht (chat_template_kwargs.enable_thinking=false);
|
-> http://127.0.0.1:5899/v1 muss in `providers:` der Hermes-Config stehen).
|
||||||
der erste Lauf nach dem Laden ist ~40 % langsamer (Warmlauf verwerfen)."""
|
|
||||||
|
Aufruf auf der Box: python3 pruefstand-hirn.py [baseline] [nemo] [u38] [u36] (Pfade unten anpassen).
|
||||||
|
Ergebnis: ~/pruefstand-<datum>.json + Log auf stdout.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
BIN = "/opt/llamacpp-vulkan/llama-server"; PORT = 5899
|
import pruefstand as ps # liegt daneben; beim Skriptaufruf steht dieser Ordner auf sys.path
|
||||||
BASE = ["-c", "65536", "-ngl", "999", "-fa", "on", "--load-mode", "none", "--jinja", "--parallel", "1", "-ctk", "q8_0", "-ctv", "q8_0"]
|
|
||||||
M = "/srv/models"
|
M = "/srv/models"
|
||||||
DFLASH_36 = f"{M}/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf"
|
DFLASH_36 = f"{M}/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf"
|
||||||
DFLASH2_38 = f"{M}/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf"
|
DFLASH2_38 = f"{M}/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf"
|
||||||
@@ -24,170 +25,65 @@ NEMO = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/NVIDIA-Nemotron-3.5-Lightning-3
|
|||||||
NEMO_MTP = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/mtp-NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q8_0.gguf"
|
NEMO_MTP = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/mtp-NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q8_0.gguf"
|
||||||
U38 = f"{M}/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf"
|
U38 = f"{M}/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf"
|
||||||
U36 = f"{M}/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
|
U36 = f"{M}/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
|
||||||
HERMES = os.path.expanduser("~/.local/bin/hermes")
|
OUT = os.path.expanduser(f"~/pruefstand-{time.strftime('%Y-%m-%d')}.json")
|
||||||
OUT = os.path.expanduser("~/pruefstand-2026-09-17.json")
|
RESULTS: list[dict] = []
|
||||||
RESULTS = []
|
|
||||||
|
|
||||||
|
|
||||||
def log(*a):
|
def save() -> None:
|
||||||
print(*a, flush=True)
|
with open(OUT, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(RESULTS, f, indent=1, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def post(url, payload, timeout=900):
|
def probes(basis: str, modell: str, label: str, with_hermes: bool, hermes_args: list[str], bild: bool = False) -> None:
|
||||||
r = urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"})
|
werte = {"label": label, **ps.pruefe("hirn", basis, modell, bild=bild)}
|
||||||
return json.loads(urllib.request.urlopen(r, timeout=timeout).read())
|
|
||||||
|
|
||||||
|
|
||||||
def chat(base, model, messages, max_tokens=200, tools=None, think=True):
|
|
||||||
p = {"model": model, "temperature": 0, "max_tokens": max_tokens, "messages": messages}
|
|
||||||
if not think:
|
|
||||||
p["chat_template_kwargs"] = {"enable_thinking": False}
|
|
||||||
if tools:
|
|
||||||
p["tools"] = tools
|
|
||||||
p["tool_choice"] = "auto"
|
|
||||||
t0 = time.time()
|
|
||||||
j = post(f"{base}/v1/chat/completions", p)
|
|
||||||
j["_wall"] = time.time() - t0
|
|
||||||
return j
|
|
||||||
|
|
||||||
|
|
||||||
PARA = ("Notiz {i}: Die Box läuft mit llama-swap auf Port 8080, das Hirn ist ein Mixture-of-Experts-Modell mit drei Milliarden "
|
|
||||||
"aktiven Parametern, der Coder ein dichtes 27B-Modell mit Draft-Beschleunigung. Lucy hört über Parakeet, spricht über "
|
|
||||||
"pocket-tts und meldet sich per Telegram. Der Sonntags-Job aktualisiert Router, Engine und Agent und rollt bei Rot zurück. ")
|
|
||||||
DEPTH_PROMPT = "".join(PARA.format(i=i) for i in range(130)) + \
|
|
||||||
"\n\nFrage: Fasse in drei Sätzen zusammen, was in den Notizen steht, und nenne die Nummer der letzten Notiz."
|
|
||||||
|
|
||||||
TOOLS = [
|
|
||||||
{"type": "function", "function": {"name": "terminal", "description": "Führt einen Shell-Befehl auf der Box aus und gibt stdout zurück.",
|
|
||||||
"parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}},
|
|
||||||
{"type": "function", "function": {"name": "read_file", "description": "Liest eine Datei und gibt den Inhalt zurück.",
|
|
||||||
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
|
|
||||||
{"type": "function", "function": {"name": "send_telegram", "description": "Schickt dem Nutzer eine Telegram-Nachricht.",
|
|
||||||
"parameters": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}},
|
|
||||||
]
|
|
||||||
TOOL_TASKS = [
|
|
||||||
("Wie viel freier Speicher ist auf /srv/models? Nutze das passende Werkzeug.", "terminal", r"df"),
|
|
||||||
("Lies die Datei /etc/hostname und sag mir, wie die Box heißt.", "read_file", r"/etc/hostname"),
|
|
||||||
("Schick mir per Telegram die Nachricht: Prüfstand läuft.", "send_telegram", r"Pr.fstand"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def timings(j):
|
|
||||||
t = j.get("timings") or {}
|
|
||||||
acc = (t.get("draft_n_accepted", 0) / t["draft_n"]) if t.get("draft_n") else None
|
|
||||||
return {"pp_n": t.get("prompt_n"), "pp_s": round((t.get("prompt_ms") or 0) / 1000, 1), "pp_tps": round(t.get("prompt_per_second") or 0),
|
|
||||||
"tg_n": t.get("predicted_n"), "tg_tps": round(t.get("predicted_per_second") or 0, 1), "acc": None if acc is None else round(acc, 2)}
|
|
||||||
|
|
||||||
|
|
||||||
def save():
|
|
||||||
json.dump(RESULTS, open(OUT, "w"), indent=1, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def probes(base, model, label, with_hermes, hermes_args):
|
|
||||||
res = {"label": label}
|
|
||||||
chat(base, model, [{"role": "user", "content": "Sag kurz hallo."}], 32) # Warmlauf
|
|
||||||
j = chat(base, model, [{"role": "user", "content": "Erkläre in fünf Sätzen, was ein Mixture-of-Experts-Modell ist."}], 200)
|
|
||||||
res["kurz"] = timings(j); log(f" kurz : {res['kurz']}")
|
|
||||||
j = chat(base, model, [{"role": "user", "content": DEPTH_PROMPT}], 160)
|
|
||||||
res["tiefe"] = timings(j); log(f" 13k : {res['tiefe']}")
|
|
||||||
tc = []
|
|
||||||
for q, want_fn, want_arg in TOOL_TASKS:
|
|
||||||
try:
|
|
||||||
j = chat(base, model, [{"role": "user", "content": q}], 1500, TOOLS)
|
|
||||||
msg = j["choices"][0]["message"]
|
|
||||||
calls = msg.get("tool_calls") or []
|
|
||||||
ok = any(c["function"]["name"] == want_fn and re.search(want_arg, c["function"].get("arguments", "")) for c in calls)
|
|
||||||
raw = "" if calls else (msg.get("content") or "")[:120].replace("\n", " ")
|
|
||||||
tc.append({"task": want_fn, "ok": ok, "calls": [(c["function"]["name"], c["function"].get("arguments", "")[:80]) for c in calls], "raw": raw})
|
|
||||||
except Exception as e:
|
|
||||||
tc.append({"task": want_fn, "ok": False, "error": str(e)[:120]})
|
|
||||||
res["tools_direkt"] = tc
|
|
||||||
log(f" tools: {sum(1 for t in tc if t['ok'])}/3 " + "; ".join(f"{t['task']}={'✓' if t['ok'] else '✗'}" for t in tc))
|
|
||||||
for t in tc:
|
|
||||||
if not t["ok"]:
|
|
||||||
log(f" {t['task']}: calls={t.get('calls')} raw={t.get('raw', '')!r} {t.get('error', '')}")
|
|
||||||
j = chat(base, model, [{"role": "user", "content": "Antworte auf Deutsch in genau drei Sätzen: Warum braucht ein Heimserver ein Backup?"}], 300, think=False)
|
|
||||||
m = j["choices"][0]["message"]
|
|
||||||
txt = (m.get("content") or "").strip() or ("[nur reasoning] " + (m.get("reasoning_content") or "")[:200])
|
|
||||||
res["deutsch"] = txt[:400]; log(f" de : {txt[:160]!r}")
|
|
||||||
j = chat(base, model, [{"role": "user", "content": 'Antworte NUR mit JSON, ohne Erklärung: {"modell": "<dein Name>", "ok": true}'}], 120, think=False)
|
|
||||||
m = j["choices"][0]["message"]
|
|
||||||
txt = (m.get("content") or "").strip()
|
|
||||||
try:
|
|
||||||
json.loads(txt[txt.find("{"): txt.rfind("}") + 1]); js = True
|
|
||||||
except Exception:
|
|
||||||
js = False
|
|
||||||
res["json_ok"] = js; log(f" json : {'✓' if js else '✗'} {txt[:80]!r}")
|
|
||||||
if with_hermes:
|
if with_hermes:
|
||||||
hz = []
|
werte["hermes"] = ps.hermes_smoke(hermes_args)
|
||||||
for q, want in [("Führe im Terminal exakt den Befehl echo pruefstand-ok aus und gib mir nur dessen Ausgabe zurück.", "pruefstand-ok"),
|
RESULTS.append(werte)
|
||||||
("Welche Verzeichnisse liegen direkt unter /srv/models? Nenne mir drei davon.", "GGUF")]:
|
save()
|
||||||
t0 = time.time()
|
|
||||||
|
|
||||||
|
def candidate(label: str, model: str, extra: list[str], with_hermes: bool, mmproj: str | None = None) -> None:
|
||||||
|
ps.log(f"\n=== {label}")
|
||||||
|
ps.log(f" {os.path.basename(model)} {' '.join(extra)}")
|
||||||
try:
|
try:
|
||||||
p = subprocess.run([HERMES, "chat", *hermes_args, "-Q", "--oneshot", "--max-turns", "6", "-q", q],
|
server = ps.starte_server(model, mmproj=mmproj, flags=extra, ctx=ps.CTX_STANDARD)
|
||||||
capture_output=True, text=True, timeout=900)
|
except ps.ServerFehler as e:
|
||||||
out = (p.stdout + p.stderr)[-600:]
|
ps.log(f" {e}")
|
||||||
except subprocess.TimeoutExpired:
|
RESULTS.append({"label": label, "fehler": str(e)[:300]})
|
||||||
out = "TIMEOUT"
|
save()
|
||||||
ok = want.lower() in out.lower()
|
return
|
||||||
hz.append({"ok": ok, "s": round(time.time() - t0), "tail": out[-160:].replace("\n", " ")})
|
|
||||||
log(f" hermes: {'✓' if ok else '✗'} {round(time.time() - t0)} s {out[-120:].replace(chr(10), ' ')!r}")
|
|
||||||
res["hermes"] = hz
|
|
||||||
RESULTS.append(res); save()
|
|
||||||
|
|
||||||
|
|
||||||
def start_server(model, extra):
|
|
||||||
cmd = [BIN, "-m", model, "--host", "127.0.0.1", "--port", str(PORT), *BASE, *extra]
|
|
||||||
logf = open("/tmp/pruefstand-server.log", "w")
|
|
||||||
p = subprocess.Popen(cmd, stdout=logf, stderr=subprocess.STDOUT)
|
|
||||||
t0 = time.time()
|
|
||||||
for _ in range(150):
|
|
||||||
time.sleep(2)
|
|
||||||
if p.poll() is not None:
|
|
||||||
tail = open("/tmp/pruefstand-server.log", errors="replace").read()[-500:]
|
|
||||||
return None, f"LOAD-CRASH rc={p.returncode}: {tail.strip()[-300:]}"
|
|
||||||
try:
|
try:
|
||||||
if b"ok" in urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=2).read():
|
probes(ps.KANDIDAT_URL, "kandidat", label, with_hermes, ["--provider", "pruefstand", "-m", "kandidat"],
|
||||||
return p, round(time.time() - t0)
|
bild=bool(mmproj))
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
p.kill()
|
|
||||||
return None, "TIMEOUT beim Laden"
|
|
||||||
|
|
||||||
|
|
||||||
def candidate(label, model, extra, with_hermes):
|
|
||||||
log(f"\n=== {label}"); log(f" {os.path.basename(model)} {' '.join(extra)}")
|
|
||||||
if not os.path.exists(model):
|
|
||||||
log(" FEHLT: Datei nicht da"); RESULTS.append({"label": label, "fehler": "Datei fehlt"}); save(); return
|
|
||||||
p, info = start_server(model, extra)
|
|
||||||
if p is None:
|
|
||||||
log(f" {info}"); RESULTS.append({"label": label, "fehler": info}); save(); return
|
|
||||||
log(f" geladen in {info} s")
|
|
||||||
try:
|
|
||||||
probes(f"http://127.0.0.1:{PORT}", "kandidat", label, with_hermes, ["--provider", "pruefstand", "-m", "kandidat"])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f" FEHLER in Proben: {e}"); RESULTS.append({"label": label, "fehler": str(e)[:200]}); save()
|
ps.log(f" FEHLER in Proben: {e}")
|
||||||
|
RESULTS.append({"label": label, "fehler": str(e)[:200]})
|
||||||
|
save()
|
||||||
finally:
|
finally:
|
||||||
p.terminate()
|
ps.stoppe_server(server)
|
||||||
try:
|
|
||||||
p.wait(timeout=60)
|
|
||||||
except Exception:
|
|
||||||
p.kill()
|
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
which = set(sys.argv[1:]) or {"baseline", "nemo", "u38", "u36"}
|
which = set(sys.argv[1:]) or {"baseline", "nemo", "u38", "u36"}
|
||||||
if "baseline" in which:
|
if "baseline" in which:
|
||||||
log("\n=== BASELINE fast (live, llama-swap)"); probes("http://127.0.0.1:8080", "fast", "fast-live", True, ["-m", "fast"])
|
ps.log("\n=== BASELINE fast (live, llama-swap)")
|
||||||
log("\n=== BASELINE coder = Qwen3.8-27B+DFlash2 (live)"); probes("http://127.0.0.1:8080", "coder", "coder-live", True, ["-m", "coder"])
|
probes(ps.SWAP_URL, "fast", "fast-live", True, ["-m", "fast"])
|
||||||
|
ps.log("\n=== BASELINE coder (live, llama-swap)")
|
||||||
|
probes(ps.SWAP_URL, "coder", "coder-live", True, ["-m", "coder"], bild=True)
|
||||||
if "nemo" in which:
|
if "nemo" in which:
|
||||||
candidate("Nemotron 3.5 Lightning · MTP n3", NEMO, ["--spec-type", "draft-mtp", "--spec-draft-model", NEMO_MTP, "--spec-draft-n-max", "3"], True)
|
candidate("Nemotron 3.5 Lightning · MTP n3", NEMO,
|
||||||
|
ps.draft_flags(NEMO_MTP, "draft-mtp", 3), True)
|
||||||
candidate("Nemotron 3.5 Lightning · ohne Spec", NEMO, [], False)
|
candidate("Nemotron 3.5 Lightning · ohne Spec", NEMO, [], False)
|
||||||
if "u38" in which:
|
if "u38" in which:
|
||||||
candidate("Qwen3.8-27B-Uncensored · MTP fused n2", U38, ["--spec-type", "draft-mtp", "--spec-draft-n-max", "2"], True)
|
candidate("Qwen3.8-27B · MTP fused n2", U38, ps.draft_flags(None, "draft-mtp", 2), True)
|
||||||
candidate("Qwen3.8-27B-Uncensored · DFlash2 (z-lab) n3", U38, ["--spec-type", "draft-dflash", "--spec-draft-model", DFLASH2_38, "--spec-draft-n-max", "3"], False)
|
candidate("Qwen3.8-27B · DFlash2 (z-lab) n3", U38, ps.draft_flags(DFLASH2_38, "draft-dflash", 3), False)
|
||||||
candidate("Qwen3.8-27B-Uncensored · ohne Spec", U38, [], False)
|
candidate("Qwen3.8-27B · ohne Spec", U38, [], False)
|
||||||
if "u36" in which:
|
if "u36" in which:
|
||||||
candidate("Qwen3.6-35B-A3B-Uncensored-Aggressive · DFlash (giocom)", U36, ["--spec-type", "draft-dflash", "--spec-draft-model", DFLASH_36], True)
|
candidate("Qwen3.6-35B-A3B · DFlash (giocom)", U36, ps.draft_flags(DFLASH_36, "draft-dflash"), True)
|
||||||
candidate("Qwen3.6-35B-A3B-Uncensored-Aggressive · ohne Spec", U36, [], False)
|
candidate("Qwen3.6-35B-A3B · ohne Spec", U36, [], False)
|
||||||
log("\nPRUEFSTAND_DONE")
|
ps.log("\nPRUEFSTAND_DONE")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,11 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
# 4. Systemd Units neu laden (falls sich .service Dateien geändert haben)
|
||||||
|
# Das Modell-Radar (09/2026) ist die erste Unit, die deploy.sh selbst einspielt: Sie gehört ganz dem
|
||||||
|
# Repo, auf der Box wird an ihr nichts von Hand gedreht. Die übrigen Units bleiben wie bisher Handarbeit.
|
||||||
|
install -m 644 deploy/mc2-radar.service deploy/mc2-radar.timer "$HOME/.config/systemd/user/"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now mc2-radar.timer
|
||||||
|
|
||||||
# 4.5 Hermes Skills synchronisieren
|
# 4.5 Hermes Skills synchronisieren
|
||||||
echo "Synchronisiere Hermes Skills..."
|
echo "Synchronisiere Hermes Skills..."
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ if [ "${1:-}" = "--nur-stimme" ]; then
|
|||||||
--data "$(python3 -c 'import json,sys; print(json.dumps({"text": open(sys.argv[1], encoding="utf-8").read()[:1500]}))' "$SPRECHDATEI")" \
|
--data "$(python3 -c 'import json,sys; print(json.dumps({"text": open(sys.argv[1], encoding="utf-8").read()[:1500]}))' "$SPRECHDATEI")" \
|
||||||
-o "$TON" -w '%{http_code}' 2>/dev/null)
|
-o "$TON" -w '%{http_code}' 2>/dev/null)
|
||||||
|
|
||||||
|
# Sprechfassung wegraeumen (Grund siehe Textfassung unten) — auch wenn die Stimme scheiterte.
|
||||||
|
mv -f "$SPRECHDATEI" "${SPRECHDATEI%.txt}.gesendet.txt" 2>/dev/null || true
|
||||||
|
|
||||||
[ "$CODE" = "200" ] && [ -s "$TON" ] || exit 0
|
[ "$CODE" = "200" ] && [ -s "$TON" ] || exit 0
|
||||||
|
|
||||||
# Telegram zeigt eine ECHTE Sprachnachricht (runde Blase, Wellenform) nur bei
|
# Telegram zeigt eine ECHTE Sprachnachricht (runde Blase, Wellenform) nur bei
|
||||||
@@ -57,8 +60,16 @@ fi
|
|||||||
# ------------------------------------------- Erster Durchgang: Text + Start ---
|
# ------------------------------------------- Erster Durchgang: Text + Start ---
|
||||||
TEXTDATEI="${1:-/tmp/news-text.md}"
|
TEXTDATEI="${1:-/tmp/news-text.md}"
|
||||||
SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
|
SPRECHDATEI="${2:-/tmp/news-sprich.txt}"
|
||||||
|
GESENDET="${TEXTDATEI%.md}.gesendet.md"
|
||||||
|
|
||||||
if [ ! -s "$TEXTDATEI" ]; then
|
if [ ! -s "$TEXTDATEI" ]; then
|
||||||
|
# Zweiter Aufruf desselben Laufs, nachdem der erste den Bericht schon verschickt und
|
||||||
|
# weggeraeumt hat? Dann ist nichts zu tun (die Doppelversand-Sperre unten sieht die
|
||||||
|
# Datei nicht mehr).
|
||||||
|
if [ -s "$GESENDET" ] && [ $(( $(date +%s) - $(stat -c %Y "$GESENDET" 2>/dev/null || echo 0) )) -lt 7200 ]; then
|
||||||
|
echo "gemeldet (dieser Bericht ging bereits raus — nicht doppelt geschickt)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
bash "$NOTIFY" -s "[News]" "Der Nachrichtenbericht ist leer geblieben ($TEXTDATEI). Da lief etwas schief."
|
bash "$NOTIFY" -s "[News]" "Der Nachrichtenbericht ist leer geblieben ($TEXTDATEI). Da lief etwas schief."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -78,6 +89,11 @@ find /tmp -maxdepth 1 -name '.news-gesendet-*' -mtime +2 -delete 2>/dev/null
|
|||||||
|
|
||||||
bash "$NOTIFY" -s "" "$(cat "$TEXTDATEI")"
|
bash "$NOTIFY" -s "" "$(cat "$TEXTDATEI")"
|
||||||
|
|
||||||
|
# Bericht wegraeumen (23.09.2026): Hermes' write_file ueberschreibt keine vorhandene Datei.
|
||||||
|
# Blieb der Bericht vom Vortag liegen, scheiterte jeder Morgenlauf erst an
|
||||||
|
# „Refusing to overwrite /tmp/news-text.md“ (4 von 5 Versuchen, jeden Tag seit Wochen).
|
||||||
|
mv -f "$TEXTDATEI" "$GESENDET" 2>/dev/null && touch "$GESENDET" 2>/dev/null || true
|
||||||
|
|
||||||
# Stimme abkoppeln: eigene Sitzung, kein Elternprozess, keine offenen Deskriptoren.
|
# Stimme abkoppeln: eigene Sitzung, kein Elternprozess, keine offenen Deskriptoren.
|
||||||
# Damit ueberlebt sie das Ende dieses Skripts und blockiert es nicht.
|
# Damit ueberlebt sie das Ende dieses Skripts und blockiert es nicht.
|
||||||
if [ -s "$SPRECHDATEI" ]; then
|
if [ -s "$SPRECHDATEI" ]; then
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# systemd-USER-Unit für das Modell-Radar (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
|
||||||
|
# Sucht höchstens einmal am Tag nach neuen Modellen für Hirn und Coder und testet nachts im Fenster
|
||||||
|
# 00:30–02:30 höchstens einen neuen Kandidaten pro Woche (backend/radar_lauf.py, services/radar.py).
|
||||||
|
# Um 03:00 braucht der NerdQuiz-Nachtlauf das Hirn: Der Lauf hört um 02:30 selbst auf, um 02:35 zieht
|
||||||
|
# seine Notbremse, und RuntimeMaxSec beendet ihn spätestens 02:40 samt llama-server (control-group).
|
||||||
|
# Type=simple statt oneshot: Bei oneshot greift RuntimeMaxSec nicht.
|
||||||
|
# Sudo-frei wie alle MC2-Units: ~/.config/systemd/user/ + systemctl --user.
|
||||||
|
# Ablage: ~/.config/systemd/user/mc2-radar.service (+ mc2-radar.timer) ; dann:
|
||||||
|
# systemctl --user daemon-reload && systemctl --user enable --now mc2-radar.timer
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=MC2 Modell-Radar (Suche + nächtlicher Prüfstand 00:30–02:30)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=%h/mission-control-v2/backend
|
||||||
|
ExecStart=%h/mission-control-v2/backend/.venv/bin/python radar_lauf.py
|
||||||
|
Environment=PYTHONPATH=%h/mission-control-v2
|
||||||
|
Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080
|
||||||
|
Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
|
||||||
|
Environment=MC_MODELS_DIR=/srv/models
|
||||||
|
Environment=MC_LOCAL_TZ=Europe/Berlin
|
||||||
|
# Kein Nice=: der Kandidaten-Server erbt die Priorität — gemessen wird gegen das Live-Modell.
|
||||||
|
RuntimeMaxSec=2h10min
|
||||||
|
KillMode=control-group
|
||||||
|
TimeoutStopSec=60
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nächtliches Modell-Radar (00:30, Test-Fenster bis 02:30)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 00:30:00
|
||||||
|
Persistent=true
|
||||||
|
AccuracySec=1min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -91,6 +91,16 @@ for name in $REPOS; do
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Leeres Repo auf Gitea (angelegt, aber nie etwas gepusht): Es gibt keinen Branch, also
|
||||||
|
# scheitert jeder Pull mit „no such ref was fetched“. So lief projekte-sync ab dem
|
||||||
|
# 07.09.2026 stuendlich rot (TTT2-Toolbox_ClaudeEdition). Leer ist kein Fehler.
|
||||||
|
# Scheitert ls-remote selbst (Netz weg), geht es normal weiter und der Pull meldet es.
|
||||||
|
if kopf="$(git -C "$checkout" ls-remote --heads origin 2>/dev/null)" && [ -z "$kopf" ]; then
|
||||||
|
log " - $name: auf Gitea noch leer — nichts zu holen"
|
||||||
|
verknuepfe "$checkout" "$name"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
if [ $DRY -eq 1 ]; then
|
if [ $DRY -eq 1 ]; then
|
||||||
log " ~ $name: wuerde pullen (${checkout/$HOME/\~})"
|
log " ~ $name: wuerde pullen (${checkout/$HOME/\~})"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"_hinweis": "Merkliste des Modell-Radars (backend/services/radar.py). Einträge hier werden vor den Funden der Hugging-Face-Entdeckung getestet, in dieser Reihenfolge. Felder: name, rolle (hirn|coder), repo, quant (Standard Q4_K_M), eng (true = passt nur knapp neben das Warm-Set), notiz (erscheint in der Begründung), ueberspringen (Grund; Eintrag wird nicht getestet), optional draft {repo, datei, typ, n_max} und flags (zusätzliche llama-server-Flags). Getestet wird nur, was ein mmproj hat und neben das Warm-Set passt (27 GB + Kandidat inkl. KV-Cache <= 115 GB).",
|
||||||
|
"kandidaten": [
|
||||||
|
{
|
||||||
|
"name": "Ornith-1.5-35B-A3B",
|
||||||
|
"rolle": "hirn",
|
||||||
|
"repo": "ornith-ai/Ornith-1.5-35B-A3B-GGUF",
|
||||||
|
"quant": "Q4_K_M",
|
||||||
|
"notiz": "MoE mit 3B aktiven Parametern (Architektur qwen35moe), mmproj im Repo."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Qwen3.8-Flash-Next",
|
||||||
|
"rolle": "coder",
|
||||||
|
"repo": "unsloth/Qwen3.8-Flash-Next-GGUF",
|
||||||
|
"quant": "UD-IQ3_XXS",
|
||||||
|
"eng": true,
|
||||||
|
"notiz": "UD-IQ3_XXS ist ca. 82 GB groß und passt nur knapp neben das Warm-Set (UD-IQ4_XS mit 94 GB passt nicht)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "K2-Horizon-MoVA-36B-A4B",
|
||||||
|
"rolle": "hirn",
|
||||||
|
"repo": "IFM/K2-Horizon-MoVA-36B-A4B-GGUF",
|
||||||
|
"quant": "Q4_K_M",
|
||||||
|
"ueberspringen": "läuft noch nicht im offiziellen llama.cpp."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
-6
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{a as e,c as t,i as n,s as r,t as i,u as a}from"./button-Dd8hfusv.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-CgRgLCFz.js";var p=r();a();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`dialog`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`dialog-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,r),t[3]=r,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`dialog-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(t){let r=(0,p.c)(15),a,o,s,l;r[0]===t?(a=r[1],o=r[2],s=r[3],l=r[4]):({className:o,children:a,showCloseButton:l,...s}=t,r[0]=t,r[1]=a,r[2]=o,r[3]=s,r[4]=l);let d=l===void 0||l,f;r[5]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,m.jsx)(_,{}),r[5]=f):f=r[5];let h;r[6]===o?h=r[7]:(h=n(`fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95`,o),r[6]=o,r[7]=h);let v;r[8]===d?v=r[9]:(v=d&&(0,m.jsx)(c,{"data-slot":`dialog-close`,asChild:!0,children:(0,m.jsxs)(i,{variant:`ghost`,className:`absolute top-2 right-2`,size:`icon-sm`,children:[(0,m.jsx)(e,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),r[8]=d,r[9]=v);let y;return r[10]!==a||r[11]!==s||r[12]!==h||r[13]!==v?(y=(0,m.jsxs)(g,{children:[f,(0,m.jsxs)(u,{"data-slot":`dialog-content`,className:h,...s,children:[a,v]})]}),r[10]=a,r[11]=s,r[12]=h,r[13]=v,r[14]=y):y=r[14],y}function y(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`flex flex-col gap-2`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`dialog-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(14),r,a,o,s;t[0]===e?(r=t[1],a=t[2],o=t[3],s=t[4]):({className:a,showCloseButton:s,children:r,...o}=e,t[0]=e,t[1]=r,t[2]=a,t[3]=o,t[4]=s);let l=s!==void 0&&s,u;t[5]===a?u=t[6]:(u=n(`-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end`,a),t[5]=a,t[6]=u);let d;t[7]===l?d=t[8]:(d=l&&(0,m.jsx)(c,{asChild:!0,children:(0,m.jsx)(i,{variant:`outline`,children:`Schließen`})}),t[7]=l,t[8]=d);let f;return t[9]!==r||t[10]!==o||t[11]!==u||t[12]!==d?(f=(0,m.jsxs)(`div`,{"data-slot":`dialog-footer`,className:u,...o,children:[r,d]}),t[9]=r,t[10]=o,t[11]=u,t[12]=d,t[13]=f):f=t[13],f}function x(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`font-heading text-base leading-none font-medium`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`dialog-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function S(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`dialog-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function C(e){let t=(0,p.c)(26),{offen:n,titel:r,text:a,knopf:o,gefahr:s,onJa:c,onNein:l,children:u}=e,d;t[0]===l?d=t[1]:(d=e=>!e&&l(),t[0]=l,t[1]=d);let f;t[2]===r?f=t[3]:(f=(0,m.jsx)(x,{className:`schild text-xl`,children:r}),t[2]=r,t[3]=f);let g;t[4]===a?g=t[5]:(g=(0,m.jsx)(S,{className:`text-[15px] leading-relaxed text-text-2`,children:a}),t[4]=a,t[5]=g);let _;t[6]!==f||t[7]!==g?(_=(0,m.jsxs)(y,{children:[f,g]}),t[6]=f,t[7]=g,t[8]=_):_=t[8];let C;t[9]===l?C=t[10]:(C=(0,m.jsx)(i,{variant:`outline`,onClick:l,children:`Abbrechen`}),t[9]=l,t[10]=C);let w=s?`gefahr`:`default`,T;t[11]!==o||t[12]!==c||t[13]!==w?(T=(0,m.jsx)(i,{variant:w,onClick:c,children:o}),t[11]=o,t[12]=c,t[13]=w,t[14]=T):T=t[14];let E;t[15]!==C||t[16]!==T?(E=(0,m.jsxs)(b,{className:`gap-2 sm:gap-2`,children:[C,T]}),t[15]=C,t[16]=T,t[17]=E):E=t[17];let D;t[18]!==u||t[19]!==_||t[20]!==E?(D=(0,m.jsxs)(v,{className:`border-linie bg-panel sm:max-w-lg`,children:[_,u,E]}),t[18]=u,t[19]=_,t[20]=E,t[21]=D):D=t[21];let O;return t[22]!==n||t[23]!==d||t[24]!==D?(O=(0,m.jsx)(h,{open:n,onOpenChange:d,children:D}),t[22]=n,t[23]=d,t[24]=D,t[25]=O):O=t[25],O}export{C as t};
|
||||||
-36
File diff suppressed because one or more lines are too long
-51
File diff suppressed because one or more lines are too long
-16
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{A as a,L as o,f as s,j as c,y as l}from"./index-B-ORi4lf.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=o(),_=s(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=l(v);async function C(e){x(null);try{let t=await a(`/api/maintenance/restart`,{service:e});t.ok?c(`erfolg`,`${e} startet neu.`):c(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){c(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{A as i,F as a,I as o,L as s,j as c,k as l}from"./index-B-ORi4lf.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=s(),_=o({queryKey:[`geheimnisse`],queryFn:()=>l(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await i(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),c(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){c(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(a,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
|
||||||
-36
File diff suppressed because one or more lines are too long
-40
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
|||||||
import{u,ae as m,k as x,j as s,y as n,af as b,b as p,n as f,q as h}from"./index-DMY9lUCb.js";function y(){const d=u(),{data:t=[]}=m(),{showAlert:l,dialogElement:o}=x();async function i(e){try{await f(`/api/jobs/${e}/cancel`,{method:"POST"}),d.invalidateQueries({queryKey:h.jobs})}catch(c){l("Fehler",c.message)}}const a=t.filter(e=>e.state==="running"||e.state==="queued"),r=t.filter(e=>e.state!=="running"&&e.state!=="queued").slice(-3);return a.length===0&&r.length===0?null:s.jsxs("div",{className:"space-y-3 mc-card p-4",children:[s.jsx("div",{className:"text-[11px] font-semibold text-muted-foreground uppercase tracking-wider",children:"Aktive Downloads"}),a.map(e=>s.jsxs("div",{className:"space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40",children:[s.jsxs("div",{className:"flex justify-between items-center text-xs",children:[s.jsx("span",{className:"font-semibold truncate max-w-[250px]",children:e.label}),s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsxs("span",{className:"text-muted-foreground font-mono",children:[e.progress??0,"% • ",n(e.done_bytes),"/",n(e.total_bytes),e.eta_s?` • ETA ${b(e.eta_s)}`:""]}),s.jsx("button",{onClick:()=>i(e.id),className:"text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer",children:"Abbrechen"})]})]}),s.jsx("div",{className:"h-1.5 overflow-hidden rounded-full bg-muted",children:s.jsx("div",{className:"h-full rounded-full bg-primary transition-all duration-500",style:{width:`${e.progress??0}%`}})})]},e.id)),r.map(e=>s.jsxs("div",{className:"flex justify-between items-center text-xs text-muted-foreground px-1",children:[s.jsx("span",{className:"truncate",children:e.label}),s.jsx("span",{className:p("font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",e.state==="done"?"bg-emerald-500/10 text-emerald-400":"bg-amber-500/10 text-amber-400"),children:e.state})]},e.id)),o]})}export{y as J};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{O as h,u as g,P as p,r as d,j as e,b as x,T as j,S as N,n as v,U as m,V as w,q as f}from"./index-DMY9lUCb.js";import{E as k}from"./external-link-DcJao8h4.js";import{R as y}from"./refresh-cw-hAcZthO8.js";function K(){const{data:t}=h(),u=g(),a=t!=null&&t.box_console_url?p(t.box_console_url):void 0,n=t==null?void 0:t.box_console_reachable,[o,i]=d.useState(!1),[c,l]=d.useState("");async function b(){i(!0),l("");try{const s=await v("/api/maintenance/restart",{method:"POST",body:JSON.stringify({service:"box-console"})}),r=s.ok?"Konsolen-Dienst neu gestartet — einen Moment, dann lädt das Terminal.":`Neustart fehlgeschlagen: ${s.err||"Unbekannter Fehler"}`;l(r),m(s.ok?"erfolg":"fehler",r),w(u,f.agentStatus,f.services)}catch(s){const r=`Neustart fehlgeschlagen: ${(s==null?void 0:s.message)||s}`;l(r),m("fehler",r)}finally{i(!1)}}return e.jsxs("div",{className:"flex h-full flex-col gap-4",children:[e.jsxs("div",{className:"flex flex-wrap items-end justify-between gap-3",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent",children:"Konsole"}),e.jsx("p",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:"Direkte Shell auf der Box — wie ein SSH-Fenster, mitten im Browser."})]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground",children:[e.jsx("span",{className:x("h-2 w-2 rounded-full",n?"bg-emerald-500 animate-pulse":"bg-amber-500")}),n?"online":"offline"]}),a&&e.jsxs("a",{href:a,target:"_blank",rel:"noopener",className:"flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground",children:[e.jsx(k,{className:"h-3.5 w-3.5"})," In neuem Tab"]})]})]}),a?e.jsxs("div",{className:"relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25",children:[n===!1&&e.jsxs("div",{className:"absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center",children:[e.jsx(j,{className:"h-8 w-8 text-amber-400"}),e.jsx("div",{className:"text-sm font-semibold text-amber-300",children:"Konsole nicht erreichbar"}),e.jsxs("p",{className:"max-w-sm text-[11px] leading-normal text-muted-foreground",children:["Der Terminal-Dienst (",e.jsx("code",{className:"font-mono text-primary",children:"box-console"}),") läuft gerade nicht."]}),e.jsxs("button",{onClick:b,disabled:o,className:"flex h-9 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 text-[11px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50",children:[e.jsx(y,{className:x("h-3.5 w-3.5",o&&"animate-spin")})," Dienst neu starten"]}),c&&e.jsx("p",{className:"max-w-sm text-[11px] text-muted-foreground",children:c})]}),e.jsx("iframe",{src:a,title:"Box-Konsole",className:"h-full w-full border-0",style:{minHeight:"58vh"}})]}):e.jsxs("div",{className:"flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground",children:[e.jsx(N,{className:"mr-2 h-4 w-4"})," Lade Konsole…"]})]})}export{K as KonsoleView};
|
|
||||||
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
import{c as e,s as t}from"./button-Dd8hfusv.js";import{F as n,N as r,P as i}from"./index-B-ORi4lf.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue};
|
||||||
+1
File diff suppressed because one or more lines are too long
-83
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{c as e,s as t}from"./button-Dd8hfusv.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-DWG2Q04V.js";var s=t(),c=e();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`schild text-lg`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`w-full border-linie bg-panel sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{j as e}from"./index-DMY9lUCb.js";function n({icon:t,children:s}){return e.jsxs("p",{className:"flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60",children:[e.jsx(t,{className:"h-3.5 w-3.5"})," ",s]})}export{n as S};
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
var E=r=>{throw TypeError(r)};var O=(r,e,t)=>e.has(r)||E("Cannot "+t);var a=(r,e,t)=>(O(r,e,"read from private field"),t?t.call(r):e.get(r)),y=(r,e,t)=>e.has(r)?E("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),v=(r,e,t,n)=>(O(r,e,"write to private field"),n?n.call(r,t):e.set(r,t),t),j=(r,e,t)=>(O(r,e,"access private method"),t);import{a6 as Q,a7 as A,a8 as M,a9 as B,aa as P,u as J,r as f,ab as F,ac as I,c as T,ad as U,j as s,J as D,T as H,b as K,L,Q as V,q as _,n as R}from"./index-DMY9lUCb.js";import{J as z}from"./JobsBar-95gsUJZs.js";var c,g,o,h,m,w,C,q,G=(q=class extends Q{constructor(e,t){super();y(this,m);y(this,c);y(this,g);y(this,o);y(this,h);v(this,c,e),this.setOptions(t),this.bindMethods(),j(this,m,w).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){var n;const t=this.options;this.options=a(this,c).defaultMutationOptions(e),A(this.options,t)||a(this,c).getMutationCache().notify({type:"observerOptionsUpdated",mutation:a(this,o),observer:this}),t!=null&&t.mutationKey&&this.options.mutationKey&&M(t.mutationKey)!==M(this.options.mutationKey)?this.reset():((n=a(this,o))==null?void 0:n.state.status)==="pending"&&a(this,o).setOptions(this.options)}onUnsubscribe(){var e;this.hasListeners()||(e=a(this,o))==null||e.removeObserver(this)}onMutationUpdate(e){j(this,m,w).call(this),j(this,m,C).call(this,e)}getCurrentResult(){return a(this,g)}reset(){var e;(e=a(this,o))==null||e.removeObserver(this),v(this,o,void 0),j(this,m,w).call(this),j(this,m,C).call(this)}mutate(e,t){var n;return v(this,h,t),(n=a(this,o))==null||n.removeObserver(this),v(this,o,a(this,c).getMutationCache().build(a(this,c),this.options)),a(this,o).addObserver(this),a(this,o).execute(e)}},c=new WeakMap,g=new WeakMap,o=new WeakMap,h=new WeakMap,m=new WeakSet,w=function(){var t;const e=((t=a(this,o))==null?void 0:t.state)??B();v(this,g,{...e,isPending:e.status==="pending",isSuccess:e.status==="success",isError:e.status==="error",isIdle:e.status==="idle",mutate:this.mutate,reset:this.reset})},C=function(e){P.batch(()=>{var t,n,l,x,d,p,k,i;if(a(this,h)&&this.hasListeners()){const u=a(this,g).variables,N=a(this,g).context,S={client:a(this,c),meta:this.options.meta,mutationKey:this.options.mutationKey};if((e==null?void 0:e.type)==="success"){try{(n=(t=a(this,h)).onSuccess)==null||n.call(t,e.data,u,N,S)}catch(b){Promise.reject(b)}try{(x=(l=a(this,h)).onSettled)==null||x.call(l,e.data,null,u,N,S)}catch(b){Promise.reject(b)}}else if((e==null?void 0:e.type)==="error"){try{(p=(d=a(this,h)).onError)==null||p.call(d,e.error,u,N,S)}catch(b){Promise.reject(b)}try{(i=(k=a(this,h)).onSettled)==null||i.call(k,void 0,e.error,u,N,S)}catch(b){Promise.reject(b)}}}this.listeners.forEach(u=>{u(a(this,g))})})},q);function W(r,e){const t=J(),[n]=f.useState(()=>new G(t,r));f.useEffect(()=>{n.setOptions(r)},[n,r]);const l=f.useSyncExternalStore(f.useCallback(d=>n.subscribe(P.batchCalls(d)),[n]),()=>n.getCurrentResult(),()=>n.getCurrentResult()),x=f.useCallback((d,p)=>{n.mutate(d,p).catch(F)},[n]);if(l.error&&I(n.options.throwOnError,[l.error]))throw l.error;return{...l,mutate:x,mutateAsync:l.mutate}}/**
|
|
||||||
* @license lucide-react v0.460.0 - ISC
|
|
||||||
*
|
|
||||||
* This source code is licensed under the ISC license.
|
|
||||||
* See the LICENSE file in the root directory of this source tree.
|
|
||||||
*/const X=T("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);function ee(){const r=J(),{data:e,isLoading:t,isError:n}=U({queryKey:["skills-list"],queryFn:()=>R("/api/skills"),refetchInterval:5e3}),[l,x]=f.useState(null),[d,p]=f.useState(null),k=W({mutationFn:i=>R("/api/skills/run",{method:"POST",body:JSON.stringify({skill_name:i})}),onMutate:i=>{x(i),p(null)},onSuccess:(i,u)=>{x(null),p({name:u,ok:i.ok,msg:i.msg||i.err||"Unbekannter Fehler"}),r.invalidateQueries({queryKey:_.jobs}),r.invalidateQueries({queryKey:["skills-list"]})},onError:(i,u)=>{x(null),p({name:u,ok:!1,msg:String(i)})}});return s.jsxs("div",{className:"space-y-6",children:[s.jsx("div",{className:"flex flex-col sm:flex-row justify-between sm:items-center gap-4",children:s.jsxs("div",{children:[s.jsx("h1",{className:"text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent",children:"Skills & Jobs"}),s.jsx("p",{className:"text-sm text-muted-foreground",children:"Löst autonome Agent-Skills manuell auf der Box aus. Die Ausführung erfolgt im Hintergrund durch Hermes."})]})}),s.jsx(z,{}),d&&s.jsxs("div",{className:K("p-4 rounded-lg border flex gap-3 items-start",d.ok?"bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400":"bg-red-500/10 border-red-500/20 text-red-600 dark:text-red-400"),children:[d.ok?s.jsx(D,{className:"h-5 w-5 mt-0.5 shrink-0"}):s.jsx(H,{className:"h-5 w-5 mt-0.5 shrink-0"}),s.jsxs("div",{children:[s.jsx("div",{className:"font-semibold text-sm",children:d.name}),s.jsx("div",{className:"text-sm opacity-90",children:d.msg})]})]}),t&&s.jsx("div",{className:"flex h-32 items-center justify-center",children:s.jsx(L,{className:"h-6 w-6 animate-spin text-muted-foreground"})}),n&&s.jsx("div",{className:"text-red-500 p-4 mc-card text-sm",children:"Fehler beim Laden der Skills. Ist das Backend erreichbar?"}),(e==null?void 0:e.skills)&&s.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4",children:[e.skills.map(i=>s.jsxs("div",{className:"mc-card p-5 flex flex-col hover:border-primary/40 transition-colors group",children:[s.jsxs("div",{className:"flex items-start justify-between gap-3 mb-2",children:[s.jsxs("div",{className:"flex items-center gap-2 font-semibold text-base font-space",children:[s.jsx(V,{className:"h-5 w-5 text-primary/80"}),i.name]}),s.jsxs("button",{onClick:()=>k.mutate(i.name),disabled:l===i.name||i.running||k.isPending,className:K("shrink-0 h-8 px-3 rounded-md text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5 transition-all cursor-pointer",l===i.name||i.running?"bg-sky-500/10 border border-sky-500/40 text-sky-400 cursor-not-allowed":"bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground group-hover:shadow-md group-hover:shadow-primary/20"),children:[l===i.name||i.running?s.jsx(L,{className:"h-3.5 w-3.5 animate-spin"}):s.jsx(X,{className:"h-3.5 w-3.5"}),l===i.name||i.running?"Läuft …":"Starten"]})]}),s.jsx("div",{className:"text-sm text-muted-foreground flex-1",children:i.description})]},i.name)),e.skills.length===0&&s.jsxs("div",{className:"col-span-full p-8 text-center text-muted-foreground mc-card border-dashed border-2",children:["Keine Skills im Ordner ",s.jsx("code",{className:"text-xs text-foreground bg-muted px-1.5 py-0.5 rounded",children:"deploy/skills"})," gefunden."]})]})]})}export{ee as SkillsView};
|
|
||||||
-42
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-52
File diff suppressed because one or more lines are too long
@@ -1,6 +0,0 @@
|
|||||||
import{c as r}from"./index-DMY9lUCb.js";/**
|
|
||||||
* @license lucide-react v0.460.0 - ISC
|
|
||||||
*
|
|
||||||
* This source code is licensed under the ISC license.
|
|
||||||
* See the LICENSE file in the root directory of this source tree.
|
|
||||||
*/const o=r("ArrowRight",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"m12 5 7 7-7 7",key:"xquz4c"}]]);export{o as A};
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user