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

565 lines
22 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 LESEN:
Am Ende dieses System-Prompts findest du eine Sektion "=== GEDAECHTNIS ===" mit Fakten die du dir ueber den User und die Umgebung gemerkt hast.
WICHTIG: Beantworte Fragen IMMER anhand dieser Fakten! Wenn der User z.B. fragt "Wo fliege ich hin?" und im Gedaechtnis steht "Ist naechste Woche in Frankfurt", dann antworte direkt mit dieser Info.
Das Gedaechtnis ist deine primaere Wissensquelle ueber den User.
GEDAECHTNIS — memory_suggest:
Du MUSST memory_suggest aufrufen wenn der User etwas Nuetzliches sagt.
memory_suggest speichert DIREKT. Duplikate werden automatisch erkannt. Widersprueche werden automatisch aufgeloest (altes Item wird ersetzt).
MEMORY_TYPE — immer angeben:
- "preference" → Vorlieben ("Lieblingskaffee ist Flat White", "bevorzuge dunkles Theme")
- "relationship" → Beziehungen/Rollen ("Ali ist Ansprechpartner fuer Wohnung")
- "fact" → Stabile Fakten ("Server IP geaendert", "Jarvis aktiv seit...")
- "plan" → Konkrete Vorhaben mit Zeitbezug ("Reise nach Frankfurt am 18.03.")
- "temporary" → Kurzfristige Zustaende ("bin bis Mittwoch in Berlin", "morgen Zahnarzt")
- "uncertain" → Vage Aussagen ("vielleicht fliege ich frueher", "glaube Ali kuemmert sich")
CONFIDENCE — immer angeben:
- "high" → Klare Aussage ("Mein Lieblingskaffee ist Flat White")
- "medium" → Wahrscheinlich aber nicht 100% ("Ali kuemmert sich wohl darum")
- "low" → Sehr vage ("vielleicht aendere ich das noch")
REGELN:
- Bei plan/temporary: IMMER expires_at mit Zeitausdruck angeben ("naechste Woche", "morgen")
- Bei uncertain mit low confidence: NUR speichern wenn es trotzdem nuetzlich sein koennte
- Vages Gerede, Smalltalk, emotionale Kurzaeusserungen: NICHT speichern
- Passwoerter, Tokens: NIE speichern
- Bei Widerspruch zu bestehendem Wissen: NUR den neuen Fakt speichern (z.B. "Lieblingskaffee ist Americano"). NICHT den alten Fakt umformulieren oder als "war..." speichern. Das System erkennt Widersprueche automatisch und ersetzt den alten Eintrag.
SESSION-RUECKBLICK:
- "Was haben wir besprochen?" → session_summary OHNE topic
- "Was haben wir ueber X besprochen?" → session_summary MIT topic="X"
- NUR echte Gespraechsinhalte aus den Treffern wiedergeben.
- KEINE Negativ-Aussagen hinzufuegen. Nichts ueber Dinge sagen die NICHT besprochen wurden.
Verboten: "wurde nicht thematisiert", "keine weiteren Details", "nicht besprochen", "nicht erwaehnt".
- Wenn es nur 1 Treffer gibt, gib nur diesen 1 Treffer wieder. Fuege nichts hinzu.
- Optional 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 Fakt direkt im Langzeitgedaechtnis. IMMER aufrufen wenn der User Reiseplaene, Termine, Vorlieben, Beziehungen, Projektstatus oder stabile Fakten mitteilt. System erkennt Duplikate und ersetzt Widersprueche automatisch.",
"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": "Grobe Kategorie"},
"content": {"type": "string", "description": "Der Fakt (kurz, 3. Person, z.B. 'Lieblingskaffee ist Flat White')"},
"memory_type": {"type": "string", "enum": ["fact", "preference", "relationship", "plan", "temporary", "uncertain"], "description": "fact=stabiler Fakt, preference=Vorliebe, relationship=Beziehung/Rolle, plan=Vorhaben mit Zeitbezug, temporary=kurzfristiger Zustand, uncertain=vage Aussage"},
"confidence": {"type": "string", "enum": ["high", "medium", "low"], "description": "high=klare Aussage, medium=wahrscheinlich, low=vage"},
"expires_at": {"type": "string", "description": "Bei plan/temporary: Zeitangabe wann es ablaeuft (z.B. 'naechste Woche', 'morgen', '22.03.2026'). Leer bei dauerhaften Fakten."},
},
"required": ["scope", "kind", "content", "memory_type", "confidence"],
},
},
},
{
"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_relevant_memory(question, top_k=10)
memory_block = memory_client.format_memory_for_prompt(memory_items)
except Exception:
memory_block = ""
messages = [
{"role": "system", "content": SYSTEM_PROMPT + memory_block},
]
_RECAP_MARKERS = ["was haben wir", "worüber haben wir", "worüber hatten wir",
"worueber haben wir", "was hatten wir besprochen", "was war heute thema"]
def _is_recap(text):
t = text.lower()
return any(m in t for m in _RECAP_MARKERS)
if session_id:
try:
import memory_client
history = memory_client.get_session_messages(session_id, limit=10)
skip_next_assistant = False
for msg in history:
role = msg.get("role", "")
content = msg.get("content", "")
if not content:
continue
if role == "user" and _is_recap(content):
skip_next_assistant = True
continue
if role == "assistant" and skip_next_assistant:
skip_next_assistant = False
continue
skip_next_assistant = False
if role in ("user", "assistant"):
messages.append({"role": role, "content": 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}"
def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session_id: str = None) -> str:
"""Bild-Analyse mit optionalem Text und Tool-Calling via Vision-faehigem Modell."""
api_key = _get_api_key()
if not api_key:
return "OpenRouter API Key fehlt in homelab.conf"
try:
import memory_client
query = caption if caption else "Bild-Analyse"
memory_items = memory_client.get_relevant_memory(query, top_k=10)
memory_block = memory_client.format_memory_for_prompt(memory_items)
except Exception:
memory_block = ""
prompt_text = caption if caption else "Was siehst du auf diesem Bild? Beschreibe was du erkennst."
user_content = [
{"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}},
]
messages = [
{"role": "system", "content": SYSTEM_PROMPT + memory_block},
]
if session_id:
try:
import memory_client
history = memory_client.get_session_messages(session_id, limit=6)
for msg in history:
role = msg.get("role", "")
content = msg.get("content", "")
if content and role in ("user", "assistant"):
messages.append({"role": role, "content": content})
except Exception:
pass
messages.append({"role": "user", "content": user_content})
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}"