Plugin-System: tools/*.py auto-discovery, eine Datei pro Tool
This commit is contained in:
parent
e9d81dc9d0
commit
4e07dbec77
15 changed files with 940 additions and 714 deletions
|
|
@ -1,143 +1,53 @@
|
||||||
"""Intelligente Kontext-Sammlung für den Hausmeister-Bot.
|
"""Intelligente Kontext-Sammlung fuer den Hausmeister-Bot.
|
||||||
Entscheidet anhand der Frage welche Datenquellen abgefragt werden."""
|
|
||||||
|
Tool-Handler werden automatisch aus tools/*.py geladen.
|
||||||
|
Hier bleiben nur Hilfsfunktionen fuer /commands und Backward-Compat.
|
||||||
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
from core import config, loki_client, proxmox_client, wordpress_client, prometheus_client
|
import tool_loader
|
||||||
from core import forgejo_client, seafile_client, pbs_client, mail_client, matomo_client
|
from tools.memory import set_source_type, last_suggest_result # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
def _load_config():
|
def get_tool_handlers(session_id: str = None) -> dict:
|
||||||
return config.parse_config()
|
"""Registry: Tool-Name -> Handler. Wird von llm.ask_with_tools() genutzt."""
|
||||||
|
return tool_loader.get_handlers(session_id=session_id)
|
||||||
|
|
||||||
|
|
||||||
def _get_tokens(cfg):
|
# --- Backward-Compat fuer /commands die direkt aufrufen ---
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def gather_status() -> str:
|
def gather_status() -> str:
|
||||||
"""Komplett-Status aller Container für /status."""
|
"""Komplett-Status aller Container fuer /status."""
|
||||||
cfg = _load_config()
|
handlers = tool_loader.get_handlers()
|
||||||
containers = proxmox_client.get_all_containers(
|
return handlers["get_all_containers"]()
|
||||||
_get_passwords(cfg), _get_tokens(cfg)
|
|
||||||
)
|
|
||||||
return proxmox_client.format_containers(containers)
|
|
||||||
|
|
||||||
|
|
||||||
def gather_errors(hours: float = 2) -> str:
|
def gather_errors(hours: float = 2) -> str:
|
||||||
"""Aktuelle Fehler aus Loki — mit Anzahl + Beispiele."""
|
handlers = tool_loader.get_handlers()
|
||||||
result = loki_client.count_errors(hours=hours)
|
return handlers["get_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)
|
|
||||||
|
|
||||||
|
|
||||||
def gather_error_count(hours: float = 24) -> str:
|
def gather_error_count(hours: float = 24) -> str:
|
||||||
"""Nur die Fehleranzahl aus Loki — für Zähl-Fragen."""
|
handlers = tool_loader.get_handlers()
|
||||||
result = loki_client.count_errors(hours=hours)
|
return handlers["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)
|
|
||||||
|
|
||||||
|
|
||||||
def gather_container_status(query: str) -> str:
|
def gather_container_status(query: str) -> str:
|
||||||
"""Status eines einzelnen Containers."""
|
handlers = tool_loader.get_handlers()
|
||||||
cfg = _load_config()
|
return handlers["get_container_detail"](query=query)
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def gather_logs(container: str, hours: float = 1) -> str:
|
def gather_logs(container: str, hours: float = 1) -> str:
|
||||||
"""Logs eines Containers aus Loki."""
|
handlers = tool_loader.get_handlers()
|
||||||
entries = loki_client.query_logs(
|
return handlers["get_container_logs"](container=container, hours=hours)
|
||||||
f'{{host="{container}"}}', hours=hours, limit=20
|
|
||||||
)
|
|
||||||
return loki_client.format_logs(entries)
|
|
||||||
|
|
||||||
|
|
||||||
def gather_health(container: str) -> str:
|
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)
|
health = loki_client.get_health(container, hours=24)
|
||||||
status_emoji = {"healthy": "✅", "warning": "⚠️", "critical": "🔴"}.get(
|
status_emoji = {"healthy": "✅", "warning": "⚠️", "critical": "🔴"}.get(
|
||||||
health.get("status", ""), "❓"
|
health.get("status", ""), "❓"
|
||||||
|
|
@ -151,281 +61,5 @@ def gather_health(container: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def gather_silence() -> str:
|
def gather_silence() -> str:
|
||||||
"""Welche Hosts senden keine Logs?"""
|
handlers = tool_loader.get_handlers()
|
||||||
silent = loki_client.check_silence(minutes=35)
|
return handlers["get_silent_hosts"]()
|
||||||
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.",
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""OpenRouter LLM-Wrapper mit Tool-Calling.
|
"""OpenRouter LLM-Wrapper mit Tool-Calling.
|
||||||
|
|
||||||
Das LLM entscheidet selbst welche Datenquellen es abfragt.
|
Neue Datenquelle = eine Datei in tools/ anlegen. Fertig.
|
||||||
Neue Datenquelle = Tool-Definition hier + Handler in context.py.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -11,6 +10,7 @@ import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
from core import config
|
from core import config
|
||||||
|
import tool_loader
|
||||||
|
|
||||||
MODEL = "openai/gpt-4o-mini"
|
MODEL = "openai/gpt-4o-mini"
|
||||||
VISION_MODEL = "openai/gpt-4o"
|
VISION_MODEL = "openai/gpt-4o"
|
||||||
|
|
@ -146,326 +146,10 @@ Bei Reisen, Geld, Behoerden, Rechnungen, Buchungen:
|
||||||
- NIEMALS praezise falsche Angaben machen.
|
- NIEMALS praezise falsche Angaben machen.
|
||||||
- Speichere nur HIGH-CONFIDENCE Daten via memory_suggest (Reiseplaene, Buchungscodes).
|
- 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:
|
TOOLS:
|
||||||
Nutze Tools fuer Live-Daten. Wenn alles OK: kurz sagen. Bei Problemen: erklaeren + Loesung."""
|
Nutze Tools fuer Live-Daten. Wenn alles OK: kurz sagen. Bei Problemen: erklaeren + Loesung."""
|
||||||
|
|
||||||
TOOLS = [
|
TOOLS = tool_loader.get_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": [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_api_key() -> str:
|
def _get_api_key() -> str:
|
||||||
|
|
@ -500,8 +184,10 @@ def ask(question: str, context: str) -> str:
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return "OpenRouter API Key fehlt in homelab.conf"
|
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 = [
|
messages = [
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
{"role": "system", "content": _full_prompt},
|
||||||
{"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"},
|
{"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"},
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
|
|
@ -528,8 +214,11 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None) -
|
||||||
except Exception:
|
except Exception:
|
||||||
memory_block = ""
|
memory_block = ""
|
||||||
|
|
||||||
|
_extra = tool_loader.get_extra_prompt()
|
||||||
|
_full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block
|
||||||
|
|
||||||
messages = [
|
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",
|
_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"}},
|
{"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 = [
|
messages = [
|
||||||
{"role": "system", "content": SYSTEM_PROMPT + memory_block},
|
{"role": "system", "content": _full_prompt},
|
||||||
]
|
]
|
||||||
|
|
||||||
if session_id:
|
if session_id:
|
||||||
|
|
|
||||||
81
homelab-ai-bot/tool_loader.py
Normal file
81
homelab-ai-bot/tool_loader.py
Normal file
|
|
@ -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
|
||||||
1
homelab-ai-bot/tools/__init__.py
Normal file
1
homelab-ai-bot/tools/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Plugin-Tools — automatisch geladen via tool_loader.py
|
||||||
39
homelab-ai-bot/tools/feeds.py
Normal file
39
homelab-ai-bot/tools/feeds.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
74
homelab-ai-bot/tools/forgejo.py
Normal file
74
homelab-ai-bot/tools/forgejo.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
118
homelab-ai-bot/tools/loki.py
Normal file
118
homelab-ai-bot/tools/loki.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
114
homelab-ai-bot/tools/mail.py
Normal file
114
homelab-ai-bot/tools/mail.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
60
homelab-ai-bot/tools/matomo.py
Normal file
60
homelab-ai-bot/tools/matomo.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
183
homelab-ai-bot/tools/memory.py
Normal file
183
homelab-ai-bot/tools/memory.py
Normal file
|
|
@ -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),
|
||||||
|
}
|
||||||
25
homelab-ai-bot/tools/pbs.py
Normal file
25
homelab-ai-bot/tools/pbs.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
45
homelab-ai-bot/tools/prometheus.py
Normal file
45
homelab-ai-bot/tools/prometheus.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
110
homelab-ai-bot/tools/proxmox.py
Normal file
110
homelab-ai-bot/tools/proxmox.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
25
homelab-ai-bot/tools/seafile.py
Normal file
25
homelab-ai-bot/tools/seafile.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
25
homelab-ai-bot/tools/wordpress.py
Normal file
25
homelab-ai-bot/tools/wordpress.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue