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

441 lines
16 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 fuer ein Homelab. Deutsch, kurz, direkt, operativ.
STIL:
- So wenig Worte wie moeglich, solange nichts Wichtiges fehlt.
- KEINE Abschlussformeln ("Wenn du weitere Informationen benoetigst...").
- KEINE kuenstlichen Wuensche ("Guten Flug!", "Viel Erfolg!").
- KEINE Rueckfragen ob der User mehr wissen will.
- Emojis nur wenn sie Information tragen. Telegram-Format (kein Markdown).
GEDAECHTNIS — memory_suggest:
Du MUSST memory_suggest aufrufen wenn der User etwas sagt das spaeter nuetzlich ist:
- Reiseplaene ("fliege nach...", "bin naechste Woche in...")
- Zeitliche Plaene ("Montag mache ich...", "ab Mai...")
- Neue stabile Fakten ("mein neuer Server...", "IP hat sich geaendert...")
- Projektstatus ("Jarvis ist jetzt aktiv", "Flugscanner laeuft wieder")
- Vorlieben/Korrekturen ("nenn mich...", "ich bevorzuge...")
Nach dem Aufruf sagst du kurz: "Notiert." — kein langes Erklaeren.
NICHT speichern: Passwoerter, Tokens, Smalltalk, Hoeflichkeiten, reine Fragen.
SESSION-RUECKBLICK:
- "Was haben wir besprochen?" → session_summary OHNE topic
- "Was haben wir ueber X besprochen?" → session_summary MIT topic="X"
- Bei thematischer Filterung: Zuerst die passenden Punkte, dann kurz erwaehnen was sonst noch Thema war.
- session_search nur fuer Stichwort-Suche in ALTEN Sessions (nicht aktuelle).
TOOLS:
Nutze Tools fuer Live-Daten. Wenn alles OK: kurz sagen. Bei Problemen: erklaeren + Loesung."""
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": "Speichert einen neuen Fakt als Kandidat. IMMER aufrufen wenn der User Reiseplaene, zeitliche Vorhaben, Projektstatus, Vorlieben oder stabile Fakten mitteilt. Beispiele: 'Ich fliege nach X', 'Ab Mai nutze ich Y', 'Mein neuer Server heisst Z'. NICHT fuer Smalltalk, Fragen oder Passwoerter.",
"parameters": {
"type": "object",
"properties": {
"scope": {"type": "string", "enum": ["user", "environment", "project"], "description": "user=persoenlich, environment=Infrastruktur, project=Projekt"},
"kind": {"type": "string", "enum": ["fact", "preference", "rule", "note"], "description": "fact=Tatsache, preference=Vorliebe, note=Notiz"},
"content": {"type": "string", "description": "Der Fakt (kurz, 3. Person, z.B. 'Fliegt naechste Woche nach Frankfurt')"},
},
"required": ["scope", "kind", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "session_search",
"description": "Volltextsuche in vergangenen Sessions nach konkreten Stichworten. Fuer gezielte Suche wie 'Was habe ich ueber Backup gesagt?' oder 'Wann war das mit Seafile?'.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchbegriffe"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "session_summary",
"description": "Zusammenfassung der aktuellen Session. Ohne topic = alle Themen. Mit topic = nur thematisch passende Punkte. Nutze topic wenn die Frage ein klares Thema enthaelt (z.B. 'Container', 'Jarvis', 'Backup').",
"parameters": {
"type": "object",
"properties": {
"topic": {"type": "string", "description": "Themenbegriff zum Filtern (z.B. 'Container', 'Backup'). Leer lassen fuer allgemeine Zusammenfassung."},
},
"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, 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
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}"