diff --git a/backend/app.py b/backend/app.py index 412a830..1930f21 100644 --- a/backend/app.py +++ b/backend/app.py @@ -34,12 +34,13 @@ from routers import ( models, routing, system, + sysadmin, voice, wissen, zeitmaschine, ) from routers import reminders as reminders_router -from services import ketten_digest, metrics_history, reminders, sentry, warmer +from services import ketten_digest, metrics_history, reminders, sentry, warmer, sysadmin_report from starlette.requests import Request # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration @@ -65,6 +66,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: if sentry.ENABLED: tasks.append(asyncio.create_task(sentry.sentry_loop())) tasks.append(asyncio.create_task(reminders.reminders_loop())) + # Einmal am Tag (08:00) Telegram Report als Sysadmin + tasks.append(asyncio.create_task(sysadmin_report.report_loop())) if ketten_digest.ENABLED: # Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls. tasks.append(asyncio.create_task(ketten_digest.digest_loop())) @@ -109,6 +112,7 @@ app.include_router(health.router) app.include_router(models.router) app.include_router(routing.router) app.include_router(system.router) +app.include_router(sysadmin.router) app.include_router(connect.router) app.include_router(agent.router) app.include_router( diff --git a/backend/routers/sysadmin.py b/backend/routers/sysadmin.py new file mode 100644 index 0000000..39c5cb3 --- /dev/null +++ b/backend/routers/sysadmin.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +from services import sysadmin_report + +router = APIRouter(prefix="/api") + +@router.post("/sysadmin/report/trigger") +async def trigger_sysadmin_report() -> dict: + """Stößt den Sysadmin-Telegram-Report sofort manuell an.""" + success = await sysadmin_report.generate_and_send_report() + return {"ok": success, "message": "Report wurde angestoßen und via Telegram versendet." if success else "Fehler beim Versenden des Reports."} diff --git a/backend/services/sysadmin_report.py b/backend/services/sysadmin_report.py new file mode 100644 index 0000000..8720b1a --- /dev/null +++ b/backend/services/sysadmin_report.py @@ -0,0 +1,126 @@ +import asyncio +import json +import logging +import psutil +import time +from datetime import datetime, timezone +import httpx + +from services import maintenance, announce, llamaswap +from config import V1_UPSTREAM + +log = logging.getLogger(__name__) + +# Wenn aktiv, wird der Report um die angegebene Stunde (0-23) lokaler Zeit verschickt +SCHEDULE_HOUR = 8 + +async def generate_and_send_report() -> bool: + """Sammelt System-Status, fragt das lokale LLM nach einer Zusammenfassung als Lucy und sendet via Telegram.""" + log.info("Sysadmin-Report gestartet...") + + # 1. Daten sammeln + ram = psutil.virtual_memory() + ram_gb_total = round(ram.total / (1024**3), 1) + ram_gb_used = round(ram.used / (1024**3), 1) + cpu_percent = psutil.cpu_percent(interval=1) + disk = psutil.disk_usage('/') + disk_gb_free = round(disk.free / (1024**3), 1) + + try: + upd = maintenance.updates() + except Exception: + upd = {"os": 0, "engine": False, "swap": False, "hermes": False} + + running_models = llamaswap.get_running_models() + + events = announce.list_recent(limit=15) + event_lines = [] + for e in events: + ts_str = datetime.fromtimestamp(e["ts"]).strftime("%d.%m. %H:%M") + event_lines.append(f"- [{ts_str}] {e.get('subject', '')}: {e.get('text', '')}") + + events_str = "\n".join(event_lines) if event_lines else "Keine besonderen Vorkommnisse." + + evidence = f""" +Hardware: RAM {ram_gb_used}/{ram_gb_total} GB ({ram.percent}%), CPU {cpu_percent}%, Speicher {disk_gb_free} GB frei. +Laufende Modelle: {', '.join(running_models) if running_models else 'Keine'} +Updates anstehend: OS: {upd.get('os', 0)}, Engine: {upd.get('engine', False)}, Swap: {upd.get('swap', False)}, Hermes: {upd.get('hermes', False)} + +Kürzliche Ereignisse (Chronik): +{events_str} +""" + + prompt = f"""Du bist Lucy, die freche und fähige KI-Sysadmin der Mission-Control-Box. +Dein Boss (der Commander) möchte einen täglichen Statusbericht. Fasse die folgenden rohen Systemdaten zu einem gehaltvollen, aber knackigen Morgen-Digest zusammen. + +Beachte dabei: +- Nenne konkrete Fakten (RAM, laufende Modelle, Updates). +- Erwähne die wichtigsten Vorkommnisse aus der Chronik. +- Strukturiere die Nachricht übersichtlich (z.B. mit kleinen Emojis oder Bulletpoints), aber bleibe in deiner Rolle als Lucy. Keine langen Romane. +- Antworte direkt mit dem Bericht. Keine Einleitung wie "Hier ist dein Bericht". + +Rohe Systemdaten: +{evidence} +""" + + endpoint = "http://127.0.0.1:8080/v1/chat/completions" # Direktes Engine-Fallback + # Da das Gateway (Router) auf :9010 lauscht, können wir auch das nutzen, + # aber Port 8080 (llama-swap engine) ist der direkteste Weg für den internen LLM-Call. + model = "gpt-oss-120b" # Fallback, falls auto nicht geht, llama-swap routet das meist passend + + req_body = { + "model": "auto", + "messages": [ + {"role": "system", "content": "Du bist Lucy, KI-Sysadmin der Box."}, + {"role": "user", "content": prompt} + ], + "max_tokens": 1200, + "temperature": 0.4 + } + + digest = "" + try: + async with httpx.AsyncClient(timeout=180.0) as client: + resp = await client.post(endpoint, json=req_body) + resp.raise_for_status() + data = resp.json() + digest = data["choices"][0]["message"].get("content", "").strip() + except Exception as e: + log.error(f"Fehler beim LLM-Aufruf für Sysadmin-Report: {e}") + # Fallback, falls LLM nicht erreichbar ist + digest = f"🤖 [LLM offline] Hier sind die rohen Daten:\n{evidence}" + + if digest: + announce.notify_telegram("[🌅 Morgen-Digest]", digest) + log.info("Sysadmin-Report via Telegram versendet.") + return True + return False + + +async def report_loop() -> None: + """Täglicher Background-Task für den Sysadmin-Report um SCHEDULE_HOUR Uhr.""" + log.info("Sysadmin-Report Background-Loop gestartet.") + while True: + try: + now = datetime.now() + # Finde die Zeit bis zum nächsten SCHEDULE_HOUR:00 + target_hour = SCHEDULE_HOUR + if now.hour >= target_hour: + # Nächster Tag + seconds_until = ((24 - now.hour - 1) * 3600) + ((60 - now.minute - 1) * 60) + (60 - now.second) + (target_hour * 3600) + else: + # Selber Tag + seconds_until = ((target_hour - now.hour - 1) * 3600) + ((60 - now.minute - 1) * 60) + (60 - now.second) + + log.info(f"Sysadmin-Report: Warte {seconds_until} Sekunden bis zum nächsten Report.") + await asyncio.sleep(seconds_until) + + await generate_and_send_report() + + # Warte kurz, um nicht sofort wieder zu triggern, falls es extrem schnell ging + await asyncio.sleep(60) + except asyncio.CancelledError: + break + except Exception as e: + log.error(f"Unerwarteter Fehler im Sysadmin-Report-Loop: {e}", exc_info=True) + await asyncio.sleep(300)