414 lines
15 KiB
Python
414 lines
15 KiB
Python
"""Intelligente Kontext-Sammlung für den Hausmeister-Bot.
|
|
Entscheidet anhand der Frage welche Datenquellen abgefragt werden."""
|
|
|
|
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
|
|
|
|
|
|
def _load_config():
|
|
return config.parse_config()
|
|
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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}"
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
|
|
def gather_health(container: str) -> str:
|
|
"""Health-Check eines Containers."""
|
|
health = loki_client.get_health(container, hours=24)
|
|
status_emoji = {"healthy": "✅", "warning": "⚠️", "critical": "🔴"}.get(
|
|
health.get("status", ""), "❓"
|
|
)
|
|
return (
|
|
f"{status_emoji} {health.get('host', container)}\n"
|
|
f"Status: {health.get('status', '?')}\n"
|
|
f"Fehler (24h): {health.get('errors_last_{hours}h', '?')}\n"
|
|
f"Sendet Logs: {'ja' if health.get('sending_logs') else 'nein'}"
|
|
)
|
|
|
|
|
|
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_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, "candidate_id": None}
|
|
|
|
|
|
def _tool_memory_suggest(scope, kind, content, memory_type="temporary", expires_at=None):
|
|
import memory_client
|
|
from datetime import datetime
|
|
global last_suggest_result
|
|
last_suggest_result = {"type": None, "candidate_id": None}
|
|
_log.info("memory_suggest aufgerufen: scope=%s kind=%s type=%s content=%s", scope, kind, memory_type, content[:80])
|
|
|
|
if memory_type not in ("temporary", "permanent"):
|
|
memory_type = "temporary"
|
|
|
|
exp_epoch = None
|
|
if memory_type == "temporary":
|
|
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": "candidate",
|
|
"memory_type": memory_type,
|
|
}
|
|
if exp_epoch:
|
|
data["expires_at"] = exp_epoch
|
|
|
|
result = memory_client._post("/memory", data)
|
|
if result and result.get("duplicate"):
|
|
ex_status = result.get("existing_status", "?")
|
|
ex_type = result.get("existing_memory_type", "")
|
|
ex_exp = result.get("existing_expires_at")
|
|
ex_id = result.get("existing_id")
|
|
|
|
if ex_status == "candidate":
|
|
last_suggest_result = {"type": "existing_candidate", "candidate_id": ex_id}
|
|
_log.info("Duplikat: bestehender Kandidat ID=%s", ex_id)
|
|
return "Noch nicht bestaetigt — zeige Auswahl erneut."
|
|
elif ex_status == "active":
|
|
if ex_type == "temporary" and ex_exp:
|
|
exp_str = datetime.fromtimestamp(ex_exp).strftime("%d.%m.%Y")
|
|
last_suggest_result = {"type": "active_temporary", "candidate_id": None}
|
|
return f"Schon temporaer gespeichert bis {exp_str}."
|
|
elif ex_type == "permanent":
|
|
last_suggest_result = {"type": "active_permanent", "candidate_id": None}
|
|
return "Schon dauerhaft gespeichert."
|
|
else:
|
|
last_suggest_result = {"type": "active_other", "candidate_id": None}
|
|
return "Bereits aktiv gespeichert."
|
|
elif ex_status == "archived":
|
|
last_suggest_result = {"type": "existing_candidate", "candidate_id": ex_id}
|
|
memory_client._patch(f"/memory/{ex_id}", {"status": "candidate"})
|
|
return "War archiviert — erneut als Kandidat vorgeschlagen."
|
|
else:
|
|
return "Bereits vorhanden."
|
|
|
|
if result and result.get("ok"):
|
|
last_suggest_result = {"type": "new_candidate", "candidate_id": None}
|
|
type_label = "temporaer" if memory_type == "temporary" else "dauerhaft"
|
|
return f"Vorschlag gespeichert als Kandidat ({type_label})."
|
|
return "Konnte Vorschlag 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),
|
|
"memory_read": lambda scope="": _tool_memory_read(scope),
|
|
"memory_suggest": lambda scope, kind, content, memory_type="temporary", expires_at=None: _tool_memory_suggest(scope, kind, content, memory_type=memory_type, 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.",
|
|
}
|