backup: kompletter Live-Stand CT116 Bot (3 ungepushte Commits + uncommittete Juni-Edits), Snapshot 2026-06-21
This commit is contained in:
parent
00837a435e
commit
eff739949f
19 changed files with 3648 additions and 34 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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]))
|
||||
|
|
|
|||
225
homelab-ai-bot/core/loki_client.py.bak.20260603_hermes
Normal file
225
homelab-ai-bot/core/loki_client.py.bak.20260603_hermes
Normal file
|
|
@ -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)
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
740
homelab-ai-bot/llm.py.bak.20260601_163620
Normal file
740
homelab-ai-bot/llm.py.bak.20260601_163620
Normal file
|
|
@ -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}"
|
||||
740
homelab-ai-bot/llm.py.bak.20260605_before-gemini
Normal file
740
homelab-ai-bot/llm.py.bak.20260605_before-gemini
Normal file
|
|
@ -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}"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
403
homelab-ai-bot/monitor.py.bak-hermes-monitor
Normal file
403
homelab-ai-bot/monitor.py.bak-hermes-monitor
Normal file
|
|
@ -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()
|
||||
461
homelab-ai-bot/monitor.py.bak.20260601_163058
Normal file
461
homelab-ai-bot/monitor.py.bak.20260601_163058
Normal file
|
|
@ -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()
|
||||
469
homelab-ai-bot/monitor.py.bak.20260603_hermes
Normal file
469
homelab-ai-bot/monitor.py.bak.20260603_hermes
Normal file
|
|
@ -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()
|
||||
248
homelab-ai-bot/savetv_enrich.py.v1-ollama-bak
Normal file
248
homelab-ai-bot/savetv_enrich.py.v1-ollama-bak
Normal file
|
|
@ -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()
|
||||
|
|
@ -447,15 +447,11 @@ 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…"
|
||||
)
|
||||
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
|
||||
|
|
@ -506,15 +502,11 @@ 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…"
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue