From a217eab970381be1149fbe5626f168ba08a3390b Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 14:32:56 +0700 Subject: [PATCH] Tool-Calling: LLM entscheidet selbst welche Datenquellen abgefragt werden --- homelab-ai-bot/context.py | 95 ++++++++-------- homelab-ai-bot/llm.py | 202 +++++++++++++++++++++++++++++++-- homelab-ai-bot/telegram_bot.py | 4 +- 3 files changed, 237 insertions(+), 64 deletions(-) diff --git a/homelab-ai-bot/context.py b/homelab-ai-bot/context.py index cc5489f5..df9dd17f 100644 --- a/homelab-ai-bot/context.py +++ b/homelab-ai-bot/context.py @@ -130,58 +130,53 @@ def gather_silence() -> str: return "\n".join(lines) -def gather_context_for_question(question: str) -> str: - """Sammelt relevanten Kontext für eine Freitext-Frage.""" - q = question.lower() - parts = [] +def _tool_get_server_metrics(host: str = None) -> str: + if host: + return prometheus_client.format_host_detail(host) + return prometheus_client.format_overview() + + +def _tool_get_server_warnings() -> str: + warnings = prometheus_client.get_warnings() + return "\n".join(warnings) if warnings else "Keine Warnungen — alle Werte normal." + + +def _tool_get_wordpress_stats() -> str: cfg = _load_config() + wordpress_client.init(cfg) + return wordpress_client.format_overview(cfg) - if any(w in q for w in ["fehler", "error", "problem", "kaputt", "down"]): - parts.append("=== Aktuelle Fehler ===\n" + gather_errors(hours=2)) - if any(w in q for w in ["status", "läuft", "container", "übersicht", "alles"]): - parts.append("=== Container Status ===\n" + gather_status()) +def _tool_get_feed_stats() -> str: + cfg = _load_config() + ct_109 = config.get_container(cfg, vmid=109) + if not ct_109 or not ct_109.tailscale_ip: + return "RSS Manager nicht erreichbar." + import requests as _req + try: + r = _req.get(f"http://{ct_109.tailscale_ip}:8080/api/feed-stats", timeout=10) + if not r.ok: + return "RSS Manager API Fehler." + stats = r.json() + lines = [f"Artikel heute: {stats['today']}, gestern: {stats['yesterday']}"] + for f in stats.get("feeds", []): + if f["posts_today"] > 0: + lines.append(f" {f['name']}: {f['posts_today']} heute") + return "\n".join(lines) + except Exception as e: + return f"RSS Manager Fehler: {e}" - if any(w in q for w in ["still", "silence", "stumm", "logs"]): - parts.append("=== Stille Hosts ===\n" + gather_silence()) - # WordPress-Daten für Blog-Fragen - if any(w in q for w in ["wordpress", "blog", "post", "artikel", "kommentar", "plugin"]): - wordpress_client.init(cfg) - wp_overview = wordpress_client.format_overview(cfg) - parts.append("=== WordPress ===\n" + wp_overview) - - # Prometheus-Metriken für System-Fragen - if any(w in q for w in ["cpu", "ram", "speicher", "memory", "disk", "platte", - "festplatte", "auslastung", "load", "uptime", "server", - "metriken", "prometheus", "performance", "ressource"]): - host_match = None - for name in ["pve-hetzner", "pve-ka-1", "pve-ka-2", "pve-ka-3", - "pve-mu-2", "pve-mu-3", "pve-he"]: - if name.replace("-", "") in q.replace("-", "").replace(" ", ""): - host_match = name - break - if host_match: - parts.append(f"=== Prometheus {host_match} ===\n" + - prometheus_client.format_host_detail(host_match)) - else: - parts.append("=== Prometheus Übersicht ===\n" + - prometheus_client.format_overview()) - - ct_match = re.search(r'\bct[- ]?(\d{3})\b', q) - if ct_match: - parts.append(f"=== CT {ct_match.group(1)} ===\n" + gather_container_status(ct_match.group(1))) - - for name in ["wordpress", "rss", "seafile", "forgejo", "portainer", - "fuenfvoracht", "redax", "flugscanner", "edelmetall"]: - if name in q: - parts.append(f"=== {name} ===\n" + gather_container_status(name)) - - if not parts: - parts.append("=== Container Status ===\n" + gather_status()) - parts.append("=== Aktuelle Fehler ===\n" + gather_errors(hours=1)) - warnings = prometheus_client.get_warnings() - if warnings: - parts.append("=== Prometheus Warnungen ===\n" + "\n".join(warnings)) - - return "\n\n".join(parts) +def get_tool_handlers() -> dict: + """Registry: Tool-Name -> Handler-Funktion. Wird von llm.ask_with_tools() genutzt.""" + return { + "get_all_containers": lambda: gather_status(), + "get_container_detail": lambda query: gather_container_status(query), + "get_errors": lambda hours=2: gather_errors(hours=hours), + "get_container_logs": lambda container, hours=1: gather_logs(container, hours=hours), + "get_silent_hosts": lambda: gather_silence(), + "get_server_metrics": lambda host=None: _tool_get_server_metrics(host), + "get_server_warnings": lambda: _tool_get_server_warnings(), + "get_wordpress_stats": lambda: _tool_get_wordpress_stats(), + "get_feed_stats": lambda: _tool_get_feed_stats(), + } diff --git a/homelab-ai-bot/llm.py b/homelab-ai-bot/llm.py index 89066456..2579c4e7 100644 --- a/homelab-ai-bot/llm.py +++ b/homelab-ai-bot/llm.py @@ -1,5 +1,10 @@ -"""OpenRouter LLM-Wrapper für natürliche Antworten.""" +"""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 @@ -8,20 +13,145 @@ 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 bekommst Live-Daten aus Loki (Logs), Proxmox (Container-Status) und homelab.conf. +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": []}, + }, + }, +] + 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=30, + ) + r.raise_for_status() + return r.json() + + def ask(question: str, context: str) -> str: - """Stellt eine Frage mit Kontext an OpenRouter.""" + """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" @@ -30,15 +160,63 @@ def ask(question: str, context: str) -> str: {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"}, ] - try: - r = requests.post( - "https://openrouter.ai/api/v1/chat/completions", - headers={"Authorization": f"Bearer {api_key}"}, - json={"model": MODEL, "messages": messages, "max_tokens": 500}, - timeout=30, - ) - r.raise_for_status() - return r.json()["choices"][0]["message"]["content"] + 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}" diff --git a/homelab-ai-bot/telegram_bot.py b/homelab-ai-bot/telegram_bot.py index ab80711e..95f491d4 100644 --- a/homelab-ai-bot/telegram_bot.py +++ b/homelab-ai-bot/telegram_bot.py @@ -277,8 +277,8 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("🤔 Denke nach...") try: - data = context.gather_context_for_question(text) - answer = llm.ask(text, data) + handlers = context.get_tool_handlers() + answer = llm.ask_with_tools(text, handlers) await update.message.reply_text(answer[:4000], reply_markup=KEYBOARD) except Exception as e: log.exception("Fehler bei Freitext")