"""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. Dabei IMMER memory_type angeben: TEMPORARY (memory_type="temporary") — hat ein Ablaufdatum: - Reiseplaene ("fliege nach...", "bin naechste Woche in...") - Termine ("morgen 14 Uhr Zahnarzt", "am Freitag Meeting") - Kurzfristige Aufenthalte ("bin bis Mittwoch in Berlin") - Einmalige Vorhaben ("will diese Woche den Server migrieren") Bei temporary: expires_at mit dem relevanten Zeitausdruck angeben (z.B. "naechste Woche", "morgen", "am Freitag"). PERMANENT (memory_type="permanent") — bleibt dauerhaft: - Persoenliche Praeferenzen ("ich bevorzuge...", "nenn mich...") - Rollen/Beziehungen ("Ali ist mein Ansprechpartner fuer...") - Stabile Infrastruktur-Fakten ("neuer Server heisst...", "IP geaendert auf...") - Projektstatus ("Jarvis ist jetzt aktiv") - Wiederkehrende Regeln ("API-Kosten monatlich beobachten") Im Zweifel: lieber temporary als permanent. 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" - 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 neuen Fakt als Kandidat. IMMER aufrufen wenn der User Reiseplaene, zeitliche Vorhaben, Projektstatus, Vorlieben oder stabile Fakten mitteilt. IMMER memory_type angeben: 'temporary' fuer zeitlich begrenzte Dinge (Reisen, Termine, einmalige Vorhaben), 'permanent' fuer stabile Fakten (Praeferenzen, Rollen, Regeln, Infrastruktur). Im Zweifel temporary.", "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')"}, "memory_type": {"type": "string", "enum": ["temporary", "permanent"], "description": "temporary=zeitlich begrenzt (Reisen, Termine), permanent=dauerhaft (Praeferenzen, Rollen, Regeln)"}, "expires_at": {"type": "string", "description": "Nur bei temporary: Zeitangabe wann es ablaeuft (z.B. 'naechste Woche', 'morgen', 'am Freitag', '22.03.2026'). Leer lassen wenn unklar."}, }, "required": ["scope", "kind", "content", "memory_type"], }, }, }, { "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}, ] _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}"