homelab-brain/homelab-ai-bot/llm.py

275 lines
9.2 KiB
Python

"""OpenRouter LLM-Wrapper mit Tool-Calling.
Das LLM entscheidet selbst welche Datenquellen es abfragt.
Neue Datenquelle = Tool-Definition hier + Handler in context.py.
"""
import json
import requests
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from core import config
MODEL = "openai/gpt-4o-mini"
MAX_TOOL_ROUNDS = 3
SYSTEM_PROMPT = """Du bist der Hausmeister-Bot für ein Homelab mit mehreren Proxmox-Servern.
Du antwortest kurz, präzise und auf Deutsch.
Du hast Tools um Live-Daten abzufragen. Nutze sie um Fragen zu beantworten.
Wenn alles in Ordnung ist, sag das kurz. Bei Problemen erkläre was los ist und schlage Lösungen vor.
Nutze Emojis sparsam. Formatiere für Telegram (kein Markdown, nur einfacher Text)."""
TOOLS = [
{
"type": "function",
"function": {
"name": "get_all_containers",
"description": "Status aller Container auf allen Proxmox-Servern (running/stopped, RAM, Uptime)",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_container_detail",
"description": "Detail-Status eines einzelnen Containers. Suche per VMID (z.B. 101) oder Name (z.B. wordpress, rss-manager, forgejo)",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "VMID (z.B. '109') oder Container-Name (z.B. 'wordpress')"}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "get_errors",
"description": "Aktuelle Fehler-Logs aus Loki (alle Container)",
"parameters": {
"type": "object",
"properties": {
"hours": {"type": "number", "description": "Zeitraum in Stunden (default: 2)", "default": 2}
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_container_logs",
"description": "Letzte Logs eines bestimmten Containers aus Loki",
"parameters": {
"type": "object",
"properties": {
"container": {"type": "string", "description": "Hostname des Containers (z.B. 'rss-manager', 'wordpress-v2')"},
"hours": {"type": "number", "description": "Zeitraum in Stunden (default: 1)", "default": 1},
},
"required": ["container"],
},
},
},
{
"type": "function",
"function": {
"name": "get_silent_hosts",
"description": "Welche Hosts senden keine Logs mehr? (Stille-Check)",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_server_metrics",
"description": "CPU, RAM, Disk, Load, Uptime von Proxmox-Servern via Prometheus. Ohne host = alle Server.",
"parameters": {
"type": "object",
"properties": {
"host": {
"type": "string",
"description": "Hostname (pve-hetzner, pve-ka-1, pve-ka-2, pve-ka-3, pve-mu-2, pve-mu-3, pve-he, pbs-mu). Leer = alle.",
}
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_server_warnings",
"description": "Nur Warnungen: Server mit CPU>80%, RAM>85% oder Disk>85%",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_wordpress_stats",
"description": "WordPress/Blog-Statistiken: Posts heute/gestern/Woche, offene Kommentare, letzte Artikel, Plugin-Status",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_feed_stats",
"description": "RSS-Feed-Status: Aktive Feeds, Artikel heute/gestern, Fehler",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_forgejo_status",
"description": "Forgejo Git-Server: Offene Issues/TODOs, letzte Commits, Repos",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "create_issue",
"description": "Neues TODO/Issue in Forgejo erstellen",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Titel des Issues"},
"body": {"type": "string", "description": "Beschreibung (optional)", "default": ""},
},
"required": ["title"],
},
},
},
{
"type": "function",
"function": {
"name": "close_issue",
"description": "Ein bestehendes Issue/TODO in Forgejo schliessen (als erledigt markieren)",
"parameters": {
"type": "object",
"properties": {
"number": {"type": "integer", "description": "Issue-Nummer (z.B. 3)"},
},
"required": ["number"],
},
},
},
{
"type": "function",
"function": {
"name": "get_seafile_status",
"description": "Seafile Cloud-Speicher: Belegter Speicherplatz, Bibliotheken",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_backup_status",
"description": "Proxmox Backup Server (PBS): Datastore-Belegung, letzte Backups, Snapshot-Anzahl",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
]
def _get_api_key() -> str:
cfg = config.parse_config()
return cfg.api_keys.get("openrouter_key", "")
def _call_openrouter(messages: list, api_key: str, use_tools: bool = True) -> dict:
payload = {
"model": MODEL,
"messages": messages,
"max_tokens": 600,
}
if use_tools:
payload["tools"] = TOOLS
payload["tool_choice"] = "auto"
r = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json=payload,
timeout=60,
)
r.raise_for_status()
return r.json()
def ask(question: str, context: str) -> str:
"""Legacy-Funktion fuer /commands die bereits Kontext mitbringen."""
api_key = _get_api_key()
if not api_key:
return "OpenRouter API Key fehlt in homelab.conf"
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"},
]
try:
data = _call_openrouter(messages, api_key, use_tools=False)
return data["choices"][0]["message"]["content"]
except Exception as e:
return f"LLM-Fehler: {e}"
def ask_with_tools(question: str, tool_handlers: dict) -> str:
"""Freitext-Frage mit automatischem Tool-Calling.
tool_handlers: dict von tool_name -> callable(**kwargs) -> str
"""
api_key = _get_api_key()
if not api_key:
return "OpenRouter API Key fehlt in homelab.conf"
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question},
]
try:
for _round in range(MAX_TOOL_ROUNDS):
data = _call_openrouter(messages, api_key, use_tools=True)
choice = data["choices"][0]
msg = choice["message"]
tool_calls = msg.get("tool_calls")
if not tool_calls:
return msg.get("content", "Keine Antwort vom LLM.")
messages.append(msg)
for tc in tool_calls:
fn_name = tc["function"]["name"]
try:
fn_args = json.loads(tc["function"]["arguments"])
except (json.JSONDecodeError, KeyError):
fn_args = {}
handler = tool_handlers.get(fn_name)
if handler:
try:
result = handler(**fn_args)
except Exception as e:
result = f"Fehler bei {fn_name}: {e}"
else:
result = f"Unbekanntes Tool: {fn_name}"
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": str(result)[:3000],
})
data = _call_openrouter(messages, api_key, use_tools=False)
return data["choices"][0]["message"]["content"]
except Exception as e:
return f"LLM-Fehler: {e}"