diff --git a/hardware-ai-local-coding.md b/hardware-ai-local-coding.md index 18778d268..c0a8be7f3 100644 --- a/hardware-ai-local-coding.md +++ b/hardware-ai-local-coding.md @@ -190,3 +190,39 @@ Neue Leitlinie: - **M5 Ultra** nur dann, wenn ein konkretes Modell das rechtfertigt. `homelab.conf` und `MASTER_INDEX.md` werden parallel angepasst. + +## Entscheidungsnotiz: Qwen3.6 + BeeLlama fuer opencode-Tests + +Stand: 2026-05-18 + +Qwen3.6 ueber BeeLlama ist fuer einfache, kontrollierte `opencode`-Tests ausreichend: + +- Git-Repos klonen +- README, `package.json` und Setup-Hinweise lesen +- Projektstruktur erkunden +- Shell-/Git-Kommandos ausfuehren +- Tests finden und starten +- kleine, klar begrenzte Codeaenderungen vorbereiten + +Wichtig: BeeLlama macht das Modell schneller und lokal praktikabler, aber nicht automatisch intelligenter. Fuer komplexe Coding-Agent-Aufgaben wie grosse Refactorings, viele Dateien gleichzeitig, schwierige Debug-Schleifen oder produktive Aenderungen bleibt ein spezialisiertes Coding-Modell zuverlaessiger. + +Empfohlener erster Testauftrag: + +```text +Klon dieses Repo nach /tmp/opencode-test, lies README und package.json, aber aendere nichts. Sag danach, wie man es startet und welche Tests definiert sind. +``` + +## Entscheidungsnotiz: zweite RTX 3090 + +Eine zweite RTX 3090 verbessert die Qualitaet desselben Modells nicht direkt. Dasselbe Modell mit derselben Quantisierung liefert grundsaetzlich dieselbe Antwortqualitaet. + +Eine zweite GPU hilft indirekt durch: + +- groessere Modelle, die vorher nicht in den VRAM passten +- bessere Quantisierung, z.B. Q5/Q6 statt Q4 +- mehr Kontext/KV-Cache +- weniger CPU-Offload +- hoehere Tokenrate bei gutem Layer-/Tensor-Splitting +- mehr parallele Requests + +Fuer `opencode` waere der echte Qualitaetsgewinn daher nicht die zweite GPU selbst, sondern dass dadurch eventuell ein besseres oder groesseres Coding-Modell lokal betrieben werden kann. diff --git a/homelab-ai-bot/JELLYFIN.md b/homelab-ai-bot/JELLYFIN.md index 28755720c..9241018d9 100644 --- a/homelab-ai-bot/JELLYFIN.md +++ b/homelab-ai-bot/JELLYFIN.md @@ -140,3 +140,58 @@ curl -s "http://100.77.105.3:8096/Items?api_key=7285b4a8793541648bf156599ae05b43 | Ralf | nein | | rolf | nein | | wilfrid | nein | + +## Troubleshooting: Details fehlen / Remote-Zugriff wirkt kaputt + +Stand: 2026-05-16 + +Symptom: +- Filmübersicht ist erreichbar, aber Detailseiten/Filmdetails werden im Client nicht angezeigt. +- Zugriff über Tailscale-IP `100.77.105.3:8096` hängt oder läuft in Timeouts. + +Befund vom 2026-05-16: +- Jellyfin selbst war lokal gesund: + - `http://192.168.178.149:8096/health` -> `Healthy` + - API `/Items/Counts` lieferte `897` Filme. + - Beispiel-Film `2 Guns` hatte Beschreibung, Genres, TMDB/IMDB IDs und Bilder. +- Medien-Mounts waren aktiv: + - `/mnt/nas` + - `/srv/media` +- Docker-Container `jellyfin` war `healthy`. +- Ursache war Tailscale auf dem Jellyfin-Host: + - `tailscale ip -4` meldete zuerst `no current Tailscale IPs; state: NeedsLogin` + - `tailscale status` meldete `Logged out`. +- Nach Re-Login war `100.77.105.3` wieder aktiv und `http://100.77.105.3:8096/health` antwortete wieder `Healthy`. +- Danach waren die Filmdetails im Client wieder sichtbar. + +Schnelltest: +```bash +# Von pve3 lokal im LAN: +curl -sS http://192.168.178.149:8096/health + +# Von CT 116 / Hausmeister oder einem Tailscale-Host: +curl -sS http://100.77.105.3:8096/health +curl -sS "http://100.77.105.3:8096/Items/Counts?api_key=7285b4a8793541648bf156599ae05b43" +``` + +Fix: +1. Auf dem Jellyfin-Host Tailscale-Status prüfen: + ```bash + tailscale ip -4 + tailscale status --self + ``` +2. Wenn `NeedsLogin` oder `Logged out`: Tailscale neu anmelden. +3. Danach erneut prüfen: + ```bash + curl -sS http://100.77.105.3:8096/health + ``` +4. Im Browser Jellyfin hart neu laden oder neu einloggen, falls der Webclient alte kaputte API-Antworten gecacht hat. + +Wichtig: +- Fehlende Details bedeuten nicht automatisch kaputte Jellyfin-Datenbank oder fehlende Metadaten. +- Erst lokale API gegen `192.168.178.149:8096` prüfen, dann Tailscale/Cloudflare-Zugriff. +- `systemctl is-active jellyfin` kann irreführend sein, weil Jellyfin hier im Docker-Container läuft. Aussagekräftiger ist: + ```bash + docker ps | grep jellyfin + curl -sS http://127.0.0.1:8096/health + ``` diff --git a/homelab-ai-bot/__pycache__/llm.cpython-311.pyc b/homelab-ai-bot/__pycache__/llm.cpython-311.pyc index 2c5514119..7ce9a3fa8 100644 Binary files a/homelab-ai-bot/__pycache__/llm.cpython-311.pyc and b/homelab-ai-bot/__pycache__/llm.cpython-311.pyc differ diff --git a/homelab-ai-bot/__pycache__/monitor.cpython-311.pyc b/homelab-ai-bot/__pycache__/monitor.cpython-311.pyc index 9ee00642d..856285c05 100644 Binary files a/homelab-ai-bot/__pycache__/monitor.cpython-311.pyc and b/homelab-ai-bot/__pycache__/monitor.cpython-311.pyc differ diff --git a/homelab-ai-bot/core/__pycache__/config.cpython-311.pyc b/homelab-ai-bot/core/__pycache__/config.cpython-311.pyc index 6558057b8..4a9d4a0a1 100644 Binary files a/homelab-ai-bot/core/__pycache__/config.cpython-311.pyc and b/homelab-ai-bot/core/__pycache__/config.cpython-311.pyc differ diff --git a/homelab-ai-bot/core/__pycache__/loki_client.cpython-311.pyc b/homelab-ai-bot/core/__pycache__/loki_client.cpython-311.pyc index 0d051bd46..fd9a8d646 100644 Binary files a/homelab-ai-bot/core/__pycache__/loki_client.cpython-311.pyc and b/homelab-ai-bot/core/__pycache__/loki_client.cpython-311.pyc differ diff --git a/homelab-ai-bot/core/loki_client.py b/homelab-ai-bot/core/loki_client.py index aef9ca567..144ed7c27 100644 --- a/homelab-ai-bot/core/loki_client.py +++ b/homelab-ai-bot/core/loki_client.py @@ -5,6 +5,28 @@ from datetime import datetime, timezone, timedelta LOKI_URL = "http://100.109.206.43:3100" +# Transiente cloudflared/Cloudflare-Edge-Flaps erzeugen oft error="..." +# in WRN/ERR-Zeilen, sind aber keine App-Fehler von WordPress/RSS. +ERROR_EXCLUDE_RE = ( + "caller=metrics|query_hash=|executing query|scheduler_processor|" + "Aborted connection|systemd-networkd-wait-online|context canceled|" + "AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|" + "control stream encountered a failure while serving|Serve tunnel error|" + "failed to serve tunnel connection|Connection terminated|" + "failed to dial to edge with quic|Failed to dial a quic connection|" + "no recent network activity|failed to accept QUIC stream|" + "control stream|accept stream listener|serve tunnel|tunnel connection|" + "edge with quic|quic connection|QUIC stream|" + "accept stream listener encountered a failure while serving|" + "datagram manager encountered a failure while serving|" + "datagram manager|datagram handler|" + "failed to run the datagram handler|failed to accept incoming stream requests|" + "Application error 0x0|connIndex=|" + "beellama-tunnel.service: Main process exited|" + "beellama-expose.service: Main process exited|" + "health\(warnable=mapresponse-timeout\)" +) + def _query(endpoint: str, params: dict, base_url: str = None) -> dict: url = f"{base_url or LOKI_URL}{endpoint}" @@ -49,9 +71,9 @@ def query_logs(query: str, hours: float = 1, limit: int = 100) -> list[dict]: def get_errors(container: str = None, hours: float = 1, limit: int = 200) -> list[dict]: """Get error-level logs, optionally filtered by container hostname.""" if container: - q = f'{{host="{container}"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror"' + q = f'{{host="{container}"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' else: - q = '{job=~".+"} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror"' + q = f'{{job=~".+"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' return query_logs(q, hours=hours, limit=limit) @@ -102,7 +124,8 @@ def get_health(container: str, hours: float = 24) -> dict: return { "host": container, - "errors_last_{hours}h": error_count, + "error_count": error_count, + f"errors_last_{hours}h": error_count, "sending_logs": has_recent, "status": "healthy" if error_count < 5 and has_recent else "warning" if error_count < 20 else "critical", @@ -114,6 +137,7 @@ WATCHED_SERVICES = [ ("wordpress-v2", "wordpress"), ("fuenfvoracht", "fuenfvoracht"), ("homelab-ai-bot", "hausmeister"), + ("hermes-mu", "hermes-gateway"), ] @@ -121,7 +145,7 @@ def count_errors(hours: float = 24) -> dict: """Zählt Fehler-Log-Einträge über einen Zeitraum via Loki metric query.""" now = datetime.now(timezone.utc) start = now - timedelta(hours=hours) - q = '{job=~".+"} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror"' + q = f'{{job=~".+"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' # Loki instant metric query für Gesamtanzahl data = _query("/loki/api/v1/query_range", { "query": q, @@ -160,7 +184,7 @@ def check_service_restarts(minutes: int = 35) -> list[dict]: ERROR_RATE_THRESHOLDS = { "rss-manager": 15, "wordpress-v2": 10, - "forgejo": 200, # Oeffentlich — Scanner-404er sind normal + "forgejo": 200, # Oeffentlich – Scanner-404er sind normal } ERROR_RATE_DEFAULT = 25 @@ -171,7 +195,7 @@ def check_error_rate(minutes: int = 30) -> list[dict]: alerts = [] now = datetime.now(timezone.utc) for host in all_hosts: - q = f'count_over_time({{host="{host}"}} |~ "(?i)error" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror" [{minutes}m])' + q = f'count_over_time({{host="{host}"}} |~ "(?i)error" !~ "{ERROR_EXCLUDE_RE}" [{minutes}m])' data = _query("/loki/api/v1/query", {"query": q, "time": _ns(now)}) count = sum( int(float(r.get("value", [None, "0"])[1])) diff --git a/homelab-ai-bot/core/loki_client.py.bak.20260603_hermes b/homelab-ai-bot/core/loki_client.py.bak.20260603_hermes new file mode 100644 index 000000000..9822b0205 --- /dev/null +++ b/homelab-ai-bot/core/loki_client.py.bak.20260603_hermes @@ -0,0 +1,225 @@ +"""Loki API client for querying centralized logs.""" + +import requests +from datetime import datetime, timezone, timedelta + +LOKI_URL = "http://100.109.206.43:3100" + +# Transiente cloudflared/Cloudflare-Edge-Flaps erzeugen oft error="..." +# in WRN/ERR-Zeilen, sind aber keine App-Fehler von WordPress/RSS. +ERROR_EXCLUDE_RE = ( + "caller=metrics|query_hash=|executing query|scheduler_processor|" + "Aborted connection|systemd-networkd-wait-online|context canceled|" + "AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|" + "control stream encountered a failure while serving|Serve tunnel error|" + "failed to serve tunnel connection|Connection terminated|" + "failed to dial to edge with quic|Failed to dial a quic connection|" + "no recent network activity|failed to accept QUIC stream|" + "control stream|accept stream listener|serve tunnel|tunnel connection|" + "edge with quic|quic connection|QUIC stream|" + "accept stream listener encountered a failure while serving|" + "datagram manager encountered a failure while serving|" + "datagram manager|datagram handler|" + "failed to run the datagram handler|failed to accept incoming stream requests|" + "Application error 0x0|connIndex=" +) + + +def _query(endpoint: str, params: dict, base_url: str = None) -> dict: + url = f"{base_url or LOKI_URL}{endpoint}" + try: + r = requests.get(url, params=params, timeout=10) + r.raise_for_status() + return r.json() + except requests.RequestException as e: + return {"error": str(e)} + + +def _ns(dt: datetime) -> str: + return str(int(dt.timestamp() * 1e9)) + + +def query_logs(query: str, hours: float = 1, limit: int = 100) -> list[dict]: + """Run a LogQL query and return log entries.""" + now = datetime.now(timezone.utc) + start = now - timedelta(hours=hours) + data = _query("/loki/api/v1/query_range", { + "query": query, + "start": _ns(start), + "end": _ns(now), + "limit": limit, + "direction": "backward", + }) + if "error" in data: + return [{"error": data["error"]}] + + entries = [] + for stream in data.get("data", {}).get("result", []): + labels = stream.get("stream", {}) + for ts, line in stream.get("values", []): + entries.append({ + "timestamp": ts, + "host": labels.get("host", labels.get("job", "unknown")), + "line": line, + }) + return entries + + +def get_errors(container: str = None, hours: float = 1, limit: int = 200) -> list[dict]: + """Get error-level logs, optionally filtered by container hostname.""" + if container: + q = f'{{host="{container}"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' + else: + q = f'{{job=~".+"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' + return query_logs(q, hours=hours, limit=limit) + + +def get_labels() -> list[str]: + """Get all available label values for 'host'.""" + data = _query("/loki/api/v1/label/host/values", {}) + if "error" in data: + return [] + return data.get("data", []) + + +def check_silence(minutes: int = 35) -> list[dict]: + """Find hosts that haven't sent logs within the given timeframe.""" + all_hosts = get_labels() + if not all_hosts: + return [{"error": "Could not fetch host labels from Loki"}] + + now = datetime.now(timezone.utc) + start = now - timedelta(minutes=minutes) + silent = [] + + for host in all_hosts: + data = _query("/loki/api/v1/query_range", { + "query": f'count_over_time({{host="{host}"}}[{minutes}m])', + "start": _ns(start), + "end": _ns(now), + "limit": 1, + }) + results = data.get("data", {}).get("result", []) + has_logs = any( + int(v[1]) > 0 + for r in results + for v in r.get("values", []) + ) + if not has_logs: + silent.append({"host": host, "silent_minutes": minutes}) + + return silent + + +def get_health(container: str, hours: float = 24) -> dict: + """Get a health summary for a specific container.""" + errors = get_errors(container=container, hours=hours, limit=200) + error_count = len([e for e in errors if "error" not in e]) + + recent = query_logs(f'{{host="{container}"}}', hours=0.5, limit=5) + has_recent = len([e for e in recent if "error" not in e]) > 0 + + return { + "host": container, + "errors_last_{hours}h": error_count, + "sending_logs": has_recent, + "status": "healthy" if error_count < 5 and has_recent else + "warning" if error_count < 20 else "critical", + } + + +WATCHED_SERVICES = [ + ("rss-manager", "rss-manager"), + ("wordpress-v2", "wordpress"), + ("fuenfvoracht", "fuenfvoracht"), + ("homelab-ai-bot", "hausmeister"), + ("hermes-mu", "hermes-gateway"), +] + + +def count_errors(hours: float = 24) -> dict: + """Zählt Fehler-Log-Einträge über einen Zeitraum via Loki metric query.""" + now = datetime.now(timezone.utc) + start = now - timedelta(hours=hours) + q = f'{{job=~".+"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' + # Loki instant metric query für Gesamtanzahl + data = _query("/loki/api/v1/query_range", { + "query": q, + "start": _ns(start), + "end": _ns(now), + "limit": 5000, + "direction": "backward", + }) + if "error" in data: + return {"error": data["error"], "count": 0} + total = sum( + len(stream.get("values", [])) + for stream in data.get("data", {}).get("result", []) + ) + # Per-Host aufschlüsseln + per_host: dict[str, int] = {} + for stream in data.get("data", {}).get("result", []): + host = stream.get("stream", {}).get("host", "unknown") + per_host[host] = per_host.get(host, 0) + len(stream.get("values", [])) + return {"count": total, "hours": hours, "per_host": per_host} + + +def check_service_restarts(minutes: int = 35) -> list[dict]: + """Findet Services die innerhalb des Zeitfensters neu gestartet haben (systemd journal via Loki).""" + restarts = [] + for host, service_name in WATCHED_SERVICES: + q = f'{{host="{host}"}} |~ "(?i)(Started|Restarting|restarted).*{service_name}"' + entries = query_logs(q, hours=minutes / 60, limit=5) + real = [e for e in entries if "error" not in e] + if real: + restarts.append({"host": host, "service": service_name, "count": len(real)}) + return restarts + + + +ERROR_RATE_THRESHOLDS = { + "rss-manager": 15, + "wordpress-v2": 10, + "forgejo": 200, # Oeffentlich – Scanner-404er sind normal +} +ERROR_RATE_DEFAULT = 25 + + +def check_error_rate(minutes: int = 30) -> list[dict]: + """Check if any host exceeds its error-rate threshold within the window.""" + all_hosts = get_labels() + alerts = [] + now = datetime.now(timezone.utc) + for host in all_hosts: + q = f'count_over_time({{host="{host}"}} |~ "(?i)error" !~ "{ERROR_EXCLUDE_RE}" [{minutes}m])' + data = _query("/loki/api/v1/query", {"query": q, "time": _ns(now)}) + count = sum( + int(float(r.get("value", [None, "0"])[1])) + for r in data.get("data", {}).get("result", []) + if len(r.get("value", [])) > 1 + ) + threshold = ERROR_RATE_THRESHOLDS.get(host, ERROR_RATE_DEFAULT) + if count > threshold: + alerts.append({"host": host, "count": count, "threshold": threshold}) + return alerts + + +def format_logs(entries: list[dict], max_lines: int = 30) -> str: + """Format log entries for human/LLM consumption.""" + if not entries: + return "No log entries found." + if entries and "error" in entries[0]: + return f"Loki error: {entries[0]['error']}" + + lines = [] + for e in entries[:max_lines]: + host = e.get("host", "?") + line = e.get("line", "").strip() + if len(line) > 200: + line = line[:200] + "..." + lines.append(f"[{host}] {line}") + + total = len(entries) + if total > max_lines: + lines.append(f"\n... and {total - max_lines} more entries") + return "\n".join(lines) diff --git a/homelab-ai-bot/llm.py b/homelab-ai-bot/llm.py index 397d2f98f..301526563 100644 --- a/homelab-ai-bot/llm.py +++ b/homelab-ai-bot/llm.py @@ -27,8 +27,8 @@ OLLAMA_MODELS = set() def warmup_ollama(): - """No-Op: Text-Modell laeuft jetzt ueber OpenRouter (Grok 4.1 Fast), kein Ollama-Warmup noetig.""" - log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (Grok 4.1 Fast)') + """No-Op: Text-Modell laeuft ueber OpenRouter (x-ai/grok-4.3), kein Ollama-Warmup noetig.""" + log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (x-ai/grok-4.3)') PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"} _LOCAL_OVERRIDES = [ diff --git a/homelab-ai-bot/llm.py.bak.20260601_163620 b/homelab-ai-bot/llm.py.bak.20260601_163620 new file mode 100644 index 000000000..397d2f98f --- /dev/null +++ b/homelab-ai-bot/llm.py.bak.20260601_163620 @@ -0,0 +1,740 @@ +"""OpenRouter LLM-Wrapper mit Tool-Calling. + +Neue Datenquelle = eine Datei in tools/ anlegen. Fertig. +""" + +import json +import logging +import requests +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config +import tool_loader + +log = logging.getLogger('llm') + +OLLAMA_BASE = "http://100.84.255.83:11434" +OPENROUTER_BASE = "https://openrouter.ai/api/v1" + +MODEL_LOCAL = "x-ai/grok-4.3" +MODEL_VISION = "openai/gpt-4o-mini" +MODEL_ONLINE = "perplexity/sonar" +FALLBACK_MODEL = None +MAX_TOOL_ROUNDS = 3 +OLLAMA_MODELS = set() + + +def warmup_ollama(): + """No-Op: Text-Modell laeuft jetzt ueber OpenRouter (Grok 4.1 Fast), kein Ollama-Warmup noetig.""" + log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (Grok 4.1 Fast)') +PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"} + +_LOCAL_OVERRIDES = [ + "api kosten", "api-kosten", "guthaben", "openrouter", + "container", "status", "fehler", "logs", "service", + "backup", "feed", "memory", "gedaechtnis", "gedächtnis", + "mail", "seafile", "forgejo", "grafana", "prometheus", + "savetv", "save.tv", "filmtipp", "aufnahme", + "wordpress", "matomo", "tailscale", + "unsere api", "meine api", "die api", + "vorhersage", "prognose", "health forecast", "was bahnt", "systemstatus", + "system check", "system-check", "wie gehts dem system", "wie geht es dem system", + "versicherung", "vertrag", "verträge", "vertraege", "dokument", + "rente", "rentenversicherung", "finanzamt", "steuer", "grundsteuer", + "familienbuch", "urkunde", "bescheid", "police", "beitrag", + "meine unterlagen", "meine dokumente", "meine dateien", + "habe ich", "welche habe", "was habe ich", + "jahreskosten", "jährlichen", "jährliche", "jaehrlichen", "jaehrliche", + "monatliche kosten", "versicherungskosten", "beitragsrechnung", + # Possessiv/Persoenlich (2026-04-16) + "meine", "mein ", "meiner", "meines", "meinem", "meinen", + "wohnung", "wohnungen", "apartment", "apartments", + "condo", "condos", "kondo", "kondos", "immobilie", "immobilien", + "kambodscha", "cambodia", "phnom penh", "arakawa", + "gekostet", "gekauft", "kaufpreis", "kaufpreise", "bezahlt", + "ausgegeben", "ueberweisung", "überweisung", + "was haben", "wieviel habe ich", "wie viel habe ich", + "fuer die wohnung", "für die wohnung", + "ich fuer", "ich für", + # Chatlogger / Telegram-Mitschnitt (2026-05-01) — verhindert Sonar-Routing bei "aktueller Stand" + "concierge", "conciergerie", "bnb", "airbnb", "listing", "listings", + "vermietung", "vermieten", "vermietet", + "kaution", "mieter", "mieterin", "miete", + "g2010b", "g 2010 b", "g 2010b", "g210", "g 210", + "d1603", "d 1603", "d 16 03", + "telegram-gruppe", "telegram gruppe", "chatverlauf", "chat verlauf", + "wer hat zugesagt", "was ist offen", "muss ich reagieren", + "letzte nachricht", "im chat", "in der gruppe", +] +_WEB_TRIGGERS = [ + "recherche", "recherchiere", "suche im internet", "web search", + "preis", "preise", "kostet", "price", + "news", "nachrichten", "aktuell", "aktuelle", + "google", "finde heraus", "finde raus", + "gold", "silber", "kurs", "kurse", + "vergleich", "vergleiche", + "was kostet", "wie teuer", "wie viel", +] +_DEEP_TRIGGERS = ["deep research", "tiefenrecherche", "tiefensuche", "tiefe suche", "detailrecherche", "ausfuehrliche recherche", "ausführliche recherche", "vollstaendige recherche", "vollständige recherche", "recherchiere genau", "analysiere genau"] + +import datetime as _dt +_TODAY = _dt.date.today() +_3M_AGO = (_TODAY - _dt.timedelta(days=90)) +_DATE_LINE = f'Heutiges Datum: {_TODAY.strftime("%d. %B %Y")}. Wir sind im Jahr {_TODAY.year}. Letzte 3 Monate = {_3M_AGO.strftime("%B %Y")} bis {_TODAY.strftime("%B %Y")}.' + +SYSTEM_PROMPT = _DATE_LINE + """ +Du bist der Hausmeister-Bot fuer ein Homelab. Deutsch, kurz, direkt, operativ. + +STIL: +- So wenig Worte wie moeglich, solange nichts Wichtiges fehlt. +- KEINE Abschlussformeln ("Wenn du weitere Informationen benoetigst..."). +- KEINE kuenstlichen Wuensche ("Guten Flug!", "Viel Erfolg!"). +- KEINE Rueckfragen ob der User mehr wissen will. +- Emojis nur wenn sie Information tragen. Telegram-Format (kein Markdown). + +GEDAECHTNIS LESEN: +Am Ende dieses System-Prompts findest du eine Sektion "=== GEDAECHTNIS ===" mit Fakten die du dir ueber den User und die Umgebung gemerkt hast. +WICHTIG: Beantworte Fragen IMMER anhand dieser Fakten! Wenn der User z.B. fragt "Wo fliege ich hin?" und im Gedaechtnis steht "Ist naechste Woche in Frankfurt", dann antworte direkt mit dieser Info. +Das Gedaechtnis ist deine primaere Wissensquelle ueber den User. + +GEDAECHTNIS — memory_suggest: +Du MUSST memory_suggest aufrufen wenn der User etwas Nuetzliches sagt. +memory_suggest speichert DIREKT. Duplikate werden automatisch erkannt. Widersprueche werden automatisch aufgeloest (altes Item wird ersetzt). + +MEMORY_TYPE — immer angeben: +- "preference" → Vorlieben ("Lieblingskaffee ist Flat White", "bevorzuge dunkles Theme") +- "relationship" → Beziehungen/Rollen ("Ali ist Ansprechpartner fuer Wohnung") +- "fact" → Stabile Fakten ("Server IP geaendert", "Jarvis aktiv seit...") +- "plan" → Konkrete Vorhaben mit Zeitbezug ("Reise nach Frankfurt am 18.03.") +- "temporary" → Kurzfristige Zustaende ("bin bis Mittwoch in Berlin", "morgen Zahnarzt") +- "uncertain" → Vage Aussagen ("vielleicht fliege ich frueher", "glaube Ali kuemmert sich") + +CONFIDENCE — immer angeben: +- "high" → Klare Aussage ("Mein Lieblingskaffee ist Flat White") +- "medium" → Wahrscheinlich aber nicht 100% ("Ali kuemmert sich wohl darum") +- "low" → Sehr vage ("vielleicht aendere ich das noch") + +REGELN: +- Bei plan/temporary: IMMER expires_at mit Zeitausdruck angeben ("naechste Woche", "morgen") +- Bei uncertain mit low confidence: NUR speichern wenn es trotzdem nuetzlich sein koennte +- Vages Gerede, Smalltalk, emotionale Kurzaeusserungen: NICHT speichern +- Passwoerter, Tokens: NIE via memory_suggest speichern. ABER: Wenn der User nach Zugangsdaten fragt, nutze get_service_directory — dort sind alle URLs und Logins hinterlegt. +- Bei Widerspruch zu bestehendem Wissen: NUR den neuen Fakt speichern (z.B. "Lieblingskaffee ist Americano"). NICHT den alten Fakt umformulieren oder als "war..." speichern. Das System erkennt Widersprueche automatisch und ersetzt den alten Eintrag. + +SESSION-RUECKBLICK: +- "Was haben wir besprochen?" → session_summary OHNE topic +- "Was haben wir ueber X besprochen?" → session_summary MIT topic="X" +- NUR echte Gespraechsinhalte aus den Treffern wiedergeben. +- KEINE Negativ-Aussagen hinzufuegen. Nichts ueber Dinge sagen die NICHT besprochen wurden. + Verboten: "wurde nicht thematisiert", "keine weiteren Details", "nicht besprochen", "nicht erwaehnt". +- Wenn es nur 1 Treffer gibt, gib nur diesen 1 Treffer wieder. Fuege nichts hinzu. +- Optional kurz erwaehnen was sonst noch Thema war. +- session_search nur fuer Stichwort-Suche in ALTEN Sessions (nicht aktuelle). + +BILDERKENNUNG — ALLGEMEIN: +Wenn der User ein Bild schickt das KEIN kritisches Dokument ist (z.B. Foto, Screenshot, Landschaft): +- Beschreibe strukturiert was du siehst. +- Bei Screenshots von Fehlern/Logs: Identifiziere das Problem, ordne es einem Container/Service zu, schlage Loesung vor. +- Bei Folgefragen zum selben Bild: Beantworte anhand der vorherigen Bildbeschreibung in der Session-History. +- Speichere erkannte Termine/Plaene via memory_suggest. + +DOKUMENTENEXTRAKTION — HARTE REGELN: +Gilt fuer: Flugtickets, Buchungen, Belege, Rechnungen, Formulare, Behoerdendokumente. +Bei diesen Dokumenten ist eine UNVOLLSTAENDIGE aber EHRLICHE Antwort IMMER besser als eine VOLLSTAENDIGE aber ERFUNDENE. + +1. NIEMALS RATEN: +- Wenn ein Feld nicht sicher lesbar ist: NICHT erfinden. +- Keine plausible Uhrzeit ergaenzen. Keine Codes aus Weltwissen ergaenzen. +- Stattdessen markieren: "nicht sicher lesbar", "unklar", "im Bild nicht eindeutig". + +2. BILDWISSEN VOR WELTWISSEN: +- NUR extrahieren was im Bild steht. KEIN externes Wissen um fehlende Felder zu "reparieren". +- Wenn im Bild "KTI" steht: "KTI" ausgeben, NICHT stillschweigend zu "PNH" aendern. +- Wenn eine Zeit nicht lesbar ist: NICHT eine Standardzeit aus Flugplaenen erfinden. +- Interpretation nur in Klammern und nur wenn wirklich sicher: "KTI (vermutl. Phnom Penh Techo International)". + +3. ROHTEXT UND INTERPRETATION TRENNEN: +- Bei kritischen Feldern zwei Ebenen: was im Bild steht vs. was interpretiert wird. +- Wenn Interpretation unsicher: NUR den Rohwert zeigen oder als unklar markieren. + +4. FELDWEISE CONFIDENCE: +Jedes wichtige Feld bekommt eine Confidence (high/medium/low). +Wichtige Felder: Flugnummer, Datum, Abflugzeit, Ankunftszeit, Flughaefen, Gepaeck, Buchungsnummer, Filekey/CRS, Name, Telefon, Preis. +- Unsichere Felder NIEMALS mit hoher Sicherheit formulieren. +- Low-confidence Felder IMMER kennzeichnen. + +5. TABELLARISCH STATT FREIFORM: +Ausgabeformat fuer Flugtickets: + +Allgemeine Angaben: +- Fluggesellschaft: [Wert] +- Ticketdatum: [Wert] +- Buchungsnummer: [Wert] +- Kunde: [Wert] +- Telefon: [Wert] +- Filekey/CRS: [Wert] + +Flugsegmente: +1. [Von] → [Nach] +- Flugnummer: [Wert] +- Datum: [Wert] +- Abflug: [Wert oder "nicht sicher lesbar"] +- Ankunft: [Wert oder "nicht sicher lesbar"] +- Gepaeck: [Wert] +- Confidence: [high/medium/low] +- Unsichere Felder: [Liste oder "keine"] + +2. ... + +6. ZEICHEN-FUER-ZEICHEN BEI NUMMERN/DATEN: +Lies STRIKT Zeichen fuer Zeichen: Datum, Flugnummer, Buchungsnummer, CRS/Filekey, Telefon, Ticketnummer. +NICHT glaetten, NICHT umdeuten, NICHT kreativ korrigieren. +Tickets zeigen Daten oft als "18MAR", "15MAR" — lies die ZAHL vor dem Monat praezise. Verwechsle NICHT 18 mit 19, 13 mit 15. + +7. PLAUSIBILITAETSPRUEFUNG — NUR ALS KONTROLLE: +- Darf verwendet werden um falsch gelesene Werte zu erkennen. +- Darf NIEMALS fehlende Werte erfinden. +- Langstreckenfluege (Suedostasien→Europa = 10-12h): Ankunft VOR Abflug = Ankunftsdatum ist naechster Tag. +- Zeitzonen: Suedostasien UTC+7, Mitteleuropa CET/UTC+1 = 6h Differenz. +- RECHNE IMMER SELBST NACH. Kopiere NIEMALS Zeitberechnungen aus frueheren Antworten. + +ANSCHLUSSFLUG-PLAUSIBILITAET (PFLICHT bei aufeinanderfolgenden Segmenten): +Pruefe fuer JEDES Segmentpaar auf demselben Ticket: +a) Berechne: Differenz zwischen Ankunftszeit Segment N und Abflugzeit Segment N+1. +b) Wenn die UHRZEITEN weniger als 3 Stunden auseinander liegen (z.B. 23:30→23:35 oder 20:55→23:25), die DATEN aber einen Tag auseinander: + → Das Datum des zweiten Segments hat vermutlich Confidence MEDIUM. + → Markiere: "Datum moeglicherweise falsch gelesen — bei gleichem Tag waere Umsteigezeit [X Min/Stunden], bei Tagessprung [~24h]. Bitte pruefen." +c) Typische Umsteigezeiten auf einem Ticket: 1-6h. Wenn die berechnete Umsteigezeit >20h ist, ist das ein Warnsignal fuer ein falsch gelesenes Datum. +d) Bei Anschlussfluegen mit fast identischen Uhrzeiten (Differenz <30 Min) und genau 1 Tag Abstand: Das Datum ist mit HOHER Wahrscheinlichkeit falsch gelesen → MEDIUM confidence, expliziter Hinweis. + +8. KONSERVATIV FORMULIEREN: +Bei Reisen, Geld, Behoerden, Rechnungen, Buchungen: +- Lieber unvollstaendig aber ehrlich. +- NIEMALS praezise falsche Angaben machen. +- Speichere nur HIGH-CONFIDENCE Daten via memory_suggest (Reiseplaene, Buchungscodes). + +PREISRECHERCHE (PFLICHT): +Wenn der User nach Preisen, Kosten oder Preisentwicklung fragt: +- Nutze IMMER Tools statt Allgemeinwissen. +- Fuer schnelle Preisabfrage: web_search. +- Mache 2-3 gezielte web_search Aufrufe mit verschiedenen Suchbegriffen. +- deep_research NUR wenn User explizit 'deep research' oder 'tiefenrecherche' sagt. +- Gib konkrete Zahlen aus (EUR), nicht nur Tendenzen. +- Nenne 3-5 Quellen-Links. +- Wenn keine belastbaren Zahlen gefunden werden: klar sagen "keine belastbaren Preisdaten gefunden". + +FORMAT bei Preisantworten: +1) Zeitraum +2) Preis damals -> Preis heute (Delta in %) +3) Kurzfazit (steigend/fallend/stabil) +4) Quellen + +TOOLS: +Nutze Tools fuer Live-Daten. Wenn alles OK: kurz sagen. Bei Problemen: erklaeren + Loesung.""" + +TOOLS = tool_loader.get_tools() + + +def _get_api_key() -> str: + cfg = config.parse_config() + return cfg.api_keys.get("openrouter_key", "") + + +def _route_model(question: str) -> str: + """Entscheidet ob lokal, online (Sonar) oder deep_research.""" + q = question.lower() + if any(t in q for t in _DEEP_TRIGGERS): + return "deep_research" + if any(t in q for t in _LOCAL_OVERRIDES): + return MODEL_LOCAL + if any(t in q for t in _WEB_TRIGGERS): + return MODEL_ONLINE + return MODEL_LOCAL + + +def _ollama_timeout_for(model: str) -> int: + if model == MODEL_VISION: + return 240 + if model == FALLBACK_MODEL: + return 90 + return 180 + + +def _add_no_think(messages: list) -> None: + """Haengt /no_think an die letzte User-Nachricht fuer Ollama.""" + for msg in reversed(messages): + if msg.get("role") != "user": + continue + content = msg.get("content", "") + if isinstance(content, str) and "/no_think" not in content: + msg["content"] = content + " /no_think" + elif isinstance(content, list): + for part in content: + if part.get("type") == "text" and "/no_think" not in part.get("text", ""): + part["text"] = part["text"] + " /no_think" + break + break + + +def _call_api(messages: list, api_key: str, use_tools: bool = True, + model: str = None, max_tokens: int = 4000, + allow_fallback: bool = True) -> dict: + chosen = model or MODEL_LOCAL + use_ollama = chosen in OLLAMA_MODELS + log.info("LLM-Call: model=%s ollama=%s max_tokens=%d", chosen, use_ollama, max_tokens) + + payload = { + "model": chosen, + "messages": messages, + "max_tokens": max_tokens, + } + if use_tools: + payload["tools"] = TOOLS + payload["tool_choice"] = "auto" + + if use_ollama: + url = f"{OLLAMA_BASE}/v1/chat/completions" + headers = {"Content-Type": "application/json"} + timeout = _ollama_timeout_for(chosen) + _add_no_think(payload.get("messages", [])) + else: + url = f"{OPENROUTER_BASE}/chat/completions" + headers = {"Authorization": f"Bearer {api_key}"} + timeout = 90 + + try: + r = requests.post(url, headers=headers, json=payload, timeout=timeout) + r.raise_for_status() + return r.json() + except requests.exceptions.ReadTimeout: + if use_ollama and allow_fallback and FALLBACK_MODEL and chosen != FALLBACK_MODEL: + log.warning( + "Ollama timeout for %s after %ss, retrying with %s", + chosen, timeout, FALLBACK_MODEL, + ) + return _call_api( + messages, api_key, use_tools=use_tools, + model=FALLBACK_MODEL, max_tokens=max_tokens, + allow_fallback=False, + ) + raise + + +def ask(question: str, context: str) -> str: + """Legacy-Funktion fuer /commands die bereits Kontext mitbringen.""" + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + messages = [ + {"role": "system", "content": _full_prompt}, + {"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"}, + ] + try: + data = _call_api(messages, api_key, use_tools=False) + return data["choices"][0]["message"]["content"] + except Exception as e: + return f"LLM-Fehler: {e}" + + +def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None, document_mode: bool = False, model_override: str = None) -> str: + """Freitext-Frage mit automatischem Routing und Tool-Calling. + + Routing: + - deep_research / tiefenrecherche -> Deep Research Handler direkt + - Web/Preis/Recherche -> Perplexity Sonar (kein Tool-Calling) + - Alles andere -> Lokales Modell mit allen Tools + """ + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + route = _route_model(question) + + if document_mode and route != "deep_research": + route = MODEL_LOCAL + log.info("Betriebsart Unterlagen: lokales Modell, keine Web-Suche") + + # --- Deep Research: Perplexity Sonar Deep Research --- + if route == "deep_research": + log.info("Route: sonar-deep-research") + try: + messages_dr = [ + {"role": "system", "content": "Du bist ein Recherche-Assistent. Antworte auf Deutsch, strukturiert, mit konkreten Zahlen und Quellen."}, + {"role": "user", "content": question}, + ] + data = _call_api(messages_dr, api_key, use_tools=False, + model="perplexity/sonar-deep-research", max_tokens=4000) + return data["choices"][0]["message"].get("content", "Keine Antwort von Sonar Deep Research.") + except Exception as e: + return f"Sonar Deep Research Fehler: {e}" + + log.info("Route: %s", route) + + # --- Memory + Prompt aufbauen --- + try: + import memory_client + memory_items = memory_client.get_relevant_memory(question, top_k=10) + memory_block = memory_client.format_memory_for_prompt(memory_items) + except Exception: + memory_block = "" + + try: + import openmemory_client + openmemory_block = openmemory_client.get_openmemory_for_prompt(question, top_k=8) + except Exception: + openmemory_block = "" + + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block + (("\n" + openmemory_block) if openmemory_block else "") + + messages = [ + {"role": "system", "content": _full_prompt}, + ] + + _RECAP_MARKERS = ["was haben wir", "worüber haben wir", "worüber hatten wir", + "worueber haben wir", "was hatten wir besprochen", "was war heute thema"] + + def _is_recap(text): + t = text.lower() + return any(m in t for m in _RECAP_MARKERS) + + if session_id: + try: + import memory_client + history = memory_client.get_session_messages(session_id, limit=10) + skip_next_assistant = False + for msg in history: + role = msg.get("role", "") + content = msg.get("content", "") + if not content: + continue + if role == "user" and _is_recap(content): + skip_next_assistant = True + continue + if role == "assistant" and skip_next_assistant: + skip_next_assistant = False + continue + skip_next_assistant = False + if role in ("user", "assistant"): + messages.append({"role": role, "content": content}) + except Exception: + pass + + messages.append({"role": "user", "content": question}) + + # --- RAG-Pflicht: Bei Doc-Keywords rag_search DIREKT aufrufen --- + _DOC_KW = [ + "versicherung", "vertrag", "vertraege", "dokument", "rente", + "finanzamt", "steuer", "grundsteuer", "familienbuch", "urkunde", + "bescheid", "police", "beitrag", "mietvertrag", "arbeitsvertrag", + "kindergeld", "rechnung", "haftpflicht", "rechtsschutz", + "lebensversicherung", "bauspar", "reisepass", "personalausweis", + "lvm", "allianz", "ergo", "huk", "nuernberger", + "jahreskosten", "jährlichen", "jährliche", "jaehrlichen", "jaehrliche", + "monatliche kosten", "versicherungskosten", "beitragsrechnung", + "wohnung", "wohnungen", "immobilie", "immobilien", "mietwohnung", + "condo", "apartment", "eigentumswohnung", "häuser", "haeuser", + "haus ", " haus", + "grundstueck", "grundstück", "kambodscha", "cambodia", "takeo", + "phnom", "sihanouk", "siem reap", + ] + _q_low = question.lower() + if route == MODEL_LOCAL and (document_mode or any(k in _q_low for k in _DOC_KW)): + _rag_fn = tool_handlers.get("rag_search") + if _rag_fn: + try: + log.info("RAG-Pflicht: forciere rag_search fuer: %s", question[:80]) + _rag_q = question + if any( + x in _q_low + for x in ( + "kosten", + "jähr", + "jaehr", + "jahreskosten", + "monatliche kosten", + ) + ): + _rag_q = ( + question + + " Versicherung Beitrag Beitragsrechnung Jahresbetrag" + ) + elif any( + x in _q_low + for x in ( + "wohnung", + "immobilie", + "condo", + "apartment", + "grundstueck", + "grundstück", + "kambodscha", + "cambodia", + "takeo", + "phnom", + ) + ): + _rag_q = question + " Wohnung Immobilie Mietvertrag Kambodscha" + _rag_finance_focus = any( + x in _q_low + for x in ( + "kosten", + "beitrag", + "€", + " eur", + "eur,", + "zahlung", + "versicherung", + "jähr", + "jaehr", + "jahreskosten", + "monatliche kosten", + "beitragsrechnung", + "preis", + "gebühr", + "gebuehr", + ) + ) + _rag_res = _rag_fn(query=_rag_q, top_k=60) + if _rag_res and not _rag_res.startswith("Keine"): + log.info("RAG-Pflicht: %d Zeichen — loesche Session-History", len(str(_rag_res))) + if _rag_finance_focus: + _rag_extra = ( + "\n\nWICHTIG: Ignoriere fruehere Antworten. " + + "STIL-OVERRIDE: Trotz globalem Hausmeister-Stil (kurz): diese Antwort bewusst etwas ausfuehrlicher " + + "(mehrere Saetze, Aufzaehlungen), damit Kosten und Zeitraeume ohne Nachfrage klar sind. " + + "Die Dokumentensuche unten ist die einzige Wahrheit. " + + "Beantworte die Frage NUR basierend auf diesen Suchergebnissen. " + + "Struktur: (1) Kurzfassung 1-2 Saetze: welche Jahre/Rechnungen vorliegen und was sich daraus fuer " + + "einen Jahresbetrag ableiten laesst oder warum nicht. " + + "(2) Danach je relevantem Treffer: Dateiname, Betrag falls im Snippet, Zeitraum falls erkennbar. " + + "Wenn mehrere Treffer zur gleichen Gesellschaft gehoeren: " + + "liste jede erkannte Sparte bzw. jeden Dokumenttyp separat " + + "(z.B. Kfz/Auto, Rechtsschutz, Haftpflicht, Sach, Ausland, Kranken) " + + "mit Beleg (Dateiname oder Ordner aus den Treffern). " + + "Nicht nur den ersten Treffer nennen. " + + "Bei Kosten/Beitraegen: je Treffer Betrag und Zeitraum nennen wenn im Snippet erkennbar; " + + "wenn nicht erkennbar, ausdruecklich sagen. " + + "VERBOTEN: Antwort nur als nackte Zahl (z.B. nur eine EUR-Zeile ohne Kontext). " + + "Zu JEDEM Betrag mindestens einen Dateinamen aus den Treffern nennen. " + + "Bei Kfz/Fahrzeug: sagen welches Dokument sich darauf bezieht oder dass die Zuordnung unsicher ist." + ) + _rag_user_suffix = ( + "\n\n[Strukturiert: Kurzfassung zu Jahr/jaehrlich, dann je Dokument Dateiname mit Betrag " + + "und Zeitraum aus den Treffern; keine reine Ein-Zahl-Antwort.]" + ) + else: + _rag_extra = ( + "\n\nWICHTIG: Ignoriere fruehere Antworten und Priorisiere die Dokumentensuche unten " + + "ueber Kurzinfos aus dem Gedaechtnis. " + + "STIL-OVERRIDE: etwas ausfuehrlicher als sonst (Aufzaehlungen ok). " + + "Die Treffer sind die einzige belastbare Grundlage. " + + "Struktur: (1) Kurzfassung: was in den Treffern zur Frage passt. " + + "(2) Je relevantem Treffer: Dateiname/Ordnerpfad und erkennbare Fakten aus dem Snippet " + + "(Objekt, Ort, Datum, Parteien) — nichts erfinden. " + + "Alle plausibel passenden Treffer nennen, nicht nur den ersten. " + + "Wenn nichts passt: klar sagen dass die Suche nichts Relevantes lieferte." + ) + _rag_user_suffix = ( + "\n\n[Strukturiert: Kurzfassung, dann Liste je Treffer mit Dateiname und Fakten nur aus dem Snippet; " + + "keine erfundenen Details.]" + ) + messages = [ + {"role": "system", "content": _full_prompt + _rag_extra}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "forced_rag", "type": "function", + "function": {"name": "rag_search", + "arguments": json.dumps({"query": _rag_q, "top_k": 60})}}]}, + {"role": "tool", "tool_call_id": "forced_rag", + "content": str(_rag_res)[:100000]}, + {"role": "user", "content": question + _rag_user_suffix}, + ] + except Exception as e: + log.warning("RAG-Pflicht Fehler: %s", e) + + # --- Online (Sonar): kein Tool-Calling, Sonar sucht selbst --- + if route == MODEL_ONLINE: + try: + data = _call_api(messages, api_key, use_tools=False, model=MODEL_ONLINE) + content = data["choices"][0]["message"].get("content", "") + if session_id: + try: + memory_client.log_message(session_id, "user", question) + memory_client.log_message(session_id, "assistant", content) + except Exception: + pass + return content or "Keine Antwort von Sonar." + except Exception as e: + return f"Online-Suche Fehler: {e}" + + # --- Lokal: Tool-Calling mit allen Tools --- + local_model = model_override or MODEL_LOCAL + passthrough_result = None + + try: + for _round in range(MAX_TOOL_ROUNDS): + data = _call_api(messages, api_key, use_tools=True, model=local_model) + choice = data["choices"][0] + msg = choice["message"] + + tool_calls = msg.get("tool_calls") + if not tool_calls: + content = msg.get("content") or "" + if not content and msg.get("reasoning"): + content = msg.get("reasoning", "") + if passthrough_result: + return passthrough_result + return content or "Keine Antwort vom LLM." + + messages.append(msg) + + for tc in tool_calls: + fn_name = tc["function"]["name"] + try: + fn_args = json.loads(tc["function"]["arguments"]) + except (json.JSONDecodeError, KeyError): + fn_args = {} + + log.info("Tool-Call: %s args=%s", fn_name, str(fn_args)[:200]) + handler = tool_handlers.get(fn_name) + if handler: + try: + result = handler(**fn_args) + except Exception as e: + result = f"Fehler bei {fn_name}: {e}" + else: + result = f"Unbekanntes Tool: {fn_name}" + + result_str = str(result)[:3000] + + if fn_name in PASSTHROUGH_TOOLS and not result_str.startswith(("Fehler", "Keine")): + log.info("Passthrough-Tool %s", fn_name) + passthrough_result = result_str + + messages.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": result_str, + }) + + if passthrough_result: + return passthrough_result + data = _call_api(messages, api_key, use_tools=False, model=local_model) + return data["choices"][0]["message"]["content"] + + except Exception as e: + return f"LLM-Fehler: {e}" + + +def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session_id: str = None) -> str: + """Bild-Analyse via lokalem Vision-Modell mit Tool-Calling.""" + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + try: + import memory_client + query = caption if caption else "Bild-Analyse" + memory_items = memory_client.get_relevant_memory(query, top_k=10) + memory_block = memory_client.format_memory_for_prompt(memory_items) + except Exception: + memory_block = "" + + try: + import openmemory_client + q = caption if caption else "Bild-Analyse" + openmemory_block = openmemory_client.get_openmemory_for_prompt(q, top_k=8) + except Exception: + openmemory_block = "" + + default_prompt = ( + "Analysiere dieses Bild. " + "Wenn es ein Dokument ist (Ticket, Rechnung, Beleg, Buchung): " + "Wende die DOKUMENTENEXTRAKTION-Regeln an — feldweise, konservativ, mit Confidence pro Feld. " + "Markiere unsichere Felder explizit. Erfinde NICHTS. " + "Lies Nummern und Daten Zeichen fuer Zeichen. " + "Wenn es ein normales Bild ist: Beschreibe strukturiert was du siehst." + ) + prompt_text = caption if caption else default_prompt + user_content = [ + {"type": "text", "text": prompt_text}, + {"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 + (("\n" + openmemory_block) if openmemory_block else "") + + messages = [ + {"role": "system", "content": _full_prompt}, + ] + + if session_id: + try: + import memory_client + history = memory_client.get_session_messages(session_id, limit=6) + for msg in history: + role = msg.get("role", "") + content = msg.get("content", "") + if content and role in ("user", "assistant"): + messages.append({"role": role, "content": content}) + except Exception: + pass + + messages.append({"role": "user", "content": user_content}) + + try: + for _round in range(MAX_TOOL_ROUNDS): + data = _call_api(messages, api_key, use_tools=True, + model=MODEL_VISION, max_tokens=4000, + allow_fallback=False) + choice = data["choices"][0] + msg = choice["message"] + + tool_calls = msg.get("tool_calls") + if not tool_calls: + content = msg.get("content") or "" + if not content and msg.get("reasoning"): + content = msg.get("reasoning", "") + return content or "Keine Antwort vom LLM." + + messages.append(msg) + + for tc in tool_calls: + fn_name = tc["function"]["name"] + try: + fn_args = json.loads(tc["function"]["arguments"]) + except (json.JSONDecodeError, KeyError): + fn_args = {} + + handler = tool_handlers.get(fn_name) + if handler: + try: + result = handler(**fn_args) + except Exception as e: + result = f"Fehler bei {fn_name}: {e}" + else: + result = f"Unbekanntes Tool: {fn_name}" + + messages.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": str(result)[:3000], + }) + + data = _call_api(messages, api_key, use_tools=False, + model=MODEL_VISION, max_tokens=4000, + allow_fallback=False) + return data["choices"][0]["message"]["content"] + + except requests.exceptions.ReadTimeout: + return ( + "Das Vision-Modell antwortet nicht (Timeout). " + "Bitte in 1-2 Min erneut versuchen — das Modell wird gerade geladen." + ) + except Exception as e: + return f"Vision-LLM-Fehler: {e}" diff --git a/homelab-ai-bot/llm.py.bak.20260605_before-gemini b/homelab-ai-bot/llm.py.bak.20260605_before-gemini new file mode 100644 index 000000000..301526563 --- /dev/null +++ b/homelab-ai-bot/llm.py.bak.20260605_before-gemini @@ -0,0 +1,740 @@ +"""OpenRouter LLM-Wrapper mit Tool-Calling. + +Neue Datenquelle = eine Datei in tools/ anlegen. Fertig. +""" + +import json +import logging +import requests +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config +import tool_loader + +log = logging.getLogger('llm') + +OLLAMA_BASE = "http://100.84.255.83:11434" +OPENROUTER_BASE = "https://openrouter.ai/api/v1" + +MODEL_LOCAL = "x-ai/grok-4.3" +MODEL_VISION = "openai/gpt-4o-mini" +MODEL_ONLINE = "perplexity/sonar" +FALLBACK_MODEL = None +MAX_TOOL_ROUNDS = 3 +OLLAMA_MODELS = set() + + +def warmup_ollama(): + """No-Op: Text-Modell laeuft ueber OpenRouter (x-ai/grok-4.3), kein Ollama-Warmup noetig.""" + log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (x-ai/grok-4.3)') +PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"} + +_LOCAL_OVERRIDES = [ + "api kosten", "api-kosten", "guthaben", "openrouter", + "container", "status", "fehler", "logs", "service", + "backup", "feed", "memory", "gedaechtnis", "gedächtnis", + "mail", "seafile", "forgejo", "grafana", "prometheus", + "savetv", "save.tv", "filmtipp", "aufnahme", + "wordpress", "matomo", "tailscale", + "unsere api", "meine api", "die api", + "vorhersage", "prognose", "health forecast", "was bahnt", "systemstatus", + "system check", "system-check", "wie gehts dem system", "wie geht es dem system", + "versicherung", "vertrag", "verträge", "vertraege", "dokument", + "rente", "rentenversicherung", "finanzamt", "steuer", "grundsteuer", + "familienbuch", "urkunde", "bescheid", "police", "beitrag", + "meine unterlagen", "meine dokumente", "meine dateien", + "habe ich", "welche habe", "was habe ich", + "jahreskosten", "jährlichen", "jährliche", "jaehrlichen", "jaehrliche", + "monatliche kosten", "versicherungskosten", "beitragsrechnung", + # Possessiv/Persoenlich (2026-04-16) + "meine", "mein ", "meiner", "meines", "meinem", "meinen", + "wohnung", "wohnungen", "apartment", "apartments", + "condo", "condos", "kondo", "kondos", "immobilie", "immobilien", + "kambodscha", "cambodia", "phnom penh", "arakawa", + "gekostet", "gekauft", "kaufpreis", "kaufpreise", "bezahlt", + "ausgegeben", "ueberweisung", "überweisung", + "was haben", "wieviel habe ich", "wie viel habe ich", + "fuer die wohnung", "für die wohnung", + "ich fuer", "ich für", + # Chatlogger / Telegram-Mitschnitt (2026-05-01) — verhindert Sonar-Routing bei "aktueller Stand" + "concierge", "conciergerie", "bnb", "airbnb", "listing", "listings", + "vermietung", "vermieten", "vermietet", + "kaution", "mieter", "mieterin", "miete", + "g2010b", "g 2010 b", "g 2010b", "g210", "g 210", + "d1603", "d 1603", "d 16 03", + "telegram-gruppe", "telegram gruppe", "chatverlauf", "chat verlauf", + "wer hat zugesagt", "was ist offen", "muss ich reagieren", + "letzte nachricht", "im chat", "in der gruppe", +] +_WEB_TRIGGERS = [ + "recherche", "recherchiere", "suche im internet", "web search", + "preis", "preise", "kostet", "price", + "news", "nachrichten", "aktuell", "aktuelle", + "google", "finde heraus", "finde raus", + "gold", "silber", "kurs", "kurse", + "vergleich", "vergleiche", + "was kostet", "wie teuer", "wie viel", +] +_DEEP_TRIGGERS = ["deep research", "tiefenrecherche", "tiefensuche", "tiefe suche", "detailrecherche", "ausfuehrliche recherche", "ausführliche recherche", "vollstaendige recherche", "vollständige recherche", "recherchiere genau", "analysiere genau"] + +import datetime as _dt +_TODAY = _dt.date.today() +_3M_AGO = (_TODAY - _dt.timedelta(days=90)) +_DATE_LINE = f'Heutiges Datum: {_TODAY.strftime("%d. %B %Y")}. Wir sind im Jahr {_TODAY.year}. Letzte 3 Monate = {_3M_AGO.strftime("%B %Y")} bis {_TODAY.strftime("%B %Y")}.' + +SYSTEM_PROMPT = _DATE_LINE + """ +Du bist der Hausmeister-Bot fuer ein Homelab. Deutsch, kurz, direkt, operativ. + +STIL: +- So wenig Worte wie moeglich, solange nichts Wichtiges fehlt. +- KEINE Abschlussformeln ("Wenn du weitere Informationen benoetigst..."). +- KEINE kuenstlichen Wuensche ("Guten Flug!", "Viel Erfolg!"). +- KEINE Rueckfragen ob der User mehr wissen will. +- Emojis nur wenn sie Information tragen. Telegram-Format (kein Markdown). + +GEDAECHTNIS LESEN: +Am Ende dieses System-Prompts findest du eine Sektion "=== GEDAECHTNIS ===" mit Fakten die du dir ueber den User und die Umgebung gemerkt hast. +WICHTIG: Beantworte Fragen IMMER anhand dieser Fakten! Wenn der User z.B. fragt "Wo fliege ich hin?" und im Gedaechtnis steht "Ist naechste Woche in Frankfurt", dann antworte direkt mit dieser Info. +Das Gedaechtnis ist deine primaere Wissensquelle ueber den User. + +GEDAECHTNIS — memory_suggest: +Du MUSST memory_suggest aufrufen wenn der User etwas Nuetzliches sagt. +memory_suggest speichert DIREKT. Duplikate werden automatisch erkannt. Widersprueche werden automatisch aufgeloest (altes Item wird ersetzt). + +MEMORY_TYPE — immer angeben: +- "preference" → Vorlieben ("Lieblingskaffee ist Flat White", "bevorzuge dunkles Theme") +- "relationship" → Beziehungen/Rollen ("Ali ist Ansprechpartner fuer Wohnung") +- "fact" → Stabile Fakten ("Server IP geaendert", "Jarvis aktiv seit...") +- "plan" → Konkrete Vorhaben mit Zeitbezug ("Reise nach Frankfurt am 18.03.") +- "temporary" → Kurzfristige Zustaende ("bin bis Mittwoch in Berlin", "morgen Zahnarzt") +- "uncertain" → Vage Aussagen ("vielleicht fliege ich frueher", "glaube Ali kuemmert sich") + +CONFIDENCE — immer angeben: +- "high" → Klare Aussage ("Mein Lieblingskaffee ist Flat White") +- "medium" → Wahrscheinlich aber nicht 100% ("Ali kuemmert sich wohl darum") +- "low" → Sehr vage ("vielleicht aendere ich das noch") + +REGELN: +- Bei plan/temporary: IMMER expires_at mit Zeitausdruck angeben ("naechste Woche", "morgen") +- Bei uncertain mit low confidence: NUR speichern wenn es trotzdem nuetzlich sein koennte +- Vages Gerede, Smalltalk, emotionale Kurzaeusserungen: NICHT speichern +- Passwoerter, Tokens: NIE via memory_suggest speichern. ABER: Wenn der User nach Zugangsdaten fragt, nutze get_service_directory — dort sind alle URLs und Logins hinterlegt. +- Bei Widerspruch zu bestehendem Wissen: NUR den neuen Fakt speichern (z.B. "Lieblingskaffee ist Americano"). NICHT den alten Fakt umformulieren oder als "war..." speichern. Das System erkennt Widersprueche automatisch und ersetzt den alten Eintrag. + +SESSION-RUECKBLICK: +- "Was haben wir besprochen?" → session_summary OHNE topic +- "Was haben wir ueber X besprochen?" → session_summary MIT topic="X" +- NUR echte Gespraechsinhalte aus den Treffern wiedergeben. +- KEINE Negativ-Aussagen hinzufuegen. Nichts ueber Dinge sagen die NICHT besprochen wurden. + Verboten: "wurde nicht thematisiert", "keine weiteren Details", "nicht besprochen", "nicht erwaehnt". +- Wenn es nur 1 Treffer gibt, gib nur diesen 1 Treffer wieder. Fuege nichts hinzu. +- Optional kurz erwaehnen was sonst noch Thema war. +- session_search nur fuer Stichwort-Suche in ALTEN Sessions (nicht aktuelle). + +BILDERKENNUNG — ALLGEMEIN: +Wenn der User ein Bild schickt das KEIN kritisches Dokument ist (z.B. Foto, Screenshot, Landschaft): +- Beschreibe strukturiert was du siehst. +- Bei Screenshots von Fehlern/Logs: Identifiziere das Problem, ordne es einem Container/Service zu, schlage Loesung vor. +- Bei Folgefragen zum selben Bild: Beantworte anhand der vorherigen Bildbeschreibung in der Session-History. +- Speichere erkannte Termine/Plaene via memory_suggest. + +DOKUMENTENEXTRAKTION — HARTE REGELN: +Gilt fuer: Flugtickets, Buchungen, Belege, Rechnungen, Formulare, Behoerdendokumente. +Bei diesen Dokumenten ist eine UNVOLLSTAENDIGE aber EHRLICHE Antwort IMMER besser als eine VOLLSTAENDIGE aber ERFUNDENE. + +1. NIEMALS RATEN: +- Wenn ein Feld nicht sicher lesbar ist: NICHT erfinden. +- Keine plausible Uhrzeit ergaenzen. Keine Codes aus Weltwissen ergaenzen. +- Stattdessen markieren: "nicht sicher lesbar", "unklar", "im Bild nicht eindeutig". + +2. BILDWISSEN VOR WELTWISSEN: +- NUR extrahieren was im Bild steht. KEIN externes Wissen um fehlende Felder zu "reparieren". +- Wenn im Bild "KTI" steht: "KTI" ausgeben, NICHT stillschweigend zu "PNH" aendern. +- Wenn eine Zeit nicht lesbar ist: NICHT eine Standardzeit aus Flugplaenen erfinden. +- Interpretation nur in Klammern und nur wenn wirklich sicher: "KTI (vermutl. Phnom Penh Techo International)". + +3. ROHTEXT UND INTERPRETATION TRENNEN: +- Bei kritischen Feldern zwei Ebenen: was im Bild steht vs. was interpretiert wird. +- Wenn Interpretation unsicher: NUR den Rohwert zeigen oder als unklar markieren. + +4. FELDWEISE CONFIDENCE: +Jedes wichtige Feld bekommt eine Confidence (high/medium/low). +Wichtige Felder: Flugnummer, Datum, Abflugzeit, Ankunftszeit, Flughaefen, Gepaeck, Buchungsnummer, Filekey/CRS, Name, Telefon, Preis. +- Unsichere Felder NIEMALS mit hoher Sicherheit formulieren. +- Low-confidence Felder IMMER kennzeichnen. + +5. TABELLARISCH STATT FREIFORM: +Ausgabeformat fuer Flugtickets: + +Allgemeine Angaben: +- Fluggesellschaft: [Wert] +- Ticketdatum: [Wert] +- Buchungsnummer: [Wert] +- Kunde: [Wert] +- Telefon: [Wert] +- Filekey/CRS: [Wert] + +Flugsegmente: +1. [Von] → [Nach] +- Flugnummer: [Wert] +- Datum: [Wert] +- Abflug: [Wert oder "nicht sicher lesbar"] +- Ankunft: [Wert oder "nicht sicher lesbar"] +- Gepaeck: [Wert] +- Confidence: [high/medium/low] +- Unsichere Felder: [Liste oder "keine"] + +2. ... + +6. ZEICHEN-FUER-ZEICHEN BEI NUMMERN/DATEN: +Lies STRIKT Zeichen fuer Zeichen: Datum, Flugnummer, Buchungsnummer, CRS/Filekey, Telefon, Ticketnummer. +NICHT glaetten, NICHT umdeuten, NICHT kreativ korrigieren. +Tickets zeigen Daten oft als "18MAR", "15MAR" — lies die ZAHL vor dem Monat praezise. Verwechsle NICHT 18 mit 19, 13 mit 15. + +7. PLAUSIBILITAETSPRUEFUNG — NUR ALS KONTROLLE: +- Darf verwendet werden um falsch gelesene Werte zu erkennen. +- Darf NIEMALS fehlende Werte erfinden. +- Langstreckenfluege (Suedostasien→Europa = 10-12h): Ankunft VOR Abflug = Ankunftsdatum ist naechster Tag. +- Zeitzonen: Suedostasien UTC+7, Mitteleuropa CET/UTC+1 = 6h Differenz. +- RECHNE IMMER SELBST NACH. Kopiere NIEMALS Zeitberechnungen aus frueheren Antworten. + +ANSCHLUSSFLUG-PLAUSIBILITAET (PFLICHT bei aufeinanderfolgenden Segmenten): +Pruefe fuer JEDES Segmentpaar auf demselben Ticket: +a) Berechne: Differenz zwischen Ankunftszeit Segment N und Abflugzeit Segment N+1. +b) Wenn die UHRZEITEN weniger als 3 Stunden auseinander liegen (z.B. 23:30→23:35 oder 20:55→23:25), die DATEN aber einen Tag auseinander: + → Das Datum des zweiten Segments hat vermutlich Confidence MEDIUM. + → Markiere: "Datum moeglicherweise falsch gelesen — bei gleichem Tag waere Umsteigezeit [X Min/Stunden], bei Tagessprung [~24h]. Bitte pruefen." +c) Typische Umsteigezeiten auf einem Ticket: 1-6h. Wenn die berechnete Umsteigezeit >20h ist, ist das ein Warnsignal fuer ein falsch gelesenes Datum. +d) Bei Anschlussfluegen mit fast identischen Uhrzeiten (Differenz <30 Min) und genau 1 Tag Abstand: Das Datum ist mit HOHER Wahrscheinlichkeit falsch gelesen → MEDIUM confidence, expliziter Hinweis. + +8. KONSERVATIV FORMULIEREN: +Bei Reisen, Geld, Behoerden, Rechnungen, Buchungen: +- Lieber unvollstaendig aber ehrlich. +- NIEMALS praezise falsche Angaben machen. +- Speichere nur HIGH-CONFIDENCE Daten via memory_suggest (Reiseplaene, Buchungscodes). + +PREISRECHERCHE (PFLICHT): +Wenn der User nach Preisen, Kosten oder Preisentwicklung fragt: +- Nutze IMMER Tools statt Allgemeinwissen. +- Fuer schnelle Preisabfrage: web_search. +- Mache 2-3 gezielte web_search Aufrufe mit verschiedenen Suchbegriffen. +- deep_research NUR wenn User explizit 'deep research' oder 'tiefenrecherche' sagt. +- Gib konkrete Zahlen aus (EUR), nicht nur Tendenzen. +- Nenne 3-5 Quellen-Links. +- Wenn keine belastbaren Zahlen gefunden werden: klar sagen "keine belastbaren Preisdaten gefunden". + +FORMAT bei Preisantworten: +1) Zeitraum +2) Preis damals -> Preis heute (Delta in %) +3) Kurzfazit (steigend/fallend/stabil) +4) Quellen + +TOOLS: +Nutze Tools fuer Live-Daten. Wenn alles OK: kurz sagen. Bei Problemen: erklaeren + Loesung.""" + +TOOLS = tool_loader.get_tools() + + +def _get_api_key() -> str: + cfg = config.parse_config() + return cfg.api_keys.get("openrouter_key", "") + + +def _route_model(question: str) -> str: + """Entscheidet ob lokal, online (Sonar) oder deep_research.""" + q = question.lower() + if any(t in q for t in _DEEP_TRIGGERS): + return "deep_research" + if any(t in q for t in _LOCAL_OVERRIDES): + return MODEL_LOCAL + if any(t in q for t in _WEB_TRIGGERS): + return MODEL_ONLINE + return MODEL_LOCAL + + +def _ollama_timeout_for(model: str) -> int: + if model == MODEL_VISION: + return 240 + if model == FALLBACK_MODEL: + return 90 + return 180 + + +def _add_no_think(messages: list) -> None: + """Haengt /no_think an die letzte User-Nachricht fuer Ollama.""" + for msg in reversed(messages): + if msg.get("role") != "user": + continue + content = msg.get("content", "") + if isinstance(content, str) and "/no_think" not in content: + msg["content"] = content + " /no_think" + elif isinstance(content, list): + for part in content: + if part.get("type") == "text" and "/no_think" not in part.get("text", ""): + part["text"] = part["text"] + " /no_think" + break + break + + +def _call_api(messages: list, api_key: str, use_tools: bool = True, + model: str = None, max_tokens: int = 4000, + allow_fallback: bool = True) -> dict: + chosen = model or MODEL_LOCAL + use_ollama = chosen in OLLAMA_MODELS + log.info("LLM-Call: model=%s ollama=%s max_tokens=%d", chosen, use_ollama, max_tokens) + + payload = { + "model": chosen, + "messages": messages, + "max_tokens": max_tokens, + } + if use_tools: + payload["tools"] = TOOLS + payload["tool_choice"] = "auto" + + if use_ollama: + url = f"{OLLAMA_BASE}/v1/chat/completions" + headers = {"Content-Type": "application/json"} + timeout = _ollama_timeout_for(chosen) + _add_no_think(payload.get("messages", [])) + else: + url = f"{OPENROUTER_BASE}/chat/completions" + headers = {"Authorization": f"Bearer {api_key}"} + timeout = 90 + + try: + r = requests.post(url, headers=headers, json=payload, timeout=timeout) + r.raise_for_status() + return r.json() + except requests.exceptions.ReadTimeout: + if use_ollama and allow_fallback and FALLBACK_MODEL and chosen != FALLBACK_MODEL: + log.warning( + "Ollama timeout for %s after %ss, retrying with %s", + chosen, timeout, FALLBACK_MODEL, + ) + return _call_api( + messages, api_key, use_tools=use_tools, + model=FALLBACK_MODEL, max_tokens=max_tokens, + allow_fallback=False, + ) + raise + + +def ask(question: str, context: str) -> str: + """Legacy-Funktion fuer /commands die bereits Kontext mitbringen.""" + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + messages = [ + {"role": "system", "content": _full_prompt}, + {"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"}, + ] + try: + data = _call_api(messages, api_key, use_tools=False) + return data["choices"][0]["message"]["content"] + except Exception as e: + return f"LLM-Fehler: {e}" + + +def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None, document_mode: bool = False, model_override: str = None) -> str: + """Freitext-Frage mit automatischem Routing und Tool-Calling. + + Routing: + - deep_research / tiefenrecherche -> Deep Research Handler direkt + - Web/Preis/Recherche -> Perplexity Sonar (kein Tool-Calling) + - Alles andere -> Lokales Modell mit allen Tools + """ + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + route = _route_model(question) + + if document_mode and route != "deep_research": + route = MODEL_LOCAL + log.info("Betriebsart Unterlagen: lokales Modell, keine Web-Suche") + + # --- Deep Research: Perplexity Sonar Deep Research --- + if route == "deep_research": + log.info("Route: sonar-deep-research") + try: + messages_dr = [ + {"role": "system", "content": "Du bist ein Recherche-Assistent. Antworte auf Deutsch, strukturiert, mit konkreten Zahlen und Quellen."}, + {"role": "user", "content": question}, + ] + data = _call_api(messages_dr, api_key, use_tools=False, + model="perplexity/sonar-deep-research", max_tokens=4000) + return data["choices"][0]["message"].get("content", "Keine Antwort von Sonar Deep Research.") + except Exception as e: + return f"Sonar Deep Research Fehler: {e}" + + log.info("Route: %s", route) + + # --- Memory + Prompt aufbauen --- + try: + import memory_client + memory_items = memory_client.get_relevant_memory(question, top_k=10) + memory_block = memory_client.format_memory_for_prompt(memory_items) + except Exception: + memory_block = "" + + try: + import openmemory_client + openmemory_block = openmemory_client.get_openmemory_for_prompt(question, top_k=8) + except Exception: + openmemory_block = "" + + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block + (("\n" + openmemory_block) if openmemory_block else "") + + messages = [ + {"role": "system", "content": _full_prompt}, + ] + + _RECAP_MARKERS = ["was haben wir", "worüber haben wir", "worüber hatten wir", + "worueber haben wir", "was hatten wir besprochen", "was war heute thema"] + + def _is_recap(text): + t = text.lower() + return any(m in t for m in _RECAP_MARKERS) + + if session_id: + try: + import memory_client + history = memory_client.get_session_messages(session_id, limit=10) + skip_next_assistant = False + for msg in history: + role = msg.get("role", "") + content = msg.get("content", "") + if not content: + continue + if role == "user" and _is_recap(content): + skip_next_assistant = True + continue + if role == "assistant" and skip_next_assistant: + skip_next_assistant = False + continue + skip_next_assistant = False + if role in ("user", "assistant"): + messages.append({"role": role, "content": content}) + except Exception: + pass + + messages.append({"role": "user", "content": question}) + + # --- RAG-Pflicht: Bei Doc-Keywords rag_search DIREKT aufrufen --- + _DOC_KW = [ + "versicherung", "vertrag", "vertraege", "dokument", "rente", + "finanzamt", "steuer", "grundsteuer", "familienbuch", "urkunde", + "bescheid", "police", "beitrag", "mietvertrag", "arbeitsvertrag", + "kindergeld", "rechnung", "haftpflicht", "rechtsschutz", + "lebensversicherung", "bauspar", "reisepass", "personalausweis", + "lvm", "allianz", "ergo", "huk", "nuernberger", + "jahreskosten", "jährlichen", "jährliche", "jaehrlichen", "jaehrliche", + "monatliche kosten", "versicherungskosten", "beitragsrechnung", + "wohnung", "wohnungen", "immobilie", "immobilien", "mietwohnung", + "condo", "apartment", "eigentumswohnung", "häuser", "haeuser", + "haus ", " haus", + "grundstueck", "grundstück", "kambodscha", "cambodia", "takeo", + "phnom", "sihanouk", "siem reap", + ] + _q_low = question.lower() + if route == MODEL_LOCAL and (document_mode or any(k in _q_low for k in _DOC_KW)): + _rag_fn = tool_handlers.get("rag_search") + if _rag_fn: + try: + log.info("RAG-Pflicht: forciere rag_search fuer: %s", question[:80]) + _rag_q = question + if any( + x in _q_low + for x in ( + "kosten", + "jähr", + "jaehr", + "jahreskosten", + "monatliche kosten", + ) + ): + _rag_q = ( + question + + " Versicherung Beitrag Beitragsrechnung Jahresbetrag" + ) + elif any( + x in _q_low + for x in ( + "wohnung", + "immobilie", + "condo", + "apartment", + "grundstueck", + "grundstück", + "kambodscha", + "cambodia", + "takeo", + "phnom", + ) + ): + _rag_q = question + " Wohnung Immobilie Mietvertrag Kambodscha" + _rag_finance_focus = any( + x in _q_low + for x in ( + "kosten", + "beitrag", + "€", + " eur", + "eur,", + "zahlung", + "versicherung", + "jähr", + "jaehr", + "jahreskosten", + "monatliche kosten", + "beitragsrechnung", + "preis", + "gebühr", + "gebuehr", + ) + ) + _rag_res = _rag_fn(query=_rag_q, top_k=60) + if _rag_res and not _rag_res.startswith("Keine"): + log.info("RAG-Pflicht: %d Zeichen — loesche Session-History", len(str(_rag_res))) + if _rag_finance_focus: + _rag_extra = ( + "\n\nWICHTIG: Ignoriere fruehere Antworten. " + + "STIL-OVERRIDE: Trotz globalem Hausmeister-Stil (kurz): diese Antwort bewusst etwas ausfuehrlicher " + + "(mehrere Saetze, Aufzaehlungen), damit Kosten und Zeitraeume ohne Nachfrage klar sind. " + + "Die Dokumentensuche unten ist die einzige Wahrheit. " + + "Beantworte die Frage NUR basierend auf diesen Suchergebnissen. " + + "Struktur: (1) Kurzfassung 1-2 Saetze: welche Jahre/Rechnungen vorliegen und was sich daraus fuer " + + "einen Jahresbetrag ableiten laesst oder warum nicht. " + + "(2) Danach je relevantem Treffer: Dateiname, Betrag falls im Snippet, Zeitraum falls erkennbar. " + + "Wenn mehrere Treffer zur gleichen Gesellschaft gehoeren: " + + "liste jede erkannte Sparte bzw. jeden Dokumenttyp separat " + + "(z.B. Kfz/Auto, Rechtsschutz, Haftpflicht, Sach, Ausland, Kranken) " + + "mit Beleg (Dateiname oder Ordner aus den Treffern). " + + "Nicht nur den ersten Treffer nennen. " + + "Bei Kosten/Beitraegen: je Treffer Betrag und Zeitraum nennen wenn im Snippet erkennbar; " + + "wenn nicht erkennbar, ausdruecklich sagen. " + + "VERBOTEN: Antwort nur als nackte Zahl (z.B. nur eine EUR-Zeile ohne Kontext). " + + "Zu JEDEM Betrag mindestens einen Dateinamen aus den Treffern nennen. " + + "Bei Kfz/Fahrzeug: sagen welches Dokument sich darauf bezieht oder dass die Zuordnung unsicher ist." + ) + _rag_user_suffix = ( + "\n\n[Strukturiert: Kurzfassung zu Jahr/jaehrlich, dann je Dokument Dateiname mit Betrag " + + "und Zeitraum aus den Treffern; keine reine Ein-Zahl-Antwort.]" + ) + else: + _rag_extra = ( + "\n\nWICHTIG: Ignoriere fruehere Antworten und Priorisiere die Dokumentensuche unten " + + "ueber Kurzinfos aus dem Gedaechtnis. " + + "STIL-OVERRIDE: etwas ausfuehrlicher als sonst (Aufzaehlungen ok). " + + "Die Treffer sind die einzige belastbare Grundlage. " + + "Struktur: (1) Kurzfassung: was in den Treffern zur Frage passt. " + + "(2) Je relevantem Treffer: Dateiname/Ordnerpfad und erkennbare Fakten aus dem Snippet " + + "(Objekt, Ort, Datum, Parteien) — nichts erfinden. " + + "Alle plausibel passenden Treffer nennen, nicht nur den ersten. " + + "Wenn nichts passt: klar sagen dass die Suche nichts Relevantes lieferte." + ) + _rag_user_suffix = ( + "\n\n[Strukturiert: Kurzfassung, dann Liste je Treffer mit Dateiname und Fakten nur aus dem Snippet; " + + "keine erfundenen Details.]" + ) + messages = [ + {"role": "system", "content": _full_prompt + _rag_extra}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "forced_rag", "type": "function", + "function": {"name": "rag_search", + "arguments": json.dumps({"query": _rag_q, "top_k": 60})}}]}, + {"role": "tool", "tool_call_id": "forced_rag", + "content": str(_rag_res)[:100000]}, + {"role": "user", "content": question + _rag_user_suffix}, + ] + except Exception as e: + log.warning("RAG-Pflicht Fehler: %s", e) + + # --- Online (Sonar): kein Tool-Calling, Sonar sucht selbst --- + if route == MODEL_ONLINE: + try: + data = _call_api(messages, api_key, use_tools=False, model=MODEL_ONLINE) + content = data["choices"][0]["message"].get("content", "") + if session_id: + try: + memory_client.log_message(session_id, "user", question) + memory_client.log_message(session_id, "assistant", content) + except Exception: + pass + return content or "Keine Antwort von Sonar." + except Exception as e: + return f"Online-Suche Fehler: {e}" + + # --- Lokal: Tool-Calling mit allen Tools --- + local_model = model_override or MODEL_LOCAL + passthrough_result = None + + try: + for _round in range(MAX_TOOL_ROUNDS): + data = _call_api(messages, api_key, use_tools=True, model=local_model) + choice = data["choices"][0] + msg = choice["message"] + + tool_calls = msg.get("tool_calls") + if not tool_calls: + content = msg.get("content") or "" + if not content and msg.get("reasoning"): + content = msg.get("reasoning", "") + if passthrough_result: + return passthrough_result + return content or "Keine Antwort vom LLM." + + messages.append(msg) + + for tc in tool_calls: + fn_name = tc["function"]["name"] + try: + fn_args = json.loads(tc["function"]["arguments"]) + except (json.JSONDecodeError, KeyError): + fn_args = {} + + log.info("Tool-Call: %s args=%s", fn_name, str(fn_args)[:200]) + handler = tool_handlers.get(fn_name) + if handler: + try: + result = handler(**fn_args) + except Exception as e: + result = f"Fehler bei {fn_name}: {e}" + else: + result = f"Unbekanntes Tool: {fn_name}" + + result_str = str(result)[:3000] + + if fn_name in PASSTHROUGH_TOOLS and not result_str.startswith(("Fehler", "Keine")): + log.info("Passthrough-Tool %s", fn_name) + passthrough_result = result_str + + messages.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": result_str, + }) + + if passthrough_result: + return passthrough_result + data = _call_api(messages, api_key, use_tools=False, model=local_model) + return data["choices"][0]["message"]["content"] + + except Exception as e: + return f"LLM-Fehler: {e}" + + +def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session_id: str = None) -> str: + """Bild-Analyse via lokalem Vision-Modell mit Tool-Calling.""" + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + try: + import memory_client + query = caption if caption else "Bild-Analyse" + memory_items = memory_client.get_relevant_memory(query, top_k=10) + memory_block = memory_client.format_memory_for_prompt(memory_items) + except Exception: + memory_block = "" + + try: + import openmemory_client + q = caption if caption else "Bild-Analyse" + openmemory_block = openmemory_client.get_openmemory_for_prompt(q, top_k=8) + except Exception: + openmemory_block = "" + + default_prompt = ( + "Analysiere dieses Bild. " + "Wenn es ein Dokument ist (Ticket, Rechnung, Beleg, Buchung): " + "Wende die DOKUMENTENEXTRAKTION-Regeln an — feldweise, konservativ, mit Confidence pro Feld. " + "Markiere unsichere Felder explizit. Erfinde NICHTS. " + "Lies Nummern und Daten Zeichen fuer Zeichen. " + "Wenn es ein normales Bild ist: Beschreibe strukturiert was du siehst." + ) + prompt_text = caption if caption else default_prompt + user_content = [ + {"type": "text", "text": prompt_text}, + {"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 + (("\n" + openmemory_block) if openmemory_block else "") + + messages = [ + {"role": "system", "content": _full_prompt}, + ] + + if session_id: + try: + import memory_client + history = memory_client.get_session_messages(session_id, limit=6) + for msg in history: + role = msg.get("role", "") + content = msg.get("content", "") + if content and role in ("user", "assistant"): + messages.append({"role": role, "content": content}) + except Exception: + pass + + messages.append({"role": "user", "content": user_content}) + + try: + for _round in range(MAX_TOOL_ROUNDS): + data = _call_api(messages, api_key, use_tools=True, + model=MODEL_VISION, max_tokens=4000, + allow_fallback=False) + choice = data["choices"][0] + msg = choice["message"] + + tool_calls = msg.get("tool_calls") + if not tool_calls: + content = msg.get("content") or "" + if not content and msg.get("reasoning"): + content = msg.get("reasoning", "") + return content or "Keine Antwort vom LLM." + + messages.append(msg) + + for tc in tool_calls: + fn_name = tc["function"]["name"] + try: + fn_args = json.loads(tc["function"]["arguments"]) + except (json.JSONDecodeError, KeyError): + fn_args = {} + + handler = tool_handlers.get(fn_name) + if handler: + try: + result = handler(**fn_args) + except Exception as e: + result = f"Fehler bei {fn_name}: {e}" + else: + result = f"Unbekanntes Tool: {fn_name}" + + messages.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": str(result)[:3000], + }) + + data = _call_api(messages, api_key, use_tools=False, + model=MODEL_VISION, max_tokens=4000, + allow_fallback=False) + return data["choices"][0]["message"]["content"] + + except requests.exceptions.ReadTimeout: + return ( + "Das Vision-Modell antwortet nicht (Timeout). " + "Bitte in 1-2 Min erneut versuchen — das Modell wird gerade geladen." + ) + except Exception as e: + return f"Vision-LLM-Fehler: {e}" diff --git a/homelab-ai-bot/monitor.py b/homelab-ai-bot/monitor.py index 80a1ff6e6..b90e7fefd 100644 --- a/homelab-ai-bot/monitor.py +++ b/homelab-ai-bot/monitor.py @@ -4,6 +4,7 @@ import sys import os import json import hashlib +import re import requests import time from datetime import datetime, timezone @@ -22,6 +23,8 @@ ALERT_COOLDOWN_SECONDS = { "memory_expiry": 43200, "default": 3600, "error_rate": 1800, + "hermes": 7200, + "backup": 21600, } @@ -45,10 +48,18 @@ HTTP_HEALTH_CHECKS = [ {"name": "WordPress (CT 101)", "url": "http://10.10.10.101/robots.txt"}, {"name": "Matomo (CT 109)", "url": "http://10.10.10.109"}, {"name": "Grafana (CT 110)", "url": "http://10.10.10.110:3000/api/health"}, - {"name": "Flugscanner-Agent (pve-pp-1)", "url": "http://100.126.26.46:5010/status", - "retries": 5, "timeout": 25, "retry_delay": 6, "host": "pve-pp-1"}, ] +HERMES_HEALTH_CHECKS = [ + {"name": "Hermes Promtail", "url": "http://100.109.174.120:9080/ready", "timeout": 5}, + {"name": "Hermes node_exporter", "url": "http://100.109.174.120:9100/metrics", "timeout": 5}, + {"name": "Hermes Websuche CT121", "url": "http://100.74.196.29:8080/search?q=hausmeister-health&format=json", "timeout": 8}, + {"name": "Loki", "url": "http://100.109.206.43:3100/ready", "timeout": 5}, + {"name": "PBS Muldenstein", "url": "https://100.99.139.22:8007", "timeout": 5, "allow_status": {200, 401}}, +] + +HERMES_LOG_HOST = "hermes-mu" + EXPECTED_STOPPED = { (115, "pve-ka-1"), # flugscanner-asia-old (gestoppt, Cluster pve1) — Live CT auf pve-pp-1 (115, "pve-ka-3"), # dieselbe CT, zweite API-Sicht (Cluster) @@ -57,6 +68,9 @@ EXPECTED_STOPPED = { (504, "pve-ka-2"), # Shop-Template — stopped (8000, "pve-ka-2"), # Kunde0-Shop — stopped (8010, "pve-ka-2"), # Kunde1-Shop — stopped + (121, "pve-ka-1"), # Schawarma-Shop — gestoppt + (121, "pve-ka-2"), # Schawarma-Shop — gestoppt + (121, "pve-ka-3"), # Schawarma-Shop — gestoppt } # VMIDs, die auf jedem Proxmox-Host in CONFIG ok sind, solange status == stopped @@ -83,12 +97,60 @@ def _is_host_suppressed(host: str, suppressed_hosts: set) -> bool: return False +def check_hermes() -> list[str]: + """Prueft Hermes (CT151) als kritischen Dienst ueber HTTP und Loki.""" + alerts = [] + headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 hermes-check)"} + for check in HERMES_HEALTH_CHECKS: + allow_status = check.get("allow_status") or set(range(200, 400)) + try: + r = requests.get( + check["url"], + timeout=check.get("timeout", 5), + allow_redirects=False, + headers=headers, + verify=False, + ) + if r.status_code not in allow_status: + alerts.append(f"🔴 Hermes: {check['name']} HTTP {r.status_code}") + except requests.RequestException as e: + alerts.append(f"🔴 Hermes: {check['name']} nicht erreichbar: {str(e)[:80]}") + + health = loki_client.get_health(HERMES_LOG_HOST, hours=1) + if health.get("status") == "silent": + alerts.append("⚠️ Hermes: keine aktuellen Loki-Logs von hermes-mu") + elif health.get("status") == "critical": + cnt = health.get("error_count", health.get("errors_last_1h", "?")) + alerts.append(f"🔴 Hermes: {cnt} Fehlerlogs in Loki (1h)") + + errors = loki_client.get_errors(container=HERMES_LOG_HOST, hours=0.5, limit=20) + for e in errors: + if "error" in e: + continue + line = (e.get("line") or "").lower() + if "diskstats_linux.go" in line and "disabling udev device properties" in line: + continue + if "collector failed" in line and "node_exporter" in line: + continue + if "beellama-tunnel.service" in line or "beellama-expose.service" in line: + if "main process exited" in line: + continue + if "health(warnable=mapresponse-timeout)" in line: + continue + if any(term in line for term in ["traceback", "exception", "failed", "timeout", "tool_call", "connection refused"]): + alerts.append(f"🔴 Hermes Log: {(e.get('line') or '')[:140]}") + break + + return alerts + + def check_all() -> list[str]: """Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück.""" cfg = config.parse_config() suppressed_hosts = config.get_suppressed_hosts(cfg) suppressed_names = config.get_suppressed_container_names(cfg) alerts = [] + alerts.extend(check_hermes()) containers = proxmox_client.get_all_containers( _get_passwords(cfg), _get_tokens(cfg) @@ -322,6 +384,19 @@ def _save_alert_state(state: dict): def _alert_key(alert_text: str) -> str: + # Volatile Metrik-Alarme (error_rate) ueber einen stabilen Schluessel + # deduplizieren: nur Host, nicht der schwankende Zaehlwert. Sonst + # erzeugt jede neue Zahl einen neuen Hash und der Cooldown greift nie. + if "Fehler in 30 Min" in alert_text: + m = re.search(r"([\w.\-]+):\s*\d+\s+Fehler in 30 Min", alert_text) + if m: + return hashlib.md5(("error_rate|" + m.group(1)).encode()).hexdigest() + if "Hermes:" in alert_text and "Fehlerlogs in Loki" in alert_text: + return hashlib.md5(b"hermes|loki_errors").hexdigest() + if alert_text.startswith("🔴 Hermes Log:"): + m = re.search(r"([\w.\-]+\.service)", alert_text) + unit = m.group(1) if m else "generic" + return hashlib.md5(("hermes|log|" + unit).encode()).hexdigest() return hashlib.md5(alert_text.encode()).hexdigest() @@ -340,6 +415,10 @@ def _alert_category(alert_text: str) -> str: return "http" if "Service-Neustart" in alert_text: return "restart" + if "Hermes" in alert_text: + return "hermes" + if "Backup" in alert_text or "PBS" in alert_text: + return "backup" if "Memory läuft ab" in alert_text: return "memory_expiry" return "default" diff --git a/homelab-ai-bot/monitor.py.bak-hermes-monitor b/homelab-ai-bot/monitor.py.bak-hermes-monitor new file mode 100644 index 000000000..4bf04657b --- /dev/null +++ b/homelab-ai-bot/monitor.py.bak-hermes-monitor @@ -0,0 +1,403 @@ +"""Proaktives Monitoring — regelbasiert (Stufe 1) + KI (Stufe 2).""" + +import sys +import os +import json +import hashlib +import requests +import time +from datetime import datetime, timezone + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config, loki_client, proxmox_client, mail_client + +ALERT_STATE_FILE = "/var/cache/hausmeister-alert-state.json" +ALERT_COOLDOWN_SECONDS = { + "container": 1800, + "ram": 1800, + "panic": 3600, + "silence": 3600, + "http": 1800, + "restart": 900, + "memory_expiry": 43200, + "default": 3600, + "error_rate": 1800, +} + + +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 = cfg.passwords.get("default", "") + return {host: pw for host in proxmox_client.PROXMOX_HOSTS} + + +CRITICAL_CONTAINERS = [101, 109, 111, 112, 113, 115] + +HTTP_HEALTH_CHECKS = [ + {"name": "WordPress (CT 101)", "url": "http://10.10.10.101/robots.txt"}, + {"name": "Matomo (CT 109)", "url": "http://10.10.10.109"}, + {"name": "Grafana (CT 110)", "url": "http://10.10.10.110:3000/api/health"}, +] + +EXPECTED_STOPPED = { + (115, "pve-ka-1"), # flugscanner-asia-old (gestoppt, Cluster pve1) — Live CT auf pve-pp-1 + (115, "pve-ka-3"), # dieselbe CT, zweite API-Sicht (Cluster) + (101, "pp1"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (101, "pp2"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (504, "pve-ka-2"), # Shop-Template — stopped + (8000, "pve-ka-2"), # Kunde0-Shop — stopped + (8010, "pve-ka-2"), # Kunde1-Shop — stopped + (121, "pve-ka-1"), # Schawarma-Shop — gestoppt + (121, "pve-ka-2"), # Schawarma-Shop — gestoppt + (121, "pve-ka-3"), # Schawarma-Shop — gestoppt +} + +# VMIDs, die auf jedem Proxmox-Host in CONFIG ok sind, solange status == stopped +# (115 erscheint je nach API-Zuordnung auch als pve-hetzner o.ä., nicht nur ka-1/ka-3) +EXPECTED_STOPPED_VMIDS = {115, 504, 8000, 8010} + +IGNORED_HOSTS = {"${HOSTNAME}", ""} + +SILENCE_IGNORED_HOSTS = { + "ct-600-webcam", # kein rsyslog, Stream läuft aber + "ct-103-Intercity-Taxi", # absichtlich gestoppt + "ct-101-freshrss", # auf pve-ka-3, lokale Loki (nicht zentral) +} + + +def _is_host_suppressed(host: str, suppressed_hosts: set) -> bool: + if not host or not suppressed_hosts: + return False + h = host.lower() + for s in suppressed_hosts: + s = s.lower() + if h == s or h.startswith(s + ".") or h.startswith(s + "-"): + return True + return False + + +def check_all() -> list[str]: + """Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + alerts = [] + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + for ct in containers: + if "error" in ct: + continue + host = ct.get("_host", "") + if _is_host_suppressed(host, suppressed_hosts): + continue + vmid = ct.get("vmid", 0) + name = ct.get("name", "?") + status = ct.get("status", "unknown") + if vmid in CRITICAL_CONTAINERS and status != "running": + ok_stopped = vmid in EXPECTED_STOPPED_VMIDS and status == "stopped" + if (vmid, host) not in EXPECTED_STOPPED and not ok_stopped: + alerts.append(f"🔴 CT {vmid} ({name}) ist {status}!") + + mem = ct.get("mem", 0) + maxmem = ct.get("maxmem", 1) + if maxmem > 0 and mem / maxmem > 0.90: + pct = int(mem / maxmem * 100) + alerts.append(f"⚠️ CT {vmid} ({name}) RAM bei {pct}%") + + errors = loki_client.get_errors(hours=0.5, limit=50) + error_lines = [e for e in errors if "error" not in e] + panic_lines = [] + for e in error_lines: + line = e.get("line", "") + ll = line.lower() + if not any(w in ll for w in ["panic", "fatal", "oom", "out of memory"]): + continue + if "query=" in line or "caller=metrics" in line: + continue + if "HTTP/1." in line and ('" 200 ' in line or '" 301 ' in line or '" 302 ' in line or '" 304 ' in line): + continue + if "GET /" in line or "POST /" in line or "HEAD /" in line: + continue + panic_lines.append(e) + if panic_lines: + hosts = set(e.get("host", "?") for e in panic_lines) + hosts -= IGNORED_HOSTS + hosts = {h for h in hosts if h.lower() not in suppressed_names} + if hosts: + alerts.append(f"🔴 Kritische Fehler (panic/fatal/OOM) auf: {', '.join(hosts)}") + + error_rates = loki_client.check_error_rate(minutes=30) + for er in error_rates: + if er.get("host", "").lower() in suppressed_names: + continue + alerts.append( + f"🔴 {er['host']}: {er['count']} Fehler in 30 Min (Schwelle: {er['threshold']})" + ) + + running_names = { + ct.get("name", "").lower() + for ct in containers + if "error" not in ct and ct.get("status") == "running" + } + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in silent[0]: + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s.get("host") not in SILENCE_IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + and s["host"].lower() in running_names + ] + if names: + alerts.append(f"⚠️ Keine Logs seit 35+ Min: {', '.join(names)}") + + _headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 health-check)"} + for check in HTTP_HEALTH_CHECKS: + if _is_host_suppressed(check.get("host", ""), suppressed_hosts): + continue + timeout = check.get("timeout", 15) + retries = check.get("retries", 1) + retry_delay = check.get("retry_delay", 3) + msg = None + for attempt in range(retries): + try: + r = requests.get( + check["url"], timeout=timeout, allow_redirects=True, headers=_headers + ) + if r.status_code < 400: + msg = None + break + msg = f"🔴 {check['name']} antwortet mit HTTP {r.status_code}" + except requests.RequestException as e: + msg = f"🔴 {check['name']} nicht erreichbar: {str(e)[:80]}" + if attempt < retries - 1: + time.sleep(retry_delay) + if msg: + alerts.append(msg) + + restarts = loki_client.check_service_restarts(minutes=35) + for r in restarts: + if r.get("host", "").lower() in suppressed_names: + continue + alerts.append(f"🔄 Service-Neustart: {r['service']} auf {r['host']} ({r['count']}x in 35 Min)") + + try: + import memory_client + import time as _time + now_ts = int(_time.time()) + mem_items = memory_client.get_active_memory() + for item in mem_items: + exp = item.get("expires_at") + if exp and 0 < exp - now_ts < 86400: + from datetime import datetime as _dt + exp_str = _dt.fromtimestamp(exp).strftime("%d.%m. %H:%M") + alerts.append(f"⏰ Memory läuft ab ({exp_str}): {item['content'][:80]}") + except Exception: + pass + + try: + mail_client.init(cfg) + important = mail_client.get_important_mails(hours=1) + if important and "error" not in important[0]: + state = _load_alert_state() + seen = state.get("seen_mails", {}) + now = datetime.now(timezone.utc).timestamp() + + new_mails = [] + for m in important: + fp = hashlib.md5( + f"{m.get('date_str','')}{m.get('from','')}{m.get('subject','')}".encode() + ).hexdigest() + if fp not in seen: + new_mails.append(m) + seen[fp] = now + + seen = {k: v for k, v in seen.items() if now - v < 172800} + state["seen_mails"] = seen + _save_alert_state(state) + + if new_mails: + senders = [m["from"][:30] for m in new_mails] + alerts.append(f"📧 {len(new_mails)} neue wichtige Mail(s): {', '.join(senders)}") + except Exception: + pass + + return alerts + + +def format_report() -> str: + """Tagesbericht: Gesamtstatus aller Systeme.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + lines = ["📋 Tagesbericht Homelab\n"] + if suppressed_hosts: + lines.append(f"🔕 Stumm: {', '.join(sorted(suppressed_hosts))}\n") + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + containers = [ + c for c in containers + if not _is_host_suppressed(c.get("_host", ""), suppressed_hosts) + ] + running = [c for c in containers if c.get("status") == "running"] + stopped = [c for c in containers if c.get("status") == "stopped"] + errors_ct = [c for c in containers if "error" in c] + lines.append(f"Container: {len(running)} running, {len(stopped)} stopped, {len(errors_ct)} nicht erreichbar") + + errors = loki_client.get_errors(hours=24, limit=100) + error_count = len([ + e for e in errors + if "error" not in e and e.get("host", "").lower() not in suppressed_names + ]) + lines.append(f"Fehler (24h): {error_count}") + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in (silent[0] if silent else {}): + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + ] + if names: + lines.append(f"Stille Hosts: {', '.join(names)}") + else: + lines.append("Stille Hosts: keine") + else: + lines.append("Stille Hosts: keine") + + try: + import memory_client + mem_items = memory_client.get_active_memory() + perm = [i for i in mem_items if i.get("memory_type") != "temporary"] + temp = [i for i in mem_items if i.get("memory_type") == "temporary"] + candidates = memory_client.get_candidates() + mem_line = f"Memory: {len(perm)} dauerhaft, {len(temp)} temporär" + import time as _time + now_ts = int(_time.time()) + soon = [i for i in temp if i.get("expires_at") and i["expires_at"] - now_ts < 86400] + if soon: + mem_line += f", {len(soon)} laufen in 24h ab" + if candidates: + mem_line += f", {len(candidates)} Kandidaten offen" + lines.append(mem_line) + except Exception: + pass + + alerts = check_all() + if alerts: + lines.append(f"\n⚠️ {len(alerts)} aktive Alarme:") + lines.extend(alerts) + else: + lines.append("\n✅ Keine Alarme — alles läuft.") + + return "\n".join(lines) + + +def _load_alert_state() -> dict: + try: + with open(ALERT_STATE_FILE, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_alert_state(state: dict): + try: + with open(ALERT_STATE_FILE, "w") as f: + json.dump(state, f) + except Exception: + pass + + +def _alert_key(alert_text: str) -> str: + return hashlib.md5(alert_text.encode()).hexdigest() + + +def _alert_category(alert_text: str) -> str: + if "CT " in alert_text and "ist " in alert_text: + return "container" + if "RAM " in alert_text: + return "ram" + if "panic" in alert_text.lower() or "fatal" in alert_text.lower(): + return "panic" + if "Fehler in 30 Min" in alert_text: + return "error_rate" + if "Keine Logs" in alert_text: + return "silence" + if "antwortet mit HTTP" in alert_text or "nicht erreichbar" in alert_text: + return "http" + if "Service-Neustart" in alert_text: + return "restart" + if "Memory läuft ab" in alert_text: + return "memory_expiry" + return "default" + + +def _filter_new_alerts(alerts: list[str]) -> list[str]: + """Filtert bereits gemeldete Infra-Alerts per Cooldown. Mails werden separat in check_all() dedupliziert.""" + state = _load_alert_state() + now = datetime.now(timezone.utc).timestamp() + cooldowns = state.get("alert_cooldowns", {}) + new_alerts = [] + + for alert in alerts: + key = _alert_key(alert) + cat = _alert_category(alert) + cooldown = ALERT_COOLDOWN_SECONDS.get(cat, 3600) + + last_sent = cooldowns.get(key, {}).get("ts", 0) + if now - last_sent > cooldown: + new_alerts.append(alert) + cooldowns[key] = {"ts": now, "text": alert[:80], "cat": cat} + + cutoff = now - 86400 + cooldowns = {k: v for k, v in cooldowns.items() if v.get("ts", 0) > cutoff} + + state["alert_cooldowns"] = cooldowns + _save_alert_state(state) + return new_alerts + + +def send_alert(token: str, chat_id: str, message: str): + """Sendet eine Nachricht via Telegram.""" + requests.post( + f"https://api.telegram.org/bot{token}/sendMessage", + data={"chat_id": chat_id, "text": message}, + timeout=10, + ) + + +def run_check_and_alert(): + """Hauptfunktion für Cron: prüft und sendet Alerts falls nötig.""" + cfg = config.parse_config() + token = cfg.raw.get("TG_HAUSMEISTER_TOKEN", "") + chat_id = cfg.raw.get("TG_CHAT_ID", "") + if not token or not chat_id: + return + + alerts = check_all() + new_alerts = _filter_new_alerts(alerts) + if new_alerts: + msg = "🔧 Hausmeister-Check\n\n" + "\n".join(new_alerts) + send_alert(token, chat_id, msg) + + +if __name__ == "__main__": + import sys as _sys + if len(_sys.argv) > 1 and _sys.argv[1] == "report": + print(format_report()) + else: + run_check_and_alert() diff --git a/homelab-ai-bot/monitor.py.bak.20260601_163058 b/homelab-ai-bot/monitor.py.bak.20260601_163058 new file mode 100644 index 000000000..ce74136f9 --- /dev/null +++ b/homelab-ai-bot/monitor.py.bak.20260601_163058 @@ -0,0 +1,461 @@ +"""Proaktives Monitoring — regelbasiert (Stufe 1) + KI (Stufe 2).""" + +import sys +import os +import json +import hashlib +import requests +import time +from datetime import datetime, timezone + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config, loki_client, proxmox_client, mail_client + +ALERT_STATE_FILE = "/var/cache/hausmeister-alert-state.json" +ALERT_COOLDOWN_SECONDS = { + "container": 1800, + "ram": 1800, + "panic": 3600, + "silence": 3600, + "http": 1800, + "restart": 900, + "memory_expiry": 43200, + "default": 3600, + "error_rate": 1800, + "hermes": 900, + "backup": 21600, +} + + +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 = cfg.passwords.get("default", "") + return {host: pw for host in proxmox_client.PROXMOX_HOSTS} + + +CRITICAL_CONTAINERS = [101, 109, 111, 112, 113, 115] + +HTTP_HEALTH_CHECKS = [ + {"name": "WordPress (CT 101)", "url": "http://10.10.10.101/robots.txt"}, + {"name": "Matomo (CT 109)", "url": "http://10.10.10.109"}, + {"name": "Grafana (CT 110)", "url": "http://10.10.10.110:3000/api/health"}, +] + +HERMES_HEALTH_CHECKS = [ + {"name": "Hermes Promtail", "url": "http://100.109.174.120:9080/ready", "timeout": 5}, + {"name": "Hermes node_exporter", "url": "http://100.109.174.120:9100/metrics", "timeout": 5}, + {"name": "Hermes Websuche CT121", "url": "http://100.74.196.29:8080/search?q=hausmeister-health&format=json", "timeout": 8}, + {"name": "Loki", "url": "http://100.109.206.43:3100/ready", "timeout": 5}, + {"name": "PBS Muldenstein", "url": "https://100.99.139.22:8007", "timeout": 5, "allow_status": {200, 401}}, +] + +HERMES_LOG_HOST = "hermes-mu" + +EXPECTED_STOPPED = { + (115, "pve-ka-1"), # flugscanner-asia-old (gestoppt, Cluster pve1) — Live CT auf pve-pp-1 + (115, "pve-ka-3"), # dieselbe CT, zweite API-Sicht (Cluster) + (101, "pp1"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (101, "pp2"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (504, "pve-ka-2"), # Shop-Template — stopped + (8000, "pve-ka-2"), # Kunde0-Shop — stopped + (8010, "pve-ka-2"), # Kunde1-Shop — stopped + (121, "pve-ka-1"), # Schawarma-Shop — gestoppt + (121, "pve-ka-2"), # Schawarma-Shop — gestoppt + (121, "pve-ka-3"), # Schawarma-Shop — gestoppt +} + +# VMIDs, die auf jedem Proxmox-Host in CONFIG ok sind, solange status == stopped +# (115 erscheint je nach API-Zuordnung auch als pve-hetzner o.ä., nicht nur ka-1/ka-3) +EXPECTED_STOPPED_VMIDS = {115, 504, 8000, 8010} + +IGNORED_HOSTS = {"${HOSTNAME}", ""} + +SILENCE_IGNORED_HOSTS = { + "ct-600-webcam", # kein rsyslog, Stream läuft aber + "ct-103-Intercity-Taxi", # absichtlich gestoppt + "ct-101-freshrss", # auf pve-ka-3, lokale Loki (nicht zentral) +} + + +def _is_host_suppressed(host: str, suppressed_hosts: set) -> bool: + if not host or not suppressed_hosts: + return False + h = host.lower() + for s in suppressed_hosts: + s = s.lower() + if h == s or h.startswith(s + ".") or h.startswith(s + "-"): + return True + return False + + +def check_hermes() -> list[str]: + """Prueft Hermes (CT151) als kritischen Dienst ueber HTTP und Loki.""" + alerts = [] + headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 hermes-check)"} + for check in HERMES_HEALTH_CHECKS: + allow_status = check.get("allow_status") or set(range(200, 400)) + try: + r = requests.get( + check["url"], + timeout=check.get("timeout", 5), + allow_redirects=False, + headers=headers, + verify=False, + ) + if r.status_code not in allow_status: + alerts.append(f"🔴 Hermes: {check['name']} HTTP {r.status_code}") + except requests.RequestException as e: + alerts.append(f"🔴 Hermes: {check['name']} nicht erreichbar: {str(e)[:80]}") + + health = loki_client.get_health(HERMES_LOG_HOST, hours=1) + if health.get("status") == "silent": + alerts.append("⚠️ Hermes: keine aktuellen Loki-Logs von hermes-mu") + elif health.get("status") == "critical": + alerts.append(f"🔴 Hermes: {health.get('error_count', '?')} Fehlerlogs in Loki (1h)") + + errors = loki_client.get_errors(container=HERMES_LOG_HOST, hours=0.5, limit=20) + for e in errors: + if "error" in e: + continue + line = (e.get("line") or "").lower() + if "diskstats_linux.go" in line and "disabling udev device properties" in line: + continue + if "collector failed" in line and "node_exporter" in line: + continue + if any(term in line for term in ["traceback", "exception", "failed", "timeout", "tool_call", "connection refused"]): + alerts.append(f"🔴 Hermes Log: {(e.get('line') or '')[:140]}") + break + + return alerts + + +def check_all() -> list[str]: + """Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + alerts = [] + alerts.extend(check_hermes()) + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + for ct in containers: + if "error" in ct: + continue + host = ct.get("_host", "") + if _is_host_suppressed(host, suppressed_hosts): + continue + vmid = ct.get("vmid", 0) + name = ct.get("name", "?") + status = ct.get("status", "unknown") + if vmid in CRITICAL_CONTAINERS and status != "running": + ok_stopped = vmid in EXPECTED_STOPPED_VMIDS and status == "stopped" + if (vmid, host) not in EXPECTED_STOPPED and not ok_stopped: + alerts.append(f"🔴 CT {vmid} ({name}) ist {status}!") + + mem = ct.get("mem", 0) + maxmem = ct.get("maxmem", 1) + if maxmem > 0 and mem / maxmem > 0.90: + pct = int(mem / maxmem * 100) + alerts.append(f"⚠️ CT {vmid} ({name}) RAM bei {pct}%") + + errors = loki_client.get_errors(hours=0.5, limit=50) + error_lines = [e for e in errors if "error" not in e] + panic_lines = [] + for e in error_lines: + line = e.get("line", "") + ll = line.lower() + if not any(w in ll for w in ["panic", "fatal", "oom", "out of memory"]): + continue + if "query=" in line or "caller=metrics" in line: + continue + if "HTTP/1." in line and ('" 200 ' in line or '" 301 ' in line or '" 302 ' in line or '" 304 ' in line): + continue + if "GET /" in line or "POST /" in line or "HEAD /" in line: + continue + panic_lines.append(e) + if panic_lines: + hosts = set(e.get("host", "?") for e in panic_lines) + hosts -= IGNORED_HOSTS + hosts = {h for h in hosts if h.lower() not in suppressed_names} + if hosts: + alerts.append(f"🔴 Kritische Fehler (panic/fatal/OOM) auf: {', '.join(hosts)}") + + error_rates = loki_client.check_error_rate(minutes=30) + for er in error_rates: + if er.get("host", "").lower() in suppressed_names: + continue + alerts.append( + f"🔴 {er['host']}: {er['count']} Fehler in 30 Min (Schwelle: {er['threshold']})" + ) + + running_names = { + ct.get("name", "").lower() + for ct in containers + if "error" not in ct and ct.get("status") == "running" + } + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in silent[0]: + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s.get("host") not in SILENCE_IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + and s["host"].lower() in running_names + ] + if names: + alerts.append(f"⚠️ Keine Logs seit 35+ Min: {', '.join(names)}") + + _headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 health-check)"} + for check in HTTP_HEALTH_CHECKS: + if _is_host_suppressed(check.get("host", ""), suppressed_hosts): + continue + timeout = check.get("timeout", 15) + retries = check.get("retries", 1) + retry_delay = check.get("retry_delay", 3) + msg = None + for attempt in range(retries): + try: + r = requests.get( + check["url"], timeout=timeout, allow_redirects=True, headers=_headers + ) + if r.status_code < 400: + msg = None + break + msg = f"🔴 {check['name']} antwortet mit HTTP {r.status_code}" + except requests.RequestException as e: + msg = f"🔴 {check['name']} nicht erreichbar: {str(e)[:80]}" + if attempt < retries - 1: + time.sleep(retry_delay) + if msg: + alerts.append(msg) + + restarts = loki_client.check_service_restarts(minutes=35) + for r in restarts: + if r.get("host", "").lower() in suppressed_names: + continue + alerts.append(f"🔄 Service-Neustart: {r['service']} auf {r['host']} ({r['count']}x in 35 Min)") + + try: + import memory_client + import time as _time + now_ts = int(_time.time()) + mem_items = memory_client.get_active_memory() + for item in mem_items: + exp = item.get("expires_at") + if exp and 0 < exp - now_ts < 86400: + from datetime import datetime as _dt + exp_str = _dt.fromtimestamp(exp).strftime("%d.%m. %H:%M") + alerts.append(f"⏰ Memory läuft ab ({exp_str}): {item['content'][:80]}") + except Exception: + pass + + try: + mail_client.init(cfg) + important = mail_client.get_important_mails(hours=1) + if important and "error" not in important[0]: + state = _load_alert_state() + seen = state.get("seen_mails", {}) + now = datetime.now(timezone.utc).timestamp() + + new_mails = [] + for m in important: + fp = hashlib.md5( + f"{m.get('date_str','')}{m.get('from','')}{m.get('subject','')}".encode() + ).hexdigest() + if fp not in seen: + new_mails.append(m) + seen[fp] = now + + seen = {k: v for k, v in seen.items() if now - v < 172800} + state["seen_mails"] = seen + _save_alert_state(state) + + if new_mails: + senders = [m["from"][:30] for m in new_mails] + alerts.append(f"📧 {len(new_mails)} neue wichtige Mail(s): {', '.join(senders)}") + except Exception: + pass + + return alerts + + +def format_report() -> str: + """Tagesbericht: Gesamtstatus aller Systeme.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + lines = ["📋 Tagesbericht Homelab\n"] + if suppressed_hosts: + lines.append(f"🔕 Stumm: {', '.join(sorted(suppressed_hosts))}\n") + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + containers = [ + c for c in containers + if not _is_host_suppressed(c.get("_host", ""), suppressed_hosts) + ] + running = [c for c in containers if c.get("status") == "running"] + stopped = [c for c in containers if c.get("status") == "stopped"] + errors_ct = [c for c in containers if "error" in c] + lines.append(f"Container: {len(running)} running, {len(stopped)} stopped, {len(errors_ct)} nicht erreichbar") + + errors = loki_client.get_errors(hours=24, limit=100) + error_count = len([ + e for e in errors + if "error" not in e and e.get("host", "").lower() not in suppressed_names + ]) + lines.append(f"Fehler (24h): {error_count}") + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in (silent[0] if silent else {}): + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + ] + if names: + lines.append(f"Stille Hosts: {', '.join(names)}") + else: + lines.append("Stille Hosts: keine") + else: + lines.append("Stille Hosts: keine") + + try: + import memory_client + mem_items = memory_client.get_active_memory() + perm = [i for i in mem_items if i.get("memory_type") != "temporary"] + temp = [i for i in mem_items if i.get("memory_type") == "temporary"] + candidates = memory_client.get_candidates() + mem_line = f"Memory: {len(perm)} dauerhaft, {len(temp)} temporär" + import time as _time + now_ts = int(_time.time()) + soon = [i for i in temp if i.get("expires_at") and i["expires_at"] - now_ts < 86400] + if soon: + mem_line += f", {len(soon)} laufen in 24h ab" + if candidates: + mem_line += f", {len(candidates)} Kandidaten offen" + lines.append(mem_line) + except Exception: + pass + + alerts = check_all() + if alerts: + lines.append(f"\n⚠️ {len(alerts)} aktive Alarme:") + lines.extend(alerts) + else: + lines.append("\n✅ Keine Alarme — alles läuft.") + + return "\n".join(lines) + + +def _load_alert_state() -> dict: + try: + with open(ALERT_STATE_FILE, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_alert_state(state: dict): + try: + with open(ALERT_STATE_FILE, "w") as f: + json.dump(state, f) + except Exception: + pass + + +def _alert_key(alert_text: str) -> str: + return hashlib.md5(alert_text.encode()).hexdigest() + + +def _alert_category(alert_text: str) -> str: + if "CT " in alert_text and "ist " in alert_text: + return "container" + if "RAM " in alert_text: + return "ram" + if "panic" in alert_text.lower() or "fatal" in alert_text.lower(): + return "panic" + if "Fehler in 30 Min" in alert_text: + return "error_rate" + if "Keine Logs" in alert_text: + return "silence" + if "antwortet mit HTTP" in alert_text or "nicht erreichbar" in alert_text: + return "http" + if "Service-Neustart" in alert_text: + return "restart" + if "Hermes" in alert_text: + return "hermes" + if "Backup" in alert_text or "PBS" in alert_text: + return "backup" + if "Memory läuft ab" in alert_text: + return "memory_expiry" + return "default" + + +def _filter_new_alerts(alerts: list[str]) -> list[str]: + """Filtert bereits gemeldete Infra-Alerts per Cooldown. Mails werden separat in check_all() dedupliziert.""" + state = _load_alert_state() + now = datetime.now(timezone.utc).timestamp() + cooldowns = state.get("alert_cooldowns", {}) + new_alerts = [] + + for alert in alerts: + key = _alert_key(alert) + cat = _alert_category(alert) + cooldown = ALERT_COOLDOWN_SECONDS.get(cat, 3600) + + last_sent = cooldowns.get(key, {}).get("ts", 0) + if now - last_sent > cooldown: + new_alerts.append(alert) + cooldowns[key] = {"ts": now, "text": alert[:80], "cat": cat} + + cutoff = now - 86400 + cooldowns = {k: v for k, v in cooldowns.items() if v.get("ts", 0) > cutoff} + + state["alert_cooldowns"] = cooldowns + _save_alert_state(state) + return new_alerts + + +def send_alert(token: str, chat_id: str, message: str): + """Sendet eine Nachricht via Telegram.""" + requests.post( + f"https://api.telegram.org/bot{token}/sendMessage", + data={"chat_id": chat_id, "text": message}, + timeout=10, + ) + + +def run_check_and_alert(): + """Hauptfunktion für Cron: prüft und sendet Alerts falls nötig.""" + cfg = config.parse_config() + token = cfg.raw.get("TG_HAUSMEISTER_TOKEN", "") + chat_id = cfg.raw.get("TG_CHAT_ID", "") + if not token or not chat_id: + return + + alerts = check_all() + new_alerts = _filter_new_alerts(alerts) + if new_alerts: + msg = "🔧 Hausmeister-Check\n\n" + "\n".join(new_alerts) + send_alert(token, chat_id, msg) + + +if __name__ == "__main__": + import sys as _sys + if len(_sys.argv) > 1 and _sys.argv[1] == "report": + print(format_report()) + else: + run_check_and_alert() diff --git a/homelab-ai-bot/monitor.py.bak.20260603_hermes b/homelab-ai-bot/monitor.py.bak.20260603_hermes new file mode 100644 index 000000000..3c9dc7686 --- /dev/null +++ b/homelab-ai-bot/monitor.py.bak.20260603_hermes @@ -0,0 +1,469 @@ +"""Proaktives Monitoring — regelbasiert (Stufe 1) + KI (Stufe 2).""" + +import sys +import os +import json +import hashlib +import re +import requests +import time +from datetime import datetime, timezone + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config, loki_client, proxmox_client, mail_client + +ALERT_STATE_FILE = "/var/cache/hausmeister-alert-state.json" +ALERT_COOLDOWN_SECONDS = { + "container": 1800, + "ram": 1800, + "panic": 3600, + "silence": 3600, + "http": 1800, + "restart": 900, + "memory_expiry": 43200, + "default": 3600, + "error_rate": 1800, + "hermes": 900, + "backup": 21600, +} + + +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 = cfg.passwords.get("default", "") + return {host: pw for host in proxmox_client.PROXMOX_HOSTS} + + +CRITICAL_CONTAINERS = [101, 109, 111, 112, 113, 115] + +HTTP_HEALTH_CHECKS = [ + {"name": "WordPress (CT 101)", "url": "http://10.10.10.101/robots.txt"}, + {"name": "Matomo (CT 109)", "url": "http://10.10.10.109"}, + {"name": "Grafana (CT 110)", "url": "http://10.10.10.110:3000/api/health"}, +] + +HERMES_HEALTH_CHECKS = [ + {"name": "Hermes Promtail", "url": "http://100.109.174.120:9080/ready", "timeout": 5}, + {"name": "Hermes node_exporter", "url": "http://100.109.174.120:9100/metrics", "timeout": 5}, + {"name": "Hermes Websuche CT121", "url": "http://100.74.196.29:8080/search?q=hausmeister-health&format=json", "timeout": 8}, + {"name": "Loki", "url": "http://100.109.206.43:3100/ready", "timeout": 5}, + {"name": "PBS Muldenstein", "url": "https://100.99.139.22:8007", "timeout": 5, "allow_status": {200, 401}}, +] + +HERMES_LOG_HOST = "hermes-mu" + +EXPECTED_STOPPED = { + (115, "pve-ka-1"), # flugscanner-asia-old (gestoppt, Cluster pve1) — Live CT auf pve-pp-1 + (115, "pve-ka-3"), # dieselbe CT, zweite API-Sicht (Cluster) + (101, "pp1"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (101, "pp2"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (504, "pve-ka-2"), # Shop-Template — stopped + (8000, "pve-ka-2"), # Kunde0-Shop — stopped + (8010, "pve-ka-2"), # Kunde1-Shop — stopped + (121, "pve-ka-1"), # Schawarma-Shop — gestoppt + (121, "pve-ka-2"), # Schawarma-Shop — gestoppt + (121, "pve-ka-3"), # Schawarma-Shop — gestoppt +} + +# VMIDs, die auf jedem Proxmox-Host in CONFIG ok sind, solange status == stopped +# (115 erscheint je nach API-Zuordnung auch als pve-hetzner o.ä., nicht nur ka-1/ka-3) +EXPECTED_STOPPED_VMIDS = {115, 504, 8000, 8010} + +IGNORED_HOSTS = {"${HOSTNAME}", ""} + +SILENCE_IGNORED_HOSTS = { + "ct-600-webcam", # kein rsyslog, Stream läuft aber + "ct-103-Intercity-Taxi", # absichtlich gestoppt + "ct-101-freshrss", # auf pve-ka-3, lokale Loki (nicht zentral) +} + + +def _is_host_suppressed(host: str, suppressed_hosts: set) -> bool: + if not host or not suppressed_hosts: + return False + h = host.lower() + for s in suppressed_hosts: + s = s.lower() + if h == s or h.startswith(s + ".") or h.startswith(s + "-"): + return True + return False + + +def check_hermes() -> list[str]: + """Prueft Hermes (CT151) als kritischen Dienst ueber HTTP und Loki.""" + alerts = [] + headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 hermes-check)"} + for check in HERMES_HEALTH_CHECKS: + allow_status = check.get("allow_status") or set(range(200, 400)) + try: + r = requests.get( + check["url"], + timeout=check.get("timeout", 5), + allow_redirects=False, + headers=headers, + verify=False, + ) + if r.status_code not in allow_status: + alerts.append(f"🔴 Hermes: {check['name']} HTTP {r.status_code}") + except requests.RequestException as e: + alerts.append(f"🔴 Hermes: {check['name']} nicht erreichbar: {str(e)[:80]}") + + health = loki_client.get_health(HERMES_LOG_HOST, hours=1) + if health.get("status") == "silent": + alerts.append("⚠️ Hermes: keine aktuellen Loki-Logs von hermes-mu") + elif health.get("status") == "critical": + alerts.append(f"🔴 Hermes: {health.get('error_count', '?')} Fehlerlogs in Loki (1h)") + + errors = loki_client.get_errors(container=HERMES_LOG_HOST, hours=0.5, limit=20) + for e in errors: + if "error" in e: + continue + line = (e.get("line") or "").lower() + if "diskstats_linux.go" in line and "disabling udev device properties" in line: + continue + if "collector failed" in line and "node_exporter" in line: + continue + if any(term in line for term in ["traceback", "exception", "failed", "timeout", "tool_call", "connection refused"]): + alerts.append(f"🔴 Hermes Log: {(e.get('line') or '')[:140]}") + break + + return alerts + + +def check_all() -> list[str]: + """Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + alerts = [] + alerts.extend(check_hermes()) + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + for ct in containers: + if "error" in ct: + continue + host = ct.get("_host", "") + if _is_host_suppressed(host, suppressed_hosts): + continue + vmid = ct.get("vmid", 0) + name = ct.get("name", "?") + status = ct.get("status", "unknown") + if vmid in CRITICAL_CONTAINERS and status != "running": + ok_stopped = vmid in EXPECTED_STOPPED_VMIDS and status == "stopped" + if (vmid, host) not in EXPECTED_STOPPED and not ok_stopped: + alerts.append(f"🔴 CT {vmid} ({name}) ist {status}!") + + mem = ct.get("mem", 0) + maxmem = ct.get("maxmem", 1) + if maxmem > 0 and mem / maxmem > 0.90: + pct = int(mem / maxmem * 100) + alerts.append(f"⚠️ CT {vmid} ({name}) RAM bei {pct}%") + + errors = loki_client.get_errors(hours=0.5, limit=50) + error_lines = [e for e in errors if "error" not in e] + panic_lines = [] + for e in error_lines: + line = e.get("line", "") + ll = line.lower() + if not any(w in ll for w in ["panic", "fatal", "oom", "out of memory"]): + continue + if "query=" in line or "caller=metrics" in line: + continue + if "HTTP/1." in line and ('" 200 ' in line or '" 301 ' in line or '" 302 ' in line or '" 304 ' in line): + continue + if "GET /" in line or "POST /" in line or "HEAD /" in line: + continue + panic_lines.append(e) + if panic_lines: + hosts = set(e.get("host", "?") for e in panic_lines) + hosts -= IGNORED_HOSTS + hosts = {h for h in hosts if h.lower() not in suppressed_names} + if hosts: + alerts.append(f"🔴 Kritische Fehler (panic/fatal/OOM) auf: {', '.join(hosts)}") + + error_rates = loki_client.check_error_rate(minutes=30) + for er in error_rates: + if er.get("host", "").lower() in suppressed_names: + continue + alerts.append( + f"🔴 {er['host']}: {er['count']} Fehler in 30 Min (Schwelle: {er['threshold']})" + ) + + running_names = { + ct.get("name", "").lower() + for ct in containers + if "error" not in ct and ct.get("status") == "running" + } + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in silent[0]: + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s.get("host") not in SILENCE_IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + and s["host"].lower() in running_names + ] + if names: + alerts.append(f"⚠️ Keine Logs seit 35+ Min: {', '.join(names)}") + + _headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 health-check)"} + for check in HTTP_HEALTH_CHECKS: + if _is_host_suppressed(check.get("host", ""), suppressed_hosts): + continue + timeout = check.get("timeout", 15) + retries = check.get("retries", 1) + retry_delay = check.get("retry_delay", 3) + msg = None + for attempt in range(retries): + try: + r = requests.get( + check["url"], timeout=timeout, allow_redirects=True, headers=_headers + ) + if r.status_code < 400: + msg = None + break + msg = f"🔴 {check['name']} antwortet mit HTTP {r.status_code}" + except requests.RequestException as e: + msg = f"🔴 {check['name']} nicht erreichbar: {str(e)[:80]}" + if attempt < retries - 1: + time.sleep(retry_delay) + if msg: + alerts.append(msg) + + restarts = loki_client.check_service_restarts(minutes=35) + for r in restarts: + if r.get("host", "").lower() in suppressed_names: + continue + alerts.append(f"🔄 Service-Neustart: {r['service']} auf {r['host']} ({r['count']}x in 35 Min)") + + try: + import memory_client + import time as _time + now_ts = int(_time.time()) + mem_items = memory_client.get_active_memory() + for item in mem_items: + exp = item.get("expires_at") + if exp and 0 < exp - now_ts < 86400: + from datetime import datetime as _dt + exp_str = _dt.fromtimestamp(exp).strftime("%d.%m. %H:%M") + alerts.append(f"⏰ Memory läuft ab ({exp_str}): {item['content'][:80]}") + except Exception: + pass + + try: + mail_client.init(cfg) + important = mail_client.get_important_mails(hours=1) + if important and "error" not in important[0]: + state = _load_alert_state() + seen = state.get("seen_mails", {}) + now = datetime.now(timezone.utc).timestamp() + + new_mails = [] + for m in important: + fp = hashlib.md5( + f"{m.get('date_str','')}{m.get('from','')}{m.get('subject','')}".encode() + ).hexdigest() + if fp not in seen: + new_mails.append(m) + seen[fp] = now + + seen = {k: v for k, v in seen.items() if now - v < 172800} + state["seen_mails"] = seen + _save_alert_state(state) + + if new_mails: + senders = [m["from"][:30] for m in new_mails] + alerts.append(f"📧 {len(new_mails)} neue wichtige Mail(s): {', '.join(senders)}") + except Exception: + pass + + return alerts + + +def format_report() -> str: + """Tagesbericht: Gesamtstatus aller Systeme.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + lines = ["📋 Tagesbericht Homelab\n"] + if suppressed_hosts: + lines.append(f"🔕 Stumm: {', '.join(sorted(suppressed_hosts))}\n") + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + containers = [ + c for c in containers + if not _is_host_suppressed(c.get("_host", ""), suppressed_hosts) + ] + running = [c for c in containers if c.get("status") == "running"] + stopped = [c for c in containers if c.get("status") == "stopped"] + errors_ct = [c for c in containers if "error" in c] + lines.append(f"Container: {len(running)} running, {len(stopped)} stopped, {len(errors_ct)} nicht erreichbar") + + errors = loki_client.get_errors(hours=24, limit=100) + error_count = len([ + e for e in errors + if "error" not in e and e.get("host", "").lower() not in suppressed_names + ]) + lines.append(f"Fehler (24h): {error_count}") + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in (silent[0] if silent else {}): + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + ] + if names: + lines.append(f"Stille Hosts: {', '.join(names)}") + else: + lines.append("Stille Hosts: keine") + else: + lines.append("Stille Hosts: keine") + + try: + import memory_client + mem_items = memory_client.get_active_memory() + perm = [i for i in mem_items if i.get("memory_type") != "temporary"] + temp = [i for i in mem_items if i.get("memory_type") == "temporary"] + candidates = memory_client.get_candidates() + mem_line = f"Memory: {len(perm)} dauerhaft, {len(temp)} temporär" + import time as _time + now_ts = int(_time.time()) + soon = [i for i in temp if i.get("expires_at") and i["expires_at"] - now_ts < 86400] + if soon: + mem_line += f", {len(soon)} laufen in 24h ab" + if candidates: + mem_line += f", {len(candidates)} Kandidaten offen" + lines.append(mem_line) + except Exception: + pass + + alerts = check_all() + if alerts: + lines.append(f"\n⚠️ {len(alerts)} aktive Alarme:") + lines.extend(alerts) + else: + lines.append("\n✅ Keine Alarme — alles läuft.") + + return "\n".join(lines) + + +def _load_alert_state() -> dict: + try: + with open(ALERT_STATE_FILE, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_alert_state(state: dict): + try: + with open(ALERT_STATE_FILE, "w") as f: + json.dump(state, f) + except Exception: + pass + + +def _alert_key(alert_text: str) -> str: + # Volatile Metrik-Alarme (error_rate) ueber einen stabilen Schluessel + # deduplizieren: nur Host, nicht der schwankende Zaehlwert. Sonst + # erzeugt jede neue Zahl einen neuen Hash und der Cooldown greift nie. + if "Fehler in 30 Min" in alert_text: + m = re.search(r"([\w.\-]+):\s*\d+\s+Fehler in 30 Min", alert_text) + if m: + return hashlib.md5(("error_rate|" + m.group(1)).encode()).hexdigest() + return hashlib.md5(alert_text.encode()).hexdigest() + + +def _alert_category(alert_text: str) -> str: + if "CT " in alert_text and "ist " in alert_text: + return "container" + if "RAM " in alert_text: + return "ram" + if "panic" in alert_text.lower() or "fatal" in alert_text.lower(): + return "panic" + if "Fehler in 30 Min" in alert_text: + return "error_rate" + if "Keine Logs" in alert_text: + return "silence" + if "antwortet mit HTTP" in alert_text or "nicht erreichbar" in alert_text: + return "http" + if "Service-Neustart" in alert_text: + return "restart" + if "Hermes" in alert_text: + return "hermes" + if "Backup" in alert_text or "PBS" in alert_text: + return "backup" + if "Memory läuft ab" in alert_text: + return "memory_expiry" + return "default" + + +def _filter_new_alerts(alerts: list[str]) -> list[str]: + """Filtert bereits gemeldete Infra-Alerts per Cooldown. Mails werden separat in check_all() dedupliziert.""" + state = _load_alert_state() + now = datetime.now(timezone.utc).timestamp() + cooldowns = state.get("alert_cooldowns", {}) + new_alerts = [] + + for alert in alerts: + key = _alert_key(alert) + cat = _alert_category(alert) + cooldown = ALERT_COOLDOWN_SECONDS.get(cat, 3600) + + last_sent = cooldowns.get(key, {}).get("ts", 0) + if now - last_sent > cooldown: + new_alerts.append(alert) + cooldowns[key] = {"ts": now, "text": alert[:80], "cat": cat} + + cutoff = now - 86400 + cooldowns = {k: v for k, v in cooldowns.items() if v.get("ts", 0) > cutoff} + + state["alert_cooldowns"] = cooldowns + _save_alert_state(state) + return new_alerts + + +def send_alert(token: str, chat_id: str, message: str): + """Sendet eine Nachricht via Telegram.""" + requests.post( + f"https://api.telegram.org/bot{token}/sendMessage", + data={"chat_id": chat_id, "text": message}, + timeout=10, + ) + + +def run_check_and_alert(): + """Hauptfunktion für Cron: prüft und sendet Alerts falls nötig.""" + cfg = config.parse_config() + token = cfg.raw.get("TG_HAUSMEISTER_TOKEN", "") + chat_id = cfg.raw.get("TG_CHAT_ID", "") + if not token or not chat_id: + return + + alerts = check_all() + new_alerts = _filter_new_alerts(alerts) + if new_alerts: + msg = "🔧 Hausmeister-Check\n\n" + "\n".join(new_alerts) + send_alert(token, chat_id, msg) + + +if __name__ == "__main__": + import sys as _sys + if len(_sys.argv) > 1 and _sys.argv[1] == "report": + print(format_report()) + else: + run_check_and_alert() diff --git a/homelab-ai-bot/savetv_enrich.py.v1-ollama-bak b/homelab-ai-bot/savetv_enrich.py.v1-ollama-bak new file mode 100644 index 000000000..6f9ce23bf --- /dev/null +++ b/homelab-ai-bot/savetv_enrich.py.v1-ollama-bak @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Save.TV Film-Enricher — reichert Archiv-Filme per KI an. + +Läuft als Cronjob (z.B. alle 3h) auf CT 116. +Nutzt Ollama auf dem KI-Server (gleicher Endpunkt wie llm.py). +Schreibt in /mnt/savetv/.filminfo_cache.json — dieselbe Datei +die savetv_web.py und savetv_extra_routes.py verwenden. + +Ergebnis: 3-6 Sätze Beschreibung, Hauptdarsteller, Land, Jahr, Genre. +""" + +import fcntl +import json +import logging +import os +import re +import sys +import time +import requests +from pathlib import Path + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, "/opt") + +from tools import savetv + +log = logging.getLogger("savetv_enrich") +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", +) + +OLLAMA_BASE = "http://100.84.255.83:11434" +MODEL = "qwen2.5:14b" +FALLBACK_MODEL = "qwen3:30b-a3b" + +FILMINFO_CACHE = Path("/mnt/savetv/.filminfo_cache.json") +LOCKFILE = Path("/tmp/savetv_enrich.lock") +BATCH_SIZE = 8 +SLEEP_BETWEEN = 0.5 + + +def _load_cache() -> dict: + if FILMINFO_CACHE.exists(): + try: + return json.loads(FILMINFO_CACHE.read_text()) + except Exception: + pass + return {} + + +def _save_cache(cache: dict): + tmp = FILMINFO_CACHE.with_suffix(".tmp") + tmp.write_text(json.dumps(cache, ensure_ascii=False, indent=1)) + tmp.rename(FILMINFO_CACHE) + + +def _is_enriched(entry: dict) -> bool: + return bool(entry.get("description")) + + +def _call_ollama(prompt: str, model: str = MODEL) -> str: + payload = { + "model": model, + "messages": [ + {"role": "system", "content": ( + "Du bist eine Filmdatenbank. Antworte AUSSCHLIESSLICH mit validem JSON. " + "Kein Markdown, keine Erklärungen, kein Denken. Nur JSON. Sprache: Deutsch." + )}, + {"role": "user", "content": prompt}, + ], + "stream": False, + "think": False, + "options": {"num_predict": 1024}, + } + try: + r = requests.post( + f"{OLLAMA_BASE}/api/chat", + json=payload, timeout=180, + ) + r.raise_for_status() + data = r.json() + text = data.get("message", {}).get("content", "").strip() + if text.startswith("```"): + text = re.sub(r"^```\w*\n?", "", text) + text = re.sub(r"\n?```$", "", text) + return text.strip() + except requests.exceptions.ReadTimeout: + if model != FALLBACK_MODEL: + log.warning("Timeout mit %s, Fallback auf %s", model, FALLBACK_MODEL) + return _call_ollama(prompt, model=FALLBACK_MODEL) + raise + except Exception as e: + log.error("Ollama-Fehler: %s", e) + return "" + + +def _normalize_actors(actors_raw) -> list: + if not actors_raw or not isinstance(actors_raw, list): + return [] + result = [] + for a in actors_raw[:4]: + if isinstance(a, str): + result.append(a) + elif isinstance(a, dict): + name = a.get("name") or a.get("actor") or "" + if name: + result.append(name) + return result + + +def _enrich_film(title: str) -> dict: + clean_title = re.sub(r"\s*[-\u2013\u2014]\s*.+$", "", title).strip() + + prompt = f"""Gib mir Informationen zum Film "{clean_title}". +Antworte als JSON mit exakt diesen Feldern (alle Texte auf Deutsch): +{{ + "year": "Erscheinungsjahr als String", + "countries": ["Produktionsländer als Strings"], + "genres": ["bis zu 3 Genres als Strings"], + "actors": ["bis zu 4 Hauptdarsteller als Strings"], + "director": "Regisseur als String", + "description": "3-5 Sätze auf Deutsch: Worum geht es, für wen geeignet. Keine Spoiler." +}} +Wichtig: Alle Werte müssen Strings sein. Schreibe die description komplett auf Deutsch. +Falls du den Film nicht kennst, setze description auf leeren String.""" + + raw = _call_ollama(prompt) + if not raw: + log.warning(" Leere Antwort von Ollama für '%s'", title) + return {"year": "", "countries": [], "genres": [], "actors": [], + "director": "", "description": ""} + + try: + data = json.loads(raw) + except json.JSONDecodeError: + match = re.search(r"\{[\s\S]*\}", raw) + if match: + try: + data = json.loads(match.group()) + except json.JSONDecodeError: + log.warning("JSON-Parse fehlgeschlagen für '%s': %.200s", title, raw) + return {"year": "", "countries": [], "genres": [], "actors": [], + "director": "", "description": ""} + else: + log.warning("Kein JSON gefunden für '%s': %.200s", title, raw) + return {"year": "", "countries": [], "genres": [], "actors": [], + "director": "", "description": ""} + + desc = str(data.get("description", ""))[:600] + if desc and not _is_mostly_latin(desc): + log.info(" Nicht-lateinische Beschreibung gefiltert: %.80s", desc) + desc = "" + if not desc: + log.info(" Beschreibung leer, raw year=%s actors=%s", + data.get("year"), str(data.get("actors", []))[:80]) + + return { + "year": str(data.get("year", ""))[:4], + "countries": [str(c) for c in (data.get("countries") or [])[:3]], + "genres": [str(g) for g in (data.get("genres") or [])[:3]], + "actors": _normalize_actors(data.get("actors")), + "director": str(data.get("director", ""))[:60], + "description": desc, + } + + +def _is_mostly_latin(text: str) -> bool: + if not text: + return False + latin = sum(1 for c in text if c.isascii() or '\u00C0' <= c <= '\u024F') + return latin / max(len(text), 1) > 0.7 + + +def run(): + log.info("Starte Film-Enrichment...") + + entries = savetv._get_full_archive() + if not entries: + log.warning("Keine Archiv-Einträge von Save.TV erhalten") + return + + titles = set() + for e in entries: + tc = e.get("STRTELECASTENTRY", {}) + if tc.get("SFOLGE", ""): + continue + title = tc.get("STITLE", "") + if title and not savetv._is_excluded(title): + titles.add(title) + + cache = _load_cache() + missing = [t for t in titles if not _is_enriched(cache.get(t, {}))] + + if not missing: + log.info("Alle %d Filme bereits angereichert", len(titles)) + return + + log.info("%d Filme im Archiv, %d davon noch ohne KI-Beschreibung", + len(titles), len(missing)) + + enriched = 0 + for i, title in enumerate(missing): + log.info("[%d/%d] Anreichern: %s", i + 1, len(missing), title) + try: + info = _enrich_film(title) + if info.get("description"): + cache[title] = info + enriched += 1 + log.info(" OK: %s (%s)", info.get("year", "?"), + ", ".join(info.get("actors", [])[:2])) + if enriched % BATCH_SIZE == 0: + _save_cache(cache) + log.info(" Cache gespeichert (%d angereichert)", enriched) + else: + old = cache.get(title, {}) + for k in ("year", "countries", "genres"): + if info.get(k) and not old.get(k): + old[k] = info[k] + if info.get("actors"): + old["actors"] = info["actors"] + if info.get("director"): + old["director"] = info["director"] + cache[title] = old + log.info(" Keine Beschreibung erhalten, Basisdaten übernommen") + except Exception as e: + log.error(" Fehler bei '%s': %s", title, e) + + time.sleep(SLEEP_BETWEEN) + + _save_cache(cache) + log.info("Fertig: %d von %d Filmen neu angereichert", enriched, len(missing)) + + +if __name__ == "__main__": + lock_fd = open(LOCKFILE, "w") + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + print("Enricher läuft bereits — Abbruch.") + sys.exit(0) + + try: + run() + finally: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + lock_fd.close() diff --git a/homelab-ai-bot/telegram_bot.py b/homelab-ai-bot/telegram_bot.py index b8d6540f9..9f9f72b8e 100644 --- a/homelab-ai-bot/telegram_bot.py +++ b/homelab-ai-bot/telegram_bot.py @@ -447,18 +447,14 @@ async def _run_freitext_llm_pipeline( document_mode=document_mode, ) ) - waited = 0 - while not llm_task.done(): - await asyncio.sleep(10) - waited += 10 - if not llm_task.done(): - try: - await update.message.reply_text( - "⏳ Noch dran (" + str(waited) + "s) — Save.TV/Modell kann etwas brauchen…" - ) - except Exception as te: - log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te) - answer = await llm_task + try: + answer = await asyncio.wait_for(asyncio.shield(llm_task), timeout=15.0) + except asyncio.TimeoutError: + try: + await update.message.reply_text("⏳ Noch dran — dauert etwas länger…") + except Exception as te: + log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te) + answer = await llm_task if session_id: memory_client.log_message(session_id, "user", text) @@ -506,18 +502,14 @@ async def _run_voice_llm_pipeline( document_mode=document_mode, ) ) - waited = 0 - while not llm_task.done(): - await asyncio.sleep(10) - waited += 10 - if not llm_task.done(): - try: - await update.message.reply_text( - "⏳ Noch dran (" + str(waited) + "s) — Save.TV/Modell kann etwas brauchen…" - ) - except Exception as te: - log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te) - answer = await llm_task + try: + answer = await asyncio.wait_for(asyncio.shield(llm_task), timeout=15.0) + except asyncio.TimeoutError: + try: + await update.message.reply_text("⏳ Noch dran — dauert etwas länger…") + except Exception as te: + log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te) + answer = await llm_task if session_id: memory_client.log_message(session_id, "user", text) diff --git a/infrastructure/STATE.md b/infrastructure/STATE.md index 84ab9e92e..981ab11a5 100644 --- a/infrastructure/STATE.md +++ b/infrastructure/STATE.md @@ -48,3 +48,110 @@ |---|---| | @MutterbotAI_bot | Watchdog-Alerts | | @Orbitalo_Hausmeister_bot | Homelab AI-Bot | + +## Hermes CT151 Monitoring und Backup + +Stand: 2026-05-16 + +Hermes (`hermes-mu`, CT151) ist als kritischer Dienst eingestuft und wird separat abgesichert. + +### Proxmox / PBS Backup + +Backup-Job auf `pve3` / `pve-mu-3`: + +| Feld | Wert | +|------|------| +| Job-ID | `hermes-mu-backup` | +| VMID | `151` | +| Container | `hermes-mu` | +| Node | `pve3` | +| Ziel | `pbs-nvme` / Muldenstein PBS (`100.99.139.22:nvme-pool`) | +| Modus | `snapshot` | +| Kompression | `zstd` | +| Zeitplan | täglich `03:20` | +| Nachholen | `repeat-missed=1` | +| Prune | `keep-daily=7`, `keep-weekly=4`, `keep-monthly=3` | + +Manuelles Safety-Backup wurde am 2026-05-16 erfolgreich erstellt: + +```text +ct/151/2026-05-16T10:51:53Z +Dauer: 56s +Gesichert: ca. 6.15 GiB +Komprimiert übertragen: ca. 3.98 GiB +Ergebnis: Backup job finished successfully +``` + +PBS-Auslastung danach: + +```text +pbs-nvme active, ca. 5.63% belegt +``` + +Schnelltest: + +```bash +# Auf pve3 / pve-mu-3 +pvesh get /cluster/backup/hermes-mu-backup --output-format json +pvesm status --storage pbs-nvme +``` + +### Prometheus / node_exporter + +Auf CT151 ist `prometheus-node-exporter` installiert und aktiv. + +```bash +systemctl is-active prometheus-node-exporter +curl -sS http://127.0.0.1:9100/metrics | grep -m1 '^node_uname_info' +``` + +Von CT116 / Hausmeister erreichbar über: + +```bash +curl -sS http://100.109.174.120:9100/metrics +``` + +### Loki / Promtail + +Auf CT151 läuft `promtail` und schickt Logs mit Host-Label `hermes-mu` an Loki. + +Wichtige Checks: + +```bash +systemctl is-active promtail +curl -sS http://100.109.174.120:9080/ready +``` + +### Hermes-Service-Checks + +Aktive Dienste auf CT151: + +```bash +systemctl is-active hermes-gateway beellama-tunnel promtail prometheus-node-exporter +curl -sS http://127.0.0.1:11435/v1/models +``` + +Der lokale BeeLlama/OpenAI-kompatible Tunnel lauscht in CT151 auf `127.0.0.1:11435`; er ist deshalb von CT116 nicht direkt per HTTP erreichbar. Der Health-Check erfolgt lokal auf CT151 bzw. indirekt über Hermes/Hausmeister. + +### Hausmeister-Integration + +Hausmeister (`CT116`) enthält in `/opt/homelab-ai-bot/monitor.py` einen `check_hermes()`. + +Der Check prüft: + +- Hermes Promtail: `http://100.109.174.120:9080/ready` +- Hermes node_exporter: `http://100.109.174.120:9100/metrics` +- Websuche/SearXNG CT121: `http://100.74.196.29:8080/search?...` +- Loki: `http://100.109.206.43:3100/ready` +- PBS Muldenstein: `https://100.99.139.22:8007` +- Loki-Health für Host `hermes-mu` +- auffällige Hermes-Logs der letzten 30 Minuten + +Harmloser `node_exporter`-Diskstats-Logspam (`diskstats_linux.go`, `disabling udev device properties`) wird gefiltert. + +Finaler Test am 2026-05-16: + +```text +monitor.check_hermes() -> OK +hausmeister-bot.service -> active +``` diff --git a/sessions/opencode-20260420-qwen36-workflow.md b/sessions/opencode-20260420-qwen36-workflow.md index 46c59bc38..751117474 100644 --- a/sessions/opencode-20260420-qwen36-workflow.md +++ b/sessions/opencode-20260420-qwen36-workflow.md @@ -1093,3 +1093,38 @@ _(reasoning: Der Memory wurde erfolgreich gespeichert. Ich bestätige das dem Us Willst du noch etwas anderes speichern? 🔥 +## Entscheidungsnotiz: Qwen3.6 + BeeLlama fuer opencode-Tests + +Stand: 2026-05-18 + +Qwen3.6 ueber BeeLlama ist fuer einfache, kontrollierte `opencode`-Tests ausreichend: + +- Git-Repos klonen +- README, `package.json` und Setup-Hinweise lesen +- Projektstruktur erkunden +- Shell-/Git-Kommandos ausfuehren +- Tests finden und starten +- kleine, klar begrenzte Codeaenderungen vorbereiten + +Wichtig: BeeLlama macht das Modell schneller und lokal praktikabler, aber nicht automatisch intelligenter. Fuer komplexe Coding-Agent-Aufgaben wie grosse Refactorings, viele Dateien gleichzeitig, schwierige Debug-Schleifen oder produktive Aenderungen bleibt ein spezialisiertes Coding-Modell zuverlaessiger. + +Empfohlener erster Testauftrag: + +```text +Klon dieses Repo nach /tmp/opencode-test, lies README und package.json, aber aendere nichts. Sag danach, wie man es startet und welche Tests definiert sind. +``` + +## Entscheidungsnotiz: zweite RTX 3090 + +Eine zweite RTX 3090 verbessert die Qualitaet desselben Modells nicht direkt. Dasselbe Modell mit derselben Quantisierung liefert grundsaetzlich dieselbe Antwortqualitaet. + +Eine zweite GPU hilft indirekt durch: + +- groessere Modelle, die vorher nicht in den VRAM passten +- bessere Quantisierung, z.B. Q5/Q6 statt Q4 +- mehr Kontext/KV-Cache +- weniger CPU-Offload +- hoehere Tokenrate bei gutem Layer-/Tensor-Splitting +- mehr parallele Requests + +Fuer `opencode` waere der echte Qualitaetsgewinn daher nicht die zweite GPU selbst, sondern dass dadurch eventuell ein besseres oder groesseres Coding-Modell lokal betrieben werden kann.