feat: implement daily Telegram sysadmin report
Ampel / ampel (push) Failing after 22s

This commit is contained in:
Hitonabi
2026-08-07 11:12:06 +02:00
parent e814122c73
commit 45f799c57d
3 changed files with 141 additions and 1 deletions
+5 -1
View File
@@ -34,12 +34,13 @@ from routers import (
models, models,
routing, routing,
system, system,
sysadmin,
voice, voice,
wissen, wissen,
zeitmaschine, zeitmaschine,
) )
from routers import reminders as reminders_router 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 from starlette.requests import Request
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
@@ -65,6 +66,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
if sentry.ENABLED: if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_loop())) tasks.append(asyncio.create_task(sentry.sentry_loop()))
tasks.append(asyncio.create_task(reminders.reminders_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: if ketten_digest.ENABLED:
# Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls. # Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls.
tasks.append(asyncio.create_task(ketten_digest.digest_loop())) 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(models.router)
app.include_router(routing.router) app.include_router(routing.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(sysadmin.router)
app.include_router(connect.router) app.include_router(connect.router)
app.include_router(agent.router) app.include_router(agent.router)
app.include_router( app.include_router(
+10
View File
@@ -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."}
+126
View File
@@ -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)