diff --git a/homelab-ai-bot/context.py b/homelab-ai-bot/context.py index 21fb7920..04de66af 100644 --- a/homelab-ai-bot/context.py +++ b/homelab-ai-bot/context.py @@ -1,143 +1,53 @@ -"""Intelligente Kontext-Sammlung für den Hausmeister-Bot. -Entscheidet anhand der Frage welche Datenquellen abgefragt werden.""" +"""Intelligente Kontext-Sammlung fuer den Hausmeister-Bot. + +Tool-Handler werden automatisch aus tools/*.py geladen. +Hier bleiben nur Hilfsfunktionen fuer /commands und Backward-Compat. +""" import sys import os -import re sys.path.insert(0, os.path.dirname(__file__)) -from core import config, loki_client, proxmox_client, wordpress_client, prometheus_client -from core import forgejo_client, seafile_client, pbs_client, mail_client, matomo_client +import tool_loader +from tools.memory import set_source_type, last_suggest_result # noqa: F401 -def _load_config(): - return config.parse_config() +def get_tool_handlers(session_id: str = None) -> dict: + """Registry: Tool-Name -> Handler. Wird von llm.ask_with_tools() genutzt.""" + return tool_loader.get_handlers(session_id=session_id) -def _get_tokens(cfg): - tokens = {} - tn = cfg.raw.get("PVE_TOKEN_HETZNER_NAME", "") - tv = cfg.raw.get("PVE_TOKEN_HETZNER_VALUE", "") - if tn and tv: - tokens["pve-hetzner"] = {"name": tn, "value": tv} - return tokens - - -def _get_passwords(cfg): - pw_default = cfg.passwords.get("default", "") - pw_hetzner = cfg.passwords.get("hetzner", pw_default) - pws = {"default": pw_default, "pve-hetzner": pw_hetzner} - for host in proxmox_client.PROXMOX_HOSTS: - if host not in pws: - pws[host] = pw_default - return pws - +# --- Backward-Compat fuer /commands die direkt aufrufen --- def gather_status() -> str: - """Komplett-Status aller Container für /status.""" - cfg = _load_config() - containers = proxmox_client.get_all_containers( - _get_passwords(cfg), _get_tokens(cfg) - ) - return proxmox_client.format_containers(containers) + """Komplett-Status aller Container fuer /status.""" + handlers = tool_loader.get_handlers() + return handlers["get_all_containers"]() def gather_errors(hours: float = 2) -> str: - """Aktuelle Fehler aus Loki — mit Anzahl + Beispiele.""" - result = loki_client.count_errors(hours=hours) - if "error" in result: - return f"Loki-Fehler: {result['error']}" - count = result["count"] - per_host = result.get("per_host", {}) - lines = [f"Fehler ({hours:.0f}h): {count} Einträge"] - if per_host: - top = sorted(per_host.items(), key=lambda x: x[1], reverse=True)[:5] - lines.append("Top-Hosts:") - for host, n in top: - lines.append(f" {host}: {n}x") - if count > 0: - examples = loki_client.get_errors(hours=hours, limit=5) - real = [e for e in examples if "error" not in e] - if real: - lines.append("Letzte Beispiele:") - for e in real[:3]: - lines.append(f" [{e.get('host','?')}] {e.get('line','')[:120]}") - return "\n".join(lines) + handlers = tool_loader.get_handlers() + return handlers["get_errors"](hours=hours) def gather_error_count(hours: float = 24) -> str: - """Nur die Fehleranzahl aus Loki — für Zähl-Fragen.""" - result = loki_client.count_errors(hours=hours) - if "error" in result: - return f"Loki-Fehler: {result['error']}" - count = result["count"] - per_host = result.get("per_host", {}) - lines = [f"{count} Fehler-Einträge in den letzten {hours:.0f} Stunden"] - if per_host: - top = sorted(per_host.items(), key=lambda x: x[1], reverse=True)[:8] - for host, n in top: - lines.append(f" {host}: {n}x") - return "\n".join(lines) + handlers = tool_loader.get_handlers() + return handlers["count_errors"](hours=hours) def gather_container_status(query: str) -> str: - """Status eines einzelnen Containers.""" - cfg = _load_config() - vmid = None - name = None - - m = re.search(r'\b(\d{3})\b', query) - if m: - vmid = int(m.group(1)) - else: - name = query.strip() - - ct = config.get_container(cfg, vmid=vmid, name=name) - if not ct: - return f"Container nicht gefunden: {query}" - - host_ip = proxmox_client.PROXMOX_HOSTS.get(ct.host) - if not host_ip: - return f"Host nicht erreichbar: {ct.host}" - - token = _get_tokens(cfg).get(ct.host, {}) - pw = _get_passwords(cfg).get(ct.host, "") - try: - client = proxmox_client.ProxmoxClient( - host_ip, password=pw, - token_name=token.get("name", ""), - token_value=token.get("value", ""), - ) - status = client.get_container_status(ct.vmid) - except Exception as e: - return f"Proxmox-Fehler: {e}" - - mem_mb = status.get("mem", 0) // (1024 * 1024) - maxmem_mb = status.get("maxmem", 0) // (1024 * 1024) - uptime_h = status.get("uptime", 0) // 3600 - - return ( - f"CT {ct.vmid} — {ct.name}\n" - f"Host: {ct.host}\n" - f"Status: {status.get('status', '?')}\n" - f"RAM: {mem_mb}/{maxmem_mb} MB\n" - f"CPU: {status.get('cpus', '?')} Kerne\n" - f"Uptime: {uptime_h}h\n" - f"Tailscale: {ct.tailscale_ip or '—'}\n" - f"Dienste: {ct.services}" - ) + handlers = tool_loader.get_handlers() + return handlers["get_container_detail"](query=query) def gather_logs(container: str, hours: float = 1) -> str: - """Logs eines Containers aus Loki.""" - entries = loki_client.query_logs( - f'{{host="{container}"}}', hours=hours, limit=20 - ) - return loki_client.format_logs(entries) + handlers = tool_loader.get_handlers() + return handlers["get_container_logs"](container=container, hours=hours) def gather_health(container: str) -> str: - """Health-Check eines Containers.""" + """Health-Check — bleibt hier weil es kein eigenes Tool ist.""" + from core import loki_client health = loki_client.get_health(container, hours=24) status_emoji = {"healthy": "✅", "warning": "⚠️", "critical": "🔴"}.get( health.get("status", ""), "❓" @@ -151,281 +61,5 @@ def gather_health(container: str) -> str: def gather_silence() -> str: - """Welche Hosts senden keine Logs?""" - silent = loki_client.check_silence(minutes=35) - if not silent: - return "✅ Alle Hosts senden Logs." - if silent and "error" in silent[0]: - return f"Fehler: {silent[0]['error']}" - lines = ["⚠️ Stille Hosts (keine Logs seit 35+ Min):\n"] - for s in silent: - lines.append(f" • {s['host']}") - return "\n".join(lines) - - -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) - - -def _tool_get_matomo_analytics() -> str: - cfg = _load_config() - if not matomo_client.init(cfg): - return "Matomo nicht konfiguriert (MATOMO_URL/MATOMO_TOKEN fehlt in homelab.conf)" - return matomo_client.format_analytics() - - -def _tool_get_matomo_trend(days: int = 30) -> str: - cfg = _load_config() - if not matomo_client.init(cfg): - return "Matomo nicht konfiguriert (MATOMO_URL/MATOMO_TOKEN fehlt in homelab.conf)" - return matomo_client.format_trend(days=days) - - -def _tool_create_issue(title: str, body: str = "") -> str: - cfg = _load_config() - forgejo_client.init(cfg) - result = forgejo_client.create_issue(title, body) - if "error" in result: - return result["error"] - return f"Issue #{result['number']} erstellt: {result['title']}" - - -def _tool_close_issue(number: int) -> str: - cfg = _load_config() - forgejo_client.init(cfg) - result = forgejo_client.close_issue(int(number)) - if "error" in result: - return result["error"] - return f"Issue #{result['number']} geschlossen: {result['title']}" - - -def _tool_get_forgejo_status() -> str: - cfg = _load_config() - forgejo_client.init(cfg) - return forgejo_client.format_overview() - - -def _tool_get_seafile_status() -> str: - cfg = _load_config() - seafile_client.init(cfg) - return seafile_client.format_overview() - - -def _tool_get_backup_status() -> str: - cfg = _load_config() - pbs_client.init(cfg) - return pbs_client.format_overview() - - -def _tool_get_mail_summary() -> str: - cfg = _load_config() - mail_client.init(cfg) - return mail_client.format_summary() - - -def _tool_get_mail_count() -> str: - cfg = _load_config() - mail_client.init(cfg) - counts = mail_client.get_mail_count() - if "error" in counts: - return f"Mail-Fehler: {counts['error']}" - return f"E-Mails: {counts['total']} gesamt, {counts['unread']} ungelesen ({counts['account']})" - - -def _tool_search_mail(query: str, days: int = 30) -> str: - cfg = _load_config() - mail_client.init(cfg) - results = mail_client.search_mail(query, days=days) - return mail_client.format_search_results(results) - - -def _tool_get_todays_mails() -> str: - cfg = _load_config() - mail_client.init(cfg) - mails = mail_client.get_todays_mails() - if not mails: - return "Heute keine Mails eingegangen." - if "error" in mails[0]: - return f"Mail-Fehler: {mails[0]['error']}" - lines = [f"{len(mails)} Mail(s) heute:\n"] - for r in mails: - lines.append(f" {r['date_str']} | {r['from'][:35]}") - lines.append(f" → {r['subject'][:70]}") - return "\n".join(lines) - - -def _tool_get_smart_mail_digest(hours: int = 24) -> str: - cfg = _load_config() - mail_client.init(cfg) - api_key = cfg.api_keys.get("openrouter_key", "") - digest = mail_client.get_smart_digest(hours=hours, api_key=api_key) - return mail_client.format_smart_digest(digest) - - -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}" - - -def _tool_memory_read(scope=""): - import memory_client - items = memory_client.get_active_memory() - if scope: - items = [i for i in items if i.get("scope") == scope] - if not items: - return "Keine Memory-Eintraege gefunden." - lines = [] - for i in items: - lines.append(f"[{i['scope']}/{i['kind']}] {i['content']}") - return "\n".join(lines) - - -import logging as _logging -_log = _logging.getLogger("context") - -last_suggest_result = {"type": None} - -VALID_MEMORY_TYPES = {"fact", "preference", "relationship", "plan", "temporary", "uncertain"} -VALID_CONFIDENCE = {"high", "medium", "low"} -NEEDS_EXPIRY = {"plan", "temporary"} - -_current_source_type = "telegram_text" - - -def set_source_type(st: str): - global _current_source_type - _current_source_type = st - - -def _tool_memory_suggest(scope, kind, content, memory_type="fact", confidence="high", expires_at=None): - import memory_client - from datetime import datetime - global last_suggest_result - - if memory_type not in VALID_MEMORY_TYPES: - memory_type = "fact" - if confidence not in VALID_CONFIDENCE: - confidence = "high" - - _log.info("memory_suggest: type=%s conf=%s src=%s content=%s", memory_type, confidence, _current_source_type, content[:80]) - - exp_epoch = None - if memory_type in NEEDS_EXPIRY: - if expires_at: - exp_epoch = memory_client.parse_expires_from_text(expires_at) - if not exp_epoch: - exp_epoch = memory_client.parse_expires_from_text(content) - if not exp_epoch: - exp_epoch = memory_client.default_expires() - - data = { - "scope": scope, - "kind": kind, - "content": content, - "source": "bot-suggest", - "status": "active", - "confidence": confidence, - "memory_type": memory_type, - "source_type": _current_source_type, - } - if exp_epoch: - data["expires_at"] = exp_epoch - - result = memory_client._post("/memory", data) - - if result and result.get("duplicate"): - ex_type = result.get("existing_memory_type", "") - ex_exp = result.get("existing_expires_at") - last_suggest_result = {"type": "duplicate"} - if ex_type in NEEDS_EXPIRY and ex_exp: - return f"Weiss ich schon (bis {datetime.fromtimestamp(ex_exp).strftime('%d.%m.%Y')})." - return "Weiss ich schon." - - if result and result.get("ok"): - sup = result.get("superseded_id") - last_suggest_result = {"type": "saved", "item_id": result.get("id"), "superseded": sup} - msg = f"Gemerkt ({memory_type}, {confidence})." - if sup: - msg += f" Alten Eintrag #{sup} ersetzt." - _log.info("Superseded: #%s -> #%s", sup, result.get("id")) - _log.info("Gespeichert: ID=%s type=%s conf=%s", result.get("id"), memory_type, confidence) - return msg - return "Konnte nicht speichern." - - -def _tool_session_search(query): - import memory_client - result = memory_client._get("/sessions/search", {"q": query, "limit": 20}) - if not result or not result.get("results"): - return f"Keine Ergebnisse fuer '{query}'." - lines = [f"Suche '{query}': {result['count']} Treffer"] - for r in result["results"][:10]: - role = r.get("role", "?") - content = (r.get("content") or "")[:150] - lines.append(f" [{role}] {content}") - return "\n".join(lines) - - -def _tool_session_summary(session_id, topic=None): - import memory_client - return memory_client.get_session_summary(session_id, limit=20, topic=topic or None) - - -def get_tool_handlers(session_id: str = None) -> 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), - "count_errors": lambda hours=24: gather_error_count(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(), - "get_forgejo_status": lambda: _tool_get_forgejo_status(), - "create_issue": lambda title, body="": _tool_create_issue(title, body), - "close_issue": lambda number: _tool_close_issue(number), - "get_seafile_status": lambda: _tool_get_seafile_status(), - "get_backup_status": lambda: _tool_get_backup_status(), - "get_mail_summary": lambda: _tool_get_mail_summary(), - "get_mail_count": lambda: _tool_get_mail_count(), - "search_mail": lambda query, days=30: _tool_search_mail(query, days=days), - "get_todays_mails": lambda: _tool_get_todays_mails(), - "get_smart_mail_digest": lambda hours=24: _tool_get_smart_mail_digest(hours=hours), - "get_matomo_analytics": lambda: _tool_get_matomo_analytics(), - "get_matomo_trend": lambda days=30: _tool_get_matomo_trend(days=days), - "memory_read": lambda scope="": _tool_memory_read(scope), - "memory_suggest": lambda scope, kind, content, memory_type="fact", confidence="high", expires_at=None: _tool_memory_suggest(scope, kind, content, memory_type=memory_type, confidence=confidence, expires_at=expires_at), - "session_search": lambda query: _tool_session_search(query), - "session_summary": lambda topic="": _tool_session_summary(session_id, topic=topic) if session_id else "Keine Session aktiv.", - } + handlers = tool_loader.get_handlers() + return handlers["get_silent_hosts"]() diff --git a/homelab-ai-bot/llm.py b/homelab-ai-bot/llm.py index 4a7a0819..40b82cc6 100644 --- a/homelab-ai-bot/llm.py +++ b/homelab-ai-bot/llm.py @@ -1,7 +1,6 @@ """OpenRouter LLM-Wrapper mit Tool-Calling. -Das LLM entscheidet selbst welche Datenquellen es abfragt. -Neue Datenquelle = Tool-Definition hier + Handler in context.py. +Neue Datenquelle = eine Datei in tools/ anlegen. Fertig. """ import json @@ -11,6 +10,7 @@ import sys sys.path.insert(0, os.path.dirname(__file__)) from core import config +import tool_loader MODEL = "openai/gpt-4o-mini" VISION_MODEL = "openai/gpt-4o" @@ -146,326 +146,10 @@ Bei Reisen, Geld, Behoerden, Rechnungen, Buchungen: - NIEMALS praezise falsche Angaben machen. - Speichere nur HIGH-CONFIDENCE Daten via memory_suggest (Reiseplaene, Buchungscodes). -ANALYTICS-INTERPRETATION (Matomo): -Wenn du Analytics-Daten bekommst (get_matomo_analytics, get_matomo_trend), interpretiere sie QUALIFIZIERT: -- Nenne nicht nur Zahlen, sondern BEWERTE sie ("88% Bounce Rate ist schlecht", "42s Verweildauer ist zu kurz") -- Vergleiche IMMER mit der Vorwoche wenn Daten vorhanden ("30% mehr als letzte Woche") -- Nenne den TREND klar ("Traffic steigt seit 5 Tagen", "Ruecklaeufig seit Montag") -- Bei Peaks: Vermute WARUM ("Am 24.02. 147 Besucher — pruefe welcher Artikel viral ging") -- Bei hoher Abhaengigkeit von einer Quelle: WARNE ("80% kommt von Google — riskant") -- Gib 1-2 konkrete EMPFEHLUNGEN ("Bounce Rate senken: Ueberschriften verbessern, Ladezeit pruefen") -- Wochentags-Muster nutzen: "Dienstag ist dein staerkster Tag — poste neue Artikel dienstags" -- NICHT: endlose Zahlentabellen wiedergeben. Fasse zusammen, hebe das Wichtige hervor. -- Format: Kurze Absaetze, KEINE langen Listen. Wie ein Analytics-Berater der auf den Punkt kommt. - 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 INTERNE Statistiken: Anzahl veroeffentlichter Posts/Artikel, offene Kommentare, Plugin-Status. NICHT fuer Besucherzahlen — dafuer get_matomo_analytics nutzen.", - "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_matomo_analytics", - "description": "Matomo Website-Analytik: Besucher heute, Trend, Top-Seiten, Traffic-Quellen, Laender. Fuer alle Fragen zu Besucherzahlen, Zuschauern, Traffic, Seitenaufrufen, Bounce Rate, Herkunftslaendern.", - "parameters": {"type": "object", "properties": {}, "required": []}, - }, - }, - { - "type": "function", - "function": { - "name": "get_matomo_trend", - "description": "Besucherentwicklung ueber Zeit: Tageweise Besucherzahlen ueber N Tage. Nutze bei Fragen wie 'wie entwickeln sich die Zuschauer', 'Besuchertrend', 'wachsen wir'.", - "parameters": { - "type": "object", - "properties": { - "days": {"type": "number", "description": "Anzahl Tage zurueckblicken (default: 30)", "default": 30} - }, - "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": [], - }, - }, - }, -] +TOOLS = tool_loader.get_tools() def _get_api_key() -> str: @@ -500,8 +184,10 @@ def ask(question: str, context: str) -> str: if not api_key: return "OpenRouter API Key fehlt in homelab.conf" + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": _full_prompt}, {"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"}, ] try: @@ -528,8 +214,11 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) - except Exception: memory_block = "" + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block + messages = [ - {"role": "system", "content": SYSTEM_PROMPT + memory_block}, + {"role": "system", "content": _full_prompt}, ] _RECAP_MARKERS = ["was haben wir", "worüber haben wir", "worüber hatten wir", @@ -632,8 +321,11 @@ def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}", "detail": "high"}}, ] + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block + messages = [ - {"role": "system", "content": SYSTEM_PROMPT + memory_block}, + {"role": "system", "content": _full_prompt}, ] if session_id: diff --git a/homelab-ai-bot/tool_loader.py b/homelab-ai-bot/tool_loader.py new file mode 100644 index 00000000..aa680856 --- /dev/null +++ b/homelab-ai-bot/tool_loader.py @@ -0,0 +1,81 @@ +"""Auto-Discovery Plugin-Loader fuer Hausmeister-Bot Tools. + +Laedt alle tools/*.py Dateien und sammelt: +- TOOLS: Tool-Definitionen fuer das LLM +- HANDLERS / get_handlers(): Handler-Funktionen +- SYSTEM_PROMPT_EXTRA: Zusaetzliche Prompt-Abschnitte +""" + +import importlib +import os +import glob +import logging + +log = logging.getLogger("tool_loader") + +_cache = {"tools": None, "prompt": None, "modules": None} + + +def _discover_modules(): + if _cache["modules"] is not None: + return _cache["modules"] + + tools_dir = os.path.join(os.path.dirname(__file__), "tools") + modules = [] + + for path in sorted(glob.glob(os.path.join(tools_dir, "*.py"))): + name = os.path.basename(path)[:-3] + if name.startswith("_"): + continue + try: + mod = importlib.import_module(f"tools.{name}") + modules.append(mod) + log.debug("Geladen: tools/%s (%d Tools)", name, + len(getattr(mod, "TOOLS", []))) + except Exception as e: + log.error("Fehler beim Laden von tools/%s: %s", name, e) + + _cache["modules"] = modules + log.info("%d Tool-Module geladen", len(modules)) + return modules + + +def get_tools() -> list: + """Alle Tool-Definitionen fuer das LLM.""" + if _cache["tools"] is not None: + return _cache["tools"] + + all_tools = [] + for mod in _discover_modules(): + all_tools.extend(getattr(mod, "TOOLS", [])) + + _cache["tools"] = all_tools + return all_tools + + +def get_extra_prompt() -> str: + """Alle SYSTEM_PROMPT_EXTRA Abschnitte zusammengefuegt.""" + if _cache["prompt"] is not None: + return _cache["prompt"] + + parts = [] + for mod in _discover_modules(): + extra = getattr(mod, "SYSTEM_PROMPT_EXTRA", "") + if extra: + parts.append(extra.strip()) + + _cache["prompt"] = "\n\n".join(parts) + return _cache["prompt"] + + +def get_handlers(session_id: str = None) -> dict: + """Alle Handler-Funktionen. Wird pro Request aufgerufen (session_id kann variieren).""" + all_handlers = {} + for mod in _discover_modules(): + factory = getattr(mod, "get_handlers", None) + if factory: + all_handlers.update(factory(session_id=session_id)) + else: + all_handlers.update(getattr(mod, "HANDLERS", {})) + + return all_handlers diff --git a/homelab-ai-bot/tools/__init__.py b/homelab-ai-bot/tools/__init__.py new file mode 100644 index 00000000..8fbc77c7 --- /dev/null +++ b/homelab-ai-bot/tools/__init__.py @@ -0,0 +1 @@ +# Plugin-Tools — automatisch geladen via tool_loader.py diff --git a/homelab-ai-bot/tools/feeds.py b/homelab-ai-bot/tools/feeds.py new file mode 100644 index 00000000..1ecb4885 --- /dev/null +++ b/homelab-ai-bot/tools/feeds.py @@ -0,0 +1,39 @@ +"""RSS Feed Statistik Tool.""" + +import requests as _req +from core import config + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_feed_stats", + "description": "RSS Feed-Statistik: Wieviele Artikel wurden heute gesammelt? Welche Quellen sind aktiv?", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, +] + + +def handle_get_feed_stats(**kw): + cfg = config.parse_config() + ct_109 = config.get_container(cfg, vmid=109) + if not ct_109 or not ct_109.tailscale_ip: + return "RSS Manager nicht erreichbar." + 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}" + + +HANDLERS = { + "get_feed_stats": handle_get_feed_stats, +} diff --git a/homelab-ai-bot/tools/forgejo.py b/homelab-ai-bot/tools/forgejo.py new file mode 100644 index 00000000..dbcc9f03 --- /dev/null +++ b/homelab-ai-bot/tools/forgejo.py @@ -0,0 +1,74 @@ +"""Forgejo Git/Issue Tools.""" + +from core import config, forgejo_client + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_forgejo_status", + "description": "Forgejo Git-Server Status: Repos, offene Issues, letzte Commits.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "create_issue", + "description": "Neues Forgejo-Issue/TODO 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": "Forgejo-Issue als erledigt schliessen.", + "parameters": { + "type": "object", + "properties": { + "number": {"type": "number", "description": "Issue-Nummer"} + }, + "required": ["number"], + }, + }, + }, +] + + +def handle_get_forgejo_status(**kw): + cfg = config.parse_config() + forgejo_client.init(cfg) + return forgejo_client.format_overview() + + +def handle_create_issue(title, body="", **kw): + cfg = config.parse_config() + forgejo_client.init(cfg) + result = forgejo_client.create_issue(title, body) + if "error" in result: + return result["error"] + return f"Issue #{result['number']} erstellt: {result['title']}" + + +def handle_close_issue(number, **kw): + cfg = config.parse_config() + forgejo_client.init(cfg) + result = forgejo_client.close_issue(int(number)) + if "error" in result: + return result["error"] + return f"Issue #{result['number']} geschlossen: {result['title']}" + + +HANDLERS = { + "get_forgejo_status": handle_get_forgejo_status, + "create_issue": handle_create_issue, + "close_issue": handle_close_issue, +} diff --git a/homelab-ai-bot/tools/loki.py b/homelab-ai-bot/tools/loki.py new file mode 100644 index 00000000..8c2a0b0c --- /dev/null +++ b/homelab-ai-bot/tools/loki.py @@ -0,0 +1,118 @@ +"""Loki Log-Abfrage Tools.""" + +from core import loki_client + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_errors", + "description": "Aktuelle Fehler-Logs aus Loki mit Details und Beispielen. Nutze bei 'welche Fehler', 'zeig Fehler', 'was ist kaputt'.", + "parameters": { + "type": "object", + "properties": { + "hours": {"type": "number", "description": "Zeitraum in Stunden (default: 2)", "default": 2} + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "count_errors", + "description": "Zaehlt Fehler-Logs aus Loki und gibt ANZAHL pro Host zurueck. Nutze bei 'wieviele Fehler', 'wie oft', 'Fehleranzahl'.", + "parameters": { + "type": "object", + "properties": { + "hours": {"type": "number", "description": "Zeitraum in Stunden (default: 24)", "default": 24} + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_container_logs", + "description": "Logs eines bestimmten Containers aus Loki. Nutze bei 'zeig Logs von X', 'was macht Container Y'.", + "parameters": { + "type": "object", + "properties": { + "container": {"type": "string", "description": "Container-Name/Host"}, + "hours": {"type": "number", "description": "Zeitraum (default: 1)", "default": 1} + }, + "required": ["container"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_silent_hosts", + "description": "Welche Hosts/Container senden keine Logs mehr? Fuer Silence-Detection.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, +] + + +def handle_get_errors(hours=2, **kw): + result = loki_client.count_errors(hours=hours) + if "error" in result: + return f"Loki-Fehler: {result['error']}" + count = result["count"] + per_host = result.get("per_host", {}) + lines = [f"Fehler ({hours:.0f}h): {count} Eintraege"] + if per_host: + top = sorted(per_host.items(), key=lambda x: x[1], reverse=True)[:5] + lines.append("Top-Hosts:") + for host, n in top: + lines.append(f" {host}: {n}x") + if count > 0: + examples = loki_client.get_errors(hours=hours, limit=5) + real = [e for e in examples if "error" not in e] + if real: + lines.append("Letzte Beispiele:") + for e in real[:3]: + lines.append(f" [{e.get('host','?')}] {e.get('line','')[:120]}") + return "\n".join(lines) + + +def handle_count_errors(hours=24, **kw): + result = loki_client.count_errors(hours=hours) + if "error" in result: + return f"Loki-Fehler: {result['error']}" + count = result["count"] + per_host = result.get("per_host", {}) + lines = [f"{count} Fehler-Eintraege in den letzten {hours:.0f} Stunden"] + if per_host: + top = sorted(per_host.items(), key=lambda x: x[1], reverse=True)[:8] + for host, n in top: + lines.append(f" {host}: {n}x") + return "\n".join(lines) + + +def handle_get_container_logs(container, hours=1, **kw): + entries = loki_client.query_logs(f'{{host="{container}"}}', hours=hours, limit=20) + return loki_client.format_logs(entries) + + +def handle_get_silent_hosts(**kw): + silent = loki_client.check_silence(minutes=35) + if not silent: + return "Alle Hosts senden Logs." + if silent and "error" in silent[0]: + return f"Fehler: {silent[0]['error']}" + lines = ["Stille Hosts (keine Logs seit 35+ Min):\n"] + for s in silent: + lines.append(f" {s['host']}") + return "\n".join(lines) + + +HANDLERS = { + "get_errors": handle_get_errors, + "count_errors": handle_count_errors, + "get_container_logs": handle_get_container_logs, + "get_silent_hosts": handle_get_silent_hosts, +} diff --git a/homelab-ai-bot/tools/mail.py b/homelab-ai-bot/tools/mail.py new file mode 100644 index 00000000..9712927e --- /dev/null +++ b/homelab-ai-bot/tools/mail.py @@ -0,0 +1,114 @@ +"""E-Mail Tools (IMAP).""" + +from core import config, mail_client + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_mail_summary", + "description": "E-Mail Zusammenfassung: Letzte Mails, Absender, Betreff.", + "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": "Volltextsuche in E-Mails nach Betreff/Absender.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Suchbegriff"}, + "days": {"type": "number", "description": "Zeitraum in Tagen (default: 30)", "default": 30} + }, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_todays_mails", + "description": "Alle heutigen E-Mails auflisten.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "get_smart_mail_digest", + "description": "KI-Zusammenfassung der letzten Mails: Was ist wichtig, was kann warten?", + "parameters": { + "type": "object", + "properties": { + "hours": {"type": "number", "description": "Zeitraum in Stunden (default: 24)", "default": 24} + }, + "required": [], + }, + }, + }, +] + + +def _cfg(): + cfg = config.parse_config() + mail_client.init(cfg) + return cfg + + +def handle_get_mail_summary(**kw): + cfg = _cfg() + return mail_client.format_summary() + + +def handle_get_mail_count(**kw): + _cfg() + counts = mail_client.get_mail_count() + if "error" in counts: + return f"Mail-Fehler: {counts['error']}" + return f"E-Mails: {counts['total']} gesamt, {counts['unread']} ungelesen ({counts['account']})" + + +def handle_search_mail(query, days=30, **kw): + _cfg() + results = mail_client.search_mail(query, days=days) + return mail_client.format_search_results(results) + + +def handle_get_todays_mails(**kw): + _cfg() + mails = mail_client.get_todays_mails() + if not mails: + return "Heute keine Mails eingegangen." + if "error" in mails[0]: + return f"Mail-Fehler: {mails[0]['error']}" + lines = [f"{len(mails)} Mail(s) heute:\n"] + for r in mails: + lines.append(f" {r['date_str']} | {r['from'][:35]}") + lines.append(f" -> {r['subject'][:70]}") + return "\n".join(lines) + + +def handle_get_smart_mail_digest(hours=24, **kw): + cfg = _cfg() + api_key = cfg.api_keys.get("openrouter_key", "") + digest = mail_client.get_smart_digest(hours=hours, api_key=api_key) + return mail_client.format_smart_digest(digest) + + +HANDLERS = { + "get_mail_summary": handle_get_mail_summary, + "get_mail_count": handle_get_mail_count, + "search_mail": handle_search_mail, + "get_todays_mails": handle_get_todays_mails, + "get_smart_mail_digest": handle_get_smart_mail_digest, +} diff --git a/homelab-ai-bot/tools/matomo.py b/homelab-ai-bot/tools/matomo.py new file mode 100644 index 00000000..c4271d83 --- /dev/null +++ b/homelab-ai-bot/tools/matomo.py @@ -0,0 +1,60 @@ +"""Matomo Website-Analytics Tools.""" + +from core import config, matomo_client + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_matomo_analytics", + "description": "Matomo Website-Analytik: Besucher heute, Trend, Top-Seiten, Traffic-Quellen, Laender. Fuer alle Fragen zu Besucherzahlen, Zuschauern, Traffic, Seitenaufrufen, Bounce Rate, Herkunftslaendern.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "get_matomo_trend", + "description": "Besucherentwicklung ueber Zeit: Tageweise Besucherzahlen ueber N Tage. Nutze bei 'wie entwickeln sich die Zuschauer', 'Besuchertrend', 'wachsen wir'.", + "parameters": { + "type": "object", + "properties": { + "days": {"type": "number", "description": "Anzahl Tage zurueckblicken (default: 30)", "default": 30} + }, + "required": [], + }, + }, + }, +] + +SYSTEM_PROMPT_EXTRA = """ANALYTICS-INTERPRETATION (Matomo): +Wenn du Analytics-Daten bekommst (get_matomo_analytics, get_matomo_trend), interpretiere sie QUALIFIZIERT: +- Nenne nicht nur Zahlen, sondern BEWERTE sie ("88% Bounce Rate ist schlecht", "42s Verweildauer ist zu kurz") +- Vergleiche IMMER mit der Vorwoche wenn Daten vorhanden ("30% mehr als letzte Woche") +- Nenne den TREND klar ("Traffic steigt seit 5 Tagen", "Ruecklaeufig seit Montag") +- Bei Peaks: Vermute WARUM ("Am 24.02. 147 Besucher — pruefe welcher Artikel viral ging") +- Bei hoher Abhaengigkeit von einer Quelle: WARNE ("80% kommt von Google — riskant") +- Gib 1-2 konkrete EMPFEHLUNGEN ("Bounce Rate senken: Ueberschriften verbessern, Ladezeit pruefen") +- Wochentags-Muster nutzen: "Dienstag ist dein staerkster Tag — poste neue Artikel dienstags" +- NICHT: endlose Zahlentabellen wiedergeben. Fasse zusammen, hebe das Wichtige hervor. +- Format: Kurze Absaetze, KEINE langen Listen. Wie ein Analytics-Berater der auf den Punkt kommt.""" + + +def handle_get_matomo_analytics(**kw): + cfg = config.parse_config() + if not matomo_client.init(cfg): + return "Matomo nicht konfiguriert (MATOMO_URL/MATOMO_TOKEN fehlt in homelab.conf)" + return matomo_client.format_analytics() + + +def handle_get_matomo_trend(days=30, **kw): + cfg = config.parse_config() + if not matomo_client.init(cfg): + return "Matomo nicht konfiguriert (MATOMO_URL/MATOMO_TOKEN fehlt in homelab.conf)" + return matomo_client.format_trend(days=days) + + +HANDLERS = { + "get_matomo_analytics": handle_get_matomo_analytics, + "get_matomo_trend": handle_get_matomo_trend, +} diff --git a/homelab-ai-bot/tools/memory.py b/homelab-ai-bot/tools/memory.py new file mode 100644 index 00000000..18f58c0e --- /dev/null +++ b/homelab-ai-bot/tools/memory.py @@ -0,0 +1,183 @@ +"""Memory/RAG Tools — Gedaechtnis, Sessions, Fakten-Speicherung.""" + +import logging + +_log = logging.getLogger("tools.memory") + +VALID_MEMORY_TYPES = {"fact", "preference", "relationship", "plan", "temporary", "uncertain"} +VALID_CONFIDENCE = {"high", "medium", "low"} +NEEDS_EXPIRY = {"plan", "temporary"} + +last_suggest_result = {"type": None} + +_current_source_type = "telegram_text" + + +def set_source_type(st: str): + global _current_source_type + _current_source_type = st + + +TOOLS = [ + { + "type": "function", + "function": { + "name": "memory_read", + "description": "Liest gespeicherte Fakten/Erinnerungen. Optional nach Scope filtern (z.B. 'user', 'system').", + "parameters": { + "type": "object", + "properties": { + "scope": {"type": "string", "description": "Filter nach Scope (optional)", "default": ""} + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "memory_suggest", + "description": "Speichert einen neuen Fakt/Erinnerung. Duplikate werden automatisch erkannt, Widersprueche aufgeloest.", + "parameters": { + "type": "object", + "properties": { + "scope": {"type": "string", "description": "Bereich: 'user', 'homelab', 'project'"}, + "kind": {"type": "string", "description": "Unterkategorie: 'reise', 'server', 'kontakt', 'todo', 'allgemein'"}, + "content": {"type": "string", "description": "Der zu speichernde Fakt"}, + "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.", + "parameters": { + "type": "object", + "properties": { + "topic": {"type": "string", "description": "Themenbegriff zum Filtern (optional)"}, + }, + "required": [], + }, + }, + }, +] + + +def _handle_memory_read(scope="", **kw): + import memory_client + items = memory_client.get_active_memory() + if scope: + items = [i for i in items if i.get("scope") == scope] + if not items: + return "Keine Memory-Eintraege gefunden." + lines = [] + for i in items: + lines.append(f"[{i['scope']}/{i['kind']}] {i['content']}") + return "\n".join(lines) + + +def _handle_memory_suggest(scope, kind, content, memory_type="fact", confidence="high", expires_at=None, **kw): + import memory_client + from datetime import datetime + global last_suggest_result + + if memory_type not in VALID_MEMORY_TYPES: + memory_type = "fact" + if confidence not in VALID_CONFIDENCE: + confidence = "high" + + _log.info("memory_suggest: type=%s conf=%s src=%s content=%s", + memory_type, confidence, _current_source_type, content[:80]) + + exp_epoch = None + if memory_type in NEEDS_EXPIRY: + if expires_at: + exp_epoch = memory_client.parse_expires_from_text(expires_at) + if not exp_epoch: + exp_epoch = memory_client.parse_expires_from_text(content) + if not exp_epoch: + exp_epoch = memory_client.default_expires() + + data = { + "scope": scope, + "kind": kind, + "content": content, + "source": "bot-suggest", + "status": "active", + "confidence": confidence, + "memory_type": memory_type, + "source_type": _current_source_type, + } + if exp_epoch: + data["expires_at"] = exp_epoch + + result = memory_client._post("/memory", data) + + if result and result.get("duplicate"): + ex_type = result.get("existing_memory_type", "") + ex_exp = result.get("existing_expires_at") + last_suggest_result = {"type": "duplicate"} + if ex_type in NEEDS_EXPIRY and ex_exp: + return f"Weiss ich schon (bis {datetime.fromtimestamp(ex_exp).strftime('%d.%m.%Y')})." + return "Weiss ich schon." + + if result and result.get("ok"): + sup = result.get("superseded_id") + last_suggest_result = {"type": "saved", "item_id": result.get("id"), "superseded": sup} + msg = f"Gemerkt ({memory_type}, {confidence})." + if sup: + msg += f" Alten Eintrag #{sup} ersetzt." + _log.info("Superseded: #%s -> #%s", sup, result.get("id")) + _log.info("Gespeichert: ID=%s type=%s conf=%s", result.get("id"), memory_type, confidence) + return msg + return "Konnte nicht speichern." + + +def _handle_session_search(query, **kw): + import memory_client + result = memory_client._get("/sessions/search", {"q": query, "limit": 20}) + if not result or not result.get("results"): + return f"Keine Ergebnisse fuer '{query}'." + lines = [f"Suche '{query}': {result['count']} Treffer"] + for r in result["results"][:10]: + role = r.get("role", "?") + content = (r.get("content") or "")[:150] + lines.append(f" [{role}] {content}") + return "\n".join(lines) + + +def _handle_session_summary(session_id, topic=None, **kw): + import memory_client + if not session_id: + return "Keine Session aktiv." + return memory_client.get_session_summary(session_id, limit=20, topic=topic or None) + + +def get_handlers(session_id=None): + """Factory — session_id wird fuer session_summary gebraucht.""" + return { + "memory_read": _handle_memory_read, + "memory_suggest": _handle_memory_suggest, + "session_search": _handle_session_search, + "session_summary": lambda topic="", **kw: _handle_session_summary(session_id, topic=topic), + } diff --git a/homelab-ai-bot/tools/pbs.py b/homelab-ai-bot/tools/pbs.py new file mode 100644 index 00000000..224a48fc --- /dev/null +++ b/homelab-ai-bot/tools/pbs.py @@ -0,0 +1,25 @@ +"""Proxmox Backup Server Tool.""" + +from core import config, pbs_client + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_backup_status", + "description": "Backup-Status: Letzte Backups, Speicherplatz, Pruning. Proxmox Backup Server.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, +] + + +def handle_get_backup_status(**kw): + cfg = config.parse_config() + pbs_client.init(cfg) + return pbs_client.format_overview() + + +HANDLERS = { + "get_backup_status": handle_get_backup_status, +} diff --git a/homelab-ai-bot/tools/prometheus.py b/homelab-ai-bot/tools/prometheus.py new file mode 100644 index 00000000..8572889a --- /dev/null +++ b/homelab-ai-bot/tools/prometheus.py @@ -0,0 +1,45 @@ +"""Prometheus Server-Metriken Tools.""" + +from core import prometheus_client + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_server_metrics", + "description": "Server-Metriken (CPU, RAM, Disk, Netzwerk) von Prometheus. Ohne host = Uebersicht aller Server.", + "parameters": { + "type": "object", + "properties": { + "host": {"type": "string", "description": "Hostname fuer Detail-Ansicht (optional)"} + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_server_warnings", + "description": "Aktuelle Server-Warnungen: hohe CPU, volle Disks, wenig RAM etc.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, +] + + +def handle_get_server_metrics(host=None, **kw): + if host: + return prometheus_client.format_host_detail(host) + return prometheus_client.format_overview() + + +def handle_get_server_warnings(**kw): + warnings = prometheus_client.get_warnings() + return "\n".join(warnings) if warnings else "Keine Warnungen — alle Werte normal." + + +HANDLERS = { + "get_server_metrics": handle_get_server_metrics, + "get_server_warnings": handle_get_server_warnings, +} diff --git a/homelab-ai-bot/tools/proxmox.py b/homelab-ai-bot/tools/proxmox.py new file mode 100644 index 00000000..0b2d518b --- /dev/null +++ b/homelab-ai-bot/tools/proxmox.py @@ -0,0 +1,110 @@ +"""Proxmox Container-Status Tools.""" + +import re +from core import config, proxmox_client + +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"], + }, + }, + }, +] + + +def _cfg(): + return config.parse_config() + + +def _tokens(cfg): + tokens = {} + tn = cfg.raw.get("PVE_TOKEN_HETZNER_NAME", "") + tv = cfg.raw.get("PVE_TOKEN_HETZNER_VALUE", "") + if tn and tv: + tokens["pve-hetzner"] = {"name": tn, "value": tv} + return tokens + + +def _passwords(cfg): + pw_default = cfg.passwords.get("default", "") + pw_hetzner = cfg.passwords.get("hetzner", pw_default) + pws = {"default": pw_default, "pve-hetzner": pw_hetzner} + for host in proxmox_client.PROXMOX_HOSTS: + if host not in pws: + pws[host] = pw_default + return pws + + +def handle_get_all_containers(**kw): + cfg = _cfg() + containers = proxmox_client.get_all_containers(_passwords(cfg), _tokens(cfg)) + return proxmox_client.format_containers(containers) + + +def handle_get_container_detail(query, **kw): + cfg = _cfg() + vmid = None + name = None + m = re.search(r'\b(\d{3})\b', query) + if m: + vmid = int(m.group(1)) + else: + name = query.strip() + + ct = config.get_container(cfg, vmid=vmid, name=name) + if not ct: + return f"Container nicht gefunden: {query}" + + host_ip = proxmox_client.PROXMOX_HOSTS.get(ct.host) + if not host_ip: + return f"Host nicht erreichbar: {ct.host}" + + token = _tokens(cfg).get(ct.host, {}) + pw = _passwords(cfg).get(ct.host, "") + try: + client = proxmox_client.ProxmoxClient( + host_ip, password=pw, + token_name=token.get("name", ""), + token_value=token.get("value", ""), + ) + status = client.get_container_status(ct.vmid) + except Exception as e: + return f"Proxmox-Fehler: {e}" + + mem_mb = status.get("mem", 0) // (1024 * 1024) + maxmem_mb = status.get("maxmem", 0) // (1024 * 1024) + uptime_h = status.get("uptime", 0) // 3600 + + return ( + f"CT {ct.vmid} — {ct.name}\n" + f"Host: {ct.host}\n" + f"Status: {status.get('status', '?')}\n" + f"RAM: {mem_mb}/{maxmem_mb} MB\n" + f"CPU: {status.get('cpus', '?')} Kerne\n" + f"Uptime: {uptime_h}h\n" + f"Tailscale: {ct.tailscale_ip or '—'}\n" + f"Dienste: {ct.services}" + ) + + +HANDLERS = { + "get_all_containers": handle_get_all_containers, + "get_container_detail": handle_get_container_detail, +} diff --git a/homelab-ai-bot/tools/seafile.py b/homelab-ai-bot/tools/seafile.py new file mode 100644 index 00000000..a7ab3a25 --- /dev/null +++ b/homelab-ai-bot/tools/seafile.py @@ -0,0 +1,25 @@ +"""Seafile Cloud-Speicher Tool.""" + +from core import config, seafile_client + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_seafile_status", + "description": "Seafile Cloud-Speicher: Bibliotheken, Speicherplatz, Sync-Status.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, +] + + +def handle_get_seafile_status(**kw): + cfg = config.parse_config() + seafile_client.init(cfg) + return seafile_client.format_overview() + + +HANDLERS = { + "get_seafile_status": handle_get_seafile_status, +} diff --git a/homelab-ai-bot/tools/wordpress.py b/homelab-ai-bot/tools/wordpress.py new file mode 100644 index 00000000..286603b1 --- /dev/null +++ b/homelab-ai-bot/tools/wordpress.py @@ -0,0 +1,25 @@ +"""WordPress Statistik Tool.""" + +from core import config, wordpress_client + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_wordpress_stats", + "description": "WordPress INTERNE Statistiken: Anzahl veroeffentlichter Posts/Artikel, offene Kommentare, Plugin-Status. NICHT fuer Besucherzahlen — dafuer get_matomo_analytics nutzen.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, +] + + +def handle_get_wordpress_stats(**kw): + cfg = config.parse_config() + wordpress_client.init(cfg) + return wordpress_client.format_overview(cfg) + + +HANDLERS = { + "get_wordpress_stats": handle_get_wordpress_stats, +}