Compare commits
4 commits
main
...
backup/liv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eff739949f | ||
|
|
00837a435e | ||
|
|
4829b012a7 | ||
|
|
16667efbed |
22 changed files with 3813 additions and 38 deletions
|
|
@ -190,3 +190,39 @@ Neue Leitlinie:
|
||||||
- **M5 Ultra** nur dann, wenn ein konkretes Modell das rechtfertigt.
|
- **M5 Ultra** nur dann, wenn ein konkretes Modell das rechtfertigt.
|
||||||
|
|
||||||
`homelab.conf` und `MASTER_INDEX.md` werden parallel angepasst.
|
`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 |
|
| Ralf | nein |
|
||||||
| rolf | nein |
|
| rolf | nein |
|
||||||
| wilfrid | 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"
|
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:
|
def _query(endpoint: str, params: dict, base_url: str = None) -> dict:
|
||||||
url = f"{base_url or LOKI_URL}{endpoint}"
|
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]:
|
def get_errors(container: str = None, hours: float = 1, limit: int = 200) -> list[dict]:
|
||||||
"""Get error-level logs, optionally filtered by container hostname."""
|
"""Get error-level logs, optionally filtered by container hostname."""
|
||||||
if container:
|
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:
|
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)
|
return query_logs(q, hours=hours, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -102,7 +124,8 @@ def get_health(container: str, hours: float = 24) -> dict:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"host": container,
|
"host": container,
|
||||||
"errors_last_{hours}h": error_count,
|
"error_count": error_count,
|
||||||
|
f"errors_last_{hours}h": error_count,
|
||||||
"sending_logs": has_recent,
|
"sending_logs": has_recent,
|
||||||
"status": "healthy" if error_count < 5 and has_recent else
|
"status": "healthy" if error_count < 5 and has_recent else
|
||||||
"warning" if error_count < 20 else "critical",
|
"warning" if error_count < 20 else "critical",
|
||||||
|
|
@ -114,6 +137,7 @@ WATCHED_SERVICES = [
|
||||||
("wordpress-v2", "wordpress"),
|
("wordpress-v2", "wordpress"),
|
||||||
("fuenfvoracht", "fuenfvoracht"),
|
("fuenfvoracht", "fuenfvoracht"),
|
||||||
("homelab-ai-bot", "hausmeister"),
|
("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."""
|
"""Zählt Fehler-Log-Einträge über einen Zeitraum via Loki metric query."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
start = now - timedelta(hours=hours)
|
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
|
# Loki instant metric query für Gesamtanzahl
|
||||||
data = _query("/loki/api/v1/query_range", {
|
data = _query("/loki/api/v1/query_range", {
|
||||||
"query": q,
|
"query": q,
|
||||||
|
|
@ -160,7 +184,7 @@ def check_service_restarts(minutes: int = 35) -> list[dict]:
|
||||||
ERROR_RATE_THRESHOLDS = {
|
ERROR_RATE_THRESHOLDS = {
|
||||||
"rss-manager": 15,
|
"rss-manager": 15,
|
||||||
"wordpress-v2": 10,
|
"wordpress-v2": 10,
|
||||||
"forgejo": 200, # Oeffentlich — Scanner-404er sind normal
|
"forgejo": 200, # Oeffentlich – Scanner-404er sind normal
|
||||||
}
|
}
|
||||||
ERROR_RATE_DEFAULT = 25
|
ERROR_RATE_DEFAULT = 25
|
||||||
|
|
||||||
|
|
@ -171,7 +195,7 @@ def check_error_rate(minutes: int = 30) -> list[dict]:
|
||||||
alerts = []
|
alerts = []
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
for host in all_hosts:
|
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)})
|
data = _query("/loki/api/v1/query", {"query": q, "time": _ns(now)})
|
||||||
count = sum(
|
count = sum(
|
||||||
int(float(r.get("value", [None, "0"])[1]))
|
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():
|
def warmup_ollama():
|
||||||
"""No-Op: Text-Modell laeuft jetzt ueber OpenRouter (Grok 4.1 Fast), kein Ollama-Warmup noetig."""
|
"""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 (Grok 4.1 Fast)')
|
log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (x-ai/grok-4.3)')
|
||||||
PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"}
|
PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"}
|
||||||
|
|
||||||
_LOCAL_OVERRIDES = [
|
_LOCAL_OVERRIDES = [
|
||||||
|
|
@ -341,7 +341,7 @@ def ask(question: str, context: str) -> str:
|
||||||
return f"LLM-Fehler: {e}"
|
return f"LLM-Fehler: {e}"
|
||||||
|
|
||||||
|
|
||||||
def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None, document_mode: bool = False) -> str:
|
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.
|
"""Freitext-Frage mit automatischem Routing und Tool-Calling.
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
@ -573,11 +573,12 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None, d
|
||||||
return f"Online-Suche Fehler: {e}"
|
return f"Online-Suche Fehler: {e}"
|
||||||
|
|
||||||
# --- Lokal: Tool-Calling mit allen Tools ---
|
# --- Lokal: Tool-Calling mit allen Tools ---
|
||||||
|
local_model = model_override or MODEL_LOCAL
|
||||||
passthrough_result = None
|
passthrough_result = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for _round in range(MAX_TOOL_ROUNDS):
|
for _round in range(MAX_TOOL_ROUNDS):
|
||||||
data = _call_api(messages, api_key, use_tools=True, model=MODEL_LOCAL)
|
data = _call_api(messages, api_key, use_tools=True, model=local_model)
|
||||||
choice = data["choices"][0]
|
choice = data["choices"][0]
|
||||||
msg = choice["message"]
|
msg = choice["message"]
|
||||||
|
|
||||||
|
|
@ -623,7 +624,7 @@ def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None, d
|
||||||
|
|
||||||
if passthrough_result:
|
if passthrough_result:
|
||||||
return passthrough_result
|
return passthrough_result
|
||||||
data = _call_api(messages, api_key, use_tools=False, model=MODEL_LOCAL)
|
data = _call_api(messages, api_key, use_tools=False, model=local_model)
|
||||||
return data["choices"][0]["message"]["content"]
|
return data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
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 os
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import re
|
||||||
import requests
|
import requests
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
@ -22,6 +23,8 @@ ALERT_COOLDOWN_SECONDS = {
|
||||||
"memory_expiry": 43200,
|
"memory_expiry": 43200,
|
||||||
"default": 3600,
|
"default": 3600,
|
||||||
"error_rate": 1800,
|
"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": "WordPress (CT 101)", "url": "http://10.10.10.101/robots.txt"},
|
||||||
{"name": "Matomo (CT 109)", "url": "http://10.10.10.109"},
|
{"name": "Matomo (CT 109)", "url": "http://10.10.10.109"},
|
||||||
{"name": "Grafana (CT 110)", "url": "http://10.10.10.110:3000/api/health"},
|
{"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 = {
|
EXPECTED_STOPPED = {
|
||||||
(115, "pve-ka-1"), # flugscanner-asia-old (gestoppt, Cluster pve1) — Live CT auf pve-pp-1
|
(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)
|
(115, "pve-ka-3"), # dieselbe CT, zweite API-Sicht (Cluster)
|
||||||
|
|
@ -57,6 +68,9 @@ EXPECTED_STOPPED = {
|
||||||
(504, "pve-ka-2"), # Shop-Template — stopped
|
(504, "pve-ka-2"), # Shop-Template — stopped
|
||||||
(8000, "pve-ka-2"), # Kunde0-Shop — stopped
|
(8000, "pve-ka-2"), # Kunde0-Shop — stopped
|
||||||
(8010, "pve-ka-2"), # Kunde1-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
|
# 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
|
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]:
|
def check_all() -> list[str]:
|
||||||
"""Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück."""
|
"""Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück."""
|
||||||
cfg = config.parse_config()
|
cfg = config.parse_config()
|
||||||
suppressed_hosts = config.get_suppressed_hosts(cfg)
|
suppressed_hosts = config.get_suppressed_hosts(cfg)
|
||||||
suppressed_names = config.get_suppressed_container_names(cfg)
|
suppressed_names = config.get_suppressed_container_names(cfg)
|
||||||
alerts = []
|
alerts = []
|
||||||
|
alerts.extend(check_hermes())
|
||||||
|
|
||||||
containers = proxmox_client.get_all_containers(
|
containers = proxmox_client.get_all_containers(
|
||||||
_get_passwords(cfg), _get_tokens(cfg)
|
_get_passwords(cfg), _get_tokens(cfg)
|
||||||
|
|
@ -322,6 +384,19 @@ def _save_alert_state(state: dict):
|
||||||
|
|
||||||
|
|
||||||
def _alert_key(alert_text: str) -> str:
|
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()
|
return hashlib.md5(alert_text.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -340,6 +415,10 @@ def _alert_category(alert_text: str) -> str:
|
||||||
return "http"
|
return "http"
|
||||||
if "Service-Neustart" in alert_text:
|
if "Service-Neustart" in alert_text:
|
||||||
return "restart"
|
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:
|
if "Memory läuft ab" in alert_text:
|
||||||
return "memory_expiry"
|
return "memory_expiry"
|
||||||
return "default"
|
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()
|
||||||
|
|
@ -661,7 +661,7 @@ setInterval(load,30000);
|
||||||
return
|
return
|
||||||
raw_title_part = m.group(1)
|
raw_title_part = m.group(1)
|
||||||
|
|
||||||
clean_title = raw_title_part.replace('_-_', ' - ').replace('_', ' ').strip()
|
clean_title = raw_title_part.replace('__', ' - ').replace('_-_', ' - ').replace('_', ' ').strip()
|
||||||
|
|
||||||
cache = _load_filminfo_cache()
|
cache = _load_filminfo_cache()
|
||||||
matched = _find_cache_match(cache, clean_title)
|
matched = _find_cache_match(cache, clean_title)
|
||||||
|
|
@ -700,6 +700,26 @@ setInterval(load,30000);
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger("savetv").warning(f"Rename fehlgeschlagen {raw_filename}: {e}")
|
logging.getLogger("savetv").warning(f"Rename fehlgeschlagen {raw_filename}: {e}")
|
||||||
|
|
||||||
|
def _rename_all_pending():
|
||||||
|
import re as _re
|
||||||
|
renamed, skipped = [], []
|
||||||
|
for fp in sorted(SAVETV_DIR.glob("*.mp4")):
|
||||||
|
m = _re.match(r"^(.+)_(\d{6,9})\.mp4$", fp.name)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
old = fp.name
|
||||||
|
tid = m.group(2)
|
||||||
|
_rename_to_jellyfin(old, tid)
|
||||||
|
if (SAVETV_DIR / old).exists():
|
||||||
|
skipped.append(old)
|
||||||
|
else:
|
||||||
|
renamed.append(old)
|
||||||
|
return {"renamed": renamed, "skipped": skipped}
|
||||||
|
|
||||||
|
@app.route("/api/rename_pending", methods=["POST"])
|
||||||
|
def api_rename_pending():
|
||||||
|
return jsonify(_rename_all_pending())
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
def health():
|
def health():
|
||||||
from tools import savetv
|
from tools import savetv
|
||||||
|
|
|
||||||
60
homelab-ai-bot/savetv_rename_nas.py
Executable file
60
homelab-ai-bot/savetv_rename_nas.py
Executable file
|
|
@ -0,0 +1,60 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Benennt Raw-MP4s auf dem NAS um (Titel_ID.mp4 -> Titel (Jahr).mp4)."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ZIEL = os.environ.get("SAVETV_NAS_DIR", "/mnt/nas/Filme zum nachbearbeiten")
|
||||||
|
FILMINFO_API = os.environ.get("SAVETV_FILMINFO_API", "http://138.201.84.95:9443/api/filminfo")
|
||||||
|
RAW_RE = re.compile(r"^(.+)_(\d{6,9})\.mp4$")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_title(raw_part):
|
||||||
|
return raw_part.replace("_-_", " - ").replace("_", " ").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def filminfo(title):
|
||||||
|
url = FILMINFO_API + "?" + urllib.parse.urlencode({"title": title})
|
||||||
|
with urllib.request.urlopen(url, timeout=60) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def rename_one(name):
|
||||||
|
m = RAW_RE.match(name)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
clean = clean_title(m.group(1))
|
||||||
|
try:
|
||||||
|
info = filminfo(clean)
|
||||||
|
year = (info or {}).get("year", "")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" filminfo fehlgeschlagen fuer {clean}: {e}")
|
||||||
|
year = ""
|
||||||
|
safe = re.sub(r'[\\/:*?"<>|]', "", clean).strip()
|
||||||
|
dest = f"{safe} ({year}).mp4" if year else f"{safe}.mp4"
|
||||||
|
src = os.path.join(ZIEL, name)
|
||||||
|
dst = os.path.join(ZIEL, dest)
|
||||||
|
if os.path.exists(dst):
|
||||||
|
print(f" Ziel existiert, loesche Raw: {name}")
|
||||||
|
os.remove(src)
|
||||||
|
return dest
|
||||||
|
os.rename(src, dst)
|
||||||
|
print(f" OK: {name} -> {dest}")
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.isdir(ZIEL):
|
||||||
|
print(f"Ordner fehlt: {ZIEL}")
|
||||||
|
return 1
|
||||||
|
pending = [n for n in os.listdir(ZIEL) if RAW_RE.match(n)]
|
||||||
|
print(f"{len(pending)} Raw-Dateien auf NAS")
|
||||||
|
for n in sorted(pending):
|
||||||
|
rename_one(n)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -447,18 +447,14 @@ async def _run_freitext_llm_pipeline(
|
||||||
document_mode=document_mode,
|
document_mode=document_mode,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
waited = 0
|
try:
|
||||||
while not llm_task.done():
|
answer = await asyncio.wait_for(asyncio.shield(llm_task), timeout=15.0)
|
||||||
await asyncio.sleep(10)
|
except asyncio.TimeoutError:
|
||||||
waited += 10
|
try:
|
||||||
if not llm_task.done():
|
await update.message.reply_text("⏳ Noch dran — dauert etwas länger…")
|
||||||
try:
|
except Exception as te:
|
||||||
await update.message.reply_text(
|
log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te)
|
||||||
"⏳ Noch dran (" + str(waited) + "s) — Save.TV/Modell kann etwas brauchen…"
|
answer = await llm_task
|
||||||
)
|
|
||||||
except Exception as te:
|
|
||||||
log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te)
|
|
||||||
answer = await llm_task
|
|
||||||
|
|
||||||
if session_id:
|
if session_id:
|
||||||
memory_client.log_message(session_id, "user", text)
|
memory_client.log_message(session_id, "user", text)
|
||||||
|
|
@ -506,18 +502,14 @@ async def _run_voice_llm_pipeline(
|
||||||
document_mode=document_mode,
|
document_mode=document_mode,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
waited = 0
|
try:
|
||||||
while not llm_task.done():
|
answer = await asyncio.wait_for(asyncio.shield(llm_task), timeout=15.0)
|
||||||
await asyncio.sleep(10)
|
except asyncio.TimeoutError:
|
||||||
waited += 10
|
try:
|
||||||
if not llm_task.done():
|
await update.message.reply_text("⏳ Noch dran — dauert etwas länger…")
|
||||||
try:
|
except Exception as te:
|
||||||
await update.message.reply_text(
|
log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te)
|
||||||
"⏳ Noch dran (" + str(waited) + "s) — Save.TV/Modell kann etwas brauchen…"
|
answer = await llm_task
|
||||||
)
|
|
||||||
except Exception as te:
|
|
||||||
log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te)
|
|
||||||
answer = await llm_task
|
|
||||||
|
|
||||||
if session_id:
|
if session_id:
|
||||||
memory_client.log_message(session_id, "user", text)
|
memory_client.log_message(session_id, "user", text)
|
||||||
|
|
@ -1016,6 +1008,28 @@ async def _send_daily_forecast(job_context):
|
||||||
session_id=None,
|
session_id=None,
|
||||||
document_mode=False,
|
document_mode=False,
|
||||||
)
|
)
|
||||||
|
if (analysis or "").startswith(("LLM-Fehler:", "Vision-LLM-Fehler:")):
|
||||||
|
log.error("Systemvorhersage LLM-Fehler: %s", analysis[:200])
|
||||||
|
analysis = await asyncio.to_thread(
|
||||||
|
llm.ask_with_tools,
|
||||||
|
prompt,
|
||||||
|
handlers,
|
||||||
|
session_id=None,
|
||||||
|
document_mode=False,
|
||||||
|
model_override=llm.MODEL_VISION,
|
||||||
|
)
|
||||||
|
if (analysis or "").startswith(("LLM-Fehler:", "Vision-LLM-Fehler:")):
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=CHAT_ID,
|
||||||
|
text=(
|
||||||
|
"🔭 *Taegliche Systemvorhersage*\n\n"
|
||||||
|
"⚠️ Analyse fehlgeschlagen (OpenRouter). "
|
||||||
|
"Bitte spaeter erneut versuchen."
|
||||||
|
),
|
||||||
|
parse_mode="Markdown",
|
||||||
|
)
|
||||||
|
log.error("Systemvorhersage endgueltig fehlgeschlagen: %s", analysis[:200])
|
||||||
|
return
|
||||||
msg = "🔭 *Taegliche Systemvorhersage*\n\n" + analysis
|
msg = "🔭 *Taegliche Systemvorhersage*\n\n" + analysis
|
||||||
await bot.send_message(chat_id=CHAT_ID, text=msg, parse_mode="Markdown")
|
await bot.send_message(chat_id=CHAT_ID, text=msg, parse_mode="Markdown")
|
||||||
log.info("Taegl. Systemvorhersage gesendet")
|
log.info("Taegl. Systemvorhersage gesendet")
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,48 @@ DOWNLOAD_FORMAT_SD = 5
|
||||||
AUTO_RECORD_SCORE = 80
|
AUTO_RECORD_SCORE = 80
|
||||||
SUGGEST_SCORE = 60
|
SUGGEST_SCORE = 60
|
||||||
|
|
||||||
|
_jellyfin_set_cache = None
|
||||||
|
_jellyfin_set_ts = 0.0
|
||||||
|
JELLYFIN_SET_TTL = 3600
|
||||||
|
|
||||||
|
|
||||||
|
def _jellyfin_normalized_set():
|
||||||
|
"""Set normalisierter Jellyfin-Titel (1h Cache). None = Abgleich fehlgeschlagen."""
|
||||||
|
global _jellyfin_set_cache, _jellyfin_set_ts
|
||||||
|
import time
|
||||||
|
now = time.time()
|
||||||
|
if _jellyfin_set_cache is not None and now - _jellyfin_set_ts < JELLYFIN_SET_TTL:
|
||||||
|
return _jellyfin_set_cache
|
||||||
|
try:
|
||||||
|
from savetv_extra_routes import _jellyfin_fetch_library
|
||||||
|
data = _jellyfin_fetch_library()
|
||||||
|
titles = data.get("normalized_titles") or []
|
||||||
|
_jellyfin_set_cache = set(titles)
|
||||||
|
_jellyfin_set_ts = now
|
||||||
|
log.info(
|
||||||
|
"Jellyfin-Abgleich: %d Titel geladen (cached=%s)",
|
||||||
|
len(_jellyfin_set_cache),
|
||||||
|
data.get("cached"),
|
||||||
|
)
|
||||||
|
return _jellyfin_set_cache
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Jellyfin-Abgleich fehlgeschlagen: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_epg_title(title):
|
||||||
|
from savetv_extra_routes import _normalize_film_title, _strip_trailing_year_in_parens
|
||||||
|
return _normalize_film_title(_strip_trailing_year_in_parens(title or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_in_jellyfin(title):
|
||||||
|
"""True wenn Film (normalisierter Titel) bereits in Jellyfin-Bibliothek."""
|
||||||
|
jf = _jellyfin_normalized_set()
|
||||||
|
if jf is None:
|
||||||
|
return False
|
||||||
|
key = _normalize_epg_title(title)
|
||||||
|
return bool(key and key in jf)
|
||||||
|
|
||||||
|
|
||||||
def _title_pattern(title):
|
def _title_pattern(title):
|
||||||
"""Titel exakt genug matchen, aber Leerzeichen/Interpunktion tolerant halten."""
|
"""Titel exakt genug matchen, aber Leerzeichen/Interpunktion tolerant halten."""
|
||||||
|
|
@ -887,6 +929,13 @@ def get_new_films():
|
||||||
if f.get("BEXISTRECORD"):
|
if f.get("BEXISTRECORD"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if _is_in_jellyfin(f.get("STITLE", "")):
|
||||||
|
log.info(
|
||||||
|
"Auto-Aufnahme übersprungen (bereits in Jellyfin): %s",
|
||||||
|
f.get("STITLE"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
if score >= AUTO_RECORD_SCORE:
|
if score >= AUTO_RECORD_SCORE:
|
||||||
tid = int(f.get("ITELECASTID", 0))
|
tid = int(f.get("ITELECASTID", 0))
|
||||||
result = _record_telecast(tid)
|
result = _record_telecast(tid)
|
||||||
|
|
@ -916,6 +965,15 @@ def handle_savetv_record(telecast_id=0, **kw):
|
||||||
title = tc.get("STITLE", title)
|
title = tc.get("STITLE", title)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if _is_in_jellyfin(title):
|
||||||
|
return (
|
||||||
|
"Save.TV: nicht aufgenommen — bereits in Jellyfin: "
|
||||||
|
+ title
|
||||||
|
+ "\n(TelecastId "
|
||||||
|
+ str(tid)
|
||||||
|
+ ")"
|
||||||
|
)
|
||||||
|
|
||||||
result = _record_telecast(tid)
|
result = _record_telecast(tid)
|
||||||
return "Save.TV: " + result + "\nSendung: " + title
|
return "Save.TV: " + result + "\nSendung: " + title
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,110 @@
|
||||||
|---|---|
|
|---|---|
|
||||||
| @MutterbotAI_bot | Watchdog-Alerts |
|
| @MutterbotAI_bot | Watchdog-Alerts |
|
||||||
| @Orbitalo_Hausmeister_bot | Homelab AI-Bot |
|
| @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? 🔥
|
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