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

411 lines
15 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).
WICHTIG — Gedaechtnis:
- Wenn der User persoenliche Infos teilt (Reiseplaene, Vorlieben, Gewohnheiten, Korrekturen), nutze SOFORT memory_suggest um es als Kandidat zu speichern.
- Wenn der User fragt was frueher besprochen wurde, nutze session_search.
- Du darfst KEINE Passwoerter, Tokens oder API-Keys in memory_suggest speichern."""
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": "count_errors",
"description": "Zählt Fehler-Logs aus Loki und gibt ANZAHL pro Host zurück. Nutze dieses Tool wenn nach der ANZAHL von Fehlern gefragt wird (z.B. 'wieviele Fehler', 'wie oft', 'Fehleranzahl').",
"parameters": {
"type": "object",
"properties": {
"hours": {"type": "number", "description": "Zeitraum in Stunden (z.B. 24 = heute, 72 = 3 Tage, 168 = 1 Woche)", "default": 24}
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "get_errors",
"description": "Zeigt Fehler-Logs aus Loki mit Beispielen (Inhalt der Fehlermeldungen). Nutze dieses Tool wenn nach dem INHALT oder DETAILS von Fehlern gefragt wird.",
"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": []},
},
},
{
"type": "function",
"function": {
"name": "get_mail_summary",
"description": "E-Mail Übersicht: Anzahl Mails, ungelesene, letzte Mails, wichtige Absender (Bank, Hoster, etc.)",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_mail_count",
"description": "Anzahl E-Mails (gesamt und ungelesen)",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "search_mail",
"description": "E-Mails durchsuchen nach Absender oder Betreff",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchbegriff (Absender oder Betreff, z.B. 'PayPal', 'Rechnung', 'Hetzner')"},
"days": {"type": "integer", "description": "Zeitraum in Tagen (default: 30)", "default": 30},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "get_todays_mails",
"description": "Alle E-Mails von heute",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "get_smart_mail_digest",
"description": "Intelligente Mail-Zusammenfassung: KI klassifiziert Mails in Wichtig/Aktion/Info/Newsletter/Spam. Nutze dies wenn der User nach 'wichtigen Mails' fragt oder wissen will ob etwas Relevantes dabei ist.",
"parameters": {
"type": "object",
"properties": {
"hours": {"type": "integer", "description": "Zeitraum in Stunden (default: 24)", "default": 24},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "memory_read",
"description": "Liest persistente Gedaechtnis-Eintraege (Fakten ueber User, Umgebung, Projekte). Nutze dieses Tool wenn du wissen willst was du dir gemerkt hast.",
"parameters": {
"type": "object",
"properties": {
"scope": {"type": "string", "description": "Filter: user, environment, project (leer = alle)", "default": ""},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "memory_suggest",
"description": "Schlage vor, einen neuen Fakt zu merken. Nutze dieses Tool PROAKTIV wenn der User etwas sagt das dauerhaft relevant ist (Vorlieben, Gewohnheiten, Umgebungsfakten). Der Vorschlag wird als Kandidat gespeichert.",
"parameters": {
"type": "object",
"properties": {
"scope": {"type": "string", "enum": ["user", "environment", "project"], "description": "Kategorie des Fakts"},
"kind": {"type": "string", "enum": ["fact", "preference", "rule", "note"], "description": "Art des Eintrags"},
"content": {"type": "string", "description": "Der Fakt der gemerkt werden soll (kurz, praezise)"},
},
"required": ["scope", "kind", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "session_search",
"description": "Durchsucht vergangene Gespraeche nach Stichworten. Nutze dieses Tool wenn der User fragt 'was haben wir besprochen', 'erinnerst du dich', 'letzte Woche' oder aehnlich.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchbegriffe (Woerter mit Leerzeichen getrennt)"},
},
"required": ["query"],
},
},
},
]
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, session_id: str = None) -> str:
"""Freitext-Frage mit automatischem Tool-Calling.
tool_handlers: dict von tool_name -> callable(**kwargs) -> str
session_id: aktive Session fuer Konversations-History
"""
api_key = _get_api_key()
if not api_key:
return "OpenRouter API Key fehlt in homelab.conf"
try:
import memory_client
memory_items = memory_client.get_active_memory()
memory_block = memory_client.format_memory_for_prompt(memory_items)
except Exception:
memory_block = ""
messages = [
{"role": "system", "content": SYSTEM_PROMPT + memory_block},
]
if session_id:
try:
import memory_client
history = memory_client.get_session_messages(session_id, limit=10)
for msg in history:
if msg.get("role") in ("user", "assistant") and msg.get("content"):
messages.append({"role": msg["role"], "content": msg["content"]})
except Exception:
pass
if not any(m.get("content") == question for m in messages):
messages.append({"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}"