backup: kompletter Live-Stand CT116 Bot (3 ungepushte Commits + uncommittete Juni-Edits), Snapshot 2026-06-21

This commit is contained in:
Homelab Cursor 2026-06-21 20:17:54 +02:00
parent 00837a435e
commit eff739949f
19 changed files with 3648 additions and 34 deletions

View file

@ -190,3 +190,39 @@ Neue Leitlinie:
- **M5 Ultra** nur dann, wenn ein konkretes Modell das rechtfertigt.
`homelab.conf` und `MASTER_INDEX.md` werden parallel angepasst.
## Entscheidungsnotiz: Qwen3.6 + BeeLlama fuer opencode-Tests
Stand: 2026-05-18
Qwen3.6 ueber BeeLlama ist fuer einfache, kontrollierte `opencode`-Tests ausreichend:
- Git-Repos klonen
- README, `package.json` und Setup-Hinweise lesen
- Projektstruktur erkunden
- Shell-/Git-Kommandos ausfuehren
- Tests finden und starten
- kleine, klar begrenzte Codeaenderungen vorbereiten
Wichtig: BeeLlama macht das Modell schneller und lokal praktikabler, aber nicht automatisch intelligenter. Fuer komplexe Coding-Agent-Aufgaben wie grosse Refactorings, viele Dateien gleichzeitig, schwierige Debug-Schleifen oder produktive Aenderungen bleibt ein spezialisiertes Coding-Modell zuverlaessiger.
Empfohlener erster Testauftrag:
```text
Klon dieses Repo nach /tmp/opencode-test, lies README und package.json, aber aendere nichts. Sag danach, wie man es startet und welche Tests definiert sind.
```
## Entscheidungsnotiz: zweite RTX 3090
Eine zweite RTX 3090 verbessert die Qualitaet desselben Modells nicht direkt. Dasselbe Modell mit derselben Quantisierung liefert grundsaetzlich dieselbe Antwortqualitaet.
Eine zweite GPU hilft indirekt durch:
- groessere Modelle, die vorher nicht in den VRAM passten
- bessere Quantisierung, z.B. Q5/Q6 statt Q4
- mehr Kontext/KV-Cache
- weniger CPU-Offload
- hoehere Tokenrate bei gutem Layer-/Tensor-Splitting
- mehr parallele Requests
Fuer `opencode` waere der echte Qualitaetsgewinn daher nicht die zweite GPU selbst, sondern dass dadurch eventuell ein besseres oder groesseres Coding-Modell lokal betrieben werden kann.

View file

@ -140,3 +140,58 @@ curl -s "http://100.77.105.3:8096/Items?api_key=7285b4a8793541648bf156599ae05b43
| Ralf | nein |
| rolf | nein |
| wilfrid | nein |
## Troubleshooting: Details fehlen / Remote-Zugriff wirkt kaputt
Stand: 2026-05-16
Symptom:
- Filmübersicht ist erreichbar, aber Detailseiten/Filmdetails werden im Client nicht angezeigt.
- Zugriff über Tailscale-IP `100.77.105.3:8096` hängt oder läuft in Timeouts.
Befund vom 2026-05-16:
- Jellyfin selbst war lokal gesund:
- `http://192.168.178.149:8096/health` -> `Healthy`
- API `/Items/Counts` lieferte `897` Filme.
- Beispiel-Film `2 Guns` hatte Beschreibung, Genres, TMDB/IMDB IDs und Bilder.
- Medien-Mounts waren aktiv:
- `/mnt/nas`
- `/srv/media`
- Docker-Container `jellyfin` war `healthy`.
- Ursache war Tailscale auf dem Jellyfin-Host:
- `tailscale ip -4` meldete zuerst `no current Tailscale IPs; state: NeedsLogin`
- `tailscale status` meldete `Logged out`.
- Nach Re-Login war `100.77.105.3` wieder aktiv und `http://100.77.105.3:8096/health` antwortete wieder `Healthy`.
- Danach waren die Filmdetails im Client wieder sichtbar.
Schnelltest:
```bash
# Von pve3 lokal im LAN:
curl -sS http://192.168.178.149:8096/health
# Von CT 116 / Hausmeister oder einem Tailscale-Host:
curl -sS http://100.77.105.3:8096/health
curl -sS "http://100.77.105.3:8096/Items/Counts?api_key=7285b4a8793541648bf156599ae05b43"
```
Fix:
1. Auf dem Jellyfin-Host Tailscale-Status prüfen:
```bash
tailscale ip -4
tailscale status --self
```
2. Wenn `NeedsLogin` oder `Logged out`: Tailscale neu anmelden.
3. Danach erneut prüfen:
```bash
curl -sS http://100.77.105.3:8096/health
```
4. Im Browser Jellyfin hart neu laden oder neu einloggen, falls der Webclient alte kaputte API-Antworten gecacht hat.
Wichtig:
- Fehlende Details bedeuten nicht automatisch kaputte Jellyfin-Datenbank oder fehlende Metadaten.
- Erst lokale API gegen `192.168.178.149:8096` prüfen, dann Tailscale/Cloudflare-Zugriff.
- `systemctl is-active jellyfin` kann irreführend sein, weil Jellyfin hier im Docker-Container läuft. Aussagekräftiger ist:
```bash
docker ps | grep jellyfin
curl -sS http://127.0.0.1:8096/health
```

View file

@ -5,6 +5,28 @@ from datetime import datetime, timezone, timedelta
LOKI_URL = "http://100.109.206.43:3100"
# Transiente cloudflared/Cloudflare-Edge-Flaps erzeugen oft error="..."
# in WRN/ERR-Zeilen, sind aber keine App-Fehler von WordPress/RSS.
ERROR_EXCLUDE_RE = (
"caller=metrics|query_hash=|executing query|scheduler_processor|"
"Aborted connection|systemd-networkd-wait-online|context canceled|"
"AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|"
"control stream encountered a failure while serving|Serve tunnel error|"
"failed to serve tunnel connection|Connection terminated|"
"failed to dial to edge with quic|Failed to dial a quic connection|"
"no recent network activity|failed to accept QUIC stream|"
"control stream|accept stream listener|serve tunnel|tunnel connection|"
"edge with quic|quic connection|QUIC stream|"
"accept stream listener encountered a failure while serving|"
"datagram manager encountered a failure while serving|"
"datagram manager|datagram handler|"
"failed to run the datagram handler|failed to accept incoming stream requests|"
"Application error 0x0|connIndex=|"
"beellama-tunnel.service: Main process exited|"
"beellama-expose.service: Main process exited|"
"health\(warnable=mapresponse-timeout\)"
)
def _query(endpoint: str, params: dict, base_url: str = None) -> dict:
url = f"{base_url or LOKI_URL}{endpoint}"
@ -49,9 +71,9 @@ def query_logs(query: str, hours: float = 1, limit: int = 100) -> list[dict]:
def get_errors(container: str = None, hours: float = 1, limit: int = 200) -> list[dict]:
"""Get error-level logs, optionally filtered by container hostname."""
if container:
q = f'{{host="{container}"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror"'
q = f'{{host="{container}"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"'
else:
q = '{job=~".+"} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror"'
q = f'{{job=~".+"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"'
return query_logs(q, hours=hours, limit=limit)
@ -102,7 +124,8 @@ def get_health(container: str, hours: float = 24) -> dict:
return {
"host": container,
"errors_last_{hours}h": error_count,
"error_count": error_count,
f"errors_last_{hours}h": error_count,
"sending_logs": has_recent,
"status": "healthy" if error_count < 5 and has_recent else
"warning" if error_count < 20 else "critical",
@ -114,6 +137,7 @@ WATCHED_SERVICES = [
("wordpress-v2", "wordpress"),
("fuenfvoracht", "fuenfvoracht"),
("homelab-ai-bot", "hausmeister"),
("hermes-mu", "hermes-gateway"),
]
@ -121,7 +145,7 @@ def count_errors(hours: float = 24) -> dict:
"""Zählt Fehler-Log-Einträge über einen Zeitraum via Loki metric query."""
now = datetime.now(timezone.utc)
start = now - timedelta(hours=hours)
q = '{job=~".+"} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror"'
q = f'{{job=~".+"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"'
# Loki instant metric query für Gesamtanzahl
data = _query("/loki/api/v1/query_range", {
"query": q,
@ -160,7 +184,7 @@ def check_service_restarts(minutes: int = 35) -> list[dict]:
ERROR_RATE_THRESHOLDS = {
"rss-manager": 15,
"wordpress-v2": 10,
"forgejo": 200, # Oeffentlich Scanner-404er sind normal
"forgejo": 200, # Oeffentlich Scanner-404er sind normal
}
ERROR_RATE_DEFAULT = 25
@ -171,7 +195,7 @@ def check_error_rate(minutes: int = 30) -> list[dict]:
alerts = []
now = datetime.now(timezone.utc)
for host in all_hosts:
q = f'count_over_time({{host="{host}"}} |~ "(?i)error" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror" [{minutes}m])'
q = f'count_over_time({{host="{host}"}} |~ "(?i)error" !~ "{ERROR_EXCLUDE_RE}" [{minutes}m])'
data = _query("/loki/api/v1/query", {"query": q, "time": _ns(now)})
count = sum(
int(float(r.get("value", [None, "0"])[1]))

View 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)

View file

@ -27,8 +27,8 @@ OLLAMA_MODELS = set()
def warmup_ollama():
"""No-Op: Text-Modell laeuft jetzt ueber OpenRouter (Grok 4.1 Fast), kein Ollama-Warmup noetig."""
log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (Grok 4.1 Fast)')
"""No-Op: Text-Modell laeuft ueber OpenRouter (x-ai/grok-4.3), kein Ollama-Warmup noetig."""
log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (x-ai/grok-4.3)')
PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"}
_LOCAL_OVERRIDES = [

View 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}"

View 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}"

View file

@ -4,6 +4,7 @@ import sys
import os
import json
import hashlib
import re
import requests
import time
from datetime import datetime, timezone
@ -22,6 +23,8 @@ ALERT_COOLDOWN_SECONDS = {
"memory_expiry": 43200,
"default": 3600,
"error_rate": 1800,
"hermes": 7200,
"backup": 21600,
}
@ -45,10 +48,18 @@ HTTP_HEALTH_CHECKS = [
{"name": "WordPress (CT 101)", "url": "http://10.10.10.101/robots.txt"},
{"name": "Matomo (CT 109)", "url": "http://10.10.10.109"},
{"name": "Grafana (CT 110)", "url": "http://10.10.10.110:3000/api/health"},
{"name": "Flugscanner-Agent (pve-pp-1)", "url": "http://100.126.26.46:5010/status",
"retries": 5, "timeout": 25, "retry_delay": 6, "host": "pve-pp-1"},
]
HERMES_HEALTH_CHECKS = [
{"name": "Hermes Promtail", "url": "http://100.109.174.120:9080/ready", "timeout": 5},
{"name": "Hermes node_exporter", "url": "http://100.109.174.120:9100/metrics", "timeout": 5},
{"name": "Hermes Websuche CT121", "url": "http://100.74.196.29:8080/search?q=hausmeister-health&format=json", "timeout": 8},
{"name": "Loki", "url": "http://100.109.206.43:3100/ready", "timeout": 5},
{"name": "PBS Muldenstein", "url": "https://100.99.139.22:8007", "timeout": 5, "allow_status": {200, 401}},
]
HERMES_LOG_HOST = "hermes-mu"
EXPECTED_STOPPED = {
(115, "pve-ka-1"), # flugscanner-asia-old (gestoppt, Cluster pve1) — Live CT auf pve-pp-1
(115, "pve-ka-3"), # dieselbe CT, zweite API-Sicht (Cluster)
@ -57,6 +68,9 @@ EXPECTED_STOPPED = {
(504, "pve-ka-2"), # Shop-Template — stopped
(8000, "pve-ka-2"), # Kunde0-Shop — stopped
(8010, "pve-ka-2"), # Kunde1-Shop — stopped
(121, "pve-ka-1"), # Schawarma-Shop — gestoppt
(121, "pve-ka-2"), # Schawarma-Shop — gestoppt
(121, "pve-ka-3"), # Schawarma-Shop — gestoppt
}
# VMIDs, die auf jedem Proxmox-Host in CONFIG ok sind, solange status == stopped
@ -83,12 +97,60 @@ def _is_host_suppressed(host: str, suppressed_hosts: set) -> bool:
return False
def check_hermes() -> list[str]:
"""Prueft Hermes (CT151) als kritischen Dienst ueber HTTP und Loki."""
alerts = []
headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 hermes-check)"}
for check in HERMES_HEALTH_CHECKS:
allow_status = check.get("allow_status") or set(range(200, 400))
try:
r = requests.get(
check["url"],
timeout=check.get("timeout", 5),
allow_redirects=False,
headers=headers,
verify=False,
)
if r.status_code not in allow_status:
alerts.append(f"🔴 Hermes: {check['name']} HTTP {r.status_code}")
except requests.RequestException as e:
alerts.append(f"🔴 Hermes: {check['name']} nicht erreichbar: {str(e)[:80]}")
health = loki_client.get_health(HERMES_LOG_HOST, hours=1)
if health.get("status") == "silent":
alerts.append("⚠️ Hermes: keine aktuellen Loki-Logs von hermes-mu")
elif health.get("status") == "critical":
cnt = health.get("error_count", health.get("errors_last_1h", "?"))
alerts.append(f"🔴 Hermes: {cnt} Fehlerlogs in Loki (1h)")
errors = loki_client.get_errors(container=HERMES_LOG_HOST, hours=0.5, limit=20)
for e in errors:
if "error" in e:
continue
line = (e.get("line") or "").lower()
if "diskstats_linux.go" in line and "disabling udev device properties" in line:
continue
if "collector failed" in line and "node_exporter" in line:
continue
if "beellama-tunnel.service" in line or "beellama-expose.service" in line:
if "main process exited" in line:
continue
if "health(warnable=mapresponse-timeout)" in line:
continue
if any(term in line for term in ["traceback", "exception", "failed", "timeout", "tool_call", "connection refused"]):
alerts.append(f"🔴 Hermes Log: {(e.get('line') or '')[:140]}")
break
return alerts
def check_all() -> list[str]:
"""Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück."""
cfg = config.parse_config()
suppressed_hosts = config.get_suppressed_hosts(cfg)
suppressed_names = config.get_suppressed_container_names(cfg)
alerts = []
alerts.extend(check_hermes())
containers = proxmox_client.get_all_containers(
_get_passwords(cfg), _get_tokens(cfg)
@ -322,6 +384,19 @@ def _save_alert_state(state: dict):
def _alert_key(alert_text: str) -> str:
# Volatile Metrik-Alarme (error_rate) ueber einen stabilen Schluessel
# deduplizieren: nur Host, nicht der schwankende Zaehlwert. Sonst
# erzeugt jede neue Zahl einen neuen Hash und der Cooldown greift nie.
if "Fehler in 30 Min" in alert_text:
m = re.search(r"([\w.\-]+):\s*\d+\s+Fehler in 30 Min", alert_text)
if m:
return hashlib.md5(("error_rate|" + m.group(1)).encode()).hexdigest()
if "Hermes:" in alert_text and "Fehlerlogs in Loki" in alert_text:
return hashlib.md5(b"hermes|loki_errors").hexdigest()
if alert_text.startswith("🔴 Hermes Log:"):
m = re.search(r"([\w.\-]+\.service)", alert_text)
unit = m.group(1) if m else "generic"
return hashlib.md5(("hermes|log|" + unit).encode()).hexdigest()
return hashlib.md5(alert_text.encode()).hexdigest()
@ -340,6 +415,10 @@ def _alert_category(alert_text: str) -> str:
return "http"
if "Service-Neustart" in alert_text:
return "restart"
if "Hermes" in alert_text:
return "hermes"
if "Backup" in alert_text or "PBS" in alert_text:
return "backup"
if "Memory läuft ab" in alert_text:
return "memory_expiry"
return "default"

View 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()

View 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()

View 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()

View 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()

View file

@ -447,18 +447,14 @@ async def _run_freitext_llm_pipeline(
document_mode=document_mode,
)
)
waited = 0
while not llm_task.done():
await asyncio.sleep(10)
waited += 10
if not llm_task.done():
try:
await update.message.reply_text(
"⏳ Noch dran (" + str(waited) + "s) — Save.TV/Modell kann etwas brauchen…"
)
except Exception as te:
log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te)
answer = await llm_task
try:
answer = await asyncio.wait_for(asyncio.shield(llm_task), timeout=15.0)
except asyncio.TimeoutError:
try:
await update.message.reply_text("⏳ Noch dran — dauert etwas länger…")
except Exception as te:
log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te)
answer = await llm_task
if session_id:
memory_client.log_message(session_id, "user", text)
@ -506,18 +502,14 @@ async def _run_voice_llm_pipeline(
document_mode=document_mode,
)
)
waited = 0
while not llm_task.done():
await asyncio.sleep(10)
waited += 10
if not llm_task.done():
try:
await update.message.reply_text(
"⏳ Noch dran (" + str(waited) + "s) — Save.TV/Modell kann etwas brauchen…"
)
except Exception as te:
log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te)
answer = await llm_task
try:
answer = await asyncio.wait_for(asyncio.shield(llm_task), timeout=15.0)
except asyncio.TimeoutError:
try:
await update.message.reply_text("⏳ Noch dran — dauert etwas länger…")
except Exception as te:
log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te)
answer = await llm_task
if session_id:
memory_client.log_message(session_id, "user", text)

View file

@ -48,3 +48,110 @@
|---|---|
| @MutterbotAI_bot | Watchdog-Alerts |
| @Orbitalo_Hausmeister_bot | Homelab AI-Bot |
## Hermes CT151 Monitoring und Backup
Stand: 2026-05-16
Hermes (`hermes-mu`, CT151) ist als kritischer Dienst eingestuft und wird separat abgesichert.
### Proxmox / PBS Backup
Backup-Job auf `pve3` / `pve-mu-3`:
| Feld | Wert |
|------|------|
| Job-ID | `hermes-mu-backup` |
| VMID | `151` |
| Container | `hermes-mu` |
| Node | `pve3` |
| Ziel | `pbs-nvme` / Muldenstein PBS (`100.99.139.22:nvme-pool`) |
| Modus | `snapshot` |
| Kompression | `zstd` |
| Zeitplan | täglich `03:20` |
| Nachholen | `repeat-missed=1` |
| Prune | `keep-daily=7`, `keep-weekly=4`, `keep-monthly=3` |
Manuelles Safety-Backup wurde am 2026-05-16 erfolgreich erstellt:
```text
ct/151/2026-05-16T10:51:53Z
Dauer: 56s
Gesichert: ca. 6.15 GiB
Komprimiert übertragen: ca. 3.98 GiB
Ergebnis: Backup job finished successfully
```
PBS-Auslastung danach:
```text
pbs-nvme active, ca. 5.63% belegt
```
Schnelltest:
```bash
# Auf pve3 / pve-mu-3
pvesh get /cluster/backup/hermes-mu-backup --output-format json
pvesm status --storage pbs-nvme
```
### Prometheus / node_exporter
Auf CT151 ist `prometheus-node-exporter` installiert und aktiv.
```bash
systemctl is-active prometheus-node-exporter
curl -sS http://127.0.0.1:9100/metrics | grep -m1 '^node_uname_info'
```
Von CT116 / Hausmeister erreichbar über:
```bash
curl -sS http://100.109.174.120:9100/metrics
```
### Loki / Promtail
Auf CT151 läuft `promtail` und schickt Logs mit Host-Label `hermes-mu` an Loki.
Wichtige Checks:
```bash
systemctl is-active promtail
curl -sS http://100.109.174.120:9080/ready
```
### Hermes-Service-Checks
Aktive Dienste auf CT151:
```bash
systemctl is-active hermes-gateway beellama-tunnel promtail prometheus-node-exporter
curl -sS http://127.0.0.1:11435/v1/models
```
Der lokale BeeLlama/OpenAI-kompatible Tunnel lauscht in CT151 auf `127.0.0.1:11435`; er ist deshalb von CT116 nicht direkt per HTTP erreichbar. Der Health-Check erfolgt lokal auf CT151 bzw. indirekt über Hermes/Hausmeister.
### Hausmeister-Integration
Hausmeister (`CT116`) enthält in `/opt/homelab-ai-bot/monitor.py` einen `check_hermes()`.
Der Check prüft:
- Hermes Promtail: `http://100.109.174.120:9080/ready`
- Hermes node_exporter: `http://100.109.174.120:9100/metrics`
- Websuche/SearXNG CT121: `http://100.74.196.29:8080/search?...`
- Loki: `http://100.109.206.43:3100/ready`
- PBS Muldenstein: `https://100.99.139.22:8007`
- Loki-Health für Host `hermes-mu`
- auffällige Hermes-Logs der letzten 30 Minuten
Harmloser `node_exporter`-Diskstats-Logspam (`diskstats_linux.go`, `disabling udev device properties`) wird gefiltert.
Finaler Test am 2026-05-16:
```text
monitor.check_hermes() -> OK
hausmeister-bot.service -> active
```

View file

@ -1093,3 +1093,38 @@ _(reasoning: Der Memory wurde erfolgreich gespeichert. Ich bestätige das dem Us
Willst du noch etwas anderes speichern? 🔥
## Entscheidungsnotiz: Qwen3.6 + BeeLlama fuer opencode-Tests
Stand: 2026-05-18
Qwen3.6 ueber BeeLlama ist fuer einfache, kontrollierte `opencode`-Tests ausreichend:
- Git-Repos klonen
- README, `package.json` und Setup-Hinweise lesen
- Projektstruktur erkunden
- Shell-/Git-Kommandos ausfuehren
- Tests finden und starten
- kleine, klar begrenzte Codeaenderungen vorbereiten
Wichtig: BeeLlama macht das Modell schneller und lokal praktikabler, aber nicht automatisch intelligenter. Fuer komplexe Coding-Agent-Aufgaben wie grosse Refactorings, viele Dateien gleichzeitig, schwierige Debug-Schleifen oder produktive Aenderungen bleibt ein spezialisiertes Coding-Modell zuverlaessiger.
Empfohlener erster Testauftrag:
```text
Klon dieses Repo nach /tmp/opencode-test, lies README und package.json, aber aendere nichts. Sag danach, wie man es startet und welche Tests definiert sind.
```
## Entscheidungsnotiz: zweite RTX 3090
Eine zweite RTX 3090 verbessert die Qualitaet desselben Modells nicht direkt. Dasselbe Modell mit derselben Quantisierung liefert grundsaetzlich dieselbe Antwortqualitaet.
Eine zweite GPU hilft indirekt durch:
- groessere Modelle, die vorher nicht in den VRAM passten
- bessere Quantisierung, z.B. Q5/Q6 statt Q4
- mehr Kontext/KV-Cache
- weniger CPU-Offload
- hoehere Tokenrate bei gutem Layer-/Tensor-Splitting
- mehr parallele Requests
Fuer `opencode` waere der echte Qualitaetsgewinn daher nicht die zweite GPU selbst, sondern dass dadurch eventuell ein besseres oder groesseres Coding-Modell lokal betrieben werden kann.