Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d46adfd86 | ||
|
|
177c9a311c | ||
|
|
4b226d5d22 | ||
|
|
ed6948429a | ||
|
|
7135042752 | ||
|
|
790de19f1a | ||
|
|
f3af2adec3 | ||
|
|
bfb07a92c3 | ||
|
|
907289d7dc | ||
|
|
3d2c9549fb | ||
|
|
12dadfe6ef | ||
|
|
3669a6e7dc | ||
|
|
cd54fe4a0f | ||
|
|
1e7a6d974f | ||
|
|
61f226e86d | ||
|
|
6809d357c0 | ||
|
|
502a6010f1 |
+28
-26
@@ -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.
|
||||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||||
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
# 24-h-Metrik-Verlauf: 10-s-Sampler, Ringpuffer, persistiert.
|
||||||
|
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:
|
||||||
|
|||||||
+3
-1
@@ -26,9 +26,11 @@ DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
|||||||
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
||||||
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
||||||
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
||||||
|
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg
|
||||||
|
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026).
|
||||||
_DEFAULT_CMD_TEMPLATE = (
|
_DEFAULT_CMD_TEMPLATE = (
|
||||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||||
"-c {ctx} -ngl 999 -fa on --no-mmap"
|
"-c {ctx} -ngl 999 -fa on --load-mode none"
|
||||||
)
|
)
|
||||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||||
if "{model}" not in CMD_TEMPLATE:
|
if "{model}" not in CMD_TEMPLATE:
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -717,10 +732,15 @@ def _hermes_update_cmd() -> str:
|
|||||||
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
|
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
|
||||||
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
|
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
|
||||||
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
|
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
|
||||||
|
# --no-gateway-restart (17.09.2026): `hermes update` startet das Gateway sonst selbst neu und
|
||||||
|
# endet mit Exit 1, wenn sein Fleet-Check danach keine Zeilen sieht (#93406) — unter systemd
|
||||||
|
# bei uns der Normalfall. Die &&-Kette brach ab (kein doctor, kein /hermes-ui/-Build), der
|
||||||
|
# Job war rot, autoupdate.sh rollte zurück und pinnte — bei GRÜNEM Gehirn-Check (06./17.09.).
|
||||||
|
# Der Neustart passiert unten ohnehin; Richter bleibt der Postcheck.
|
||||||
return ("RC=0; "
|
return ("RC=0; "
|
||||||
f"bash {backup} || true; "
|
f"bash {backup} || true; "
|
||||||
f"systemctl --user stop hermes-builtin-ui || true; "
|
f"systemctl --user stop hermes-builtin-ui || true; "
|
||||||
f"{{ cd {path} && {py} -m hermes_cli.main update --yes "
|
f"{{ cd {path} && {py} -m hermes_cli.main update --yes --no-gateway-restart "
|
||||||
f"&& {py} -m hermes_cli.main doctor "
|
f"&& {py} -m hermes_cli.main doctor "
|
||||||
f"&& {build_cmd}; }} || RC=1; "
|
f"&& {build_cmd}; }} || RC=1; "
|
||||||
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
|
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
|
||||||
|
|||||||
@@ -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)
|
|
||||||
+19
-26
@@ -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 = [
|
Bis 09/2026 stand hier /opt/llamacpp als Standard — ein verwaister Ordner aus der ROCm-Zeit
|
||||||
os.path.join(engine_path, "llama-server"),
|
mit eigenem git-Stand. MC2 zeigte deshalb „1 (1ec44d1)“, während Build 11057 lief. Die
|
||||||
os.path.join(engine_path, "bin", "llama-server"),
|
Binary braucht ihre .so-Dateien aus dem eigenen Ordner, daher LD_LIBRARY_PATH."""
|
||||||
"/usr/local/bin/llama-server",
|
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
|
||||||
"/usr/bin/llama-server",
|
binary = os.path.join(engine_path, "llama-server")
|
||||||
"llama-server"
|
if not os.path.exists(binary):
|
||||||
]
|
return {"type": "unknown"}
|
||||||
|
try:
|
||||||
for binary in candidates:
|
env = dict(os.environ, LD_LIBRARY_PATH=engine_path)
|
||||||
if binary != "llama-server" and not os.path.exists(binary):
|
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20, env=env)
|
||||||
continue
|
text = (res.stdout or "") + (res.stderr or "")
|
||||||
try:
|
except Exception:
|
||||||
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
|
return {"type": "unknown"}
|
||||||
output = (res.stdout or "").strip() or (res.stderr or "").strip()
|
if (m := re.search(r"\bbuild\s+(\d{3,})\b", text)):
|
||||||
if output:
|
return {"type": "binary", "build": int(m.group(1)), "version_text": f"b{m.group(1)}"}
|
||||||
lines = output.splitlines()
|
zeile = next((z for z in text.splitlines() if z.startswith("version")), "")
|
||||||
ver = lines[0] if lines else "unknown"
|
return {"type": "binary", "version_text": zeile or "unbekannt"}
|
||||||
return {"version_text": ver, "type": "binary"}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"type": "unknown"}
|
|
||||||
|
|
||||||
|
|
||||||
_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"] == []
|
||||||
@@ -7,7 +7,7 @@ set -u
|
|||||||
MODEL="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}"
|
MODEL="${BRAIN_GGUF:-/srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf}"
|
||||||
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
|
BIN="${BENCH_BIN:-/opt/llamacpp-vulkan/llama-server}"
|
||||||
PORT="${BENCH_PORT:-5899}"
|
PORT="${BENCH_PORT:-5899}"
|
||||||
BASE="-c 65536 -ngl 999 -fa on --no-mmap --jinja --parallel 1"
|
BASE="-c 65536 -ngl 999 -fa on --load-mode none --jinja --parallel 1"
|
||||||
|
|
||||||
run() { # $1=name $2=extra-flags
|
run() { # $1=name $2=extra-flags
|
||||||
echo "=== $1 ==="
|
echo "=== $1 ==="
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ PORT="${BENCH_PORT:-5899}"
|
|||||||
CTX="${BENCH_CTX:-32768}"
|
CTX="${BENCH_CTX:-32768}"
|
||||||
|
|
||||||
echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ==="
|
echo "=== Bench: $(basename "$MODEL") (ctx $CTX${EXTRA:+, $EXTRA}) ==="
|
||||||
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --no-mmap --jinja $EXTRA \
|
$BIN -m "$MODEL" --host 127.0.0.1 --port "$PORT" -c "$CTX" -ngl 999 -fa on --load-mode none --jinja $EXTRA \
|
||||||
>/tmp/model-bench-server.log 2>&1 &
|
>/tmp/model-bench-server.log 2>&1 &
|
||||||
PID=$!
|
PID=$!
|
||||||
trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT
|
trap 'kill $PID 2>/dev/null; wait $PID 2>/dev/null' EXIT
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prüfstand für Hirn-Kandidaten (17.09.2026), seit 09/2026 eine dünne Hülle um pruefstand.py.
|
||||||
|
|
||||||
|
Die Messungen selbst (Tempo, 13k-Tiefe, Werkzeuge, Deutsch/JSON, Bild-Probe, Server auf :5899) liegen
|
||||||
|
im Modul — dasselbe nutzt das Modell-Radar nachts. Dieses Skript bleibt der Handbetrieb: Kandidaten
|
||||||
|
mit festen Pfaden gegen die Live-Modelle (llama-swap :8080) messen, optional mit dem Tool-Smoke durch
|
||||||
|
den ECHTEN Hermes-Agenten (`hermes chat --provider pruefstand -m kandidat`; der Provider `pruefstand`
|
||||||
|
-> http://127.0.0.1:5899/v1 muss in `providers:` der Hermes-Config stehen).
|
||||||
|
|
||||||
|
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 os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pruefstand as ps # liegt daneben; beim Skriptaufruf steht dieser Ordner auf sys.path
|
||||||
|
|
||||||
|
M = "/srv/models"
|
||||||
|
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"
|
||||||
|
NEMO = f"{M}/Nemotron-3.5-Lightning-30B-A3B-GGUF/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-Q4_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"
|
||||||
|
U36 = f"{M}/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
|
||||||
|
OUT = os.path.expanduser(f"~/pruefstand-{time.strftime('%Y-%m-%d')}.json")
|
||||||
|
RESULTS: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
def save() -> None:
|
||||||
|
with open(OUT, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(RESULTS, f, indent=1, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def probes(basis: str, modell: str, label: str, with_hermes: bool, hermes_args: list[str], bild: bool = False) -> None:
|
||||||
|
werte = {"label": label, **ps.pruefe("hirn", basis, modell, bild=bild)}
|
||||||
|
if with_hermes:
|
||||||
|
werte["hermes"] = ps.hermes_smoke(hermes_args)
|
||||||
|
RESULTS.append(werte)
|
||||||
|
save()
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
server = ps.starte_server(model, mmproj=mmproj, flags=extra, ctx=ps.CTX_STANDARD)
|
||||||
|
except ps.ServerFehler as e:
|
||||||
|
ps.log(f" {e}")
|
||||||
|
RESULTS.append({"label": label, "fehler": str(e)[:300]})
|
||||||
|
save()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
probes(ps.KANDIDAT_URL, "kandidat", label, with_hermes, ["--provider", "pruefstand", "-m", "kandidat"],
|
||||||
|
bild=bool(mmproj))
|
||||||
|
except Exception as e:
|
||||||
|
ps.log(f" FEHLER in Proben: {e}")
|
||||||
|
RESULTS.append({"label": label, "fehler": str(e)[:200]})
|
||||||
|
save()
|
||||||
|
finally:
|
||||||
|
ps.stoppe_server(server)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
which = set(sys.argv[1:]) or {"baseline", "nemo", "u38", "u36"}
|
||||||
|
if "baseline" in which:
|
||||||
|
ps.log("\n=== BASELINE fast (live, llama-swap)")
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
if "u38" in which:
|
||||||
|
candidate("Qwen3.8-27B · MTP fused n2", U38, ps.draft_flags(None, "draft-mtp", 2), True)
|
||||||
|
candidate("Qwen3.8-27B · DFlash2 (z-lab) n3", U38, ps.draft_flags(DFLASH2_38, "draft-dflash", 3), False)
|
||||||
|
candidate("Qwen3.8-27B · ohne Spec", U38, [], False)
|
||||||
|
if "u36" in which:
|
||||||
|
candidate("Qwen3.6-35B-A3B · DFlash (giocom)", U36, ps.draft_flags(DFLASH_36, "draft-dflash"), True)
|
||||||
|
candidate("Qwen3.6-35B-A3B · ohne Spec", U36, [], False)
|
||||||
|
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
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ models:
|
|||||||
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. (Q4_K-KV stand mal im Template,
|
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec. (Q4_K-KV stand mal im Template,
|
||||||
# lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der
|
# lief aber NIE live — bewusst bei Q8_0 geblieben: Qualität vor ein paar GB, seit der
|
||||||
# Warm-Set-Diät 07.07. ist RAM nicht mehr der Engpass.)
|
# Warm-Set-Diät 07.07. ist RAM nicht mehr der Engpass.)
|
||||||
|
# 17.09.2026: Uncensored-Variante (HauhauCS Aggressive, Q4_K_M) mit demselben giocom-DFlash-Draft. Prüfstand: 100,8 t/s
|
||||||
|
# @13k (Original 92,8), Prefill 13,1 s, Tools 11/12 (1 Denk-Ausreißer), Hermes-Smoke grün. Original bleibt in
|
||||||
|
# Qwen3.6-35B-A3B-MTP-GGUF liegen — Rückweg = Pfad zurück.
|
||||||
|
# --reasoning-budget 2048 (17.09.2026): Deckel für Denk-Tokens — die abliterierte Variante hatte im Prüfstand 1/12
|
||||||
|
# Denk-Ausreißer ohne Antwort; mit Deckel wird daraus eine kürzer bedachte Antwort statt Stille. Normale Denkphasen
|
||||||
|
# des Hirns liegen bei ein paar hundert Tokens.
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf
|
llama-server -m /srv/models/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.6-35B-A3B-DFlash-GGUF/giocom-Qwen3.6-35B-A3B-DFlash-Q8_0.gguf --reasoning-budget 2048
|
||||||
ttl: 0
|
ttl: 0
|
||||||
aliases:
|
aliases:
|
||||||
- hermes
|
- hermes
|
||||||
@@ -27,8 +33,11 @@ models:
|
|||||||
# Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität.
|
# Unser neuer Haupt-Coder mit 262k Kontext, Thinking Mode und extrem sauberer Code-Qualität.
|
||||||
# DFlash2-Draft (z-lab, Q4_K_M) seit 04.09.2026: gemessen 12,6 -> 31 t/s (Akzeptanz 0,78, n-max 3 default;
|
# DFlash2-Draft (z-lab, Q4_K_M) seit 04.09.2026: gemessen 12,6 -> 31 t/s (Akzeptanz 0,78, n-max 3 default;
|
||||||
# n-max 5 und f16-KV bringen nichts, Q4-Draft = Q8-Draft). Braucht llama.cpp >= 28.08. (PR 27342 + Vulkan-Fix 27812).
|
# n-max 5 und f16-KV bringen nichts, Q4-Draft = Q8-Draft). Braucht llama.cpp >= 28.08. (PR 27342 + Vulkan-Fix 27812).
|
||||||
|
# 17.09.2026: Uncensored-Variante (JonathanColetti, Heretic-Abliteration: KL 0,12, Refusals 98->12/100, Benchmarks -0,5)
|
||||||
|
# mit demselben DFlash2-Draft (Akzeptanz 0,82 @13k). Prüfstand: 30,4 t/s @13k (Original 26,2), Tools 6/6, Coding 3/3,
|
||||||
|
# Hermes-Smoke grün. Original bleibt in Qwen3.8-27B-GGUF liegen — Rückweg = beide Pfade zurück.
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3.8-27B-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf
|
llama-server -m /srv/models/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3.8-27B-Uncensored-GGUF/mmproj-Qwen3.8-27B-Uncensored-F16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf
|
||||||
ttl: 5400
|
ttl: 5400
|
||||||
aliases:
|
aliases:
|
||||||
- coder
|
- coder
|
||||||
@@ -44,7 +53,7 @@ models:
|
|||||||
# Meta Muse Glimmer 30B: Dichtes 30B-Agenten-Modell mit DFlash-Speculative-Drafting
|
# Meta Muse Glimmer 30B: Dichtes 30B-Agenten-Modell mit DFlash-Speculative-Drafting
|
||||||
# und 1.8B Perception Multimodal Projector. Ideal als Runtime-Debugger & Fehler-Diagnostiker.
|
# und 1.8B Perception Multimodal Projector. Ideal als Runtime-Debugger & Fehler-Diagnostiker.
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Muse-Glimmer-30B-GGUF/Muse-Glimmer-30B-UD-Q4_K_XL.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Muse-Glimmer-30B-GGUF/mmproj-kquant.gguf --jinja --spec-type draft-dflash --spec-draft-model /srv/models/Muse-Glimmer-30B-GGUF/dflash-kquant.gguf
|
llama-server -m /srv/models/Muse-Glimmer-30B-GGUF/Muse-Glimmer-30B-UD-Q4_K_XL.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Muse-Glimmer-30B-GGUF/mmproj-kquant.gguf --jinja --spec-type draft-dflash --spec-draft-model /srv/models/Muse-Glimmer-30B-GGUF/dflash-kquant.gguf
|
||||||
ttl: 600
|
ttl: 600
|
||||||
aliases:
|
aliases:
|
||||||
- debugger
|
- debugger
|
||||||
@@ -55,7 +64,7 @@ models:
|
|||||||
context: 65536
|
context: 65536
|
||||||
Qwen3-Embedding-0.6B:
|
Qwen3-Embedding-0.6B:
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
|
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --load-mode none -c 8192
|
||||||
ttl: 0
|
ttl: 0
|
||||||
aliases:
|
aliases:
|
||||||
- embed
|
- embed
|
||||||
@@ -63,7 +72,7 @@ models:
|
|||||||
# heavy (B5-Entscheid 03.07.) + nächtlicher CHEF-GUTACHTER (04:30-Cron). 60 GB —
|
# heavy (B5-Entscheid 03.07.) + nächtlicher CHEF-GUTACHTER (04:30-Cron). 60 GB —
|
||||||
# passt seit der Warm-Set-Diät (07.07., vision on-demand) wieder NEBEN Hirn+embed.
|
# passt seit der Warm-Set-Diät (07.07., vision on-demand) wieder NEBEN Hirn+embed.
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
|
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --load-mode none --jinja --cache-reuse 256 -cram 16384
|
||||||
ttl: 600
|
ttl: 600
|
||||||
# 04.09.2026: Rolle `heavy` an Qwen3.8-27B abgegeben (AA-Index 52 vs 24, 17 statt 60 GB, 31 t/s mit DFlash2).
|
# 04.09.2026: Rolle `heavy` an Qwen3.8-27B abgegeben (AA-Index 52 vs 24, 17 statt 60 GB, 31 t/s mit DFlash2).
|
||||||
# Ohne Alias = Rollback-Reserve, direkt als `gpt-oss-120b` ansprechbar.
|
# Ohne Alias = Rollback-Reserve, direkt als `gpt-oss-120b` ansprechbar.
|
||||||
@@ -74,7 +83,7 @@ models:
|
|||||||
context: 32768
|
context: 32768
|
||||||
Qwen3-VL-30B-A3B-Instruct:
|
Qwen3-VL-30B-A3B-Instruct:
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
|
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
|
||||||
ttl: 900
|
ttl: 900
|
||||||
aliases:
|
aliases:
|
||||||
- vision
|
- vision
|
||||||
@@ -87,7 +96,7 @@ models:
|
|||||||
# Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!).
|
# Relevanz (/v1/rerank, Mungert-GGUF — Community-Konvertierungen liefern oft Nullscores!).
|
||||||
# Winzig (~0,7 GB) → in brains (verdrängungssicher); lädt in ~1-2 s, erste Anfrage wärmt.
|
# Winzig (~0,7 GB) → in brains (verdrängungssicher); lädt in ~1-2 s, erste Anfrage wärmt.
|
||||||
cmd: |
|
cmd: |
|
||||||
llama-server -m /srv/models/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-q8_0.gguf --host 127.0.0.1 --port ${PORT} --reranking --pooling rank --embedding -ngl 999 -fa on --no-mmap -c 8192
|
llama-server -m /srv/models/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-q8_0.gguf --host 127.0.0.1 --port ${PORT} --reranking --pooling rank --embedding -ngl 999 -fa on --load-mode none -c 8192
|
||||||
ttl: 0
|
ttl: 0
|
||||||
aliases:
|
aliases:
|
||||||
- reranker
|
- reranker
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+13
-1
@@ -53,6 +53,12 @@ Neue Fallen: hier eintragen, mit Datum und Symptom._
|
|||||||
- **`pkill -f` über SSH matcht die eigene Remote-Shell** → Muster als `'[g]en…'` klammern.
|
- **`pkill -f` über SSH matcht die eigene Remote-Shell** → Muster als `'[g]en…'` klammern.
|
||||||
- **Deploy restartet voice-service** → Übernacht-Jobs mit STT-QA-Gate brauchen
|
- **Deploy restartet voice-service** → Übernacht-Jobs mit STT-QA-Gate brauchen
|
||||||
Wiederanlauf-Logik (Backoff-Retry im hear()-Pfad).
|
Wiederanlauf-Logik (Backoff-Retry im hear()-Pfad).
|
||||||
|
- **llama.cpp streicht Flags ohne Vorwarnung:** `--no-mmap` gibt es seit b10936 nicht mehr
|
||||||
|
(`error: invalid argument`) → JEDES Modell stirbt 2 s nach dem Start, llama-swap meldet nur
|
||||||
|
„upstream command exited prematurely", der Stack-Check nur „rot". Ersatz: `--load-mode none`
|
||||||
|
(gleiche Leistung, gemessen 17.09.2026: Coder 32 t/s, Hirn 85 t/s). Vor einem Engine-Sprung
|
||||||
|
jede Config-Kommandozeile mit dem neuen `llama-server` parsen lassen (Port 5899, `timeout 6`,
|
||||||
|
`${PORT}` ersetzen) — Argumentfehler kommen sofort, vor dem Laden.
|
||||||
|
|
||||||
## Hermes
|
## Hermes
|
||||||
|
|
||||||
@@ -68,7 +74,7 @@ Neue Fallen: hier eintragen, mit Datum und Symptom._
|
|||||||
- **Cron-Fallen:** kein TZ-Feld (next_run_at = System-TZ zum Anlege-Zeitpunkt; nach
|
- **Cron-Fallen:** kein TZ-Feld (next_run_at = System-TZ zum Anlege-Zeitpunkt; nach
|
||||||
TZ-Wechsel `cron edit --schedule "<gleich>"` zum Neuberechnen) · Prompt/Positionsargumente
|
TZ-Wechsel `cron edit --schedule "<gleich>"` zum Neuberechnen) · Prompt/Positionsargumente
|
||||||
MÜSSEN vor die Flags · `--script` allein reicht nicht (braucht Prompt oder Skill) ·
|
MÜSSEN vor die Flags · `--script` allein reicht nicht (braucht Prompt oder Skill) ·
|
||||||
Cron-Kontext hat `HERMES_CRON_SESSION=1` und `approvals.cron_mode: deny`.
|
Cron-Kontext hat `HERMES_CRON_SESSION=1` (`approvals.cron_mode` ist seit dem KISS-Umbau `auto`, nicht mehr `deny`).
|
||||||
- **Feed-Skripte: Pipe + Heredoc gleichzeitig = Heredoc gewinnt stdin** → JSON via Temp-Datei
|
- **Feed-Skripte: Pipe + Heredoc gleichzeitig = Heredoc gewinnt stdin** → JSON via Temp-Datei
|
||||||
übergeben. Außerdem ehrlichen Blind-Pfad einbauen (API kaputt → „AUSGEFALLEN", nicht „0 Funde").
|
übergeben. Außerdem ehrlichen Blind-Pfad einbauen (API kaputt → „AUSGEFALLEN", nicht „0 Funde").
|
||||||
- **Hooks:** Consent persistiert NUR über CLI-/Gateway-Start mit `HERMES_ACCEPT_HOOKS=1`;
|
- **Hooks:** Consent persistiert NUR über CLI-/Gateway-Start mit `HERMES_ACCEPT_HOOKS=1`;
|
||||||
@@ -84,6 +90,12 @@ Neue Fallen: hier eintragen, mit Datum und Symptom._
|
|||||||
Erst `git add -A`, dann `git diff --cached`, „Diff leer → STOPP".
|
Erst `git add -A`, dann `git diff --cached`, „Diff leer → STOPP".
|
||||||
- **Werkstatt-Gate:** `git -C ~/mission-control-v2 status --porcelain -uno` MUSS leer sein
|
- **Werkstatt-Gate:** `git -C ~/mission-control-v2 status --porcelain -uno` MUSS leer sein
|
||||||
(curl /api/health allein ist ein Loch — alter Code läuft im RAM weiter).
|
(curl /api/health allein ist ein Loch — alter Code läuft im RAM weiter).
|
||||||
|
- **`hermes update` meldet Exit 1 trotz Erfolg:** nach seinem eigenen Gateway-Neustart erwartet es
|
||||||
|
Zeilen vom „Fleet version check"; unter systemd kommen keine → Exit 1 (#93406). Der MC2-Job
|
||||||
|
bricht dann die `&&`-Kette ab (kein doctor, UI ohne `/hermes-ui/`-Basis), autoupdate.sh rollt
|
||||||
|
zurück und PINNT — obwohl der Gehirn-Check grün war (06. und 17.09.2026). Deshalb
|
||||||
|
`--no-gateway-restart`: Neustart und Urteil gehören dem Job. Pins stehen in
|
||||||
|
`/srv/models/mc2-pins.json` und werden NUR von Hand gelöst — gepinnt = still eingefroren.
|
||||||
|
|
||||||
|
|
||||||
## Lucy / Windows-PC
|
## Lucy / Windows-PC
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VE
|
|||||||
|
|
||||||
## Lucy
|
## Lucy
|
||||||
|
|
||||||
- **Raphael-Umbau (04.09.2026) — zwei Branches offen, Reihenfolge zählt (Branch → Ampel →
|
- **Raphael-Umbau (04.09.2026) — AUSGEROLLT 19:10 (MC2 `f9f2e11` deployt, Lucy `be686c4` gebaut),
|
||||||
User-Merge → deploy.sh bzw. `npm run dist`, NICHT übers Auftragsbuch):** erst MC2
|
Details [RAPHAEL.md](RAPHAEL.md) „Ausgerollt". Ursprünglicher Plan:** erst MC2
|
||||||
`wartung/lucy-stimme-proxy-raphael` (Proxy `/api/lucy/stimme/*`), dann Lucy
|
`wartung/lucy-stimme-proxy-raphael` (Proxy `/api/lucy/stimme/*`), dann Lucy
|
||||||
`feature/raphael-innere-stimme` (HUD-Client, Stimme von der Box). Danach Hand-Schritte auf der
|
`feature/raphael-innere-stimme` (HUD-Client, Stimme von der Box). Danach Hand-Schritte auf der
|
||||||
Box: `pocket_server.py` nach `~/.lucy-stimme` syncen (OpenAI-Fassade), Hermes-TTS auf
|
Box: `pocket_server.py` nach `~/.lucy-stimme` syncen (OpenAI-Fassade), Hermes-TTS auf
|
||||||
@@ -76,9 +76,17 @@ keine zweite Liste anlegen. Verdikte werden NICHT als „offen" geführt → [VE
|
|||||||
blieben: `deploy/werkstatt-SOUL.md` + `projektstart-SOUL.md` (Werkstatt-Persona, seit Kanban-Aus
|
blieben: `deploy/werkstatt-SOUL.md` + `projektstart-SOUL.md` (Werkstatt-Persona, seit Kanban-Aus
|
||||||
ohnehin tot — eigener Aufräum-Faden), Chronik-Kategorie „auftragsbuch" (historische Einträge).
|
ohnehin tot — eigener Aufräum-Faden), Chronik-Kategorie „auftragsbuch" (historische Einträge).
|
||||||
Auf der Box: `mc2-bagatell.timer` deaktiviert, PC-Executor-URL in `~/.hermes/config.yaml` auf .22.
|
Auf der Box: `mc2-bagatell.timer` deaktiviert, PC-Executor-URL in `~/.hermes/config.yaml` auf .22.
|
||||||
|
- **Ampel-Runner tot seit 28.08.:** alle Läufe „Waiting to run", kein act_runner auf der Box, Gitea-LXC 104
|
||||||
|
prüfen (`act_runner`-Dienst). Bis dahin gelten lokale Gates.
|
||||||
- **Nach dem Ohr-Test:** VERDIKTE.md-Einträge „Electron, nicht Tauri" (Begründung Overlay
|
- **Nach dem Ohr-Test:** VERDIKTE.md-Einträge „Electron, nicht Tauri" (Begründung Overlay
|
||||||
entfällt) und „STT läuft auf der BOX / Stimme lokal" ersetzen; Lucy-Repo-Altlasten
|
entfällt) und „STT läuft auf der BOX / Stimme lokal" ersetzen; Lucy-Repo-Altlasten
|
||||||
(`mini-stimme/`, Kokoro-Notebooks, `Lucy-Startklar.bat` mit totem Pfad) mit User-Ja räumen.
|
(`mini-stimme/`, Kokoro-Notebooks, `Lucy-Startklar.bat` mit totem Pfad) mit User-Ja räumen.
|
||||||
|
- **Nächster Stimm-Kandidat: Qwen3-TTS über audio.cpp (Vulkan)** — Einstieg liegt bereit: Binaries
|
||||||
|
v0.7.1 unter `~/audiocpp-test/bin` auf der Box (audiocpp_cli/audiocpp_server, Vulkan), Modelle von
|
||||||
|
`huggingface.co/audio-cpp/audio.cpp-gguf` (Qwen3-TTS-12Hz-1.7B-Base q8_0 2,7 GB; CustomVoice/VoiceDesign
|
||||||
|
ebenfalls), CLI: `--task tts --family qwen3_tts --backend vulkan --voice-ref <wav> --reference-text <Transkript>
|
||||||
|
--language de`. Prüfstand: `lucy-tts/referenz/zonos2_bench.py` um einen audio.cpp-Aufrufer erweitern
|
||||||
|
(Server: `POST /v1/audio/speech`). Messlatte = pocket neu: TTFB 1,25 s, RTF 0,97, WER 10 %, 0 Kollaps.
|
||||||
- **Eigene Fäden, nicht im Umbau:** pocket-tts 2.1 → 3.1 auf der Box + Lucy-Klon mit dem neuen
|
- **Eigene Fäden, nicht im Umbau:** pocket-tts 2.1 → 3.1 auf der Box + Lucy-Klon mit dem neuen
|
||||||
Trainingscode nachtrainieren (ersetzt Mini-Lucy v2) · Streaming-STT (Nemotron 3.5 ASR
|
Trainingscode nachtrainieren (ersetzt Mini-Lucy v2) · Streaming-STT (Nemotron 3.5 ASR
|
||||||
Streaming 0.6B oder Voxtral Realtime) als Opt-in im Voice-Sidecar, gegen Parakeet messen.
|
Streaming 0.6B oder Voxtral Realtime) als Opt-in im Voice-Sidecar, gegen Parakeet messen.
|
||||||
|
|||||||
@@ -106,6 +106,23 @@ beschreibt nur noch den manuellen Weg. Wer `wartung/auftragsbuch-ausbau` merged,
|
|||||||
**Nächster Hebel (braucht User-Ja, systemd-Unit):** `Environment=LUCY_WORKERS=2` und
|
**Nächster Hebel (braucht User-Ja, systemd-Unit):** `Environment=LUCY_WORKERS=2` und
|
||||||
`OMP_NUM_THREADS` 4 → 8 in `lucy-stimme.service` — der PC-Pfad lief mit 2 Workern.
|
`OMP_NUM_THREADS` 4 → 8 in `lucy-stimme.service` — der PC-Pfad lief mit 2 Workern.
|
||||||
|
|
||||||
|
## Ausgerollt (04.09.2026, 19:10, auf User-Anweisung „deploye erstmal den Stand")
|
||||||
|
|
||||||
|
- **MC2 main = `f9f2e11`** (Proxy `/api/lucy/stimme/*` + Auftragsbuch-Ausbau + Doku), per `deploy.sh`
|
||||||
|
auf der Box; Health, Proxy-Health (workers=2), Frontend, alle Dienste grün. Die Ampel war seit
|
||||||
|
28.08. ohne Runner („Waiting to run") — Gate waren die lokalen Läufe: eslint 0 Fehler, 59 Tests,
|
||||||
|
tsc + vite build, py_compile, ruff.
|
||||||
|
- **Lucy main = `be686c4`**, dist am PC gebaut (`npm ci && npm run dist`), Box-Checkout `~/lucy` nachgezogen,
|
||||||
|
`~/.lucy-stimme/app/pocket_server.py` identisch mit dem Repo (Sperren-Fix live).
|
||||||
|
- **Stimmserver:** Referenz **Artoria v2**, `OMP_NUM_THREADS=8`, `LUCY_WORKERS=2` (User-Ja); gemessen
|
||||||
|
TTFB median 1,25 s (vorher 2,04), RTF 0,97 (vorher 1,64), 0 Kollaps, 0 Drift, Ähnlichkeit 0,98.
|
||||||
|
‼️ Die Box kann NICHT klonen (nur das Modell ohne Voice-Cloning im Cache, kein HF-Token) — Klon-Zustände
|
||||||
|
werden am PC exportiert (Anleitung `lucy-tts/referenz/README.md`, Abschnitt 5). Rückfall-Dateien
|
||||||
|
(`ref.mp3.bak-saber-alt`, `lucy_voice_saber-alt.safetensors`, Unit-Backup) liegen daneben.
|
||||||
|
- **ZONOS2 geprüft und verworfen** (Sandkasten `~/zonos2-test`, Server gestoppt): Vulkan-Tempo top
|
||||||
|
(TTFB 0,88 s), aber deutscher Klon unzuverlässig (WER 35 %, 8/30 Läufe Brabbeln, kühlerer Sampler
|
||||||
|
schlimmer). Nächster Kandidat: Qwen3-TTS über audio.cpp (Vulkan), gleicher Prüfstand.
|
||||||
|
|
||||||
## Mobil: Telegram ist die Tür, die Stimme ist dieselbe
|
## Mobil: Telegram ist die Tür, die Stimme ist dieselbe
|
||||||
|
|
||||||
Der User will Raphael-Lucy **auch auf dem Handy, gleiche Stimme, gleiches Skillset**. Das ist
|
Der User will Raphael-Lucy **auch auf dem Handy, gleiche Stimme, gleiches Skillset**. Das ist
|
||||||
|
|||||||
+28
-13
@@ -1,7 +1,8 @@
|
|||||||
# Stack-Wahrheiten — Infrastruktur, Dienste, Modelle
|
# Stack-Wahrheiten — Infrastruktur, Dienste, Modelle
|
||||||
|
|
||||||
_Live gegen die Box verifiziert am **19.08.2026** (Dienste-Liste, llama-swap-Config, hermes
|
_Live gegen die Box verifiziert am **19.08.2026** (Dienste-Liste, llama-swap-Config, hermes
|
||||||
--version v0.20.4, llama.cpp b10502, /api/health). Bei Widerspruch zu älteren Docs gewinnt
|
--version, /api/health); Engine, Hermes und llama-swap-Config nachgemessen am **17.09.2026**
|
||||||
|
(llama.cpp b11026, Hermes v0.21.3, `--load-mode none`). Bei Widerspruch zu älteren Docs gewinnt
|
||||||
diese Datei. Wer sie ändert: erst messen, dann schreiben._
|
diese Datei. Wer sie ändert: erst messen, dann schreiben._
|
||||||
|
|
||||||
## Maschinen & Zugänge
|
## Maschinen & Zugänge
|
||||||
@@ -28,11 +29,11 @@ diese Datei. Wer sie ändert: erst messen, dann schreiben._
|
|||||||
|
|
||||||
| Dienst | Port | Zweck |
|
| Dienst | Port | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `llama-swap` (System-Dienst) | `:8080` | Modell-Router; Engine = llama.cpp **Vulkan/RADV** (`/opt/llamacpp-vulkan`, b10502) |
|
| `llama-swap` (System-Dienst) | `:8080` | Modell-Router; Engine = llama.cpp **Vulkan/RADV** (`/opt/llamacpp-vulkan`, b11026 seit 17.09.) |
|
||||||
| `mission-control-2` (User) | `:9001` | MC2 Cockpit (FastAPI + React Frontend) & Steuerpult |
|
| `mission-control-2` (User) | `:9001` | MC2 Cockpit (FastAPI + React Frontend) & Steuerpult |
|
||||||
| `mc2-gateway` (User) | `:9010` | `/v1`-Datenpfad (model:auto Routing + native Bildweiche) |
|
| `mc2-gateway` (User) | `:9010` | `/v1`-Datenpfad (model:auto Routing + native Bildweiche) |
|
||||||
| `mc2-steward` (User) | — | Hintergrundwächter (Re-Warm, Health-Sentry, Mem0-Dedupe) |
|
| `mc2-steward` (User) | — | Hintergrundwächter (Re-Warm, Health-Sentry, Mem0-Dedupe) |
|
||||||
| `hermes-gateway` (User) | `:8642` | Hermes Agent Core API (v0.20.4, Bot Mode) |
|
| `hermes-gateway` (User) | `:8642` | Hermes Agent Core API (v0.21.3, Bot Mode) |
|
||||||
| `hermes-builtin-ui` (User) | `:9119` (loopback) | Eingebaute Hermes-GUI; LAN-Zugang via MC2-Proxy `/hermes-ui/` |
|
| `hermes-builtin-ui` (User) | `:9119` (loopback) | Eingebaute Hermes-GUI; LAN-Zugang via MC2-Proxy `/hermes-ui/` |
|
||||||
| `mem0-service` (User) | `:8765` | Semantischer Chroma-Gedächtnis-Sidecar |
|
| `mem0-service` (User) | `:8765` | Semantischer Chroma-Gedächtnis-Sidecar |
|
||||||
| `voice-service` (User) | `:8650` | STT (faster-whisper) + TTS (Piper/Chatterbox) |
|
| `voice-service` (User) | `:8650` | STT (faster-whisper) + TTS (Piper/Chatterbox) |
|
||||||
@@ -48,15 +49,22 @@ Qwen3.6-35B-A3B + Qwen3-Embedding-0.6B + Qwen3-Reranker-0.6B. Sonst NICHTS dauer
|
|||||||
|
|
||||||
| Alias/Rolle | Modell | ttl | Notizen |
|
| Alias/Rolle | Modell | ttl | Notizen |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `hermes` + `fast` | Qwen3.6-35B-A3B (UD-Q4_K_M, DFlash) | 0 | Agent-Hirn. `-c 131072 --parallel 2` → **65536/Slot**, KV **q8_0**, ~70–90 t/s |
|
| `hermes` + `fast` | Qwen3.6-35B-A3B **Uncensored** (HauhauCS Aggressive, Q4_K_M, giocom-DFlash) — seit 17.09.2026 | 0 | Agent-Hirn. `-c 131072 --parallel 2` → **65536/Slot**, KV **q8_0**, ~94–101 t/s (Prüfstand 17.09.). Original (MTP-GGUF UD-Q4_K_M) liegt daneben, Rückweg = Pfad |
|
||||||
| `coder` | Qwen3.8-27B (Q4_K_M, mmproj BF16) | 5400 | **131072 ctx** — `--parallel 1`, der Coder bekommt den ganzen Slot (34a9862). Multimodal, ~12,7 t/s (dicht = bandbreitengebunden) |
|
| `coder` | Qwen3.8-27B **Uncensored** (JonathanColetti/Heretic, Q4_K_M, eigene mmproj F16) — seit 17.09.2026 | 5400 | **131072 ctx** — `--parallel 1`, der Coder bekommt den ganzen Slot (34a9862). Multimodal, **~32 t/s mit DFlash2-Draft** (17.09.), ohne Draft 12,7 (dicht = bandbreitengebunden) |
|
||||||
| `debugger` | Muse-Glimmer-30B (Q4_K_XL, DFlash) | 600 | 65536 ctx; Runtime-Diagnostik & Fehler-Debugger (multimodal) |
|
| `debugger` | Muse-Glimmer-30B (Q4_K_XL, DFlash) | 600 | 65536 ctx; Runtime-Diagnostik & Fehler-Debugger (multimodal) |
|
||||||
| `vision` | Qwen3-VL-30B-A3B-Instruct (Q4_K_M) | 900 | 32768 ctx; Multimodal-Augen (On-Demand) |
|
| `vision` | Qwen3-VL-30B-A3B-Instruct (Q4_K_M) | 900 | 32768 ctx; Multimodal-Augen (On-Demand) |
|
||||||
| `heavy` | **= Qwen3.8-27B** (zweiter Alias des Coders, seit 04.09.) | 5400 | Planen/Review (OpenChamber) + chat-Lane des Gateways. gpt-oss-120b (60 GB, AA-Index 24 vs 52) liegt ohne Rolle als Rollback bereit, direkt als `gpt-oss-120b` ansprechbar |
|
| `heavy` | **= Qwen3.8-27B** (zweiter Alias des Coders, seit 04.09.) | 5400 | Planen/Review (OpenChamber) + chat-Lane des Gateways. gpt-oss-120b (60 GB, AA-Index 24 vs 52) liegt ohne Rolle als Rollback bereit, direkt als `gpt-oss-120b` ansprechbar |
|
||||||
| `reranker` | Qwen3-Reranker-0.6B (q8_0, Mungert) | 0 | Sortiert Suchtreffer nach echter Relevanz (`/v1/rerank`) |
|
| `reranker` | Qwen3-Reranker-0.6B (q8_0, Mungert) | 0 | Sortiert Suchtreffer nach echter Relevanz (`/v1/rerank`) |
|
||||||
| `embed` | Qwen3-Embedding-0.6B (f16) | 0 | Vektorisierung für Suche/Sortierung |
|
| `embed` | Qwen3-Embedding-0.6B (f16) | 0 | Vektorisierung für Suche/Sortierung |
|
||||||
|
|
||||||
> **Diese sieben sind ALLES** — abgeglichen mit `/etc/llama-swap/config.yaml` am 27.08.2026.
|
> **Diese sieben sind ALLES** — abgeglichen mit `/etc/llama-swap/config.yaml` am 27.08.2026 (Pfade 17.09.2026).
|
||||||
|
>
|
||||||
|
> **Prüfstand 17.09.2026** (`deploy/bench/pruefstand-hirn.py`; Kandidat auf `:5899`, Hermes spricht per `--provider pruefstand`
|
||||||
|
> aus `providers:` der Hermes-Config dagegen — Live-Stack bleibt unberührt): Uncensored-3.6 = 100,8 t/s @13k (Original 92,8),
|
||||||
|
> Tools 11/12, Hermes-Smoke grün · Uncensored-27B = 30,4 t/s @13k (Original 26,2), Tools 6/6, Coding 3/3 · **Nemotron 3.5 Lightning
|
||||||
|
> 30B-A3B** (Q4_0 + MTP-Draft, `/srv/models/Nemotron-3.5-Lightning-30B-A3B-GGUF/`) = Hirn-Klasse: 10,5 s Prefill @13k, 91,8 t/s,
|
||||||
|
> Tools 3/3, Hermes grün — liegt als Reserve-Kandidat bereit, nicht besetzt. ‼ **Bild + DFlash2 = HTTP 500** („failed to process
|
||||||
|
> speculative batch") beim Coder — Vorbestand seit 04.09., unabhängig vom Modell; Bilder laufen über die Gateway-Bildweiche zu `vision`.
|
||||||
> Frühere Tabellen führten zusätzlich `kritiker` (Devstral-Small-2-24B), `scout` (GLM-4.6V-Flash),
|
> Frühere Tabellen führten zusätzlich `kritiker` (Devstral-Small-2-24B), `scout` (GLM-4.6V-Flash),
|
||||||
> `dense-planer` und `doctor`. Die gibt es nicht mehr: die Modell-Konsolidierung (`9a35be9`, 19.08.)
|
> `dense-planer` und `doctor`. Die gibt es nicht mehr: die Modell-Konsolidierung (`9a35be9`, 19.08.)
|
||||||
> warf sie aus llama-swap, und `fremdblick.sh` — der einzige Aufrufer des Kritikers — fiel mit dem
|
> warf sie aus llama-swap, und `fremdblick.sh` — der einzige Aufrufer des Kritikers — fiel mit dem
|
||||||
@@ -68,11 +76,15 @@ Config: `/etc/llama-swap/config.yaml` (sudo zum Schreiben).
|
|||||||
|
|
||||||
## Hermes (Agent)
|
## Hermes (Agent)
|
||||||
|
|
||||||
- **v0.20.6** (2026.8.27, git-Install `~/.hermes/hermes-agent`), Config `~/.hermes/config.yaml`, Persona `~/.hermes/SOUL.md` = **Lucy**.
|
- **v0.21.3** (2026.9.14; git-Install `~/.hermes/hermes-agent`, main @ dd13b475 vom 17.09.2026), Config `~/.hermes/config.yaml`, Persona `~/.hermes/SOUL.md` = **Lucy**.
|
||||||
- **Bot-Roster: leer** (live geprüft 27.08.2026). Hermes *kann* mehrere Bots, genutzt wird es nicht —
|
- **Bot-Roster: leer** (live geprüft 27.08.2026). Hermes *kann* mehrere Bots, genutzt wird es nicht —
|
||||||
die Profile fahren `hermes`, `fast` und `vision`. Frühere Fassungen behaupteten hier einen Roster
|
die Profile fahren `hermes`, `fast` und `vision`. Frühere Fassungen behaupteten hier einen Roster
|
||||||
mit `Coder`/`Qwen3-Coder-Next` und `Debugger`; beides trifft nicht zu.
|
mit `Coder`/`Qwen3-Coder-Next` und `Debugger`; beides trifft nicht zu.
|
||||||
- `approvals.mode: smart`, `approvals.cron_mode: deny`.
|
- `approvals.mode: 'off'`, `approvals.cron_mode: auto` — **User-Entscheid** (so seit spätestens dem KISS-Umbau
|
||||||
|
21.08.2026, bestätigt 17.09.2026: „approvals bleibt off"). Lucy fragt nicht nach Freigaben; die Grenze ziehen
|
||||||
|
ufw, PC-Executor-Token und command_allowlist (siehe Security). Frühere Fassungen nannten `smart`/`deny` (Stand 03.07.).
|
||||||
|
- Config-Schema **v45** (migriert 17.09.2026): `connections`-Toolset (Nous-Konnektoren, `manage_connections`) bewusst in
|
||||||
|
`agent.disabled_toolsets`; Curator archiviert ungenutzte Skills nach 30 Tagen (`skills/.archive/`, `hermes curator restore`).
|
||||||
- MCP-Server (aus `~/mission-control-v2/mcp/`): mission-control-stack, hermes-pc-control, hermes-web-fetch, mcp_voice.
|
- MCP-Server (aus `~/mission-control-v2/mcp/`): mission-control-stack, hermes-pc-control, hermes-web-fetch, mcp_voice.
|
||||||
(`mission-control-memory` ist am 27.08.2026 entfallen — Hermes führt sein Gedächtnis selbst.)
|
(`mission-control-memory` ist am 27.08.2026 entfallen — Hermes führt sein Gedächtnis selbst.)
|
||||||
|
|
||||||
@@ -97,10 +109,12 @@ Config: `/etc/llama-swap/config.yaml` (sudo zum Schreiben).
|
|||||||
| 10 9, 16.08. | **Gedaechtnis-Check** (einmalig: Gedächtnis-Schutt räumen, alte Einträge bereinigen) |
|
| 10 9, 16.08. | **Gedaechtnis-Check** (einmalig: Gedächtnis-Schutt räumen, alte Einträge bereinigen) |
|
||||||
| So 04:30 | **Auto-Update** Router→Engine→Hermes (Fangnetz: Backup→Postcheck→Auto-Rollback+Pin) |
|
| So 04:30 | **Auto-Update** Router→Engine→Hermes (Fangnetz: Backup→Postcheck→Auto-Rollback+Pin) |
|
||||||
|
|
||||||
**`mc2-autoupdate.timer` ist AN (User-Entscheid 10.07.,** ersetzt „nur bestätigt" vom 04.07.):
|
**Sonntags-Update = Hermes-Cron `0ec52231783b`** (`~/.hermes/scripts/sonntags-update.sh` →
|
||||||
Fremd-Software-Updates (Router/Engine/Hermes) dürfen automatisch laufen; der User klickt sie
|
`deploy/autoupdate.sh`, seit dem KISS-Umbau 21.08.); `mc2-autoupdate.timer` ist disabled — gewollt.
|
||||||
zusätzlich oft manuell im Wartungs-Drawer. deploy.sh enablet den Timer bewusst NICHT selbst —
|
Fremd-Software-Updates (Router/Engine/Hermes) dürfen automatisch laufen (User-Entscheid 10.07.); der
|
||||||
der Schaltzustand wird nur von Hand geändert. Manueller Lauf: `bash deploy/autoupdate.sh`.
|
User klickt sie zusätzlich oft manuell im Wartungs-Drawer. Rot ⇒ Rollback + Pin in
|
||||||
|
`/srv/models/mc2-pins.json`; gepinnte Ebenen werden **still übersprungen**, bis der Pin von Hand
|
||||||
|
gelöst wird (06.–17.09.2026 stand die Box so zwei Wochen still). Manueller Lauf: `bash deploy/autoupdate.sh`.
|
||||||
OS-Sicherheitsupdates laufen separat via unattended-upgrades.
|
OS-Sicherheitsupdates laufen separat via unattended-upgrades.
|
||||||
|
|
||||||
## Security (Stufe 0, live)
|
## Security (Stufe 0, live)
|
||||||
@@ -109,7 +123,8 @@ PC-Executor nur mit Bearer `HERMES_PC_TOKEN` (fail-closed) · ufw default deny,
|
|||||||
LAN→22/9001/8080 (+Alt-Port 7681) · Prompt-Injection-Guard `mcp/guard.py` (wrappt
|
LAN→22/9001/8080 (+Alt-Port 7681) · Prompt-Injection-Guard `mcp/guard.py` (wrappt
|
||||||
Web-/Bildschirm-Text) · mc2-memory lernt nicht aus untrusted Turns · command_allowlist =
|
Web-/Bildschirm-Text) · mc2-memory lernt nicht aus untrusted Turns · command_allowlist =
|
||||||
5 exakt gepinnte curls (fnmatch; Shell-Operatoren hart geblockt — NIE offenes `curl *`) ·
|
5 exakt gepinnte curls (fnmatch; Shell-Operatoren hart geblockt — NIE offenes `curl *`) ·
|
||||||
Box-sudo braucht Passwort; NOPASSWD nur für update-engine/swap (`/etc/sudoers.d/mc2-autonomie`).
|
Box-sudo ist **NOPASSWD: ALL** (live `sudo -n -l`, 04.09. und 17.09.2026); die alte Whitelist in
|
||||||
|
`/etc/sudoers.d/mc2-autonomie` (update-engine/swap, Dienst-Restarts, journalctl) steht daneben weiter.
|
||||||
**Security-Config (approvals/Tokens/ufw) NIE ohne explizites User-Ja ändern.**
|
**Security-Config (approvals/Tokens/ufw) NIE ohne explizites User-Ja ändern.**
|
||||||
|
|
||||||
## Deploy & Pipeline
|
## Deploy & Pipeline
|
||||||
|
|||||||
@@ -84,8 +84,10 @@ einen User-Entscheid — kein „Best Practice sagt aber…" (siehe ARBEITSWEISE
|
|||||||
- **Zed ACP / Hermes-im-Editor: NICHT machen.** Editor-Agent + Box-coder-Modell + AGENTS.md
|
- **Zed ACP / Hermes-im-Editor: NICHT machen.** Editor-Agent + Box-coder-Modell + AGENTS.md
|
||||||
ist der Sweet Spot; Hermes dazwischen = 70-KB-Prompt-Overhead. (Zed selbst ist inzwischen
|
ist der Sweet Spot; Hermes dazwischen = 70-KB-Prompt-Overhead. (Zed selbst ist inzwischen
|
||||||
vom PC entfernt — Hermes Desktop übernahm.)
|
vom PC entfernt — Hermes Desktop übernahm.)
|
||||||
- **`approvals.cron_mode: deny` bleibt** (Autonomie-Härtung 1c, User wählte Option A):
|
- **Approvals bleiben AUS** (`approvals.mode: 'off'`, `cron_mode: auto`; so seit spätestens dem KISS-Umbau
|
||||||
Gefahr-Befehle + execute_code sind im Cron-/Autonom-Kontext geblockt, interaktiv `smart`.
|
21.08.2026, vom User am 17.09.2026 bestätigt). Die Schutzlinie liegt außen — ufw, PC-Executor-Token,
|
||||||
|
command_allowlist — nicht in der Freigabe-Frage. *Überholt damit:* „`approvals.cron_mode: deny` bleibt"
|
||||||
|
(Autonomie-Härtung 1c, Option A: Gefahr-Befehle + execute_code im Cron-Kontext geblockt, interaktiv `smart`).
|
||||||
- **Fremd-Kritik via `fremdblick.sh`** (Ein-Schuss an leichtes Fremdmodell), NIE via
|
- **Fremd-Kritik via `fremdblick.sh`** (Ein-Schuss an leichtes Fremdmodell), NIE via
|
||||||
delegate_task→heavy (64k-Floor + Lade-Tod). Raster: prose=GLM-4.7-Flash (kritiker),
|
delegate_task→heavy (64k-Floor + Lade-Tod). Raster: prose=GLM-4.7-Flash (kritiker),
|
||||||
code=Coder-Next. Harte Regel: **REPRODUZIERT-ODER-ABGELEHNT** — Freispruch nur mit
|
code=Coder-Next. Harte Regel: **REPRODUZIERT-ODER-ABGELEHNT** — Freispruch nur mit
|
||||||
|
|||||||
@@ -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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user