diff --git a/backend/app.py b/backend/app.py index b149407..6805dc3 100644 --- a/backend/app.py +++ b/backend/app.py @@ -21,10 +21,9 @@ import httpx from config import FRONTEND_DIST, V1_UPSTREAM, VERSION from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from routers import ( - agent, boxwart, events, gateway_proxy, @@ -39,7 +38,8 @@ from routers import ( zeitmaschine, ) from routers import reminders as reminders_router -from services import metrics_history, reminders, warmer +from services import reminders, warmer +from services.herkunft import erlaubt from starlette.requests import Request # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration @@ -63,14 +63,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: "Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", warmer.INTERVAL, ) - # Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen und - # Messverlauf gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern. + # Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen + # gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern. if os.environ.get("MC_PROBELAUF", "") != "1": tasks.append(asyncio.create_task(reminders.reminders_loop())) - # 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.") + log.info("Probelauf: Erinnerungen bleiben beim echten MC2.") # 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). app.state.gw_client = httpx.AsyncClient( @@ -96,6 +94,15 @@ app.add_middleware( ) +# Herkunftsprüfung (24.09.2026, User-Entscheid: keine Anmeldung): Knöpfe fremder Webseiten werden +# abgelehnt, Skripte, Desktop-Lucy und /v1 bleiben unberührt. Regeln in services/herkunft.py. +@app.middleware("http") +async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]): + if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")): + return JSONResponse({"detail": "Diese Aktion geht nur von der Box-Wart-Seite selbst aus."}, status_code=403) + return await call_next(request) + + @app.middleware("http") async def no_cache( request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]] @@ -113,7 +120,6 @@ app.include_router(models.router) app.include_router(routing.router) app.include_router(system.router) -app.include_router(agent.router) app.include_router( voice.router ) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab) diff --git a/backend/config.py b/backend/config.py index 47f4be5..7c83140 100644 --- a/backend/config.py +++ b/backend/config.py @@ -9,8 +9,6 @@ Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen. import os from pathlib import Path -from ruamel.yaml import YAML - # --- Engine (llama-swap) ----------------------------------------------------- LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/") CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml")) @@ -103,14 +101,6 @@ VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:865 # UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter # /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy. LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/") -# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback -# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001 -# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole -# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff. -# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard. -BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/") -# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel). -BOX_CONSOLE_PATH = "/console/" # Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit # Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback: # ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein @@ -123,14 +113,9 @@ BOX_CONSOLE_PATH = "/console/" # liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig. HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/") HERMES_BUILTIN_UI_PATH = "/hermes-ui/" -# GitHub-Repo für Update-Checks. -HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent") HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) -# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN. -PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.22:7777").rstrip("/") # PC-IP seit 09/2026 .22 (vorher .98) # --- Server ------------------------------------------------------------------ -HOST = os.environ.get("MC_HOST", "0.0.0.0") PORT = int(os.environ.get("MC_PORT", "9000")) # Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus; # im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher. @@ -138,7 +123,3 @@ FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resol # Version (Phase 0 — Greenfield-Skeleton). VERSION = "2.0.0-w8" - -# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml). -yaml = YAML() -yaml.preserve_quotes = True diff --git a/backend/migrate_config.py b/backend/migrate_config.py deleted file mode 100644 index aabd4a1..0000000 --- a/backend/migrate_config.py +++ /dev/null @@ -1,57 +0,0 @@ -import sys -from pathlib import Path - -# Add backend directory to sys.path so we can import services -sys.path.append(str(Path(__file__).resolve().parent)) - -from config import CONFIG_PATH -from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config - - -def migrate(): - print(f"Reading config from {CONFIG_PATH}...") - if not CONFIG_PATH.exists(): - print(f"Config path {CONFIG_PATH} does not exist. Skipping.") - return - - cfg = read_config() - models = cfg.get("models", {}) - - for name, spec in models.items(): - if not isinstance(spec, dict): - continue - cmd = spec.get("cmd", "") - if not cmd: - continue - - print(f"Migrating model: {name}") - - # 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags, - # die llama-server ablehnt → Start scheitert). Caching macht llama-server - # automatisch pro Slot. - cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "") - - # 2. Extract aliases/role - aliases = spec.get("aliases", []) - role = aliases[0] if aliases else None - - # 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder. - # spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an; - # ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt. - if role in ("fast", "coder"): - if "--parallel" not in cmd: - cmd = cmd.strip() + " --parallel 2" - if "--spec-draft-model" not in cmd: - target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else "" - cmd = cmd.strip() + spec_draft_flags(target) - - # Update cmd - from ruamel.yaml.scalarstring import LiteralScalarString - spec["cmd"] = LiteralScalarString(cmd.strip() + "\n") - - print(f"Writing updated config back to {CONFIG_PATH}...") - write_config(cfg) - print("Migration completed successfully!") - -if __name__ == "__main__": - migrate() diff --git a/backend/routers/agent.py b/backend/routers/agent.py deleted file mode 100644 index a7517fd..0000000 --- a/backend/routers/agent.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht).""" - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from services import agent_aktivitaet -from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model - -router = APIRouter(prefix="/api") - - -class BrainReq(BaseModel): - model: str - - -class SetBrainReq(BaseModel): - model_id: str - - -@router.get("/agent/status") -def status() -> dict: - return agent_status() - - -@router.get("/agent/brain") -def brain_info() -> dict: - """Aktuelles Agent-Hirn (hermes) + bestes NousResearch-Hermes-Update.""" - return hermes_brain_info() - - -@router.post("/agent/brain/set") -def set_brain(body: SetBrainReq) -> dict: - """Setzt ein installiertes Modell als Agent-Hirn (Alias + warm-Gruppe + Config).""" - res = set_agent_brain(body.model_id) - if not res.get("ok"): - raise HTTPException(400, res.get("reason", "Fehler beim Setzen des Agent-Hirns")) - return res - - -@router.post("/agent/brain") -def set_brain_model(body: BrainReq) -> dict: - ok = update_brain_model(body.model) - return {"ok": ok} - - -@router.get("/agent/aktivitaet") -def aktivitaet(limit: int = 60) -> dict: - """Werkzeug-Verlauf des Agenten (v3-Umbau P6). - - Gelesen aus Hermes' eigenem Log — MC2 patcht dort nichts, es schaut nur zu. Was - NICHT drin steht (Denkstrom, Werkzeug-Argumente), verspricht die Ansicht auch nicht. - """ - return agent_aktivitaet.uebersicht(max(1, min(limit, 300))) diff --git a/backend/routers/boxwart.py b/backend/routers/boxwart.py index cb07657..9d5ed8f 100644 --- a/backend/routers/boxwart.py +++ b/backend/routers/boxwart.py @@ -5,7 +5,6 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm 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 GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt @@ -40,11 +39,12 @@ 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: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1} UPDATES_CACHE_S = 600 def _updates_holen() -> None: + stand = maintenance.update_stand try: daten = maintenance.updates() except Exception: @@ -53,6 +53,7 @@ def _updates_holen() -> None: if daten is not None or _updates_cache["daten"] is None: _updates_cache["daten"] = daten or {} _updates_cache["ts"] = time.time() + _updates_cache["stand"] = stand _updates_cache["laeuft"] = False @@ -61,7 +62,10 @@ def _updates_gecacht() -> dict: 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 + # Nach jedem abgeschlossenen Update zählt maintenance.update_stand hoch — dann sofort neu holen, + # statt bis zu 10 Minuten „Aktualisieren“ für schon eingespielte Updates zu zeigen (24.09.2026). + veraltet = (_updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S + or _updates_cache["stand"] != maintenance.update_stand) if veraltet and not _updates_cache["laeuft"]: _updates_cache["laeuft"] = True threading.Thread(target=_updates_holen, name="updates-holen", daemon=True).start() @@ -83,6 +87,15 @@ def _bausteine(u: dict) -> list[dict]: return liste +NAMEN_UNKLAR = {"os": "Betriebssystem", "engine": "Motor", "swap": "llama-swap", "hermes": "Hermes"} + + +def _unklar(u: dict) -> list[dict]: + """Bausteine, deren Update-Prüfung scheiterte — ehrlich „unbekannt“ statt „aktuell“.""" + return [{"id": k, "name": NAMEN_UNKLAR.get(k, k), "grund": grund} + for k, grund in (u.get("pruef_fehler") or {}).items() if grund] + + def _zeit_kurz(ts: float | None) -> str: if not ts: return "–" @@ -143,12 +156,17 @@ def _lampen(stand: dict, u: dict) -> list[dict]: n = len(_bausteine(u)) fest = len(update_verlauf.festgehalten()) + unklar = len(_unklar(u)) 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")) + elif n: + lampen.append(lampe("updates", "Updates", "info", f"{n} bereit")) + elif unklar: + lampen.append(lampe("updates", "Updates", "warn", "Prüfung unklar")) else: - lampen.append(lampe("updates", "Updates", "info" if n else "ok", f"{n} bereit" if n else "aktuell")) + lampen.append(lampe("updates", "Updates", "ok", "aktuell")) return lampen @@ -183,7 +201,7 @@ def start() -> dict: "verlauf": stand["verlauf"][:20], "lampen": _lampen(stand, u), "box": _box(), - "updates": {"bausteine": _bausteine(u), "modelle": u.get("model_list") or [], + "updates": {"bausteine": _bausteine(u), "unklar": _unklar(u), "festgehalten": update_verlauf.festgehalten(), "gelesen": _updates_cache["daten"] is not None}, "flugplan": zeitplan.flugplan(), @@ -208,11 +226,6 @@ 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).""" diff --git a/backend/routers/events.py b/backend/routers/events.py index b3b484f..05ad84c 100644 --- a/backend/routers/events.py +++ b/backend/routers/events.py @@ -28,7 +28,6 @@ import json import logging from pathlib import Path -from config import MODELS_DIR from fastapi import APIRouter, Request from fastapi.responses import StreamingResponse @@ -69,8 +68,6 @@ def _fingerprints() -> dict[str, object]: ) except Exception: pass - # Erinnerungen: Datei-mtime (das Auftragsbuch ist seit 04.09.2026 ausgebaut) - fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json") return fp @@ -80,7 +77,9 @@ async def _strom(request: Request, mit_metrik: bool): Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) — ein globaler Snapshot würde bei mehreren Clients Events verschlucken. """ - alt = _fingerprints() + # Die Abdrücke fragen u. a. llama-swap ab (bis 2 s). Im Event-Loop blockierte das jede andere + # Anfrage dieses Prozesses, auch Lucys /v1-Weiterleitung — seit 24.09.2026 im Thread. + alt = await asyncio.to_thread(_fingerprints) yield ": verbunden\n\n" seit_abdruck = 0.0 @@ -97,7 +96,8 @@ async def _strom(request: Request, mit_metrik: bool): if mit_metrik: try: from services.system import metrik_punkt - yield f"event: metrik\ndata: {json.dumps(metrik_punkt())}\n\n" + punkt = await asyncio.to_thread(metrik_punkt) + yield f"event: metrik\ndata: {json.dumps(punkt)}\n\n" seit_ping = 0.0 except Exception: # Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt @@ -106,7 +106,7 @@ async def _strom(request: Request, mit_metrik: bool): if seit_abdruck >= ABDRUCK_S: seit_abdruck = 0.0 - neu = _fingerprints() + neu = await asyncio.to_thread(_fingerprints) keys = [k for k, v in neu.items() if k in alt and v != alt[k]] alt.update(neu) if keys: diff --git a/backend/routers/gateway_proxy.py b/backend/routers/gateway_proxy.py index 5b7f215..f2ecde2 100644 --- a/backend/routers/gateway_proxy.py +++ b/backend/routers/gateway_proxy.py @@ -161,10 +161,20 @@ def _inject_language(body: dict, alias: str) -> None: elif isinstance(first_sys.get("content"), list): first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE}) +def _upstream_fehler(text: str, status: int = 502, headers: dict | None = None) -> JSONResponse: + """Fehler im OpenAI-Format statt eines nackten 500 (24.09.2026): Hermes, OpenChamber und Lucy + können damit umgehen und zeigen den Grund an.""" + return JSONResponse({"error": {"message": text, "type": "upstream_error"}}, + status_code=status, headers=headers) + + @router.get("/models") async def models(request: Request): client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) - r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) + try: + r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) + except httpx.HTTPError as exc: + return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}") try: data = r.json() except ValueError: @@ -275,7 +285,10 @@ async def _proxy(path: str, request: Request): url = f"{LLAMA_SWAP_URL}{path}" if body.get("stream"): req = client.build_request("POST", url, json=body, timeout=None) - r = await client.send(req, stream=True) + try: + r = await client.send(req, stream=True) + except httpx.HTTPError as exc: + return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed) if r.status_code != 200: await r.aread() try: @@ -293,8 +306,15 @@ async def _proxy(path: str, request: Request): await r.aclose() return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) - r = await client.post(url, json=body, timeout=600.0) - resp_json = r.json() + try: + r = await client.post(url, json=body, timeout=600.0) + except httpx.HTTPError as exc: + return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed) + try: + resp_json = r.json() + except ValueError: + log.warning("%s: Engine-Antwort ist kein JSON (HTTP %s): %.200s", path, r.status_code, r.text) + return _upstream_fehler(f"Engine lieferte keine gültige Antwort (HTTP {r.status_code}).", headers=routed) if isinstance(resp_json, dict): record_usage(resp_json.get("usage"), alias) if any(isinstance(c, dict) and c.get("finish_reason") == "length" diff --git a/backend/routers/maintenance.py b/backend/routers/maintenance.py index 25af859..61051d9 100644 --- a/backend/routers/maintenance.py +++ b/backend/routers/maintenance.py @@ -78,11 +78,6 @@ def update_all() -> dict: return maintenance.update_all_job() -@router.post("/maintenance/reboot") -def reboot() -> dict: - return maintenance.reboot() - - @router.post("/maintenance/restart") def restart(body: RestartReq) -> dict: return maintenance.restart_service(body.service) diff --git a/backend/routers/models.py b/backend/routers/models.py index d7322e3..7c9e654 100644 --- a/backend/routers/models.py +++ b/backend/routers/models.py @@ -1,11 +1,12 @@ -"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups.""" +"""Modelle-Endpoints: Liste, Discover, Suche und Installation bei Hugging Face, Jobs, Rollen, +Laden/Entladen/Löschen. Fit-, Kontext-, Draft- und Gruppen-Routen sind am 24.09.2026 entfallen +(ohne Nutzer seit dem Box-Wart-Umbau).""" import psutil from config import HF_DOWNLOAD_ENV, MODELS_DIR from fastapi import APIRouter, HTTPException from pydantic import BaseModel from services import budget, discover, geheimnisse, hf, jobengine, llamaswap -from services.fit import evaluate_fit, max_ctx_for router = APIRouter(prefix="/api") @@ -29,27 +30,6 @@ def discover_models(force: bool = False) -> dict: return {**data, "sys_ram_gb": round(ram, 1)} -@router.get("/fit") -def fit(params_b: float = 0, quant: str = "Q4_K_M", ctx: int = 8192, - name: str = "", role: str = "") -> dict: - """Hardware-Fit-Vorschau. params_b<=0 → aus KATALOG (echte Metadaten, MoE-bewusst) - oder sonst aus dem Namen geschätzt. assigned_ctx = der ctx, der TATSÄCHLICH vergeben - würde: SETUP-BEWUSST (neben Hirn/warmem Set), nicht nur gegen den Gesamt-RAM. - So sieht die 'Erweiterte Ansicht' vor dem Download Ampel + echten ctx.""" - ram = _ram_gb() - pb = params_b if params_b > 0 else budget.params_b_for(name) - saw = budget.setup_aware_ctx(pb, quant, role=role or None) - return { - "params_b": round(pb, 1), - "fit": evaluate_fit(pb, quant, ctx, ram, name=name), - "optimal_ctx": max_ctx_for(pb, quant, ram), # Roh-Obergrenze (Modell allein) - "assigned_ctx": saw["ctx"], # setup-bewusst vergeben - "budget": {"gtt_gb": saw["gtt_gb"], "reserved_gb": saw["reserved_gb"], - "budget_gb": saw["budget_gb"], "mode": saw["mode"]}, - "sys_ram_gb": round(ram, 1), - } - - class RegisterReq(BaseModel): model_path: str role: str | None = None @@ -61,6 +41,10 @@ class RegisterReq(BaseModel): @router.post("/models/register") def register(req: RegisterReq) -> dict: + # Nur Dateien aus dem Modellordner (24.09.2026): der Pfad landet im Startbefehl der Engine. + for pfad in (req.model_path, req.mmproj_path): + if pfad and not llamaswap.im_modellordner(pfad): + raise HTTPException(400, f"Pfad liegt nicht im Modellordner ({MODELS_DIR}): {pfad}") try: model_id = llamaswap.register_model( req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl, @@ -148,8 +132,9 @@ def install(req: InstallReq) -> dict: env = dict(HF_DOWNLOAD_ENV) if token := geheimnisse.hf_token(): env["HF_TOKEN"] = token - job_id = jobengine.start_job(args, f"download {req.repo}", env=env, - on_done=_apply_role if role else None) + # Gruppe „download“: so taucht der Download in der Oberfläche mit Fortschritt und Abbrechen auf. + job_id = jobengine.start_job(args, f"Download {repo}", env=env, group="download", + on_done=_apply_role if role else None, zeitlimit_s=6 * 3600) jobengine.attach_download_progress(job_id, str(target), info["total_bytes"]) return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path, "total_bytes": info["total_bytes"], "files": len(info["files"])} @@ -169,14 +154,6 @@ class RoleReq(BaseModel): role: str | None = None -@router.get("/roles/{role}/recommend") -def recommend_role(role: str) -> dict: - """Welches installierte Modell passt am besten auf diese Rolle? (Capability + setup- - bewusster Fit). Basis für 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal.""" - from services import roles - return roles.recommend_for_role(role) - - @router.post("/models/{model_id}/role") def set_model_role(model_id: str, body: RoleReq) -> dict: new_role = (body.role or "").strip().lower() @@ -201,54 +178,6 @@ def set_model_role(model_id: str, body: RoleReq) -> dict: return {"ok": True} -class CtxReq(BaseModel): - ctx: int - - -@router.get("/models/{model_id}/ctx/auto") -def auto_ctx(model_id: str) -> dict: - """Setup-bewusster Optimal-ctx für ein bestehendes Modell (Rolle/Params/Quant + - aktuelles Setup). Basis für den 'Auto'-Button an der Modellkarte.""" - m = next((x for x in llamaswap.list_models() if x["name"] == model_id), None) - if not m: - raise HTTPException(404, "Modell nicht gefunden") - saw = budget.setup_aware_ctx_for_model(m) - return {"model_id": model_id, "current_ctx": m.get("ctx"), - "params_b": round(budget.params_of_model(m), 1), "quant": m.get("quant"), - "role": m.get("role"), **saw} - - -@router.post("/models/{model_id}/ctx") -def set_model_ctx(model_id: str, body: CtxReq) -> dict: - if not llamaswap.set_ctx(model_id, body.ctx): - raise HTTPException(404, "Modell nicht gefunden") - return {"ok": True} - - -@router.get("/models/drafts") -def list_drafts(target: str = "") -> dict: - """Verfügbare Draft-Modelle + ihre Vocab-Kompatibilität zum Ziel-Modell - (target = GGUF-Pfad). Basis für die idiotensichere Spec-Draft-Auswahl im UI.""" - return llamaswap.drafts_for(target) - - -class DraftReq(BaseModel): - draft_path: str | None = None - - -@router.post("/models/{model_id}/draft") -def set_model_draft(model_id: str, body: DraftReq) -> dict: - """Setzt/entfernt den Speculative-Decoding-Draft eines Modells. Inkompatible - (oder nicht prüfbare) Drafts werden serverseitig abgelehnt.""" - try: - res = llamaswap.set_spec_draft(model_id, body.draft_path) - except PermissionError as exc: - raise HTTPException(500, str(exc)) - if not res["ok"]: - raise HTTPException(400 if "kompatib" in res["reason"].lower() else 404, res["reason"]) - return res - - @router.post("/models/unload") def unload_all_models() -> dict: import httpx @@ -297,24 +226,3 @@ def delete(model_id: str) -> dict: if not llamaswap.delete_model(model_id): raise HTTPException(404, "Modell nicht gefunden") return {"ok": True} - - -@router.get("/groups") -def groups() -> dict: - return {"groups": llamaswap.list_groups()} - - -class GroupReq(BaseModel): - group: str - members: list[str] - swap: bool = False - persist: bool = False - - -@router.put("/groups") -def set_group(req: GroupReq) -> dict: - try: - llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist) - except PermissionError as exc: - raise HTTPException(500, str(exc)) - return {"ok": True} diff --git a/backend/routers/routing.py b/backend/routers/routing.py index ae41659..a01c257 100644 --- a/backend/routers/routing.py +++ b/backend/routers/routing.py @@ -1,9 +1,8 @@ -"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload).""" +"""Routing-Übersicht (Lanes, Gateway erreichbar). Der Policy-Editor ist seit dem Box-Wart-Umbau +aus der Oberfläche raus; die Policy selbst liest der Gateway weiter aus mc2-routing.json.""" -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from fastapi import APIRouter from services import gateway -from services.routing_policy import policy_meta, save_policy router = APIRouter(prefix="/api") @@ -11,32 +10,3 @@ router = APIRouter(prefix="/api") @router.get("/routing") def routing() -> dict: return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()} - - -@router.get("/routing/policy") -def get_policy() -> dict: - """Aktuelle Policy + Defaults (für „Zurücksetzen“) + Feld-Spezifikation für den Editor.""" - return policy_meta() - - -class PolicyPatch(BaseModel): - fast: str | None = None - heavy: str | None = None - coder: str | None = None - coder_lite: str | None = None - heavy_chars: int | None = None - coding_escalate_chars: int | None = None - fast_no_think: bool | None = None - - -@router.put("/routing/policy") -def put_policy(patch: PolicyPatch) -> dict: - """Teil-Update der Routing-Policy. Validiert, persistiert atomar, sofort wirksam (hot-reload).""" - fields = {k: v for k, v in patch.model_dump().items() if v is not None} - if not fields: - raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren.") - try: - new_policy = save_policy(fields) - except (ValueError, TypeError) as e: - raise HTTPException(status_code=400, detail=f"Ungültige Policy: {e}") - return {"policy": new_policy} diff --git a/backend/routers/system.py b/backend/routers/system.py index fb876dc..6d8358c 100644 --- a/backend/routers/system.py +++ b/backend/routers/system.py @@ -1,4 +1,4 @@ -"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box). +"""System-Endpoints: Live-Status, Dienste-Liste, Sicherung, Neustart, Token-Verbrauch. Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern). Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler @@ -26,22 +26,11 @@ log = logging.getLogger(__name__) router = APIRouter(prefix="/api") -# Quelle für Self-Update (auf der Box ~/mission-control-v2). -SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")) - - @router.get("/system/status") def status() -> dict: return system_status() -@router.get("/system/history") -def history(minutes: int = 60) -> dict: - """Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history.""" - from services import metrics_history - return metrics_history.history(minutes) - - def _voice_reachable() -> bool: try: return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200 @@ -124,15 +113,6 @@ def backups() -> dict: return {"backups": backup_svc.list_backups()} -def _run(cmd: list[str], cwd: str | None = None) -> dict: - try: - p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180) - return {"ok": p.returncode == 0, "code": p.returncode, - "out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]} - except Exception as exc: - return {"ok": False, "code": -1, "out": "", "err": str(exc)} - - class RestartReq(BaseModel): service: str @@ -147,15 +127,6 @@ def restart(req: RestartReq) -> dict: return maintenance.restart_service(req.service) -@router.post("/system/self-update") -def self_update() -> dict: - """git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler.""" - pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR) - reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR) - restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"]) - return {"pull": pull, "reset": reset, "restart": restart_res} - - @router.get("/system/token-stats") def token_stats() -> dict: """Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT).""" diff --git a/backend/routers/voice.py b/backend/routers/voice.py index 02612bb..b82ff06 100644 --- a/backend/routers/voice.py +++ b/backend/routers/voice.py @@ -1,425 +1,289 @@ -""" -Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar). - -Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den -Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools + -eigenem Gedächtnis wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den -Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht. - -LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints. -""" - -import logging -import os - -# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv). -import sys as _sys -import time - -import httpx -from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL -from fastapi import APIRouter, File, Form, HTTPException, UploadFile -from fastapi.responses import Response, StreamingResponse -from pydantic import BaseModel -from services import announce -from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern) - Timer, - TurnTrace, - get_metrics, - get_trace, - park, - record_stage, -) - -_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp") -if _GUARD_DIR not in _sys.path: - _sys.path.insert(0, _GUARD_DIR) -try: - from guard import wrap_untrusted -except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen - def wrap_untrusted(text: str, label: str = "") -> str: - return text - -log = logging.getLogger(__name__) -router = APIRouter(prefix="/api") - -# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung -# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell -# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar. -VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision") -# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2). -VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280")) - - -async def _describe_images(image_urls: list[str], hint: str) -> str: - """Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch). - Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler.""" - multi = len(image_urls) > 1 - intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens " - "5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). " - "Keine Einleitung, keine Wiederholung der Frage. " - if multi else - "Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot " - "(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ") - content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}] - for u in image_urls: - content.append({"type": "image_url", "image_url": {"url": u}}) - try: - # 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas - # kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen. - async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client: - r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={ - "model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False, - "messages": [{"role": "user", "content": content}], - }) - r.raise_for_status() - return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip() - except Exception as exc: - log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc) - return "" - -_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern - - -class TTSIn(BaseModel): - text: str - engine: str = "piper" - voice: str = "" - language: str = "" - ref_path: str = "" - - -class ChatIn(BaseModel): - text: str # die neue User-Äußerung (STT-Ergebnis) - session_id: str # stabiler Voice-Faden → server-seitiger Transcript - session_key: str = "" # optional: Langzeit-Memory-Scope - system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen") - model: str = "" - images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen") - - -class AnnounceIn(BaseModel): - text: str # die Meldung (wird von Lucy gesprochen) - subject: str = "" # kurze Betreffzeile (z.B. "[Update]") - source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel - priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen - - -class AlarmIn(BaseModel): - text: str # die Alarm-Meldung - subject: str = "[Alarm]" # Betreff (Telegram-Präfix) - source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog") - - -@router.post("/alarm") -def alarm(body: AlarmIn) -> dict: - """Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den - Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran - der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten - nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only - wie alle MC2-Endpoints.""" - text = (body.text or "").strip() - if not text: - raise HTTPException(400, "Leere Meldung.") - subject = (body.subject or "[Alarm]").strip() - try: - item = announce.add(text, subject, body.source or "alarm", "normal") - except ValueError as exc: - raise HTTPException(400, str(exc)) - announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op) - return {"ok": True, "item": item} - - -@router.post("/voice/announce") -def voice_announce(body: AnnounceIn) -> dict: - """Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter, - notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints.""" - try: - return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)} - except ValueError as exc: - raise HTTPException(400, str(exc)) - - -@router.get("/voice/announcements") -def voice_announcements(after: int | None = None, limit: int = 20) -> dict: - """Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den - aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach.""" - return announce.list_after(after, limit) - - -@router.get("/voice/health") -def voice_health() -> dict: - """Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist.""" - out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)} - try: - r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0)) - out["sidecar"] = r.status_code == 200 - out["detail"] = r.json() if r.status_code == 200 else None - except Exception as exc: - out["error"] = str(exc) - return out - - -@router.get("/voice/metrics") -def voice_metrics() -> dict: - """Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh.""" - return get_metrics() - - -@router.get("/voice/trace") -def voice_trace(limit: int = 20) -> dict: - """Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn · - Generierung). Neueste zuerst. Für die Latenz-Ansicht im Cockpit — - damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt.""" - return {"turns": get_trace(limit)} - - -@router.get("/voice/voices") -def voice_voices() -> dict: - try: - r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0)) - r.raise_for_status() - return r.json() - except Exception as exc: - raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}") - - -@router.post("/voice/stt") -async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: - """Mikro-Audio → Text (Proxy auf Sidecar /stt).""" - data = await audio.read() - if not data: - raise HTTPException(400, "Leeres Audio.") - files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - _t0 = time.perf_counter() - r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) - _ms = (time.perf_counter() - _t0) * 1000.0 - record_stage("stt", _ms) - park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein - r.raise_for_status() - return r.json() - except httpx.HTTPError as exc: - raise HTTPException(502, f"STT fehlgeschlagen: {exc}") - - -@router.post("/voice/turn") -async def voice_turn(audio: UploadFile = File(...)) -> dict: - """Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar.""" - data = await audio.read() - if not data: - raise HTTPException(400, "Leeres Audio.") - files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")} - try: - async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: - with Timer("turn"): - r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files) - r.raise_for_status() - return r.json() - except httpx.HTTPError as exc: - # Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen. - log.warning("Turn-Check fehlgeschlagen: %s", exc) - return {"complete": True, "probability": 1.0, "engine": "fallback"} - - -@router.post("/voice/reference") -async def voice_set_reference(audio: UploadFile = File(...)) -> dict: - """Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar.""" - data = await audio.read() - if not data: - raise HTTPException(400, "Leeres Audio.") - files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")} - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files) - r.raise_for_status() - return r.json() - except httpx.HTTPError as exc: - raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}") - - -@router.get("/voice/reference") -def voice_get_reference() -> dict: - try: - r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) - r.raise_for_status() - return r.json() - except Exception as exc: - return {"active": False, "error": str(exc)} - - -@router.delete("/voice/reference") -def voice_clear_reference() -> dict: - try: - r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) - r.raise_for_status() - return r.json() - except httpx.HTTPError as exc: - raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}") - - -@router.post("/voice/tts") -async def voice_tts(body: TTSIn) -> Response: - """Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes.""" - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - with Timer("tts"): - r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump()) - r.raise_for_status() - return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav")) - except httpx.HTTPError as exc: - raise HTTPException(502, f"TTS fehlgeschlagen: {exc}") - - -@router.post("/voice/chat") -async def voice_chat(body: ChatIn) -> StreamingResponse: - """Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht. - - Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht. - Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401.""" - if not HERMES_API_KEY: - raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.") - - headers = { - "Authorization": f"Bearer {HERMES_API_KEY}", - "X-Hermes-Session-Id": body.session_id, - } - if body.session_key: - headers["X-Hermes-Session-Key"] = body.session_key - - async def gen(): - # Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung zu EINEM - # Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger. - trace = TurnTrace(session_id=body.session_id, kind="voice") - first = True - first_content = True - committed = False - - def _commit() -> None: - nonlocal committed - if not committed: - committed = True - trace.commit() - - try: - # Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und - # der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt - # dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt. - user_text = body.text - imgs = [u for u in (body.images or []) if u] - trace.had_images = bool(imgs) - if imgs: - yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n' - _tv = time.perf_counter() - desc = await _describe_images(imgs, body.text) - trace.note_vision((time.perf_counter() - _tv) * 1000.0) - if desc: - safe_desc = wrap_untrusted(desc, "BILDSCHIRM") - user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}" - messages = [] - if body.system: - messages.append({"role": "system", "content": body.system}) - messages.append({"role": "user", "content": user_text}) - trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen) - payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True} - # Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, - # sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB). - # Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py). - if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"): - payload["chat_template_kwargs"] = {"enable_thinking": False} - try: - async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client: - async with client.stream( - "POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers, - ) as r: - if r.status_code != 200: - detail = (await r.aread()).decode("utf-8", "replace")[:500] - trace.error = f"Hermes {r.status_code}" - yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode() - return - async for chunk in r.aiter_raw(): - if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead) - trace.note_ttfb() - first = False - # Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT) — - # chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind. - if first_content and b'"content"' in chunk: - trace.note_first_content() - first_content = False - yield chunk - except httpx.HTTPError as exc: - trace.error = "verbindung" - yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode() - finally: - _commit() # Turn immer verbuchen (auch bei Fehler/Abbruch) - - return StreamingResponse(gen(), media_type="text/event-stream") - - -# --------------------------------------------------------------------------------------------- -# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts -# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht seit dem Umbau nicht mehr mit -# einem eigenen pocket_server, sondern mit DIESEM — dieselbe Stimme wie die Telegram-Sprachnachrichten. -# Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, /tts/stream -> PCM16 + X-Sample-Rate). -# LAN-only wie alle MC2-Endpoints. - -class LucyTtsIn(BaseModel): - text: str - emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins - - -@router.get("/lucy/stimme/health") -def lucy_stimme_health() -> dict: - """Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading).""" - try: - r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0)) - r.raise_for_status() - return r.json() - except Exception as exc: - raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}") - - -@router.post("/lucy/stimme/tts") -async def lucy_stimme_tts(body: LucyTtsIn) -> Response: - """Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen.""" - try: - async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client: - with Timer("lucy_tts"): - r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True)) - r.raise_for_status() - return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"), - headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")}) - except httpx.HTTPStatusError as exc: - raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}") - except httpx.HTTPError as exc: - raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}") - - -@router.post("/lucy/stimme/tts/stream") -async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse: - """Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate). - Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt - offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream.""" - client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) - try: - req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True)) - upstream = await client.send(req, stream=True) - except httpx.HTTPError as exc: - await client.aclose() - raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}") - if upstream.status_code != 200: - detail = (await upstream.aread()).decode("utf-8", "replace")[:200] - await upstream.aclose(); await client.aclose() - raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}") - - async def gen(): - try: - async for chunk in upstream.aiter_raw(): - yield chunk - finally: - await upstream.aclose() - await client.aclose() - - return StreamingResponse(gen(), media_type="application/octet-stream", - headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")}) +""" +Sprach- und Melde-Endpunkte für Lucy (Desktop-App, Telegram-Spiegel, Wächter). + +Dünner Layer: STT und die Turn-Erkennung gehen an den Voice-Sidecar (:8650, schläft seit +24.09.2026 bis zur Android-App), der Chat an den Hermes-`api_server` (:8642, OpenAI-kompatibel) — +derselbe volle Agent mit Werkzeugen und Gedächtnis wie Telegram. Mit stabilem +`X-Hermes-Session-Id` hält die Plattform den Verlauf, der Client schickt je Turn nur die neue +Nachricht. Lucys Stimme (pocket-tts, :8021) wird ins LAN gereicht. + +Abgebaut am 24.09.2026 (ohne Nutzer): Voice-Health, Latenz-Metriken und -Trace, Stimmenliste, +Klon-Referenz und das alte Piper/Chatterbox-TTS. +""" + +import json +import logging +import os + +# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv). +import sys as _sys + +import httpx +from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import Response, StreamingResponse +from pydantic import BaseModel +from services import announce + +_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp") +if _GUARD_DIR not in _sys.path: + _sys.path.insert(0, _GUARD_DIR) +try: + from guard import wrap_untrusted +except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen + def wrap_untrusted(text: str, label: str = "") -> str: + return text + +log = logging.getLogger(__name__) +router = APIRouter(prefix="/api") + +# Bildschirm-Sicht: das Bild-Modell beschreibt die Screenshots; die Beschreibung geht als TEXT an +# Hermes -> Lucy behält ihr volles Hirn und Gedächtnis. Per Env umstellbar. +VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision") +# Knappe Beschreibung = schnellere Generierung UND weniger Kontext für Hermes. +VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280")) +_STT_TIMEOUT = httpx.Timeout(120.0, connect=5.0) + + +def _sse_fehler(text: str) -> bytes: + """SSE-Fehlerzeile als gültiges JSON (Anführungszeichen im Text brachen früher den Lucy-Client).""" + return f"data: {json.dumps({'error': text}, ensure_ascii=False)}\n\n".encode() + + +async def _describe_images(image_urls: list[str], hint: str) -> str: + """Lässt das Bild-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch). + Mehrere Bilder gehen in EINER Nachricht ans Modell. Leerer String bei Fehler.""" + multi = len(image_urls) > 1 + intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens " + "5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). " + "Keine Einleitung, keine Wiederholung der Frage. " + if multi else + "Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot " + "(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ") + content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}] + for u in image_urls: + content.append({"type": "image_url", "image_url": {"url": u}}) + try: + # 45 s: Wenn das Bild-Modell so lange braucht, ist etwas kaputt, und Lucy soll lieber ohne + # Bildschirm-Kontext antworten als ewig hängen. + async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client: + r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={ + "model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False, + "messages": [{"role": "user", "content": content}], + }) + r.raise_for_status() + return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip() + except Exception as exc: + log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc) + return "" + + +class ChatIn(BaseModel): + text: str # die neue User-Äußerung (STT-Ergebnis) + session_id: str # stabiler Gesprächsfaden → server-seitiger Verlauf + session_key: str = "" # optional an Hermes durchgereicht (X-Hermes-Session-Key) + system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen") + model: str = "" + images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen") + + +class AnnounceIn(BaseModel): + text: str # die Meldung (wird von Lucy gesprochen) + subject: str = "" # kurze Betreffzeile (z.B. "[Update]") + source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel + priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen + + +class AlarmIn(BaseModel): + text: str # die Alarm-Meldung + subject: str = "[Alarm]" # Betreff (Telegram-Präfix) + source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog") + + +@router.post("/alarm") +def alarm(body: AlarmIn) -> dict: + """Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den + Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran + der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt. Der Betreff „[Alarm]“ geht + auch nachts sofort raus (notify.sh).""" + text = (body.text or "").strip() + if not text: + raise HTTPException(400, "Leere Meldung.") + subject = (body.subject or "[Alarm]").strip() + try: + item = announce.add(text, subject, body.source or "alarm", "normal") + except ValueError as exc: + raise HTTPException(400, str(exc)) + announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op) + return {"ok": True, "item": item} + + +@router.post("/voice/announce") +def voice_announce(body: AnnounceIn) -> dict: + """Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Wächter, notify.sh + (Updates/Radar/Telegram-Spiegel), Hermes-Cron.""" + try: + return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)} + except ValueError as exc: + raise HTTPException(400, str(exc)) + + +@router.get("/voice/announcements") +def voice_announcements(after: int | None = None, limit: int = 20) -> dict: + """Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den + aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach.""" + return announce.list_after(after, limit) + + +@router.post("/voice/stt") +async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: + """Mikro-Audio → Text (Proxy auf Sidecar /stt).""" + data = await audio.read() + if not data: + raise HTTPException(400, "Leeres Audio.") + files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} + try: + async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client: + r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) + r.raise_for_status() + return r.json() + except httpx.HTTPError as exc: + raise HTTPException(502, f"STT fehlgeschlagen: {exc}") + + +@router.post("/voice/turn") +async def voice_turn(audio: UploadFile = File(...)) -> dict: + """Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar.""" + data = await audio.read() + if not data: + raise HTTPException(400, "Leeres Audio.") + files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")} + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client: + r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files) + r.raise_for_status() + return r.json() + except httpx.HTTPError as exc: + # Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen. + log.warning("Turn-Check fehlgeschlagen: %s", exc) + return {"complete": True, "probability": 1.0, "engine": "fallback"} + + +@router.post("/voice/chat") +async def voice_chat(body: ChatIn) -> StreamingResponse: + """Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht. + + Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht. + Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401.""" + if not HERMES_API_KEY: + raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.") + + headers = { + "Authorization": f"Bearer {HERMES_API_KEY}", + "X-Hermes-Session-Id": body.session_id, + } + if body.session_key: + headers["X-Hermes-Session-Key"] = body.session_key + + async def gen(): + # Bildschirm-Sicht INNERHALB des Streams: so startet die SSE-Antwort sofort und der Client + # bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt dass der + # Request hängt, während das Bild-Modell beschreibt. + user_text = body.text + imgs = [u for u in (body.images or []) if u] + if imgs: + yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n' + desc = await _describe_images(imgs, body.text) + if desc: + safe_desc = wrap_untrusted(desc, "BILDSCHIRM") + user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}" + messages = [] + if body.system: + messages.append({"role": "system", "content": body.system}) + messages.append({"role": "user", "content": user_text}) + payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True} + # Lucys Hirn ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, sonst + # generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30 s). + if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"): + payload["chat_template_kwargs"] = {"enable_thinking": False} + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client: + async with client.stream( + "POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers, + ) as r: + if r.status_code != 200: + detail = (await r.aread()).decode("utf-8", "replace")[:500] + yield _sse_fehler(f"Hermes {r.status_code}: {detail}") + return + async for chunk in r.aiter_raw(): + yield chunk + except httpx.HTTPError as exc: + yield _sse_fehler(f"Verbindung zu Hermes fehlgeschlagen: {exc}") + + return StreamingResponse(gen(), media_type="text/event-stream") + + +# --------------------------------------------------------------------------------------------- +# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts +# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht mit DIESEM — dieselbe Stimme wie +# die Telegram-Sprachnachrichten. Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, +# /tts/stream -> PCM16 + X-Sample-Rate). + +class LucyTtsIn(BaseModel): + text: str + emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins + + +@router.get("/lucy/stimme/health") +def lucy_stimme_health() -> dict: + """Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading).""" + try: + r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0)) + r.raise_for_status() + return r.json() + except Exception as exc: + raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}") + + +@router.post("/lucy/stimme/tts") +async def lucy_stimme_tts(body: LucyTtsIn) -> Response: + """Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen.""" + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client: + r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True)) + r.raise_for_status() + return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"), + headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")}) + except httpx.HTTPStatusError as exc: + raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}") + except httpx.HTTPError as exc: + raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}") + + +@router.post("/lucy/stimme/tts/stream") +async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse: + """Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate). + Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt + offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream.""" + client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) + try: + req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True)) + upstream = await client.send(req, stream=True) + except httpx.HTTPError as exc: + await client.aclose() + raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}") + if upstream.status_code != 200: + detail = (await upstream.aread()).decode("utf-8", "replace")[:200] + await upstream.aclose(); await client.aclose() + raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}") + + async def gen(): + try: + async for chunk in upstream.aiter_raw(): + yield chunk + finally: + await upstream.aclose() + await client.aclose() + + return StreamingResponse(gen(), media_type="application/octet-stream", + headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")}) diff --git a/backend/routers/zeitmaschine.py b/backend/routers/zeitmaschine.py index 60b1021..0f1b050 100644 --- a/backend/routers/zeitmaschine.py +++ b/backend/routers/zeitmaschine.py @@ -31,17 +31,6 @@ def list_snapshots() -> dict: return {"available": os.name == "posix", "backups": backup_svc.list_backups()} -@router.get("/zeitmaschine/inhalt") -def snapshot_contents(file: str) -> dict: - """Top-Level-Komponenten eines Snapshots (hermes, llama-swap, MANIFEST …).""" - if not _FILE_RX.match(file): - raise HTTPException(400, "Ungültiger Snapshot-Name.") - p = backup_svc.BACKUP_DIR / file - if not p.is_file(): - raise HTTPException(404, "Snapshot nicht gefunden.") - return {"file": file, "components": backup_svc.snapshot_components(p)} - - @router.post("/zeitmaschine/restore") def restore(body: RestoreIn) -> dict: """Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein diff --git a/backend/scripts/parse_mc2_timeout.py b/backend/scripts/parse_mc2_timeout.py deleted file mode 100644 index 71d4c85..0000000 --- a/backend/scripts/parse_mc2_timeout.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -""" -Parse systemd timeout errors for mission-control-2.service from journalctl. - -Captures lines containing "State 'stop-sigterm' timed out" and appends them -to ~/logs/mc2-timeout.log with ISO timestamps. - -Permission errors are handled gracefully. - -Uses user journal (systemctl --user) to query logs. -""" - -import subprocess -from datetime import datetime, timezone -from pathlib import Path - -LOG_DIR = Path.home() / "logs" -LOG_FILE = LOG_DIR / "mc2-timeout.log" -SERVICE_NAME = "mission-control-2.service" -SEARCH_PATTERN = "State 'stop-sigterm' timed out" -JOURNALCTL_LIMIT = 1000 - - -def ensure_log_dir() -> None: - """Create log directory if it doesn't exist.""" - LOG_DIR.mkdir(parents=True, exist_ok=True) - - -def get_timeout_entries() -> list[str]: - """Run journalctl --user and return lines matching the timeout pattern.""" - cmd = [ - "journalctl", - "--user", - "-u", SERVICE_NAME, - "--no-pager", - "-n", str(JOURNALCTL_LIMIT), - ] - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30, - ) - except subprocess.TimeoutExpired: - print("journalctl timed out") - return [] - except PermissionError: - print("Permission denied: journalctl requires access to user logs") - return [] - - if result.returncode != 0: - if "Permission denied" in result.stderr: - print("Permission denied: journalctl requires access to user logs") - else: - print(f"journalctl failed: {result.stderr.strip()}") - return [] - - return [ - line - for line in result.stdout.splitlines() - if SEARCH_PATTERN in line - ] - - -def write_to_log(entries: list[str]) -> int: - """Append entries to log file with ISO timestamps. Returns count written.""" - ensure_log_dir() - - timestamp = datetime.now(timezone.utc).isoformat() - written = 0 - - with LOG_FILE.open("a", encoding="utf-8") as f: - for entry in entries: - f.write(f"[{timestamp}] {entry}\n") - written += 1 - - return written - - -def main() -> None: - entries = get_timeout_entries() - if not entries: - print("No timeout entries found.") - return - - count = write_to_log(entries) - print(f"Appended {count} timeout entry/ies to {LOG_FILE}") - - -if __name__ == "__main__": - main() diff --git a/backend/services/agent.py b/backend/services/agent.py index bb47bac..26a1218 100644 --- a/backend/services/agent.py +++ b/backend/services/agent.py @@ -6,31 +6,18 @@ Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md). import logging import os -import re import httpx from config import ( - BOX_CONSOLE_PATH, - BOX_CONSOLE_UPSTREAM, HERMES_API_URL, HERMES_BUILTIN_UI_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_HOME, - PC_EXECUTOR_URL, ) log = logging.getLogger(__name__) -def _hermes_version(name: str) -> float | None: - """Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0.""" - low = (name or "").lower() - if "hermes" not in low: - return None - m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low) - return float(m.group(1)) if m else None - - def _active_brain_name() -> str: """Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model).""" try: @@ -106,67 +93,18 @@ def _reach(url: str, path: str = "") -> bool: return False -def _count_enabled_mcp_servers() -> int: - config_path = HERMES_HOME / "config.yaml" - if not config_path.exists(): - return 0 - try: - from ruamel.yaml import YAML - r_yaml = YAML() - with config_path.open("r", encoding="utf-8") as f: - cfg = r_yaml.load(f) or {} - mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {} - if not isinstance(mcp_servers, dict): - return 0 - return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True)) - except Exception: - log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True) - return 0 - - def agent_status() -> dict: - """Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise.""" - home = HERMES_HOME - brain_model = "auto" - config_path = home / "config.yaml" - if config_path.exists(): - try: - from ruamel.yaml import YAML - r_yaml = YAML() - with config_path.open("r", encoding="utf-8") as f: - cfg = r_yaml.load(f) or {} - if isinstance(cfg, dict): - # Hermes nutzt model.default als aktives Modell (model.model = Provider-Param). - m = cfg.get("model", {}) or {} - brain_model = m.get("default") or m.get("model") or "auto" - except Exception: - log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True) - + """Erreichbarkeit des Hermes-Gateways (:8642) und des Hermes-Dashboards — für die Dienste-Liste. + Bis 24.09.2026 fragte die Funktion bei jedem Aufruf auch die abgebaute Konsole und den + PC-Ausführer ab (3 s Zeitlimit) und las die Hermes-Config, obwohl nur diese Felder gebraucht + werden. Seit der PC-Ausführer schläft, hätte das jede Dienste-Abfrage um 3 s verzögert.""" return { "gateway_url": HERMES_API_URL, - # Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/). - "box_console_url": BOX_CONSOLE_PATH, # Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/). - # Seit dem Umzug auf Hermes Desktop ist :9119 zugleich das Desktop-Gateway. "hermes_ui_url": HERMES_BUILTIN_UI_PATH, "gateway_reachable": _reach(HERMES_API_URL, "/health"), - # Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console). - "box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"), - # Erreichbarkeit der eingebauten Hermes-GUI / des Desktop-Gateways (Loopback :9119). "hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"), - "home_exists": home.exists(), - "brain_model": brain_model, - # Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig). - "has_config": (home / "config.yaml").exists() or (home / "config.json").exists(), - "has_skills": (home / "skills").exists(), - "has_memories": (home / "memories").exists(), - # Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit. - "telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")), - "mcp_server_count": _count_enabled_mcp_servers(), - "pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"), - # Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.). - "pc_executor_url": PC_EXECUTOR_URL, } @@ -220,49 +158,59 @@ def set_agent_brain(model_id: str) -> dict: def update_brain_model(new_model: str) -> bool: + """Setzt Lucys Hirn in Hermes' config.yaml (model.default + model.model) und startet den + Hermes-Gateway neu. + + Seit 24.09.2026 vorsichtig: Ist die Datei nicht lesbar (halb geschrieben, Syntaxfehler), wird + NICHTS geschrieben — früher entstand dann eine neue Datei nur mit dem model-Block, und der Rest + von Hermes' Konfiguration wäre weg gewesen. Geschrieben wird atomar (tmp + os.replace), vorher + liegt eine Sicherung config.yaml.bak-hirn- daneben.""" + import shutil + import time as _time + from config import HERMES_HOME - home = HERMES_HOME - config_path = home / "config.yaml" - - # Ensure home directory exists - home.mkdir(parents=True, exist_ok=True) - - cfg = {} - if config_path.exists(): - try: - from ruamel.yaml import YAML - r_yaml = YAML() - with config_path.open("r", encoding="utf-8") as f: - cfg = r_yaml.load(f) or {} - except Exception: - log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True) - cfg = {} - + from ruamel.yaml import YAML + + config_path = HERMES_HOME / "config.yaml" + if not config_path.exists(): + log.warning("update_brain_model: %s fehlt — Hirn wird nicht umgestellt", config_path) + return False + r_yaml = YAML() + r_yaml.preserve_quotes = True + r_yaml.width = 100 + r_yaml.indent(mapping=2, sequence=4, offset=2) + try: + with config_path.open("r", encoding="utf-8") as f: + cfg = r_yaml.load(f) + except Exception: + log.warning("update_brain_model: config.yaml nicht lesbar — nichts geschrieben", exc_info=True) + return False if not isinstance(cfg, dict): - cfg = {} - + log.warning("update_brain_model: config.yaml hat unerwarteten Inhalt — nichts geschrieben") + return False + if "model" not in cfg or not isinstance(cfg["model"], dict): cfg["model"] = {} - # Hermes liest model.default als aktives Modell; model.model ist der Provider-Param. - # Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt). + # Beide setzen, sonst greift die Umschaltung nicht. cfg["model"]["default"] = new_model cfg["model"]["model"] = new_model + tmp = config_path.with_suffix(".yaml.neu") try: - from ruamel.yaml import YAML - r_yaml = YAML() - with config_path.open("w", encoding="utf-8") as f: + shutil.copy2(config_path, config_path.with_name(f"config.yaml.bak-hirn-{_time.strftime('%Y%m%d-%H%M%S')}")) + with tmp.open("w", encoding="utf-8") as f: r_yaml.dump(cfg, f) - - # Restart the user-space service to apply changes - try: - from services import maintenance - maintenance.restart_service("hermes-gateway") - except Exception: - log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True) - - return True + YAML(typ="safe").load(tmp.read_text(encoding="utf-8")) # muss wieder lesbar sein + os.replace(tmp, config_path) except Exception: log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True) + tmp.unlink(missing_ok=True) return False + + try: + from services import maintenance + maintenance.restart_service("hermes-gateway") + except Exception: + log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True) + return True diff --git a/backend/services/agent_aktivitaet.py b/backend/services/agent_aktivitaet.py deleted file mode 100644 index 44ffce6..0000000 --- a/backend/services/agent_aktivitaet.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Werkzeug-Verlauf des Agenten (v3-Umbau P6). - -WARUM ES DAS GIBT: Lucys Arbeit war eine Blackbox mit Statuswort. Der Blueprint sah -dafür eine „Live Agent Matrix" mit Denkstrom vor (§4.4) — die ist so nicht baubar: Die -Denkschritte entstehen im Hermes-Prozess, und `AGENTS.md` verbietet es, dessen Quellcode -zu patchen. - -Beim Nachsehen zeigte sich aber, dass die HÄLFTE davon längst offen daliegt: Hermes -protokolliert jeden Werkzeug-Ruf nach `~/.hermes/logs/agent.log`. Eine Log-Datei zu lesen -ist kein Patchen. Was dadurch sichtbar wird — welches Werkzeug, wie lange, mit welchem -Ergebnis, in welchem Lauf — ist für die Frage „was tut sie gerade und wo hängt es" oft -nützlicher als der Fließtext ihrer Gedanken. - -WAS NICHT GEHT (und hier auch nicht so tut): der Denkstrom selbst und die Argumente eines -Werkzeug-Rufs. Beides steht nicht im Log. Die Ansicht verspricht deshalb nur, was sie -halten kann. - -ES HAT SICH SOFORT GELOHNT: Beim ersten Lesen fiel auf, dass `web_extract` seit -mindestens dem 25.08. JEDEN Morgen um 07:00 scheitert (der Suchanbieter kann keine -Seiten abrufen). Vier Tage lang, ohne dass es irgendwo aufgefallen wäre. -""" - -import logging -import os -import re -from datetime import datetime -from pathlib import Path -from zoneinfo import ZoneInfo - -log = logging.getLogger(__name__) - -# Die Box läuft in Europe/Berlin (AGENTS.md). Hermes' Log trägt Ortszeit ohne Offset. -LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) - -LOG_PFAD = Path(os.path.expanduser( - os.environ.get("MC_HERMES_AGENT_LOG", "~/.hermes/logs/agent.log"))) - -# Nur das Ende der Datei lesen. Sie wächst auf mehrere MB und wird rotiert; für einen -# Verlauf der letzten Stunden reicht der Schwanz — und er kostet nichts. -LESE_BYTES = 512 * 1024 - -# Die drei Formen, in denen Hermes einen Werkzeug-Ruf notiert (am Log gemessen, nicht -# geraten). Die Lauf-Kennung in eckigen Klammern fehlt bei Nicht-Cron-Läufen. -# -# INFO [cron_…] agent.tool_executor: tool terminal completed (1.40s, 53 chars) -# INFO agent.tool_executor: tool web_search completed (2.61s, 2944 chars) -# WARNING [cron_…] agent.tool_executor: Tool web_extract returned error (0.17s): {…} -# INFO agent.tool_executor: tool web_extract failed (0.17s): {…} -_ZEIT = r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+" -_LAUF = r"(?:\[(?P[^\]]+)\] )?" - -_FERTIG = re.compile( - _ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P\S+) completed " - r"\((?P[\d.]+)s, (?P\d+) chars\)") - -_FEHLER = re.compile( - _ZEIT + r" \w+ " + _LAUF + r"agent\.tool_executor: [Tt]ool (?P\S+) " - r"(?:failed|returned error) \((?P[\d.]+)s\)(?::\s*(?P.*))?") - - -def _schwanz(pfad: Path, n: int) -> list[str]: - """Die letzten n Bytes als Zeilen. Die erste Zeile kann angeschnitten sein und - wird verworfen — ein halber Zeitstempel passt auf kein Muster, aber sicher ist besser.""" - try: - groesse = pfad.stat().st_size - with pfad.open("rb") as f: - f.seek(max(0, groesse - n)) - roh = f.read() - zeilen = roh.decode("utf-8", errors="replace").splitlines() - return zeilen[1:] if groesse > n and zeilen else zeilen - except OSError: - return [] - - -def _kurz(detail: str | None) -> str | None: - """Fehlertext des Werkzeugs auf einen lesbaren Satz eindampfen. Hermes legt dort ein - JSON ab; interessant ist daran nur das `error`-Feld.""" - if not detail: - return None - treffer = re.search(r'"error"\s*:\s*"([^"]{1,300})"', detail) - text = treffer.group(1) if treffer else detail.strip() - return (text[:297] + "…") if len(text) > 300 else text - - -def rufe(limit: int = 60) -> list[dict]: - """Die jüngsten Werkzeug-Rufe, ältester zuerst.""" - ergebnis: list[dict] = [] - for zeile in _schwanz(LOG_PFAD, LESE_BYTES): - m = _FERTIG.match(zeile) - if m: - ergebnis.append({ - "zeit": _iso(m.group("zeit")), - "lauf": m.group("lauf"), - "werkzeug": m.group("werkzeug"), - "dauer_s": float(m.group("dauer")), - "zeichen": int(m.group("zeichen")), - "ok": True, - "fehler": None, - }) - continue - m = _FEHLER.match(zeile) - if m: - ergebnis.append({ - "zeit": _iso(m.group("zeit")), - "lauf": m.group("lauf"), - "werkzeug": m.group("werkzeug"), - "dauer_s": float(m.group("dauer")), - "zeichen": None, - "ok": False, - "fehler": _kurz(m.group("detail")), - }) - return ergebnis[-limit:] - - -def _iso(s: str) -> str: - """`2026-08-28 07:03:18` → ISO MIT Zeitzone. - - Hermes schreibt seine Log-Zeitstempel in Ortszeit ohne Offset. Die naiv zu lassen - wäre bequem (der Klient zeigt sie nur an) — aber genau daran hängt eine - Projektregel: Naive Zeiten werden über MC_LOCAL_TZ aufgelöst, nie geraten - (AGENTS.md; ruff DTZ007 erzwingt es). Sobald jemand später damit rechnet — Dauer - über Mitternacht, Vergleich mit einem Cron-Plan — wäre eine offsetlose Zeit eine - Falle, die erst zur Zeitumstellung zuschnappt.""" - try: - return datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).isoformat() - except ValueError: - return s - - -def uebersicht(limit: int = 60) -> dict: - """Was die Agent-Ansicht braucht: die Rufe selbst, je Werkzeug eine Bilanz und die - wiederkehrenden Fehler zusammengefasst. - - Die Bilanz ist der eigentliche Nutzen: Ein Werkzeug, das IMMER scheitert, verschwindet - in einer Zeitleiste — in einer Zeile „web_extract · 4 Rufe · 4 Fehler" nicht.""" - liste = rufe(limit) - if not LOG_PFAD.exists(): - return {"verfuegbar": False, "pfad": str(LOG_PFAD), "rufe": [], - "werkzeuge": [], "laeufe": []} - - bilanz: dict[str, dict] = {} - for r in liste: - b = bilanz.setdefault(r["werkzeug"], { - "werkzeug": r["werkzeug"], "rufe": 0, "fehler": 0, - "dauer_summe": 0.0, "letzter_fehler": None, - }) - b["rufe"] += 1 - b["dauer_summe"] += r["dauer_s"] - if not r["ok"]: - b["fehler"] += 1 - b["letzter_fehler"] = r["fehler"] - - werkzeuge = sorted( - ({**b, "dauer_schnitt_s": round(b["dauer_summe"] / max(b["rufe"], 1), 2)} - for b in bilanz.values()), - key=lambda b: (-b["fehler"], -b["rufe"]), - ) - - # Läufe in der Reihenfolge ihres ersten Auftretens (nicht sortiert nach Kennung — - # die trägt zwar ein Datum, aber darauf sollte sich niemand verlassen). - laeufe: list[dict] = [] - gesehen: dict[str, dict] = {} - for r in liste: - schluessel = r["lauf"] or "(interaktiv)" - if schluessel not in gesehen: - gesehen[schluessel] = {"lauf": schluessel, "von": r["zeit"], "bis": r["zeit"], - "rufe": 0, "fehler": 0} - laeufe.append(gesehen[schluessel]) - e = gesehen[schluessel] - e["bis"] = r["zeit"] - e["rufe"] += 1 - if not r["ok"]: - e["fehler"] += 1 - - return {"verfuegbar": True, "pfad": str(LOG_PFAD), "rufe": liste, - "werkzeuge": werkzeuge, "laeufe": laeufe} diff --git a/backend/services/announce.py b/backend/services/announce.py index b537dfe..ad046cb 100644 --- a/backend/services/announce.py +++ b/backend/services/announce.py @@ -112,14 +112,6 @@ def notify_telegram(subject: str, text: str) -> None: log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True) -def list_recent(limit: int = 150) -> list[dict]: - """Die jüngsten Einträge (neueste zuerst) — Datenquelle der Chronik: alles, was die Box - dem Commander je aktiv gemeldet hat (Health-Wächter, Updates, Radar, Träume, Alarme).""" - with _lock: - state = _load() - return list(reversed(state["items"][-max(1, min(limit, MAX_ITEMS)):])) - - def list_after(after: int | None, limit: int = 20) -> dict: """Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern.""" diff --git a/backend/services/aufraeumen.py b/backend/services/aufraeumen.py index 0ebed3d..c6c8f05 100644 --- a/backend/services/aufraeumen.py +++ b/backend/services/aufraeumen.py @@ -129,14 +129,15 @@ def loeschen(art: str, name: str) -> dict: if art == "ordner": frei = _ordner_loeschen(name) else: - cfg = llamaswap.read_config() - models = cfg.get("models") or {} - vorher = _genutzte_ordner(models) - del models[name] - for gruppe in (cfg.get("groups") or {}).values(): - if isinstance(gruppe, dict) and name in (gruppe.get("members") or []): - gruppe["members"] = [m for m in gruppe["members"] if m != name] - llamaswap.write_config(cfg) + with llamaswap.config_sperre(): + cfg = llamaswap.read_config() + models = cfg.get("models") or {} + vorher = _genutzte_ordner(models) + del models[name] + for gruppe in (cfg.get("groups") or {}).values(): + if isinstance(gruppe, dict) and name in (gruppe.get("members") or []): + gruppe["members"] = [m for m in gruppe["members"] if m != name] + llamaswap.write_config(cfg) nachher = _genutzte_ordner(models) for ordner in sorted(set(vorher) - set(nachher)): # nur Ordner, auf die jetzt nichts mehr zeigt frei += _ordner_loeschen(ordner) diff --git a/backend/services/backup.py b/backend/services/backup.py index c2915cc..0c73115 100644 --- a/backend/services/backup.py +++ b/backend/services/backup.py @@ -27,11 +27,6 @@ def _latest() -> Path | None: return snaps[0] if snaps else None -def snapshot_components(tarball: Path) -> list[str]: - """Öffentliche Sicht auf die Snapshot-Komponenten (Zeitmaschine-Detail in der UI).""" - return _components(tarball) - - def _components(tarball: Path) -> list[str]: """Top-Level-Einträge im Tarball (zur Anzeige im UI).""" try: diff --git a/backend/services/budget.py b/backend/services/budget.py index 4084e04..e41fd00 100644 --- a/backend/services/budget.py +++ b/backend/services/budget.py @@ -169,37 +169,3 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic } -def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int: - """Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt).""" - from services.fit import _NICE_CTX - if cap: - raw_ctx = min(raw_ctx, cap) - best = _NICE_CTX[0] - for c in _NICE_CTX: - if c <= raw_ctx: - best = c - return best - - -def setup_aware_ctx_for_model(model: dict) -> dict: - """Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der - Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) — - Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist.""" - from services import gguf_meta - quant = model.get("quant") or "Q4_K_M" - path = model.get("gguf_path") - meta = gguf_meta.arch_meta(path) if path else None - if not meta: - return setup_aware_ctx(params_of_model(model), quant, role=model.get("role")) - - gtt = gtt_budget_gb() - r = reserved_gb(model.get("role")) - budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0) - bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) - size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) - weights = max(params_of_model(model) * bpp, size_gb) - ck, cv = _cache_types(model.get("cmd") or "") - per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv) - ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048 - return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1), - "budget_gb": round(budget, 1), "mode": r["mode"]} diff --git a/backend/services/discover.py b/backend/services/discover.py index 3041d17..1e01fad 100644 --- a/backend/services/discover.py +++ b/backend/services/discover.py @@ -25,8 +25,6 @@ from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS log = logging.getLogger(__name__) -_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2} - def _categorize(repo_id: str) -> str: low = repo_id.lower() diff --git a/backend/services/fit.py b/backend/services/fit.py index 30fce7d..d0afdd4 100644 --- a/backend/services/fit.py +++ b/backend/services/fit.py @@ -99,8 +99,3 @@ def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int: return max_ctx_in_budget(params_b, quant, max(sys_ram_gb - 4.0, 0) * 0.8) -def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict: - ctx = max_ctx_for(params_b, quant, sys_ram_gb) - k = ctx // 1024 - return {"ctx": ctx, "k": k, - "note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."} diff --git a/backend/services/gateway_stream.py b/backend/services/gateway_stream.py index f605b58..42fa9ac 100644 --- a/backend/services/gateway_stream.py +++ b/backend/services/gateway_stream.py @@ -7,6 +7,7 @@ verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparse import json import logging +import threading import time from services.token_stats import increment_tokens @@ -37,12 +38,17 @@ def warn_truncation(model: str, max_tokens: int | None = None) -> None: return _trunc_last[model] = now log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model) + # Im eigenen Thread abliefern: announce.add spricht im Gateway-Prozess per HTTP mit MC2 (bis 5 s). + # Synchron hätte das den Event-Loop des Gateways und damit jeden LLM-Strom der Box angehalten. + threading.Thread(target=_melde_abriss, args=(model,), daemon=True).start() + + +def _melde_abriss(model: str) -> None: try: from services import announce announce.add( - f"Eine Antwort von „{model}“ ist am Token-/Kontext-Limit abgerissen " - f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß " - f"geschnitten — besser in Etappen teilen (eine Etappe = ein Worker-Lauf).", + f"Eine Antwort von „{model}“ ist am Token- oder Kontext-Limit abgerissen " + f"(finish_reason=length). Meist war die Aufgabe zu groß für einen Durchgang.", "[Kontext-Limit]", "gateway", "silent") except Exception: log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True) diff --git a/backend/services/herkunft.py b/backend/services/herkunft.py new file mode 100644 index 0000000..31c2f81 --- /dev/null +++ b/backend/services/herkunft.py @@ -0,0 +1,21 @@ +"""Herkunftsprüfung für Knöpfe (24.09.2026, User-Entscheid: keine Anmeldung). + +Knöpfe schicken POST/PUT/DELETE an /api. Ein Browser setzt dabei den Origin-Header — kommt er von +einer fremden Webseite, wird die Anfrage abgelehnt. Das verhindert, dass eine fremde Seite im +Browser eines Heimnetz-Geräts heimlich Updates, Neustarts oder Löschen auslöst. + +Unverändert erlaubt: Anfragen ohne Origin (Skripte, curl, Lucy-Watchdog), Origin „null“/„file://“ +(Desktop-Lucy aus Electron) und alles unter /v1 (OpenChamber, Hermes). +""" + +ENTWICKLUNG = {"localhost:5173", "127.0.0.1:5173", "localhost:5180", "localhost:5181"} +SCHREIBEND = {"POST", "PUT", "PATCH", "DELETE"} + + +def erlaubt(methode: str, pfad: str, origin: str | None, host: str | None) -> bool: + if methode.upper() not in SCHREIBEND or not pfad.startswith("/api/"): + return True + if not origin or origin in ("null", "file://"): + return True + wirt = origin.split("://", 1)[-1].rstrip("/") + return wirt == (host or "") or wirt in ENTWICKLUNG diff --git a/backend/services/hf.py b/backend/services/hf.py index d31d5ee..d4164cf 100644 --- a/backend/services/hf.py +++ b/backend/services/hf.py @@ -33,13 +33,12 @@ def list_quants(repo: str) -> list[str]: def search(q: str = "", limit: int = 24) -> list[dict]: """Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern).""" - url = (f"https://huggingface.co/api/models?filter=gguf" - f"&sort=downloads&direction=-1&limit={limit}") + params: dict[str, str | int] = {"filter": "gguf", "sort": "downloads", "direction": -1, "limit": limit} if q and q.strip(): - url += f"&search={q.strip()}" + params["search"] = q.strip() # von httpx kodiert (vorher roh an die URL gehängt) try: with httpx.Client(timeout=12.0) as c: - data = c.get(url).json() + data = c.get("https://huggingface.co/api/models", params=params).json() except Exception: return [] out = [] @@ -80,15 +79,19 @@ def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict: # mmproj separat (Vision-Projektor) mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None) model = [e for e in ggufs if "mmproj" not in e["path"].lower()] - # bevorzugt den gewünschten Quant - pref = [e for e in model if q in e["path"].lower()] - chosen = pref or model + # Nur der gewünschte Quant. Bis 24.09.2026 fiel die Auswahl sonst auf ALLE Modelldateien zurück — + # bei aufgeteilten Repos lud der Download dann alle Teile aller Varianten (hunderte GB). + chosen = [e for e in model if q in e["path"].lower()] if not chosen: return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False} - # Split? Wenn die gewählten Dateien -of- enthalten → alle Teile dieser Gruppe. + # Split? Wenn die gewählten Dateien -of- enthalten → alle Teile EINER Gruppe (gleicher Präfix). split = any("-of-" in e["path"].lower() for e in chosen) if split: - parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"]) + def _gruppe(pfad: str) -> str: + return re.sub(r"-\d{5}-of-\d{5}\.gguf$", "", pfad, flags=re.IGNORECASE) + erste = min(e["path"] for e in chosen if "-of-" in e["path"].lower()) + parts = sorted([e for e in chosen if "-of-" in e["path"].lower() and _gruppe(e["path"]) == _gruppe(erste)], + key=lambda e: e["path"]) files = [e["path"] for e in parts] first = files[0] total = sum(_size(e) for e in parts) diff --git a/backend/services/jobengine.py b/backend/services/jobengine.py index ed93d6b..26abb40 100644 --- a/backend/services/jobengine.py +++ b/backend/services/jobengine.py @@ -1,11 +1,22 @@ """ Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt. Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job. + +Seit 24.09.2026: +- Jobs laufen in einer eigenen Prozessgruppe; „Abbrechen“ beendet die ganze Gruppe (vorher nur + die bash-Hülle — sudo, apt und hf liefen weiter, während der Job schon „abgebrochen“ hieß). +- Jede Job-Art hat ein Zeitlimit; ein hängendes apt hält den Wartungs-Riegel nicht mehr ewig. +- `start_job_exklusiv` prüft und trägt unter EINER Sperre ein: zwei Klicks starten nicht mehr + zwei Updates derselben Gruppe. +- Beendete Jobs werden nach einem Tag bzw. ab 40 Stück aufgeräumt. + +Noch offen (Phase 2): Jobs überleben keinen Neustart von MC2, sie gehören in einen eigenen Worker. """ import glob import os import shlex +import signal import subprocess import threading import time @@ -14,6 +25,11 @@ import uuid JOBS: dict[str, dict] = {} _PROCS: dict[str, subprocess.Popen] = {} _LOG_CAP = 400 +_LOCK = threading.Lock() +BEHALTEN_MAX = 40 # so viele beendete Jobs bleiben sichtbar +BEHALTEN_S = 24 * 3600 # beendete Jobs verschwinden nach einem Tag +STANDARD_ZEITLIMIT_S = 3 * 3600 +_ENDE = ("done", "failed", "canceled") def _append_log(job: dict, line: str) -> None: @@ -57,34 +73,64 @@ def _pump_output(job: dict, stream) -> None: commit() -def _run_job(job_id: str, args: list[str], env: dict | None = None): +def _beende_gruppe(proc: subprocess.Popen) -> None: + """Den Job-Prozess samt Kindern beenden (posix: ganze Prozessgruppe, sonst nur den Prozess).""" + try: + if os.name == "posix": + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + else: + proc.terminate() + except (ProcessLookupError, PermissionError, OSError): + pass + + +def _run_job(job_id: str, args: list[str], env: dict | None = None, zeitlimit_s: int | None = None): """Job-Prozess starten und mitschreiben. - v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an - stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben). - Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn - braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job, - der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen.""" + v3-Umbau P1 (28.08.2026): Auf der Box läuft sudo passwortlos (`NOPASSWD: ALL`), der Job + braucht keine stdin-Pipe. DEVNULL sorgt dafür, dass ein Job, der wider Erwarten nach einem + Passwort fragt, sofort scheitert statt still zu hängen.""" job = JOBS[job_id] job["state"] = "running" + wecker: threading.Timer | None = None try: proc = subprocess.Popen( list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, bufsize=0, env={**os.environ, **(env or {})}, + start_new_session=(os.name == "posix"), ) _PROCS[job_id] = proc + grenze = zeitlimit_s or STANDARD_ZEITLIMIT_S + + def _zu_lang() -> None: + job["zeitlimit"] = True + _append_log(job, f"[mc] Zeitlimit ({grenze // 60} min) überschritten, Job wird beendet.") + _beende_gruppe(proc) + + wecker = threading.Timer(grenze, _zu_lang) + wecker.daemon = True + wecker.start() _pump_output(job, proc.stdout) proc.wait() job["returncode"] = proc.returncode - job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed") + if job.get("canceled"): + job["state"] = "canceled" + elif job.get("zeitlimit"): + job["state"] = "failed" + job["error"] = "Zeitlimit überschritten" + else: + job["state"] = "done" if proc.returncode == 0 else "failed" except Exception as exc: _append_log(job, f"[mc] Fehler: {exc}") job["state"] = "failed" + job["error"] = str(exc) job["returncode"] = -1 finally: + if wecker is not None: + wecker.cancel() _PROCS.pop(job_id, None) job["finished_at"] = time.time() cb = job.pop("_on_done", None) @@ -110,7 +156,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N rate = 0.0 while True: j = JOBS.get(job_id) - if not j or j["state"] in ("done", "failed", "canceled"): + if not j or j["state"] in _ENDE: break try: inc = glob.glob(pat, recursive=True) @@ -137,25 +183,54 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N threading.Thread(target=_watch, daemon=True).start() -def start_job(args: list[str], label: str, env: dict | None = None, on_done=None, - group: str | None = None) -> str: +def _aufraeumen() -> None: + """Alte beendete Jobs entfernen (unter _LOCK aufrufen).""" + jetzt = time.time() + fertig = sorted((j for j in JOBS.values() if j["state"] in _ENDE), + key=lambda j: j.get("finished_at") or 0, reverse=True) + for i, j in enumerate(fertig): + if i >= BEHALTEN_MAX or jetzt - (j.get("finished_at") or jetzt) > BEHALTEN_S: + JOBS.pop(j["id"], None) + + +def _eintragen(args: list[str], label: str, on_done, group: str | None) -> str: job_id = uuid.uuid4().hex[:12] - log_args = list(args) JOBS[job_id] = { "id": job_id, "label": label, "state": "queued", "group": group, - "log": ["$ " + " ".join(shlex.quote(a) for a in log_args)], + "log": ["$ " + " ".join(shlex.quote(a) for a in args)], "returncode": None, "started_at": time.time(), "finished_at": None, } if on_done: JOBS[job_id]["_on_done"] = on_done - threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start() return job_id +def start_job(args: list[str], label: str, env: dict | None = None, on_done=None, + group: str | None = None, zeitlimit_s: int | None = None) -> str: + with _LOCK: + _aufraeumen() + job_id = _eintragen(list(args), label, on_done, group) + threading.Thread(target=_run_job, args=(job_id, list(args), env, zeitlimit_s), daemon=True).start() + return job_id + + +def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None, on_done=None, + zeitlimit_s: int | None = None) -> tuple[str | None, dict | None]: + """Wie start_job, aber nur, wenn in der Gruppe nichts läuft — Prüfen und Eintragen unter einer + Sperre. Rückgabe: (neue Job-ID, None) oder (None, der schon laufende Job).""" + with _LOCK: + if (laeuft := active_in_group(group)) is not None: + return None, laeuft + _aufraeumen() + job_id = _eintragen(list(args), label, on_done, group) + threading.Thread(target=_run_job, args=(job_id, list(args), env, zeitlimit_s), daemon=True).start() + return job_id, None + + def active_in_group(group: str) -> dict | None: """Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None. Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig.""" - for j in JOBS.values(): + for j in list(JOBS.values()): if j.get("group") == group and j.get("state") in ("running", "queued"): return j return None @@ -163,16 +238,13 @@ def active_in_group(group: str) -> dict | None: def cancel_job(job_id: str) -> bool: job = JOBS.get(job_id) - if not job or job["state"] in ("done", "failed", "canceled"): + if not job or job["state"] in _ENDE: return False job["canceled"] = True _append_log(job, "[mc] Abbruch angefordert…") proc = _PROCS.get(job_id) if proc is not None: - try: - proc.terminate() - except Exception: - pass + _beende_gruppe(proc) else: job["state"] = "canceled" job["finished_at"] = time.time() @@ -181,4 +253,6 @@ def cancel_job(job_id: str) -> bool: def public_jobs() -> list[dict]: """Jobs ohne interne Felder (_on_done) für die API.""" - return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()] + with _LOCK: + _aufraeumen() + return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()] diff --git a/backend/services/llamaswap.py b/backend/services/llamaswap.py index 6ae2740..d847c16 100644 --- a/backend/services/llamaswap.py +++ b/backend/services/llamaswap.py @@ -6,9 +6,13 @@ NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen, `swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz. """ +import functools import logging import os import re +import threading +from contextlib import contextmanager +from pathlib import Path import httpx from config import ( @@ -17,12 +21,18 @@ from config import ( DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL, + MODELS_DIR, SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE, ) from ruamel.yaml.scalarstring import LiteralScalarString +try: + import fcntl +except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre + fcntl = None + log = logging.getLogger(__name__) # Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS, @@ -53,6 +63,44 @@ def _gguf_total_size(path: str) -> int | None: return None +# --- Sperre ------------------------------------------------------------------ +# Die llama-swap-Config hat mehrere Schreiber: die Oberfläche, das Radar, das Aufräumen und die +# Hirn-Umstellung. Ohne Sperre gingen gleichzeitige Änderungen verloren (lesen, ändern, schreiben +# überlappten). Seit 24.09.2026 läuft jedes Lesen-Ändern-Schreiben unter dieser Sperre: im +# Prozess per RLock, zwischen Prozessen per flock auf einer Datei neben der Config. +_SPERRE = threading.RLock() +_SPERR_TIEFE = threading.local() + + +@contextmanager +def config_sperre(): + with _SPERRE: + tiefe = getattr(_SPERR_TIEFE, "n", 0) + _SPERR_TIEFE.n = tiefe + 1 + datei = None + try: + if tiefe == 0 and fcntl is not None: + try: + datei = open(CONFIG_PATH.with_name(".mc2-config.lock"), "a+") + fcntl.flock(datei, fcntl.LOCK_EX) + except OSError: + datei = None # ohne Sperrdatei (z. B. fehlende Rechte) bleibt die Prozess-Sperre + yield + finally: + _SPERR_TIEFE.n = tiefe + if datei is not None: + fcntl.flock(datei, fcntl.LOCK_UN) + datei.close() + + +def _mit_sperre(fn): + @functools.wraps(fn) + def huelle(*args, **kwargs): + with config_sperre(): + return fn(*args, **kwargs) + return huelle + + # --- Lesen ------------------------------------------------------------------- def read_config() -> dict: if not CONFIG_PATH.exists(): @@ -236,7 +284,7 @@ def write_config(cfg: dict) -> None: ) from exc - +@_mit_sperre def register_model(model_path: str, role: str | None = None, ctx: int = 8192, ttl: int | None = None, mmproj_path: str | None = None, jinja: bool = False, set_alias: bool = True) -> str: @@ -368,70 +416,8 @@ def spec_draft_flags(target_path: str) -> str: return _spec_flags_for_draft(d) if d else "" -def drafts_for(target_path: str) -> dict: - """Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe - NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts. - compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt).""" - from services import gguf_meta - exists = bool(target_path and os.path.exists(target_path)) - drafts = list_drafts() - seen = {d["path"] for d in drafts} - for p in _sibling_mtp_drafters(target_path): - if p not in seen: - drafts.append({"path": p, "filename": os.path.basename(p), - "size_bytes": os.path.getsize(p) if os.path.exists(p) else None, - "vocab": gguf_meta.fingerprint(p)}) - for d in drafts: - d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None - d["mtp"] = _is_mtp_draft(d["path"]) - return { - "target_path": target_path, - "target_exists": exists, - "target_vocab": gguf_meta.fingerprint(target_path) if exists else None, - "drafts": drafts, - } - - -def set_spec_draft(model_id: str, draft_path: str | None) -> dict: - """Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells. - Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird - abgelehnt (idiotensicher). Returns {ok, reason}.""" - cfg = read_config() - spec = (cfg.get("models") or {}).get(model_id) - if not isinstance(spec, dict): - return {"ok": False, "reason": "Modell nicht gefunden"} - cmd = str(spec.get("cmd", "")) - # vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP. - cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd) - cmd = re.sub(r"\s+-md\s+\S+", "", cmd) - cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd) - cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd) - - if draft_path: - # relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen - if not os.path.isabs(draft_path) and "/" not in draft_path: - draft_path = str(DRAFTS_DIR / draft_path) - if not os.path.exists(draft_path): - return {"ok": False, "reason": "Draft-Datei nicht gefunden"} - from services import gguf_meta - target = "" - if (mt := _PATH_RE.search(cmd)): - target = mt.group(1).replace("'", "").replace('"', "") - comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None - if comp is not True: - reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding " - "würde beim Laden scheitern." - if comp is False else - "Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.") - return {"ok": False, "reason": reason} - cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path) - - spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n") - write_config(cfg) - return {"ok": True, "reason": ""} - - # --- Groups (Ko-Residenz) ---------------------------------------------------- +@_mit_sperre def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None: """llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True → @@ -467,6 +453,7 @@ def list_groups() -> dict: return read_config().get("groups") or {} +@_mit_sperre def set_role(model_id: str, role: str | None) -> bool: """Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert).""" @@ -478,6 +465,7 @@ def set_role(model_id: str, role: str | None) -> bool: return True +@_mit_sperre 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.""" @@ -493,22 +481,7 @@ def add_role(model_id: str, role: str) -> bool: return True -def set_ctx(model_id: str, ctx: int) -> bool: - """Kontextlänge (-c) eines bestehenden Modells ändern.""" - cfg = read_config() - spec = (cfg.get("models") or {}).get(model_id) - if not spec: - return False - cmd = str(spec.get("cmd", "")) - if _CTX_RE.search(cmd): - cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd) - else: - cmd = cmd.rstrip() + f" -c {ctx}" - spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n") - write_config(cfg) - return True - - +@_mit_sperre def set_ttl(model_id: str, ttl: int) -> bool: """Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss).""" @@ -521,6 +494,15 @@ def set_ttl(model_id: str, ttl: int) -> bool: return True +@_mit_sperre +def im_modellordner(pfad: str) -> bool: + """Liegt der Pfad (aufgelöst) unter MODELS_DIR? Grenze für Löschen und Eintragen (24.09.2026).""" + try: + return Path(pfad).resolve().is_relative_to(MODELS_DIR.resolve()) + except (OSError, ValueError): + return False + + def delete_model(model_id: str) -> bool: """Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner. @@ -540,6 +522,9 @@ def delete_model(model_id: str) -> bool: m = None if m: path = m.group(1).replace("'", "").replace('"', "") + if path and not im_modellordner(path): + log.warning("delete_model: %s liegt außerhalb von %s — nur der Eintrag wird entfernt", path, MODELS_DIR) + path = "" if path: # 1. Haupt-GGUF-Datei löschen if os.path.exists(path): @@ -567,7 +552,7 @@ def delete_model(model_id: str) -> bool: mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd) if mmproj_match: m_path = mmproj_match.group(1) - if os.path.exists(m_path) and m_path not in andere: + if os.path.exists(m_path) and m_path not in andere and im_modellordner(m_path): try: os.remove(m_path) except Exception: @@ -575,7 +560,7 @@ def delete_model(model_id: str) -> bool: # 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist try: - if not os.listdir(dirname) and os.path.basename(dirname) != "models": + if not os.listdir(dirname) and Path(dirname).resolve() != MODELS_DIR.resolve(): os.rmdir(dirname) except Exception: pass diff --git a/backend/services/maintenance.py b/backend/services/maintenance.py index ce9fd51..7bdda13 100644 --- a/backend/services/maintenance.py +++ b/backend/services/maintenance.py @@ -14,9 +14,8 @@ from datetime import datetime from pathlib import Path import httpx -import psutil -from services import catalog, discover, jobengine, llamaswap, system +from services import jobengine, system # System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). SYSTEM_SERVICES = {"llama-swap"} @@ -24,7 +23,16 @@ SYSTEM_SERVICES = {"llama-swap"} # 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", "hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme", - "projekte-sync", "mc2-backup", "mc2-radar"} + "projekte-sync", "mc2-backup", "mc2-radar", "mc2-morgenmeldung", "mc2-autoupdate"} + +# Warum eine Update-Prüfung nicht klappte (None = geprüft). Bis 24.09.2026 verschluckten die +# Prüfungen Netz-, API- und apt-Fehler und meldeten „kein Update“ — das Cockpit zeigte dann +# „aktuell“, obwohl gar nicht geprüft werden konnte. +PRUEF_FEHLER: dict[str, str | None] = {"os": None, "engine": None, "swap": None, "hermes": None} + +# Zählt abgeschlossene Updates hoch; Zwischenspeicher (z. B. im Box-Wart-Start) vergleichen ihn, +# damit „Aktualisieren“ direkt nach einem Update verschwindet statt erst nach 10 Minuten. +update_stand = 0 # 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__), "..", "..")) @@ -88,10 +96,6 @@ def _installed_engine_build() -> int | None: return None -def _ram_gb() -> float: - return psutil.virtual_memory().total / (1024 ** 3) - - def _os_upgradable() -> int: try: # LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt @@ -99,8 +103,10 @@ def _os_upgradable() -> int: out = subprocess.run( ["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"], capture_output=True, text=True, timeout=10) + PRUEF_FEHLER["os"] = None return int((out.stdout or "0").strip() or 0) - except Exception: + except Exception as exc: + PRUEF_FEHLER["os"] = f"Paketliste nicht lesbar ({exc.__class__.__name__})" return 0 @@ -109,8 +115,12 @@ def _engine_update_available() -> bool: if now - _engine_cache["ts"] < 3600: return _engine_cache["avail"] avail = False + fehler = None try: - rel = _latest_engine_asset_release() or {} + rel = _latest_engine_asset_release() + if rel is None: + fehler = "Neueste Engine-Version bei GitHub nicht abrufbar" + rel = rel or {} tag = str(rel.get("tag_name", "")) latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None installed = _installed_engine_build() @@ -119,8 +129,12 @@ def _engine_update_available() -> bool: elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp() avail = pub > os.path.getmtime(ENGINE_PATH) + 86400 - except Exception: + elif fehler is None: + fehler = "Installierte Engine-Version nicht lesbar" + except Exception as exc: avail = False + fehler = f"Engine-Prüfung gescheitert ({exc.__class__.__name__})" + PRUEF_FEHLER["engine"] = fehler _engine_cache.update(ts=now, avail=avail) return avail @@ -144,6 +158,7 @@ def _swap_update_available() -> bool: if now - _swap_cache["ts"] < 3600: return _swap_cache["avail"] avail = False + fehler = None try: rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest", timeout=6, headers={"User-Agent": "MissionControl2"}).json() @@ -152,8 +167,14 @@ def _swap_update_available() -> bool: installed = _installed_swap_version() if latest is not None and installed is not None: avail = latest > installed - except Exception: + elif latest is None: + fehler = "Neueste llama-swap-Version bei GitHub nicht abrufbar" + else: + fehler = "Installierte llama-swap-Version nicht lesbar" + except Exception as exc: avail = False + fehler = f"llama-swap-Prüfung gescheitert ({exc.__class__.__name__})" + PRUEF_FEHLER["swap"] = fehler _swap_cache.update(ts=now, avail=avail) return avail @@ -198,109 +219,12 @@ def _components_cached() -> list[dict]: if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]: return _comp_cache["data"] data = [_hermes_agent_update()] + PRUEF_FEHLER["hermes"] = ("Hermes-Quelle nicht erreichbar (git fetch)" + if data[0].get("reachable") is False else None) _comp_cache.update(ts=now, data=data) return data -def _params_of(m: dict) -> float: - """Größen-bewusste Parameterzahl eines installierten Modells: max aus Namens-Schätzung - und Dateigröße (fängt namenlose wie 'Qwen3-Coder-Next' UND Split-GGUFs ab).""" - from services.fit import QUANT_BYTES_PER_PARAM - caps = m.get("capabilities") or {} - bpp = QUANT_BYTES_PER_PARAM.get((m.get("quant") or "Q4_K_M").upper(), 0.55) - size_gb = (m.get("size_bytes") or 0) / (1024 ** 3) - pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0 - return max(float(caps.get("params_b") or 0), pb_size, 0.0) - - -# Familien-Subtyp + Generations-Version aus dem Modellnamen (für „echtes Upgrade?"). -_FAM_PATS = (("qwen", r"qwen(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"), - ("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"), - ("mistral", r"mistral"), ("hermes", r"hermes[-_ ]?(\d+(?:\.\d+)?)")) - - -def _gen_key(name: str): - """(Familie+Subtyp, Generations-Version) oder None. Z.B. 'Qwen3-VL-2B' → ('qwen-vl', 3.0), - 'Qwen2.5-VL-7B' → ('qwen-vl', 2.5). Nur gleiche Familie ist sinnvoll vergleichbar.""" - low = (name or "").lower() - sub = "-vl" if any(k in low for k in ("-vl", "vl-", "vision", "llava", "pixtral")) else \ - "-coder" if ("coder" in low or "-code" in low) else "" - for fam, pat in _FAM_PATS: - m = re.search(pat, low) - if m: - ver = float(m.group(1)) if (m.groups() and m.group(1)) else 0.0 - return (fam + sub, ver) - return None - - -def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict: - """Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog, - sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik.""" - cm = catalog.meta_for_name(name) - if cm: - return {"family": cm.get("family"), "gen": cm.get("generation"), - "total": float(cm.get("total_params_b") or 0), - "active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))} - d = model_dict or {} - g = _gen_key(name) - total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0) - return {"family": (d.get("family") or (g[0] if g else None)), - "gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)), - "total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))} - - -def model_upgrades() -> list[dict]: - """Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist: - gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade - (MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens- - Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei.""" - disc = discover.safe_discover(_ram_gb()) - if not disc: - return [] - - installed = llamaswap.list_models() - inst_by_role = {m["role"]: m for m in installed if m.get("role")} - cmds = " ".join(str(s.get("cmd", "")).lower() - for s in (llamaswap.read_config().get("models") or {}).values()) - out = [] - - for c in disc.get("categories", []): - role = c["role"] - im = inst_by_role.get(role) - if im is None: - continue - rec = c.get("recommended") - if not rec: - continue - rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None) - - i = _meta(im["name"], im=im) - r = _meta(rec, model_dict=rec_model) - - if not i["family"] or not r["family"] or i["family"] != r["family"]: - continue # andere/unbekannte Familie → kein Upgrade - if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6: - continue # ältere Generation → niemals - same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6) - if same_gen: - if r["total"] and i["total"] and r["total"] < i["total"] * 1.05: - continue # gleiche Gen, nicht größer → kein Upgrade - # MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung - if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5: - continue - # Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn - ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"]) - if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3: - continue - - base = rec.split("/")[-1].lower() - stem = base.removesuffix("-gguf") - if base in cmds or (stem and stem in cmds): - continue # schon installiert - out.append({"role": role, "title": c["title"], "repo": rec}) - return out - - def _last_apt_update() -> float | None: for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]: if os.path.exists(path): @@ -312,11 +236,23 @@ def _last_apt_update() -> float | None: def updates() -> dict: - ups = model_upgrades() - return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0, - "swap": 1 if _swap_update_available() else 0, - "models": len(ups), "model_list": ups, "last_check": _last_apt_update(), - "components": _components_cached()} + """Offene Updates je Baustein. `pruef_fehler` nennt je Baustein, warum nicht geprüft werden + konnte (None = geprüft). Die frühere Modell-Upgrade-Empfehlung ist raus (24.09.2026): das + Modell-Radar hat die Aufgabe übernommen, und die Oberfläche zeigte sie nicht mehr.""" + daten = {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0, + "swap": 1 if _swap_update_available() else 0, + "last_check": _last_apt_update(), "components": _components_cached()} + daten["pruef_fehler"] = dict(PRUEF_FEHLER) + return daten + + +def _nach_update() -> None: + """Nach einem abgeschlossenen Update: Zwischenspeicher leeren und den Stand hochzählen.""" + global update_stand + _engine_cache.update(ts=0.0, avail=False) + _swap_cache.update(ts=0.0, avail=False) + _comp_cache.update(ts=0.0, data=[]) + update_stand += 1 # ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ── @@ -630,30 +566,28 @@ def logs(service: str, lines: int = 200) -> dict: return {"ok": r["ok"], "text": r["out"] or r["err"]} return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."} -def check_updates_job() -> dict: - if err := check_sudo_needs_password(): - return err - - def on_done(): - _engine_cache.update(ts=0.0, avail=False) - _comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen - - cmd = "sudo apt-get update" - job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done) +def _starten(args: list[str], label: str, on_done=None, zeitlimit_s: int | None = None) -> dict: + """Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Job gleichzeitig. Prüfen und Eintragen + passieren unter einer Sperre (jobengine.start_job_exklusiv) — vorher lag zwischen Prüfung und + Start ein langsamer Aufruf, und zwei Klicks konnten zwei Updates starten.""" + job_id, laeuft = jobengine.start_job_exklusiv("maintenance", args, label, on_done=on_done, + zeitlimit_s=zeitlimit_s) + if job_id is None: + return {"ok": False, "status": "busy", "running": (laeuft or {}).get("label"), + "detail": f"Es läuft schon: {(laeuft or {}).get('label', 'ein Update')}."} return {"ok": True, "job_id": job_id} -def _maintenance_busy() -> dict | None: - """Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Update gleichzeitig. Verhindert - Doppelklick UND parallele Updates aus zwei Tabs/Sessions (racende .bak-Sicherung/Restarts).""" - if j := jobengine.active_in_group("maintenance"): - return {"ok": False, "status": "busy", "running": j.get("label")} - return None +def check_updates_job() -> dict: + if err := check_sudo_needs_password(): + return err + # apt-get update gehört in den Wartungs-Riegel: parallel zu einem apt upgrade kollidiert es mit + # der dpkg-Sperre. In der Gruppe taucht der Job auch in der Oberfläche auf. + return _starten(["bash", "-c", "sudo apt-get update"], "Nach Updates suchen", + on_done=_nach_update, zeitlimit_s=15 * 60) def os_update_job() -> dict: - if busy := _maintenance_busy(): - return busy if err := check_sudo_needs_password(): return err # Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging). @@ -662,49 +596,26 @@ def os_update_job() -> dict: cmd = ("sudo apt-get update && " "sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' " f"&& bash {STACK_POSTCHECK}") - job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)", - group="maintenance") - return {"ok": True, "job_id": job_id} + return _starten(["bash", "-c", cmd], "OS-Update (apt)", on_done=_nach_update, zeitlimit_s=90 * 60) def engine_update_job() -> dict | None: if not ENGINE_UPDATE_CMD: return None - if busy := _maintenance_busy(): - return busy - # KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben - # (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das - # Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war. - - def on_done(): - _engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich - # update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den # Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem - # neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. - job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD], - "Engine-Update (llama.cpp Vulkan)", - group="maintenance", on_done=on_done) - return {"ok": True, "job_id": job_id} + # neuen Build. KEIN sudo-Passwort-Gate: das Skript ist per sudoers NOPASSWD freigegeben. + return _starten(["bash", "-c", ENGINE_UPDATE_CMD], "Engine-Update (llama.cpp Vulkan)", + on_done=_nach_update, zeitlimit_s=60 * 60) def swap_update_job() -> dict | None: if not SWAP_UPDATE_CMD: return None - if busy := _maintenance_busy(): - return busy - # KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine). - - def on_done(): - _swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich - # update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack - # (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer - # Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. - job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD], - "Router-Update (llama-swap)", - group="maintenance", on_done=on_done) - return {"ok": True, "job_id": job_id} + # und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer Version. + return _starten(["bash", "-c", SWAP_UPDATE_CMD], "Router-Update (llama-swap)", + on_done=_nach_update, zeitlimit_s=30 * 60) def _hermes_update_cmd() -> str: @@ -753,16 +664,9 @@ def _hermes_update_cmd() -> str: def hermes_update_job() -> dict: """Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo - (alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern).""" - if busy := _maintenance_busy(): - return busy - - def on_done(): - _comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen - - job_id = jobengine.start_job(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update", - group="maintenance", on_done=on_done) - return {"ok": True, "job_id": job_id} + (alles im User-Space). Läuft als Hintergrund-Job (kann einige Minuten dauern).""" + return _starten(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update", + on_done=_nach_update, zeitlimit_s=45 * 60) def update_all_job() -> dict: @@ -771,8 +675,9 @@ def update_all_job() -> dict: Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift; es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem.""" - if busy := _maintenance_busy(): - return busy + if laeuft := jobengine.active_in_group("maintenance"): + return {"ok": False, "status": "busy", "running": laeuft.get("label"), + "detail": f"Es läuft schon: {laeuft.get('label', 'ein Update')}."} upd = updates() parts: list[tuple[str, str]] = [] if upd.get("engine") and ENGINE_UPDATE_CMD: @@ -804,18 +709,9 @@ def update_all_job() -> dict: if not os_pending: cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte - def on_done(): - _engine_cache.update(ts=0.0, avail=False) - _swap_cache.update(ts=0.0, avail=False) - _comp_cache.update(ts=0.0, data=[]) - labels = " → ".join(label for label, _ in parts) - job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})", - group="maintenance", on_done=on_done) - return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]} - - -def reboot() -> dict: - if err := check_sudo_needs_password(): - return err - return _run(["sudo", "reboot"]) + ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})", + on_done=_nach_update, zeitlimit_s=3 * 3600) + if ergebnis.get("ok"): + ergebnis["parts"] = [label for label, _ in parts] + return ergebnis diff --git a/backend/services/metrics_history.py b/backend/services/metrics_history.py deleted file mode 100644 index ced3400..0000000 --- a/backend/services/metrics_history.py +++ /dev/null @@ -1,128 +0,0 @@ -"""24-h-Metrik-Verlauf für die Cockpit-Zeitachse (User-Wunsch 16.07.: „WANN war Last?"). - -Ein leichter Sammler (Lifespan-Task in app.py) legt alle SAMPLE_S Sekunden einen -kompakten Punkt in einen Ringpuffer: CPU/RAM/GPU/Disk in Prozent + die Token- -GESAMTZÄHLER (das Frontend rechnet Raten aus den Deltas). Der Puffer hält exakt -24 h — Älteres fällt automatisch raus (deque maxlen), nichts wächst unbegrenzt. -Periodisch wird auf Platte persistiert (übersteht MC2-Restarts/Deploys); beim -Laden fliegt alles raus, was älter als 24 h ist. - -Bewusst NICHT im Steward: die Historie ist reine UI-Ware, und ein paar Sekunden -Lücke pro Deploy sind egal. -""" - -import asyncio -import json -import logging -import os -import time -from collections import deque - -from config import MODELS_DIR - -log = logging.getLogger(__name__) - -SAMPLE_S = 10 # Abtast-Takt -RETENTION_S = 24 * 3600 # 24 h — danach löschen und neu bauen (Ringpuffer) -MAXLEN = RETENTION_S // SAMPLE_S # 8640 Punkte -FLUSH_S = 300 # höchstens alle 5 min auf Platte -MAX_RETURN_POINTS = 300 # Endpoint downsampled auf ~diese Punktzahl - -HISTORY_PATH = MODELS_DIR / "mc2-metrics-history.json" - -# Punkt = [t, cpu, ram, gpu, disk, tp, tc] (t = Epoch-Sekunden; tp/tc = Token-Totale) -_points: deque = deque(maxlen=MAXLEN) -_loaded = False -_last_flush = 0.0 - - -def _load() -> None: - global _loaded - if _loaded: - return - _loaded = True - try: - raw = json.loads(HISTORY_PATH.read_text(encoding="utf-8")) - cutoff = time.time() - RETENTION_S - for p in raw if isinstance(raw, list) else []: - if isinstance(p, list) and len(p) == 7 and isinstance(p[0], (int, float)) and p[0] >= cutoff: - _points.append(p) - if _points: - log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH) - except FileNotFoundError: - pass - except Exception: - log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True) - - -def _flush() -> None: - global _last_flush - _last_flush = time.time() - try: - tmp = HISTORY_PATH.with_suffix(".tmp") - tmp.write_text(json.dumps(list(_points), separators=(",", ":")), encoding="utf-8") - tmp.replace(HISTORY_PATH) - except OSError: - log.warning("metrics_history: %s nicht schreibbar", HISTORY_PATH, exc_info=True) - - -def _sample() -> None: - import psutil - - from services.system import _gpu_sysfs - from services.token_stats import get_stats - - vm = psutil.virtual_memory() - disk = None - try: - du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()) - disk = du.percent - except Exception: - pass - gpu = None - try: - g = _gpu_sysfs() - gpu = g.get("busy_percent") if g else None - except Exception: - pass - tp = tc = None - try: - ts = get_stats() - tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens") - except Exception: - pass - # interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal). - _points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc]) - - -async def sampler_loop() -> None: - """Dauer-Sammler fürs App-Lifespan. Fehler eines Ticks reißen die Schleife nie.""" - _load() - while True: - try: - await asyncio.to_thread(_sample) - except Exception: - log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True) - if time.time() - _last_flush >= FLUSH_S: - try: - await asyncio.to_thread(_flush) - except Exception: - pass - await asyncio.sleep(SAMPLE_S) - - -def history(minutes: int = 60) -> dict: - """Punkte der letzten N Minuten, auf ≤ MAX_RETURN_POINTS ausgedünnt (Stride).""" - _load() - minutes = max(1, min(int(minutes), RETENTION_S // 60)) - cutoff = time.time() - minutes * 60 - pts = [p for p in _points if p[0] >= cutoff] - stride = max(1, len(pts) // MAX_RETURN_POINTS) - pts = pts[::stride] - return { - "sample_s": SAMPLE_S * stride, - "points": [ - {"t": p[0], "cpu": p[1], "ram": p[2], "gpu": p[3], "disk": p[4], "tp": p[5], "tc": p[6]} - for p in pts - ], - } diff --git a/backend/services/radar.py b/backend/services/radar.py index e1ef125..b63c50c 100644 --- a/backend/services/radar.py +++ b/backend/services/radar.py @@ -1384,16 +1384,19 @@ def _tausche_ein(k: dict) -> dict: else: ziel = quelle # schon verschoben (früherer, abgebrochener Versuch) pfade = _lokale_pfade(k, ziel) - modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle], ctx=int(k.get("ctx") or CTX_ROLLE[rolle]), - mmproj_path=None, jinja=True, set_alias=False) - cfg = llamaswap.read_config() - try: # die getesteten Flags statt der Vorlage aus register_model (sonst liefe ein ungetesteter Draft mit) - cfg["models"][modell_id]["cmd"] = LiteralScalarString(betriebs_befehl(rolle, pfade, k) + "\n") - except (KeyError, TypeError) as e: - raise RuntimeError(f"{modell_id} fehlt nach dem Eintragen in der llama-swap-Config.") from e - if pfade.get("mmproj"): - _zwilling_eintragen(cfg, rolle, modell_id, pfade, k) - llamaswap.write_config(cfg) + # Eintragen, Befehl ersetzen und Zwilling anlegen unter EINER Config-Sperre (24.09.2026). + with llamaswap.config_sperre(): + modell_id = llamaswap.register_model(pfade["gguf"], role=ALIAS[rolle], + ctx=int(k.get("ctx") or CTX_ROLLE[rolle]), + mmproj_path=None, jinja=True, set_alias=False) + cfg = llamaswap.read_config() + try: # die getesteten Flags statt der Vorlage aus register_model (sonst liefe ein ungetesteter Draft mit) + cfg["models"][modell_id]["cmd"] = LiteralScalarString(betriebs_befehl(rolle, pfade, k) + "\n") + except (KeyError, TypeError) as e: + raise RuntimeError(f"{modell_id} fehlt nach dem Eintragen in der llama-swap-Config.") from e + if pfade.get("mmproj"): + _zwilling_eintragen(cfg, rolle, modell_id, pfade, k) + llamaswap.write_config(cfg) hinweis = None if rolle == "hirn": from services.agent import set_agent_brain diff --git a/backend/services/roles.py b/backend/services/roles.py deleted file mode 100644 index f86d982..0000000 --- a/backend/services/roles.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -Rollen-Empfehlung: welches INSTALLIERTE Modell passt am besten auf eine Serving-Rolle? -Capability-getrieben (Vision/Coder/Tools/MoE aus services.caps) + setup-bewusster Fit -(services.budget). Speist den 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal. - -EINE Quelle der Wahrheit mit der ctx-/Fit-Logik: nutzt budget.setup_aware_ctx_for_model -und fit.evaluate_fit — dieselbe Mathematik wie Install-Automatik und Auto-ctx-Button. -""" - -import psutil - -from services import budget, catalog, llamaswap -from services.fit import evaluate_fit - - -def _ram_gb() -> float: - return psutil.virtual_memory().total / (1024 ** 3) - - -def _capability_suit(role: str, caps: dict, name: str) -> float: - """0..1 — Capability-Eignung (HARTE Gates). 0 = grundsätzlich falsch für die Rolle. - Größe/Tempo bewertet getrennt _pref(), damit z.B. 'fast' nicht das größte Modell zieht.""" - role = (role or "").lower() - low = (name or "").lower() - vision = bool(caps.get("vision")) - coder = bool(caps.get("coder")) - tools = caps.get("tools") != "no" - - if role == "vision": - if not vision: - return 0.0 # harte Anforderung - return 1.0 if ("vl" in low or "llava" in low or "pixtral" in low) else 0.7 # dediziert > omni - if role == "coder": - return 1.0 if coder else 0.4 # Coder-Modell Pflicht für Empfehlung - if role == "hermes": - # Agent-Hirn: Hermes-Familie am robustesten; sonst natives Tool-Calling Pflicht. - if "hermes" in low: - return 1.0 - return 0.6 if tools else 0.1 - if role == "fast": - # Alltags-Hirn braucht zuverlässige Tools; Größe/Tempo macht _pref. - return 1.0 if tools else 0.6 - if role == "heavy": - return 1.0 - if role == "scout": - return 0.9 if vision else 0.7 - return 0.5 - - -def _pref(role: str, params: float, tps: float) -> float: - """0..1 — rollengerechte GRÖSSEN-/TEMPO-Präferenz. 'fast' belohnt Tempo & Kleinheit, - 'heavy' Größe (Wissen), 'hermes' moderate Größe (muss warm + ko-resident bleiben).""" - role = (role or "").lower() - if role == "fast": - speed = min(tps / 25.0, 1.0) - size_ok = 1.0 if params <= 50 else 50.0 / params - return speed * size_ok - if role == "heavy": - return min(params / 120.0, 1.0) - if role == "hermes": - return 1.0 if params <= 24 else max(0.15, 24.0 / params) # 7–24B ideal als Hirn - if role == "vision": - return 1.0 if params <= 12 else 0.7 # klein/günstig bevorzugt - if role == "coder": - return 0.5 + 0.5 * min(params / 80.0, 1.0) - if role == "scout": - return 1.0 if params <= 40 else 0.5 - return 0.5 - - -def _catalog_role_match(role: str, name: str) -> bool: - """Ist dieses Modell im kuratierten Katalog (Cookbook) genau für DIESE Rolle gelistet? - Dann ist es der prinzipien-konforme Pick → starker Bonus.""" - meta = catalog.meta_for_name(name) - return bool(meta and (meta.get("role") or "").lower() == (role or "").lower()) - - -def _reason(role: str, caps: dict, name: str, fit: dict, fits: bool, - incomplete: bool, cat_match: bool) -> str: - if incomplete: - return "Download unvollständig" - if role == "vision" and not caps.get("vision"): - return "keine Vision-Fähigkeit" - if role == "coder" and not caps.get("coder"): - return "kein Coder-Modell" - if role == "hermes" and "hermes" not in (name or "").lower() and caps.get("tools") == "no": - return "kein natives Tool-Calling" - if not fits: - return "passt nicht ins Budget (OOM)" - bits = [] - if cat_match: - bits.append("Katalog-Pick ✓") - if role == "vision": - bits.append("Vision ✓") - if role == "coder" and caps.get("coder"): - bits.append("Coder ✓") - if role == "hermes": - bits.append("Hermes" if "hermes" in (name or "").lower() - else ("Tools ✓" if caps.get("tools") != "no" else "ohne Tools")) - if caps.get("moe"): - bits.append("MoE") - bits.append(f"{fit['text']}, ~{fit['tps']:.0f} t/s") - return " · ".join(bits) - - -def recommend_for_role(role: str) -> dict: - """Rankt alle installierten Modelle für eine Rolle. Empfohlen = bester geeigneter, - passender Eintrag. Liefert pro Modell Fit/Eignung/Begründung fürs UI.""" - role = (role or "").strip().lower() - ram = _ram_gb() - out = [] - for m in llamaswap.list_models(): - caps = m.get("capabilities") or {} - params = budget.params_of_model(m) - quant = m.get("quant") or "Q4_K_M" - ctx = budget.setup_aware_ctx_for_model(m)["ctx"] - fit = evaluate_fit(params, quant, ctx, ram, name=m["name"]) - incomplete = bool(m.get("incomplete")) - fits = (fit["level"] != "too_tight") and not incomplete - tps = fit["tps"] or 0 - suit = _capability_suit(role, caps, m["name"]) - cat_match = _catalog_role_match(role, m["name"]) - suitable = suit >= 0.5 and fits - - fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0) - # Eignung dominiert (×2), rollengerechte Größe/Tempo (_pref), Katalog-Anker, dann Fit. - score = (2.0 * suit) + _pref(role, params, tps) + (0.6 if cat_match else 0.0) + fit_term - if not fits: - score -= 5.0 - - out.append({ - "name": m["name"], "current_role": m.get("role"), - "params_b": round(params, 1), "quant": quant, - "fit": fit, "suitable": suitable, "incomplete": incomplete, - "score": round(score, 3), - "reason": _reason(role, caps, m["name"], fit, fits, incomplete, cat_match), - }) - - out.sort(key=lambda x: -x["score"]) - rec = next((o["name"] for o in out if o["suitable"]), None) - for o in out: - o["recommended"] = (o["name"] == rec) - return {"role": role, "recommended": rec, "models": out} diff --git a/backend/services/router_logic.py b/backend/services/router_logic.py index 44644e2..b4c7170 100644 --- a/backend/services/router_logic.py +++ b/backend/services/router_logic.py @@ -19,8 +19,6 @@ from services.routing_policy import load_policy # (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars # sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code. -# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind. -LANES = ["coding", "chat"] LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat _HEAVY_KW = re.compile( @@ -36,18 +34,6 @@ _CODING_HEAVY_KW = re.compile( r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b", re.IGNORECASE, ) -# Code-Indikatoren — verhindert, dass echte Code-Anfragen als „trivial" auf fast abrutschen. -# Bewusst breit (inkl. natürlichsprachiger Coding-Begriffe DE+EN): in der coding-Lane soll im Zweifel -# `coder` gewinnen; nur echte Nicht-Code-Kürze ("hallo", "wie spät") rutscht auf fast. -_CODE_HINT = re.compile( - r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|" - r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|" - r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|" - r"code\w*|coden|test\w*|klasse|method\w*|methode|refactor\w*|kompil\w*|compile|" - r"build|deploy|debug|script|skript|api|endpoint|query|regex|json|yaml|" - r"npm|pip|git|docker|terminal|shell|command)\b", - re.IGNORECASE | re.MULTILINE, -) # Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet. @@ -150,6 +136,3 @@ def choose_for_lane(lane: str, body: dict) -> tuple[str, str]: return _route_chat(text, n) # chat + alles Unbekannte -def choose_model(body: dict) -> tuple[str, str]: - """Rückwärtskompatibel: altes `model:auto` == chat-Lane.""" - return choose_for_lane("chat", body) diff --git a/backend/services/routing_policy.py b/backend/services/routing_policy.py index 9437174..c5afb01 100644 --- a/backend/services/routing_policy.py +++ b/backend/services/routing_policy.py @@ -115,21 +115,3 @@ def load_policy() -> dict: return dict(_CACHE["policy"]) -def save_policy(patch: dict) -> dict: - """Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück.""" - clean = _coerce(patch) # wirft bei ungültigem Input - with _LOCK: - current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean} - POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) - tmp = POLICY_PATH.with_suffix(".json.tmp") - with open(tmp, "w", encoding="utf-8") as f: - json.dump(current, f, ensure_ascii=False, indent=2) - os.replace(tmp, POLICY_PATH) - _CACHE["mtime"] = None # nächster load_policy() lädt frisch - _CACHE["policy"] = None - return current - - -def policy_meta() -> dict: - """Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation.""" - return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS} diff --git a/backend/services/voice_metrics.py b/backend/services/voice_metrics.py deleted file mode 100644 index 76f9b72..0000000 --- a/backend/services/voice_metrics.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Per-Stage-Latenz-Metriken + Per-Turn-Trace für die Voice/Lucy-Pipeline. - -Zwei Sichten auf dieselben Messungen: - -1. **Rollende Stats** (`record_stage`/`Timer`/`get_metrics`) — avg/p50/p95/last je Stufe über die - letzten N Messungen. Gut für Trends („Wie schnell ist STT im Schnitt?"). -2. **Per-Turn-Trace** (`TurnTrace`/`get_trace`) — jeder einzelne Chat-Turn als eigener Datensatz mit - seinem Stufen-Breakdown. Nur so sieht man den EINEN langsamen Turn (der 30-s-Hänger), den ein - Durchschnitt verschluckt. Das war der eigentliche Anlass (Telegram-/Voice-Hänger diagnostizieren). - -**Was MC2 messen kann — und was nicht:** MC2 proxyt den Chat nur an Hermes (:8642). Die Stufen STT, -Vision, Hirn-TTFT (Zeit bis zum ersten Inhalts-Token) und Generierung sind hier direkt messbar. Der -**Gedächtnis-Abruf** dagegen läuft seit der Ablösung des Sidecars (07.08.2026) Hermes-intern — es -gibt keinen Rückruf an MC2 mehr, also auch keine Messung; er steckt jetzt in der Hirn-Zeit. Ebenso die -**Tool-Runden**: im Hermes-LLM-Loop ohne Callback an MC2 → unsichtbar, im „Generierung"-Bucket. - -STT läuft als eigener HTTP-Request VOR dem Turn, ohne Turn-ID. Auf einem EIN-Nutzer-Gerät genügt eine -schlanke Best-Effort-Korrelation: die zuletzt gemessene STT-Dauer wird global „geparkt" und vom -nächsten Chat-Turn eingesammelt (mit Frist-Check). Kein Turn-ID-Durchreichen durch den Lucy-Client -nötig. - -In-Memory + thread-safe (keine Datei-I/O — Latenz-Telemetrie ist transient, Restart = Reset). -""" - -import threading -import time -import uuid -from collections import deque - -_LOCK = threading.Lock() -_MAX = 200 -_STAGES: dict[str, deque] = {} -_TURNS: deque = deque(maxlen=60) # letzte N vollständige Chat-Turns (Per-Turn-Trace) - -# Bekannte Stufen (für stabile UI-Reihenfolge); unbekannte werden trotzdem erfasst. -STAGES = ("stt", "vision", "chat_ttfb", "chat_first_content", "tts") - -# Best-effort-Korrelation (Ein-Nutzer-Gerät): zuletzt gemessene STT-Dauer als -# (ms, perf_counter-Zeitstempel). Der nächste Chat-Turn sammelt sie ein und leert sie. -_PARKED: dict[str, tuple[float, float] | None] = {"stt": None} - - -def record_stage(stage: str, ms: float) -> None: - """Eine gemessene Stage-Dauer (ms) verbuchen. No-op bei negativen Werten.""" - if ms is None or ms < 0: - return - with _LOCK: - dq = _STAGES.get(stage) - if dq is None: - dq = _STAGES[stage] = deque(maxlen=_MAX) - dq.append(float(ms)) - - -def park(kind: str, ms: float) -> None: - """Eine Messung, die NICHT im Chat-Request selbst passiert (STT läuft davor), global parken, - damit der nächste Chat-Turn sie einsammeln kann.""" - if ms is None or ms < 0 or kind not in _PARKED: - return - with _LOCK: - _PARKED[kind] = (float(ms), time.perf_counter()) - - -def _take_stt(max_age: float = 20.0) -> float | None: - """Geparkte STT-Dauer einsammeln, wenn frisch (STT liegt VOR dem Turn-Start).""" - with _LOCK: - v = _PARKED.get("stt") - if v and (time.perf_counter() - v[1]) <= max_age: - _PARKED["stt"] = None - return round(v[0], 1) - return None - - -class Timer: - """Context-Manager: misst die verstrichene Zeit und verbucht sie auf `stage`. - Funktioniert um `await`-Aufrufe herum (enter → await → exit).""" - - def __init__(self, stage: str) -> None: - self.stage = stage - self._t0 = 0.0 - - def __enter__(self) -> "Timer": - self._t0 = time.perf_counter() - return self - - def __exit__(self, *exc) -> None: - record_stage(self.stage, (time.perf_counter() - self._t0) * 1000.0) - - -class TurnTrace: - """Ein Per-Turn-Trace für den Voice/Lucy-Chatpfad. In `voice.py` über die Dauer eines Chat-Turns - gehalten; `commit()` schreibt den Datensatz in den Ringpuffer UND speist die rollenden Stats. - - Balken-Stufen (zeitlich DISJUNKT, ergeben zusammen den Turn): stt · vision · hirn · gen.""" - - def __init__(self, session_id: str = "", kind: str = "voice") -> None: - self.id = uuid.uuid4().hex[:8] - self.ts = time.time() - self.perf0 = time.perf_counter() - self._brain0 = self.perf0 # Referenz für die Hirn-Zeit (nach Vision neu gesetzt) - self.session_id = (session_id or "")[:24] - self.kind = kind - self.vision_ms: float | None = None # Bildschirm-Beschreibung (falls Bilder) - self.hirn_ms: float | None = None # Zeit bis zum ersten Inhalts-Token (Agent + Gedächtnis + TTFT) - self.had_images = False - self.error: str | None = None - - def note_vision(self, ms: float) -> None: - self.vision_ms = round(ms, 1) - record_stage("vision", ms) - - def mark_brain_start(self) -> None: - """Startpunkt der Hirn-Zeit — direkt VOR dem Hermes-Request, damit die Vision-Zeit NICHT - in die Hirn-Zeit gezählt wird (sonst Doppelzählung mit dem Vision-Segment).""" - self._brain0 = time.perf_counter() - - def note_ttfb(self) -> None: - record_stage("chat_ttfb", (time.perf_counter() - self._brain0) * 1000.0) # SSE-Start (~5 ms), nur rollend - - def note_first_content(self) -> None: - """Erster Inhalts-Delta = echte Hirn-Latenz (Agent-Overhead + Gedächtnis + LLM-TTFT).""" - ms = (time.perf_counter() - self._brain0) * 1000.0 - self.hirn_ms = round(ms, 1) - record_stage("chat_first_content", ms) - - def commit(self) -> dict: - total = (time.perf_counter() - self.perf0) * 1000.0 - stt = _take_stt() # rollend bereits in /voice/stt erfasst - # Generierung = alles nach dem ersten Inhalts-Token bis Stream-Ende. - gen = round(total - self.hirn_ms, 1) if self.hirn_ms is not None else None - rec = { - "id": self.id, - "ts": round(self.ts, 3), - "session": self.session_id, - "kind": self.kind, - "had_images": self.had_images, - "stt_ms": stt, - "vision_ms": self.vision_ms, - "hirn_ms": self.hirn_ms, - "gen_ms": gen if (gen is None or gen >= 0) else 0.0, - "total_ms": round(total, 1), - "error": self.error, - } - with _LOCK: - _TURNS.append(rec) - return rec - - -def _summary(vals: list[float]) -> dict: - if not vals: - return {"count": 0} - s = sorted(vals) - n = len(s) - return { - "count": n, - "avg_ms": round(sum(s) / n, 1), - "p50_ms": round(s[n // 2], 1), - "p95_ms": round(s[min(n - 1, int(n * 0.95))], 1), - "last_ms": round(vals[-1], 1), - } - - -def get_metrics() -> dict: - """Rollende Zusammenfassung je Stufe.""" - with _LOCK: - return {stage: _summary(list(dq)) for stage, dq in _STAGES.items()} - - -def get_trace(limit: int = 20) -> list[dict]: - """Die letzten `limit` Chat-Turns (neueste zuerst) mit Stufen-Breakdown.""" - limit = max(1, min(int(limit or 20), _TURNS.maxlen or 60)) - with _LOCK: - return list(_TURNS)[-limit:][::-1] diff --git a/backend/services/waechter.py b/backend/services/waechter.py index 9188fc7..55683c6 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -480,8 +480,15 @@ def takt() -> None: continue # während eines Updates sind Neustarts normal try: befunde += pruefung() - except Exception: - log.debug("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True) + except Exception as exc: + # Bis 24.09.2026 stand das nur im Debug-Log: Die Prüfung war dann still aus, und das + # Cockpit blieb grün (z. B. wenn Hermes das Format seiner Job-Liste ändert). + log.warning("waechter: %s fehlgeschlagen", pruefung.__name__, exc_info=True) + befunde.append(Befund( + id=f"pruefung:{pruefung.__name__}", stufe="gelb", + titel="Eine Prüfung des Wächters lief nicht", + text=f"{pruefung.__name__}: {exc.__class__.__name__}: {exc}"[:240], + quelle="mc2-steward")) with _lock: hinweise: dict = _stand["hinweise"] diff --git a/backend/tests/test_herkunft.py b/backend/tests/test_herkunft.py new file mode 100644 index 0000000..7cbd7b3 --- /dev/null +++ b/backend/tests/test_herkunft.py @@ -0,0 +1,23 @@ +"""Herkunftsprüfung für Knöpfe (24.09.2026).""" + +from services.herkunft import erlaubt + +BOX = "192.168.178.151:9001" + + +def test_eigene_seite_und_skripte_duerfen(): + assert erlaubt("POST", "/api/maintenance/os-update", "http://192.168.178.151:9001", BOX) + assert erlaubt("POST", "/api/alarm", None, BOX) # Lucy-Watchdog, curl + assert erlaubt("POST", "/api/voice/chat", "null", BOX) # Desktop-Lucy (Electron) + assert erlaubt("POST", "/api/voice/stt", "file://", BOX) + assert erlaubt("POST", "/api/system/restart", "http://localhost:5173", BOX) # Vite-Entwicklung + + +def test_fremde_webseite_darf_keine_knoepfe_druecken(): + assert not erlaubt("POST", "/api/maintenance/update-all", "https://boese.example", BOX) + assert not erlaubt("DELETE", "/api/models/x", "http://192.168.178.99:8080", BOX) + + +def test_lesen_und_v1_bleiben_offen(): + assert erlaubt("GET", "/api/start", "https://boese.example", BOX) + assert erlaubt("POST", "/v1/chat/completions", "http://localhost:3000", BOX) diff --git a/backend/tests/test_radar.py b/backend/tests/test_radar.py index 7304876..7038d78 100644 --- a/backend/tests/test_radar.py +++ b/backend/tests/test_radar.py @@ -756,3 +756,22 @@ def test_immer_warme_gruppe_verdraengt_nichts(monkeypatch): assert cfg["models"]["h"]["ttl"] == 0 llamaswap.set_group("andere", ["h"], swap=True, persist=False) assert "exclusive" not in cfg["groups"]["andere"] + + +def test_hf_download_nimmt_nur_den_gewuenschten_quant(monkeypatch): + """24.09.2026: Fehlt der Quant, lädt der Download NICHTS (vorher: alle Teile aller Varianten).""" + from services import hf + + baum = [ + {"path": "Q8_0/Modell-Q8_0-00001-of-00002.gguf", "size": 50}, + {"path": "Q8_0/Modell-Q8_0-00002-of-00002.gguf", "size": 50}, + {"path": "Q4_K_M/Modell-Q4_K_M-00001-of-00002.gguf", "size": 30}, + {"path": "Q4_K_M/Modell-Q4_K_M-00002-of-00002.gguf", "size": 30}, + {"path": "mmproj-F16.gguf", "size": 5}, + ] + monkeypatch.setattr(hf, "_tree", lambda repo: baum) + leer = hf.resolve_gguf("x/y", "IQ3_XXS") + assert leer["first"] is None and leer["files"] == [] + q4 = hf.resolve_gguf("x/y", "Q4_K_M") + assert q4["files"] == ["Q4_K_M/Modell-Q4_K_M-00001-of-00002.gguf", "Q4_K_M/Modell-Q4_K_M-00002-of-00002.gguf"] + assert q4["total_bytes"] == 65 and q4["mmproj"] == "mmproj-F16.gguf" diff --git a/backend/tests/test_waechter.py b/backend/tests/test_waechter.py index b2d95fe..eafcbb6 100644 --- a/backend/tests/test_waechter.py +++ b/backend/tests/test_waechter.py @@ -120,3 +120,22 @@ def test_schlafende_dienste_sind_kein_befund(monkeypatch): assert "dienst:lucy-stimme" in ids assert waechter.schlaeft(zustaende["voice-service"]) is True assert waechter.schlaeft({"ActiveState": "failed", "UnitFileState": "disabled"}) is False + + +def test_abgestuerzte_pruefung_wird_ein_gelber_befund(monkeypatch, tmp_path): + """24.09.2026: Eine Prüfung, die abstürzt, darf nicht still verschwinden (Cockpit blieb grün).""" + def kaputt(): + raise ValueError("jobs.json hat ein neues Format") + + kaputt.__name__ = "pruefe_hermes_jobs" + monkeypatch.setattr(waechter, "PRUEFUNGEN", (kaputt,)) + monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "mc2-waechter.json") + monkeypatch.setattr(waechter, "_update_laeuft", lambda: False) + monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: None) + monkeypatch.setattr(waechter, "FAIL_AFTER", 1) + monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "stand": None, + "update_laeuft": False}) + waechter.takt() + hinweise = waechter._stand["hinweise"] + assert "pruefung:pruefe_hermes_jobs" in hinweise + assert "neues Format" in hinweise["pruefung:pruefe_hermes_jobs"]["text"] diff --git a/deploy/mission-control-2.service b/deploy/mission-control-2.service index ae33c78..56e8415 100644 --- a/deploy/mission-control-2.service +++ b/deploy/mission-control-2.service @@ -13,7 +13,7 @@ Wants=network-online.target [Service] Type=simple WorkingDirectory=%h/mission-control-v2/backend -ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 9001 +ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 9001 --timeout-graceful-shutdown 3 Environment=PYTHONPATH=%h/mission-control-v2 Environment=MC_PORT=9001 Environment=MC_LLAMA_SWAP_URL=http://127.0.0.1:8080 diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index a420b31..0000000 --- a/scripts/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Skript zur Filterung inaktiver Skills - -## Zweck - -Das Skript `filter_inactive_skills.py` identifiziert Skills aus dem Hermes Skill-Index, die **nicht** genutzt wurden und **keine** Media-Skills sind. - -## Kriterien - -Ein Skill gilt als inaktiv, wenn **mindestens eines** der folgenden Kriterien zutrifft: -- `use_count == 0` -- `last_used_at == null` (nie genutzt) - -Media-Skills (die im `~/.hermes/skills/media/`-Verzeichnis liegen) werden **ausgeschlossen**: -- gif-search -- heartmula -- songsee -- youtube-content - -## Ausgabe - -Die Ergebnisse werden als Liste sortiert nach `last_used_at` (älteste Nutzung zuerst) ausgegeben. Skills, die **nie** genutzt wurden (`last_used_at == null`), erscheinen am Ende. - -Beispiel-Ausgabe: -``` -Inaktive Skills (use_count=0 oder last_used_at=null, ohne Media-Skills): 45 - --------------------------------------------------------------------------------- - airtable use_count= 0 last_used_at=nie - apple-notes use_count= 0 last_used_at=nie - ... --------------------------------------------------------------------------------- -``` - -## Verwendung - -```bash -python3 filter_inactive_skills.py -``` - -## Dateistruktur - -``` -t_47fb5aaa/ -├── filter_inactive_skills.py # Hauptskript -└── README.md # Diese Dokumentation -``` - -## Datenquelle - -- Skill-Metadaten: `~/.hermes/skills/.usage.json` -- Media-Skills: `~/.hermes/skills/media/` - -## Historie - -- 2026-07-17: Erstellt im Rahmen von Kanban-Aufgabe t_47fb5aaa diff --git a/scripts/check_venv.sh b/scripts/check_venv.sh deleted file mode 100755 index e58cf99..0000000 --- a/scripts/check_venv.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# Prüft, ob der Python-Interpreter im MC2-Virtualenv existiert und ausfuehrbar ist. -# Gibt bei Erfolg 0 zurueck, sonst 1 mit klarer Fehlermeldung. -# -# Aufruf: ./check_venv.sh -# Ziel-Pfad: /home/hitonabi/mission-control-v2/backend/.venv/bin/python - -set -euo pipefail - -VENV_PYTHON="/home/hitonabi/mission-control-v2/backend/.venv/bin/python" - -if [[ ! -f "$VENV_PYTHON" ]]; then - echo "FEHLER: Python-Interpreter nicht gefunden: $VENV_PYTHON" >&2 - echo "Hinweis: Virtualenv fuer MC2-Gateway wurde nicht erstellt oder ist beschädigt." >&2 - exit 1 -fi - -if [[ ! -x "$VENV_PYTHON" ]]; then - echo "FEHLER: Python-Interpreter nicht ausfuehrbar: $VENV_PYTHON" >&2 - echo "Hinweis: Dateiberechtigungen pruefen." >&2 - exit 1 -fi - -# Optional: Kurze Funktionspruefung (Version ausgeben, ohne Side Effects) -if ! "$VENV_PYTHON" --version >/dev/null 2>&1; then - echo "FEHLER: Python-Interpreter ist defekt oder fehlt essentielle Pakete." >&2 - exit 1 -fi - -echo "OK: Python-Interpreter ist verfuegbar: $VENV_PYTHON ($("$VENV_PYTHON" --version 2>&1 | head -n1))" -exit 0 diff --git a/scripts/filter_inactive_skills.py b/scripts/filter_inactive_skills.py deleted file mode 100644 index eda5424..0000000 --- a/scripts/filter_inactive_skills.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -""" -Skript zur Filterung inaktiver Skills. - -Filtert Skills aus ~/.hermes/skills/.usage.json, die: -- use_count == 0 ODER last_used_at == null -- NICHT im media/ Verzeichnis liegen (Media-Skills werden ausgeschlossen) - -Ausgabe: Liste sortiert nach last_used_at (älteste zuerst), nur Skills mit use_count 0. -""" - -import json -from datetime import datetime -from pathlib import Path - - -def load_usage_data(path: Path) -> dict: - """Lädt die .usage.json-Datei.""" - with open(path, 'r', encoding='utf-8') as f: - return json.load(f) - - -def get_media_skills(skills_dir: Path) -> set: - """Ermittelt alle Media-Skills aus dem media/ Verzeichnis.""" - media_path = skills_dir / 'media' - if not media_path.is_dir(): - return set() - - media_skills = set() - for item in media_path.iterdir(): - if item.is_dir(): - media_skills.add(item.name) - return media_skills - - -def is_media_skill(skill_name: str, media_skills: set) -> bool: - """Prüft, ob ein Skill ein Media-Skill ist.""" - return skill_name in media_skills - - -def parse_datetime(dt_str: str | None) -> datetime | None: - """Parsed einen datetime-string im ISO-Format (mit timezone).""" - if dt_str is None: - return None - # Entferne den timezone-Offset für datetime.fromisoformat (Python 3.11+ supportet +00:00) - # Aber da wir nur sortieren, reicht es, den String direkt zu nutzen - try: - return datetime.fromisoformat(dt_str) - except ValueError: - return None - - -def filter_inactive_skills(usage: dict, media_skills: set) -> list: - """ - Filtert inaktive Skills. - - Kriterien: - - use_count == 0 ODER last_used_at == null - - NICHT in media_skills - - Gibt Liste zurück mit (skill_name, use_count, last_used_at) - """ - inactive = [] - - for skill_name, data in usage.items(): - if is_media_skill(skill_name, media_skills): - continue - - use_count = data.get('use_count', 0) - last_used_at = data.get('last_used_at') - - # Filter: use_count == 0 ODER last_used_at == null - if use_count == 0 or last_used_at is None: - inactive.append({ - 'name': skill_name, - 'use_count': use_count, - 'last_used_at': last_used_at - }) - - # Sortieren nach last_used_at (älteste zuerst, None am Ende) - def sort_key(item): - dt = parse_datetime(item['last_used_at']) - if dt is None: - return (1, '') # None am Ende - return (0, dt.isoformat()) - - inactive.sort(key=sort_key) - return inactive - - -def print_results(skills: list) -> None: - """Gibt die Ergebnisse lesbar aus.""" - if not skills: - print("Keine inaktiven Skills gefunden.") - return - - print(f"Inaktive Skills (use_count=0 oder last_used_at=null, ohne Media-Skills): {len(skills)}\n") - print("-" * 80) - - for skill in skills: - name = skill['name'] - use_count = skill['use_count'] - last_used = skill['last_used_at'] if skill['last_used_at'] else 'nie' - print(f" {name:30} use_count={use_count:3} last_used_at={last_used}") - - print("-" * 80) - - -def main(): - skills_dir = Path.home() / '.hermes' / 'skills' - usage_path = skills_dir / '.usage.json' - - if not usage_path.exists(): - print(f"Fehler: {usage_path} nicht gefunden.") - return - - usage = load_usage_data(usage_path) - media_skills = get_media_skills(skills_dir) - inactive_skills = filter_inactive_skills(usage, media_skills) - print_results(inactive_skills) - - -if __name__ == '__main__': - main()