From eff739949ff3dd71545fbbd758ef0cb18493b70a Mon Sep 17 00:00:00 2001 From: Homelab Cursor Date: Sun, 21 Jun 2026 20:17:54 +0200 Subject: [PATCH] backup: kompletter Live-Stand CT116 Bot (3 ungepushte Commits + uncommittete Juni-Edits), Snapshot 2026-06-21 --- hardware-ai-local-coding.md | 36 + homelab-ai-bot/JELLYFIN.md | 55 ++ .../__pycache__/llm.cpython-311.pyc | Bin 29836 -> 36099 bytes .../__pycache__/monitor.cpython-311.pyc | Bin 22655 -> 30335 bytes .../core/__pycache__/config.cpython-311.pyc | Bin 10553 -> 12440 bytes .../__pycache__/loki_client.cpython-311.pyc | Bin 13013 -> 13539 bytes homelab-ai-bot/core/loki_client.py | 36 +- .../core/loki_client.py.bak.20260603_hermes | 225 ++++++ homelab-ai-bot/llm.py | 4 +- homelab-ai-bot/llm.py.bak.20260601_163620 | 740 ++++++++++++++++++ .../llm.py.bak.20260605_before-gemini | 740 ++++++++++++++++++ homelab-ai-bot/monitor.py | 83 +- homelab-ai-bot/monitor.py.bak-hermes-monitor | 403 ++++++++++ homelab-ai-bot/monitor.py.bak.20260601_163058 | 461 +++++++++++ homelab-ai-bot/monitor.py.bak.20260603_hermes | 469 +++++++++++ homelab-ai-bot/savetv_enrich.py.v1-ollama-bak | 248 ++++++ homelab-ai-bot/telegram_bot.py | 40 +- infrastructure/STATE.md | 107 +++ sessions/opencode-20260420-qwen36-workflow.md | 35 + 19 files changed, 3648 insertions(+), 34 deletions(-) create mode 100644 homelab-ai-bot/core/loki_client.py.bak.20260603_hermes create mode 100644 homelab-ai-bot/llm.py.bak.20260601_163620 create mode 100644 homelab-ai-bot/llm.py.bak.20260605_before-gemini create mode 100644 homelab-ai-bot/monitor.py.bak-hermes-monitor create mode 100644 homelab-ai-bot/monitor.py.bak.20260601_163058 create mode 100644 homelab-ai-bot/monitor.py.bak.20260603_hermes create mode 100644 homelab-ai-bot/savetv_enrich.py.v1-ollama-bak diff --git a/hardware-ai-local-coding.md b/hardware-ai-local-coding.md index 18778d268..c0a8be7f3 100644 --- a/hardware-ai-local-coding.md +++ b/hardware-ai-local-coding.md @@ -190,3 +190,39 @@ Neue Leitlinie: - **M5 Ultra** nur dann, wenn ein konkretes Modell das rechtfertigt. `homelab.conf` und `MASTER_INDEX.md` werden parallel angepasst. + +## Entscheidungsnotiz: Qwen3.6 + BeeLlama fuer opencode-Tests + +Stand: 2026-05-18 + +Qwen3.6 ueber BeeLlama ist fuer einfache, kontrollierte `opencode`-Tests ausreichend: + +- Git-Repos klonen +- README, `package.json` und Setup-Hinweise lesen +- Projektstruktur erkunden +- Shell-/Git-Kommandos ausfuehren +- Tests finden und starten +- kleine, klar begrenzte Codeaenderungen vorbereiten + +Wichtig: BeeLlama macht das Modell schneller und lokal praktikabler, aber nicht automatisch intelligenter. Fuer komplexe Coding-Agent-Aufgaben wie grosse Refactorings, viele Dateien gleichzeitig, schwierige Debug-Schleifen oder produktive Aenderungen bleibt ein spezialisiertes Coding-Modell zuverlaessiger. + +Empfohlener erster Testauftrag: + +```text +Klon dieses Repo nach /tmp/opencode-test, lies README und package.json, aber aendere nichts. Sag danach, wie man es startet und welche Tests definiert sind. +``` + +## Entscheidungsnotiz: zweite RTX 3090 + +Eine zweite RTX 3090 verbessert die Qualitaet desselben Modells nicht direkt. Dasselbe Modell mit derselben Quantisierung liefert grundsaetzlich dieselbe Antwortqualitaet. + +Eine zweite GPU hilft indirekt durch: + +- groessere Modelle, die vorher nicht in den VRAM passten +- bessere Quantisierung, z.B. Q5/Q6 statt Q4 +- mehr Kontext/KV-Cache +- weniger CPU-Offload +- hoehere Tokenrate bei gutem Layer-/Tensor-Splitting +- mehr parallele Requests + +Fuer `opencode` waere der echte Qualitaetsgewinn daher nicht die zweite GPU selbst, sondern dass dadurch eventuell ein besseres oder groesseres Coding-Modell lokal betrieben werden kann. diff --git a/homelab-ai-bot/JELLYFIN.md b/homelab-ai-bot/JELLYFIN.md index 28755720c..9241018d9 100644 --- a/homelab-ai-bot/JELLYFIN.md +++ b/homelab-ai-bot/JELLYFIN.md @@ -140,3 +140,58 @@ curl -s "http://100.77.105.3:8096/Items?api_key=7285b4a8793541648bf156599ae05b43 | Ralf | nein | | rolf | nein | | wilfrid | nein | + +## Troubleshooting: Details fehlen / Remote-Zugriff wirkt kaputt + +Stand: 2026-05-16 + +Symptom: +- Filmübersicht ist erreichbar, aber Detailseiten/Filmdetails werden im Client nicht angezeigt. +- Zugriff über Tailscale-IP `100.77.105.3:8096` hängt oder läuft in Timeouts. + +Befund vom 2026-05-16: +- Jellyfin selbst war lokal gesund: + - `http://192.168.178.149:8096/health` -> `Healthy` + - API `/Items/Counts` lieferte `897` Filme. + - Beispiel-Film `2 Guns` hatte Beschreibung, Genres, TMDB/IMDB IDs und Bilder. +- Medien-Mounts waren aktiv: + - `/mnt/nas` + - `/srv/media` +- Docker-Container `jellyfin` war `healthy`. +- Ursache war Tailscale auf dem Jellyfin-Host: + - `tailscale ip -4` meldete zuerst `no current Tailscale IPs; state: NeedsLogin` + - `tailscale status` meldete `Logged out`. +- Nach Re-Login war `100.77.105.3` wieder aktiv und `http://100.77.105.3:8096/health` antwortete wieder `Healthy`. +- Danach waren die Filmdetails im Client wieder sichtbar. + +Schnelltest: +```bash +# Von pve3 lokal im LAN: +curl -sS http://192.168.178.149:8096/health + +# Von CT 116 / Hausmeister oder einem Tailscale-Host: +curl -sS http://100.77.105.3:8096/health +curl -sS "http://100.77.105.3:8096/Items/Counts?api_key=7285b4a8793541648bf156599ae05b43" +``` + +Fix: +1. Auf dem Jellyfin-Host Tailscale-Status prüfen: + ```bash + tailscale ip -4 + tailscale status --self + ``` +2. Wenn `NeedsLogin` oder `Logged out`: Tailscale neu anmelden. +3. Danach erneut prüfen: + ```bash + curl -sS http://100.77.105.3:8096/health + ``` +4. Im Browser Jellyfin hart neu laden oder neu einloggen, falls der Webclient alte kaputte API-Antworten gecacht hat. + +Wichtig: +- Fehlende Details bedeuten nicht automatisch kaputte Jellyfin-Datenbank oder fehlende Metadaten. +- Erst lokale API gegen `192.168.178.149:8096` prüfen, dann Tailscale/Cloudflare-Zugriff. +- `systemctl is-active jellyfin` kann irreführend sein, weil Jellyfin hier im Docker-Container läuft. Aussagekräftiger ist: + ```bash + docker ps | grep jellyfin + curl -sS http://127.0.0.1:8096/health + ``` diff --git a/homelab-ai-bot/__pycache__/llm.cpython-311.pyc b/homelab-ai-bot/__pycache__/llm.cpython-311.pyc index 2c5514119fab404d7151a96cd9038ee4c831f184..7ce9a3fa83f12b40d8fd8772c7c086f89d73656a 100644 GIT binary patch delta 11916 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 Vs9_ui%)Fe158BmjipPWu{U0`*5fA_X delta 5936 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 UcwLuYG`@~ delta 8154 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 diff --git a/homelab-ai-bot/core/__pycache__/config.cpython-311.pyc b/homelab-ai-bot/core/__pycache__/config.cpython-311.pyc index 6558057b8ec49deab16d0a8032ae30c6dfc3e859..4a9d4a0a15163f0ab29ffc2231c7efcb3c9fc249 100644 GIT binary patch delta 1722 zcmZ`&&uqzD2;wbBqn52z5Wy_49)_PVp1 z1R@8!5{D33N?Ie~LfTrQN~7jbD3#hG^-!sQfQ_tTt<=Auho(Zkw3ohF=Le)^H2b}o zH*em&efqv1$BSyyS3aKy!FV$Jd;Fh{kDEI1;-ek`w;a(rs6+Ep@1#RJaLakQNjq8? z#NqCP)C=A=@OIvET=r|b`!3kGYeE>w-H-2J>u4Z~dE%|%04u`xd=}s?%Ajjo2MCpL z;exmAHLMA10zw<`)yD$X#9JtXO9DSHl=tt;oB!YG0MO>MJMA4@S0?fGL5E)p(heF74&!rj8L2! zCK(D1k6)Y!Um6=59}b5n&S&2XUkux6V|tP%)JQO@rzT17yLvLIDk1)g9IW4`l#zGH zG>xYyxs-x;G&Vz{(=(~5dVtlsH#yJE&cUz!r*AFbe0~+cbr;2;byos~?BRojl&eYj z#$Wj8a0g#zKN0eMVZ>S?PR5LKBd|p<7LFObse~{;n7`eF1~sF4GJR^kXNsB=#!NcR zs9{iTBE~xz98Bm@HDR0z?gah}_Y4IfhsxfQ^;ztE*!+F_x9yJ(e0Kmm*~Ovdb4%xL zkK{*oLRcGIv2edEG6E~6b4UX~W%GPZ0A8%kYW=^0*gXh)!b9H%$e~RMd7JV*%Y93I zWq<#Aq~aN1%cqVdIzdpFiwKE1hm6H2LgPgcS2IB8e&Qga(2olVLwfvR_r z`2t}xXbpB7JDvq>iiJOY9VZq9;O-7b&!8(`FNhiO8c$=744%ad=OyrQ&f6o6HC%EQ z9(lWjQUf=h1xLog-pmN4M&Nda2lQ_6Ja5=C9LRn;qs-`jCQQ2ve&~n~Rm z?dp=SChfaDoFC2&XKTLw`Kz1AUt*iK9zbaQlKgY>?@l{a0hBq^YX~j z$clP9ln*V2wzg$|Z*ge#EEt%j3w2wOrBS)&@-4fU+$+k;hj(THm5Pg0U8L+H z+b6*yUTs(jt^_Nt&Z?`kY|ol^e=cMzh>gP^%HrtgsN7MQKX$|WIqdDuvj>Ggj=k<# thdtMgG4a^n(%L6{=7%G;c_r8-{0j%y&Y=JR delta 110 zcmbP{xHE`vIWI340}x~zuFEtPoyaG_n6XiviBVWFMIx0YMJ$y&jU`30g*A#VMQO4P zV=yaIFoUM@<}Hl3RCtPlfci9<{rvniMJKoER!-*AljNEK6k`P9V(HC>dZJ8|Z45YB Ii$s9}0G=Tj9{>OV diff --git a/homelab-ai-bot/core/__pycache__/loki_client.cpython-311.pyc b/homelab-ai-bot/core/__pycache__/loki_client.cpython-311.pyc index 0d051bd468309bba42955a2e1ca748cc301b689b..fd9a8d646831d3ca791544da45d29cb1fba77b2d 100644 GIT binary patch delta 3133 zcmbUjTWlOxb?)rWzF+ow?REUv@!F2PPW+NmVj3bjvE9^dNK>Z~!Aawc_g;I)Ue9dr z%*L;A7KyavgM!NyA}9_qd<0eQ2e3qZAfcB0@P!O0lC=U9)E^K(7L`Ec2hN?{b?lNr z#nsHYXU^-Md(OG%&Li@%8v0={=o4TVzfa8Fk>3l&(b9WeJq2mfg}bqfxUrjfum@%@ z@yvLz@1`{A#eO2fw|mBk12>(MKAgf~kcbdJt|0;71c|4S8?^^$)q++C_u#t!Dis5% zu&wPb>{AzUJuDKzM{xWZ#+|qk)N1h2J>%R_7j6P-?W};Ce}E>Vz~#4r|!Ad zT7etG7ZX^gL}q@ZCBmkNEb*nV)25I>SLS8kd!Tl zAh5R&KP;qm&7^u(HcU#C1(|4RJ+GOB5-cn7jG|`ql*o%2HA`fJ(Cex;Yh8eu$Yx&C zNLD74>eS+bpkeB^&>jARz zY3rqf0*XzQ_Zil7nUXZoOj#r5qE6>!1y;PSn#=pVQqpOXGv(JWj*nD!YLz!$sP5dV zh*aW`RSlD9gj#&l)%{qNJ?~rnQ{Hly_dB-2GmO#T8S;8Fwi)N*n@Cm*hccnFl=Eu(epgYza zCLFNpupAb$#1EwVK;s9mJm4_8*b)D$;>r+P^$&%fpyZQNt8@DF$s5VOBb+3^FVc1p zrX2uMVkt5`gyvW%x{l7Ud(j~@ z&UT{Lq%VQYrQ*rDVdVD%%J=e#h}f;zhc98jCE$SNz(ET^a6)$-fQKy!M__i(gCuOk zX-QPXHJTS_wH2C(qZ-0>sssUvF{}O^bdEV1hS3rmZ8(EYv%%z0^JT7lg@ZQ%7$N{! zrg2WV${shgqgCdNANxEV1WL(o_oOMik(C1WhcT5^40Ec4GNph4H2~#1mDOjBlFKj^ zGjFh=c+%mZV{AR%CQ${fuwTRneHtKnc#U7Of5w;Fz2SB5jwiOhy5mW#=eIqH^0V2r zXAl~4tph3>ehET<9ZSBDo6JK#TIM-QnTrhQ|UnRYSqq6=1q1pzxP2fG$&o-xBu zi59vKCb#y?VhOvjdqcVp5#8cZwa1zhdo1U5f%+}Lp5B7ki+vk#iI_E2Nm#~zQ?Q(y zUiNxZeXS}SG-Q)wD9GkJ`@NDXthxTT=pULI5w{kLhMsM%A&esjms#^zE?DaOVqMbm zVDCYu8GGui<*s;C!|t`TvIosEckPA)>MqKD(>#Gru|&&*;2g-)2!NDG&#^zX#L#*6 zkCqN}k2SZh#CSQ_hqC0!5jvI84Kw9uTdjxC2>Ylt-p#dB-tw`1fy?%AFv>wMfRbeD zrjn(L3?=$~D?nL?*D%q@4DBd1C=IOk{M=y?P%(9ZHNb>zvennEN&%C|Eb0QZTzh5@XM}O!N^KI z(+}6XR~$+CGCf}8$WRr3wz>`D2)1^f@; zH3W9N=LLBuKsL>C8-6tUMx`Zx`P$Ro{N0{8N8I-PIB1oeb8vm7+;j*&C}T~1htM+n zTwhY!0K)HzZ}z={>Rtvkh3KX7^ff*mVn6S1pS2z4K`uG5nl|_O{w@&9adGlrA9{j+ z^P7e8Yk~5!&N8l3RSQzM0RW0-v&SaHy|WAfB*mh delta 2251 zcmah}YiL_l9KYv2liZ|9o1{(Cu1(VC?Zd9KbH%mVb#`@g461Wzwwh%-x38s%JNI_D zdgCSx9d5G6QQWY@B03$dIEf%Ai0B7F{4hTVH6L^j{2(X`_{k#Z|J-!#B2F)y-?``i zIM3hzcWd%{z3e@Y$Hl?7t?$NUqV8N-2$|^vdG9oVJw zFqX$etez0#PTY#iAi}G=uuoUPa;~tAQKzJ2+8%xiOo9wO1u#skn2x0ySPV1!aAS3s+w2Y85_oeazLjK$q zXF-sw;G{+`{Vm#_JK))j2K!+A24`_Z!N}s?6p$O>s51DdFKEetKeLKeR$UKTiR z0b22cl_ZM1k4Z@$V(Bh#FRkUja+^9K84|mpGUF-l7G?~t7_ac80g_20fQIO=-k!d# z@Y@Z|*amQ%yRP^OoFK);0?&EI#Ir{W9P97KljtNWh@2-b<_`G$NE(8~2XnLKZ}Dh2 z7Y^(|XgAe^XVDOSu{}h~DkEr^c2(||o`kr;+-sEsNR{B1o#XccM}MmNqQ_!Klq>-& zhrkLO6fKGHSWP>@=3cS!*po)M414E9IJ%Gi9C``uq^arwbbx+PJ&d-{+l{^TPqXwm zgS`L-4-kov5Hsuppr@-NNT<``9-0rw9uNsT`)t6zGb52o8fN18iOdW!vJ-i?p{Ma= zdOVTJj2n5`Fq7ttp$TLw{VN>xvmJiX6VLyxZWZ5OeG#Xko$HobQuIS#u{aF#glpd+eZ;IK*se+zO`U)?5DVykhFbET_1!i`R5 z6-!yo^|0J3c*WAVtizIvF%6vMz>ZR{>0jH+U32;9K>htn`J4cJsGzg;kJIn!Kl11h zOMCztPj=EXks#Vd-;Fe)m+3c=+2CR3Wkry8Jgbw$M8+^R7yTpH(4QBKnQ1$buqiZW zu~s&+gf<3)3_1bwl9@4+DPk}~7lUr<>!=F3Z7w8=WFkkv@f zFeRn^&5JUt8*0w^m=)e_O}c*Ex@}YOJrhWOO9|!%1R|Xg4&~fuG&^C&GDtr zkd;eFApX^qsv(tehko1YsBz8-mx0*}tL#SH z=|zhPr;FZheYAEvY`>rgUN&#HIxkk2P8ln_H)V3YzN-c;1I*c<9!Sjh#$QN5y%Q>FB7frZ$d< zPpu7B9k~k~Q$ol-)F!s1IF*@9YYORQtDU9c^a_8aR#Ruyvl$bhp^>} z(#o{U*3@N3-*g7gr1Xc#C9tt4H4cHq6a<9O64!H$>$%R+BOBV0oLk%wKm%n6G!WoI egWb*Rc6W8R-Tf0{Qh+aTci dict: url = f"{base_url or LOKI_URL}{endpoint}" @@ -49,9 +71,9 @@ def query_logs(query: str, hours: float = 1, limit: int = 100) -> list[dict]: def get_errors(container: str = None, hours: float = 1, limit: int = 200) -> list[dict]: """Get error-level logs, optionally filtered by container hostname.""" if container: - q = f'{{host="{container}"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror"' + q = f'{{host="{container}"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' else: - q = '{job=~".+"} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror"' + q = f'{{job=~".+"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' return query_logs(q, hours=hours, limit=limit) @@ -102,7 +124,8 @@ def get_health(container: str, hours: float = 24) -> dict: return { "host": container, - "errors_last_{hours}h": error_count, + "error_count": error_count, + f"errors_last_{hours}h": error_count, "sending_logs": has_recent, "status": "healthy" if error_count < 5 and has_recent else "warning" if error_count < 20 else "critical", @@ -114,6 +137,7 @@ WATCHED_SERVICES = [ ("wordpress-v2", "wordpress"), ("fuenfvoracht", "fuenfvoracht"), ("homelab-ai-bot", "hausmeister"), + ("hermes-mu", "hermes-gateway"), ] @@ -121,7 +145,7 @@ def count_errors(hours: float = 24) -> dict: """Zählt Fehler-Log-Einträge über einen Zeitraum via Loki metric query.""" now = datetime.now(timezone.utc) start = now - timedelta(hours=hours) - q = '{job=~".+"} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror"' + q = f'{{job=~".+"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' # Loki instant metric query für Gesamtanzahl data = _query("/loki/api/v1/query_range", { "query": q, @@ -160,7 +184,7 @@ def check_service_restarts(minutes: int = 35) -> list[dict]: ERROR_RATE_THRESHOLDS = { "rss-manager": 15, "wordpress-v2": 10, - "forgejo": 200, # Oeffentlich — Scanner-404er sind normal + "forgejo": 200, # Oeffentlich – Scanner-404er sind normal } ERROR_RATE_DEFAULT = 25 @@ -171,7 +195,7 @@ def check_error_rate(minutes: int = 30) -> list[dict]: alerts = [] now = datetime.now(timezone.utc) for host in all_hosts: - q = f'count_over_time({{host="{host}"}} |~ "(?i)error" !~ "caller=metrics|query_hash=|executing query|scheduler_processor|Aborted connection|systemd-networkd-wait-online|context canceled|AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|terror" [{minutes}m])' + q = f'count_over_time({{host="{host}"}} |~ "(?i)error" !~ "{ERROR_EXCLUDE_RE}" [{minutes}m])' data = _query("/loki/api/v1/query", {"query": q, "time": _ns(now)}) count = sum( int(float(r.get("value", [None, "0"])[1])) diff --git a/homelab-ai-bot/core/loki_client.py.bak.20260603_hermes b/homelab-ai-bot/core/loki_client.py.bak.20260603_hermes new file mode 100644 index 000000000..9822b0205 --- /dev/null +++ b/homelab-ai-bot/core/loki_client.py.bak.20260603_hermes @@ -0,0 +1,225 @@ +"""Loki API client for querying centralized logs.""" + +import requests +from datetime import datetime, timezone, timedelta + +LOKI_URL = "http://100.109.206.43:3100" + +# Transiente cloudflared/Cloudflare-Edge-Flaps erzeugen oft error="..." +# in WRN/ERR-Zeilen, sind aber keine App-Fehler von WordPress/RSS. +ERROR_EXCLUDE_RE = ( + "caller=metrics|query_hash=|executing query|scheduler_processor|" + "Aborted connection|systemd-networkd-wait-online|context canceled|" + "AH01630: client denied|flag evaluation succeeded|pluginsAutoUpdate|" + "control stream encountered a failure while serving|Serve tunnel error|" + "failed to serve tunnel connection|Connection terminated|" + "failed to dial to edge with quic|Failed to dial a quic connection|" + "no recent network activity|failed to accept QUIC stream|" + "control stream|accept stream listener|serve tunnel|tunnel connection|" + "edge with quic|quic connection|QUIC stream|" + "accept stream listener encountered a failure while serving|" + "datagram manager encountered a failure while serving|" + "datagram manager|datagram handler|" + "failed to run the datagram handler|failed to accept incoming stream requests|" + "Application error 0x0|connIndex=" +) + + +def _query(endpoint: str, params: dict, base_url: str = None) -> dict: + url = f"{base_url or LOKI_URL}{endpoint}" + try: + r = requests.get(url, params=params, timeout=10) + r.raise_for_status() + return r.json() + except requests.RequestException as e: + return {"error": str(e)} + + +def _ns(dt: datetime) -> str: + return str(int(dt.timestamp() * 1e9)) + + +def query_logs(query: str, hours: float = 1, limit: int = 100) -> list[dict]: + """Run a LogQL query and return log entries.""" + now = datetime.now(timezone.utc) + start = now - timedelta(hours=hours) + data = _query("/loki/api/v1/query_range", { + "query": query, + "start": _ns(start), + "end": _ns(now), + "limit": limit, + "direction": "backward", + }) + if "error" in data: + return [{"error": data["error"]}] + + entries = [] + for stream in data.get("data", {}).get("result", []): + labels = stream.get("stream", {}) + for ts, line in stream.get("values", []): + entries.append({ + "timestamp": ts, + "host": labels.get("host", labels.get("job", "unknown")), + "line": line, + }) + return entries + + +def get_errors(container: str = None, hours: float = 1, limit: int = 200) -> list[dict]: + """Get error-level logs, optionally filtered by container hostname.""" + if container: + q = f'{{host="{container}"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' + else: + q = f'{{job=~".+"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' + return query_logs(q, hours=hours, limit=limit) + + +def get_labels() -> list[str]: + """Get all available label values for 'host'.""" + data = _query("/loki/api/v1/label/host/values", {}) + if "error" in data: + return [] + return data.get("data", []) + + +def check_silence(minutes: int = 35) -> list[dict]: + """Find hosts that haven't sent logs within the given timeframe.""" + all_hosts = get_labels() + if not all_hosts: + return [{"error": "Could not fetch host labels from Loki"}] + + now = datetime.now(timezone.utc) + start = now - timedelta(minutes=minutes) + silent = [] + + for host in all_hosts: + data = _query("/loki/api/v1/query_range", { + "query": f'count_over_time({{host="{host}"}}[{minutes}m])', + "start": _ns(start), + "end": _ns(now), + "limit": 1, + }) + results = data.get("data", {}).get("result", []) + has_logs = any( + int(v[1]) > 0 + for r in results + for v in r.get("values", []) + ) + if not has_logs: + silent.append({"host": host, "silent_minutes": minutes}) + + return silent + + +def get_health(container: str, hours: float = 24) -> dict: + """Get a health summary for a specific container.""" + errors = get_errors(container=container, hours=hours, limit=200) + error_count = len([e for e in errors if "error" not in e]) + + recent = query_logs(f'{{host="{container}"}}', hours=0.5, limit=5) + has_recent = len([e for e in recent if "error" not in e]) > 0 + + return { + "host": container, + "errors_last_{hours}h": error_count, + "sending_logs": has_recent, + "status": "healthy" if error_count < 5 and has_recent else + "warning" if error_count < 20 else "critical", + } + + +WATCHED_SERVICES = [ + ("rss-manager", "rss-manager"), + ("wordpress-v2", "wordpress"), + ("fuenfvoracht", "fuenfvoracht"), + ("homelab-ai-bot", "hausmeister"), + ("hermes-mu", "hermes-gateway"), +] + + +def count_errors(hours: float = 24) -> dict: + """Zählt Fehler-Log-Einträge über einen Zeitraum via Loki metric query.""" + now = datetime.now(timezone.utc) + start = now - timedelta(hours=hours) + q = f'{{job=~".+"}} |~ "(?i)(error|fatal|panic|traceback|exception)" !~ "{ERROR_EXCLUDE_RE}"' + # Loki instant metric query für Gesamtanzahl + data = _query("/loki/api/v1/query_range", { + "query": q, + "start": _ns(start), + "end": _ns(now), + "limit": 5000, + "direction": "backward", + }) + if "error" in data: + return {"error": data["error"], "count": 0} + total = sum( + len(stream.get("values", [])) + for stream in data.get("data", {}).get("result", []) + ) + # Per-Host aufschlüsseln + per_host: dict[str, int] = {} + for stream in data.get("data", {}).get("result", []): + host = stream.get("stream", {}).get("host", "unknown") + per_host[host] = per_host.get(host, 0) + len(stream.get("values", [])) + return {"count": total, "hours": hours, "per_host": per_host} + + +def check_service_restarts(minutes: int = 35) -> list[dict]: + """Findet Services die innerhalb des Zeitfensters neu gestartet haben (systemd journal via Loki).""" + restarts = [] + for host, service_name in WATCHED_SERVICES: + q = f'{{host="{host}"}} |~ "(?i)(Started|Restarting|restarted).*{service_name}"' + entries = query_logs(q, hours=minutes / 60, limit=5) + real = [e for e in entries if "error" not in e] + if real: + restarts.append({"host": host, "service": service_name, "count": len(real)}) + return restarts + + + +ERROR_RATE_THRESHOLDS = { + "rss-manager": 15, + "wordpress-v2": 10, + "forgejo": 200, # Oeffentlich – Scanner-404er sind normal +} +ERROR_RATE_DEFAULT = 25 + + +def check_error_rate(minutes: int = 30) -> list[dict]: + """Check if any host exceeds its error-rate threshold within the window.""" + all_hosts = get_labels() + alerts = [] + now = datetime.now(timezone.utc) + for host in all_hosts: + q = f'count_over_time({{host="{host}"}} |~ "(?i)error" !~ "{ERROR_EXCLUDE_RE}" [{minutes}m])' + data = _query("/loki/api/v1/query", {"query": q, "time": _ns(now)}) + count = sum( + int(float(r.get("value", [None, "0"])[1])) + for r in data.get("data", {}).get("result", []) + if len(r.get("value", [])) > 1 + ) + threshold = ERROR_RATE_THRESHOLDS.get(host, ERROR_RATE_DEFAULT) + if count > threshold: + alerts.append({"host": host, "count": count, "threshold": threshold}) + return alerts + + +def format_logs(entries: list[dict], max_lines: int = 30) -> str: + """Format log entries for human/LLM consumption.""" + if not entries: + return "No log entries found." + if entries and "error" in entries[0]: + return f"Loki error: {entries[0]['error']}" + + lines = [] + for e in entries[:max_lines]: + host = e.get("host", "?") + line = e.get("line", "").strip() + if len(line) > 200: + line = line[:200] + "..." + lines.append(f"[{host}] {line}") + + total = len(entries) + if total > max_lines: + lines.append(f"\n... and {total - max_lines} more entries") + return "\n".join(lines) diff --git a/homelab-ai-bot/llm.py b/homelab-ai-bot/llm.py index 397d2f98f..301526563 100644 --- a/homelab-ai-bot/llm.py +++ b/homelab-ai-bot/llm.py @@ -27,8 +27,8 @@ OLLAMA_MODELS = set() def warmup_ollama(): - """No-Op: Text-Modell laeuft jetzt ueber OpenRouter (Grok 4.1 Fast), kein Ollama-Warmup noetig.""" - log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (Grok 4.1 Fast)') + """No-Op: Text-Modell laeuft ueber OpenRouter (x-ai/grok-4.3), kein Ollama-Warmup noetig.""" + log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (x-ai/grok-4.3)') PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"} _LOCAL_OVERRIDES = [ diff --git a/homelab-ai-bot/llm.py.bak.20260601_163620 b/homelab-ai-bot/llm.py.bak.20260601_163620 new file mode 100644 index 000000000..397d2f98f --- /dev/null +++ b/homelab-ai-bot/llm.py.bak.20260601_163620 @@ -0,0 +1,740 @@ +"""OpenRouter LLM-Wrapper mit Tool-Calling. + +Neue Datenquelle = eine Datei in tools/ anlegen. Fertig. +""" + +import json +import logging +import requests +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config +import tool_loader + +log = logging.getLogger('llm') + +OLLAMA_BASE = "http://100.84.255.83:11434" +OPENROUTER_BASE = "https://openrouter.ai/api/v1" + +MODEL_LOCAL = "x-ai/grok-4.3" +MODEL_VISION = "openai/gpt-4o-mini" +MODEL_ONLINE = "perplexity/sonar" +FALLBACK_MODEL = None +MAX_TOOL_ROUNDS = 3 +OLLAMA_MODELS = set() + + +def warmup_ollama(): + """No-Op: Text-Modell laeuft jetzt ueber OpenRouter (Grok 4.1 Fast), kein Ollama-Warmup noetig.""" + log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (Grok 4.1 Fast)') +PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"} + +_LOCAL_OVERRIDES = [ + "api kosten", "api-kosten", "guthaben", "openrouter", + "container", "status", "fehler", "logs", "service", + "backup", "feed", "memory", "gedaechtnis", "gedächtnis", + "mail", "seafile", "forgejo", "grafana", "prometheus", + "savetv", "save.tv", "filmtipp", "aufnahme", + "wordpress", "matomo", "tailscale", + "unsere api", "meine api", "die api", + "vorhersage", "prognose", "health forecast", "was bahnt", "systemstatus", + "system check", "system-check", "wie gehts dem system", "wie geht es dem system", + "versicherung", "vertrag", "verträge", "vertraege", "dokument", + "rente", "rentenversicherung", "finanzamt", "steuer", "grundsteuer", + "familienbuch", "urkunde", "bescheid", "police", "beitrag", + "meine unterlagen", "meine dokumente", "meine dateien", + "habe ich", "welche habe", "was habe ich", + "jahreskosten", "jährlichen", "jährliche", "jaehrlichen", "jaehrliche", + "monatliche kosten", "versicherungskosten", "beitragsrechnung", + # Possessiv/Persoenlich (2026-04-16) + "meine", "mein ", "meiner", "meines", "meinem", "meinen", + "wohnung", "wohnungen", "apartment", "apartments", + "condo", "condos", "kondo", "kondos", "immobilie", "immobilien", + "kambodscha", "cambodia", "phnom penh", "arakawa", + "gekostet", "gekauft", "kaufpreis", "kaufpreise", "bezahlt", + "ausgegeben", "ueberweisung", "überweisung", + "was haben", "wieviel habe ich", "wie viel habe ich", + "fuer die wohnung", "für die wohnung", + "ich fuer", "ich für", + # Chatlogger / Telegram-Mitschnitt (2026-05-01) — verhindert Sonar-Routing bei "aktueller Stand" + "concierge", "conciergerie", "bnb", "airbnb", "listing", "listings", + "vermietung", "vermieten", "vermietet", + "kaution", "mieter", "mieterin", "miete", + "g2010b", "g 2010 b", "g 2010b", "g210", "g 210", + "d1603", "d 1603", "d 16 03", + "telegram-gruppe", "telegram gruppe", "chatverlauf", "chat verlauf", + "wer hat zugesagt", "was ist offen", "muss ich reagieren", + "letzte nachricht", "im chat", "in der gruppe", +] +_WEB_TRIGGERS = [ + "recherche", "recherchiere", "suche im internet", "web search", + "preis", "preise", "kostet", "price", + "news", "nachrichten", "aktuell", "aktuelle", + "google", "finde heraus", "finde raus", + "gold", "silber", "kurs", "kurse", + "vergleich", "vergleiche", + "was kostet", "wie teuer", "wie viel", +] +_DEEP_TRIGGERS = ["deep research", "tiefenrecherche", "tiefensuche", "tiefe suche", "detailrecherche", "ausfuehrliche recherche", "ausführliche recherche", "vollstaendige recherche", "vollständige recherche", "recherchiere genau", "analysiere genau"] + +import datetime as _dt +_TODAY = _dt.date.today() +_3M_AGO = (_TODAY - _dt.timedelta(days=90)) +_DATE_LINE = f'Heutiges Datum: {_TODAY.strftime("%d. %B %Y")}. Wir sind im Jahr {_TODAY.year}. Letzte 3 Monate = {_3M_AGO.strftime("%B %Y")} bis {_TODAY.strftime("%B %Y")}.' + +SYSTEM_PROMPT = _DATE_LINE + """ +Du bist der Hausmeister-Bot fuer ein Homelab. Deutsch, kurz, direkt, operativ. + +STIL: +- So wenig Worte wie moeglich, solange nichts Wichtiges fehlt. +- KEINE Abschlussformeln ("Wenn du weitere Informationen benoetigst..."). +- KEINE kuenstlichen Wuensche ("Guten Flug!", "Viel Erfolg!"). +- KEINE Rueckfragen ob der User mehr wissen will. +- Emojis nur wenn sie Information tragen. Telegram-Format (kein Markdown). + +GEDAECHTNIS LESEN: +Am Ende dieses System-Prompts findest du eine Sektion "=== GEDAECHTNIS ===" mit Fakten die du dir ueber den User und die Umgebung gemerkt hast. +WICHTIG: Beantworte Fragen IMMER anhand dieser Fakten! Wenn der User z.B. fragt "Wo fliege ich hin?" und im Gedaechtnis steht "Ist naechste Woche in Frankfurt", dann antworte direkt mit dieser Info. +Das Gedaechtnis ist deine primaere Wissensquelle ueber den User. + +GEDAECHTNIS — memory_suggest: +Du MUSST memory_suggest aufrufen wenn der User etwas Nuetzliches sagt. +memory_suggest speichert DIREKT. Duplikate werden automatisch erkannt. Widersprueche werden automatisch aufgeloest (altes Item wird ersetzt). + +MEMORY_TYPE — immer angeben: +- "preference" → Vorlieben ("Lieblingskaffee ist Flat White", "bevorzuge dunkles Theme") +- "relationship" → Beziehungen/Rollen ("Ali ist Ansprechpartner fuer Wohnung") +- "fact" → Stabile Fakten ("Server IP geaendert", "Jarvis aktiv seit...") +- "plan" → Konkrete Vorhaben mit Zeitbezug ("Reise nach Frankfurt am 18.03.") +- "temporary" → Kurzfristige Zustaende ("bin bis Mittwoch in Berlin", "morgen Zahnarzt") +- "uncertain" → Vage Aussagen ("vielleicht fliege ich frueher", "glaube Ali kuemmert sich") + +CONFIDENCE — immer angeben: +- "high" → Klare Aussage ("Mein Lieblingskaffee ist Flat White") +- "medium" → Wahrscheinlich aber nicht 100% ("Ali kuemmert sich wohl darum") +- "low" → Sehr vage ("vielleicht aendere ich das noch") + +REGELN: +- Bei plan/temporary: IMMER expires_at mit Zeitausdruck angeben ("naechste Woche", "morgen") +- Bei uncertain mit low confidence: NUR speichern wenn es trotzdem nuetzlich sein koennte +- Vages Gerede, Smalltalk, emotionale Kurzaeusserungen: NICHT speichern +- Passwoerter, Tokens: NIE via memory_suggest speichern. ABER: Wenn der User nach Zugangsdaten fragt, nutze get_service_directory — dort sind alle URLs und Logins hinterlegt. +- Bei Widerspruch zu bestehendem Wissen: NUR den neuen Fakt speichern (z.B. "Lieblingskaffee ist Americano"). NICHT den alten Fakt umformulieren oder als "war..." speichern. Das System erkennt Widersprueche automatisch und ersetzt den alten Eintrag. + +SESSION-RUECKBLICK: +- "Was haben wir besprochen?" → session_summary OHNE topic +- "Was haben wir ueber X besprochen?" → session_summary MIT topic="X" +- NUR echte Gespraechsinhalte aus den Treffern wiedergeben. +- KEINE Negativ-Aussagen hinzufuegen. Nichts ueber Dinge sagen die NICHT besprochen wurden. + Verboten: "wurde nicht thematisiert", "keine weiteren Details", "nicht besprochen", "nicht erwaehnt". +- Wenn es nur 1 Treffer gibt, gib nur diesen 1 Treffer wieder. Fuege nichts hinzu. +- Optional kurz erwaehnen was sonst noch Thema war. +- session_search nur fuer Stichwort-Suche in ALTEN Sessions (nicht aktuelle). + +BILDERKENNUNG — ALLGEMEIN: +Wenn der User ein Bild schickt das KEIN kritisches Dokument ist (z.B. Foto, Screenshot, Landschaft): +- Beschreibe strukturiert was du siehst. +- Bei Screenshots von Fehlern/Logs: Identifiziere das Problem, ordne es einem Container/Service zu, schlage Loesung vor. +- Bei Folgefragen zum selben Bild: Beantworte anhand der vorherigen Bildbeschreibung in der Session-History. +- Speichere erkannte Termine/Plaene via memory_suggest. + +DOKUMENTENEXTRAKTION — HARTE REGELN: +Gilt fuer: Flugtickets, Buchungen, Belege, Rechnungen, Formulare, Behoerdendokumente. +Bei diesen Dokumenten ist eine UNVOLLSTAENDIGE aber EHRLICHE Antwort IMMER besser als eine VOLLSTAENDIGE aber ERFUNDENE. + +1. NIEMALS RATEN: +- Wenn ein Feld nicht sicher lesbar ist: NICHT erfinden. +- Keine plausible Uhrzeit ergaenzen. Keine Codes aus Weltwissen ergaenzen. +- Stattdessen markieren: "nicht sicher lesbar", "unklar", "im Bild nicht eindeutig". + +2. BILDWISSEN VOR WELTWISSEN: +- NUR extrahieren was im Bild steht. KEIN externes Wissen um fehlende Felder zu "reparieren". +- Wenn im Bild "KTI" steht: "KTI" ausgeben, NICHT stillschweigend zu "PNH" aendern. +- Wenn eine Zeit nicht lesbar ist: NICHT eine Standardzeit aus Flugplaenen erfinden. +- Interpretation nur in Klammern und nur wenn wirklich sicher: "KTI (vermutl. Phnom Penh Techo International)". + +3. ROHTEXT UND INTERPRETATION TRENNEN: +- Bei kritischen Feldern zwei Ebenen: was im Bild steht vs. was interpretiert wird. +- Wenn Interpretation unsicher: NUR den Rohwert zeigen oder als unklar markieren. + +4. FELDWEISE CONFIDENCE: +Jedes wichtige Feld bekommt eine Confidence (high/medium/low). +Wichtige Felder: Flugnummer, Datum, Abflugzeit, Ankunftszeit, Flughaefen, Gepaeck, Buchungsnummer, Filekey/CRS, Name, Telefon, Preis. +- Unsichere Felder NIEMALS mit hoher Sicherheit formulieren. +- Low-confidence Felder IMMER kennzeichnen. + +5. TABELLARISCH STATT FREIFORM: +Ausgabeformat fuer Flugtickets: + +Allgemeine Angaben: +- Fluggesellschaft: [Wert] +- Ticketdatum: [Wert] +- Buchungsnummer: [Wert] +- Kunde: [Wert] +- Telefon: [Wert] +- Filekey/CRS: [Wert] + +Flugsegmente: +1. [Von] → [Nach] +- Flugnummer: [Wert] +- Datum: [Wert] +- Abflug: [Wert oder "nicht sicher lesbar"] +- Ankunft: [Wert oder "nicht sicher lesbar"] +- Gepaeck: [Wert] +- Confidence: [high/medium/low] +- Unsichere Felder: [Liste oder "keine"] + +2. ... + +6. ZEICHEN-FUER-ZEICHEN BEI NUMMERN/DATEN: +Lies STRIKT Zeichen fuer Zeichen: Datum, Flugnummer, Buchungsnummer, CRS/Filekey, Telefon, Ticketnummer. +NICHT glaetten, NICHT umdeuten, NICHT kreativ korrigieren. +Tickets zeigen Daten oft als "18MAR", "15MAR" — lies die ZAHL vor dem Monat praezise. Verwechsle NICHT 18 mit 19, 13 mit 15. + +7. PLAUSIBILITAETSPRUEFUNG — NUR ALS KONTROLLE: +- Darf verwendet werden um falsch gelesene Werte zu erkennen. +- Darf NIEMALS fehlende Werte erfinden. +- Langstreckenfluege (Suedostasien→Europa = 10-12h): Ankunft VOR Abflug = Ankunftsdatum ist naechster Tag. +- Zeitzonen: Suedostasien UTC+7, Mitteleuropa CET/UTC+1 = 6h Differenz. +- RECHNE IMMER SELBST NACH. Kopiere NIEMALS Zeitberechnungen aus frueheren Antworten. + +ANSCHLUSSFLUG-PLAUSIBILITAET (PFLICHT bei aufeinanderfolgenden Segmenten): +Pruefe fuer JEDES Segmentpaar auf demselben Ticket: +a) Berechne: Differenz zwischen Ankunftszeit Segment N und Abflugzeit Segment N+1. +b) Wenn die UHRZEITEN weniger als 3 Stunden auseinander liegen (z.B. 23:30→23:35 oder 20:55→23:25), die DATEN aber einen Tag auseinander: + → Das Datum des zweiten Segments hat vermutlich Confidence MEDIUM. + → Markiere: "Datum moeglicherweise falsch gelesen — bei gleichem Tag waere Umsteigezeit [X Min/Stunden], bei Tagessprung [~24h]. Bitte pruefen." +c) Typische Umsteigezeiten auf einem Ticket: 1-6h. Wenn die berechnete Umsteigezeit >20h ist, ist das ein Warnsignal fuer ein falsch gelesenes Datum. +d) Bei Anschlussfluegen mit fast identischen Uhrzeiten (Differenz <30 Min) und genau 1 Tag Abstand: Das Datum ist mit HOHER Wahrscheinlichkeit falsch gelesen → MEDIUM confidence, expliziter Hinweis. + +8. KONSERVATIV FORMULIEREN: +Bei Reisen, Geld, Behoerden, Rechnungen, Buchungen: +- Lieber unvollstaendig aber ehrlich. +- NIEMALS praezise falsche Angaben machen. +- Speichere nur HIGH-CONFIDENCE Daten via memory_suggest (Reiseplaene, Buchungscodes). + +PREISRECHERCHE (PFLICHT): +Wenn der User nach Preisen, Kosten oder Preisentwicklung fragt: +- Nutze IMMER Tools statt Allgemeinwissen. +- Fuer schnelle Preisabfrage: web_search. +- Mache 2-3 gezielte web_search Aufrufe mit verschiedenen Suchbegriffen. +- deep_research NUR wenn User explizit 'deep research' oder 'tiefenrecherche' sagt. +- Gib konkrete Zahlen aus (EUR), nicht nur Tendenzen. +- Nenne 3-5 Quellen-Links. +- Wenn keine belastbaren Zahlen gefunden werden: klar sagen "keine belastbaren Preisdaten gefunden". + +FORMAT bei Preisantworten: +1) Zeitraum +2) Preis damals -> Preis heute (Delta in %) +3) Kurzfazit (steigend/fallend/stabil) +4) Quellen + +TOOLS: +Nutze Tools fuer Live-Daten. Wenn alles OK: kurz sagen. Bei Problemen: erklaeren + Loesung.""" + +TOOLS = tool_loader.get_tools() + + +def _get_api_key() -> str: + cfg = config.parse_config() + return cfg.api_keys.get("openrouter_key", "") + + +def _route_model(question: str) -> str: + """Entscheidet ob lokal, online (Sonar) oder deep_research.""" + q = question.lower() + if any(t in q for t in _DEEP_TRIGGERS): + return "deep_research" + if any(t in q for t in _LOCAL_OVERRIDES): + return MODEL_LOCAL + if any(t in q for t in _WEB_TRIGGERS): + return MODEL_ONLINE + return MODEL_LOCAL + + +def _ollama_timeout_for(model: str) -> int: + if model == MODEL_VISION: + return 240 + if model == FALLBACK_MODEL: + return 90 + return 180 + + +def _add_no_think(messages: list) -> None: + """Haengt /no_think an die letzte User-Nachricht fuer Ollama.""" + for msg in reversed(messages): + if msg.get("role") != "user": + continue + content = msg.get("content", "") + if isinstance(content, str) and "/no_think" not in content: + msg["content"] = content + " /no_think" + elif isinstance(content, list): + for part in content: + if part.get("type") == "text" and "/no_think" not in part.get("text", ""): + part["text"] = part["text"] + " /no_think" + break + break + + +def _call_api(messages: list, api_key: str, use_tools: bool = True, + model: str = None, max_tokens: int = 4000, + allow_fallback: bool = True) -> dict: + chosen = model or MODEL_LOCAL + use_ollama = chosen in OLLAMA_MODELS + log.info("LLM-Call: model=%s ollama=%s max_tokens=%d", chosen, use_ollama, max_tokens) + + payload = { + "model": chosen, + "messages": messages, + "max_tokens": max_tokens, + } + if use_tools: + payload["tools"] = TOOLS + payload["tool_choice"] = "auto" + + if use_ollama: + url = f"{OLLAMA_BASE}/v1/chat/completions" + headers = {"Content-Type": "application/json"} + timeout = _ollama_timeout_for(chosen) + _add_no_think(payload.get("messages", [])) + else: + url = f"{OPENROUTER_BASE}/chat/completions" + headers = {"Authorization": f"Bearer {api_key}"} + timeout = 90 + + try: + r = requests.post(url, headers=headers, json=payload, timeout=timeout) + r.raise_for_status() + return r.json() + except requests.exceptions.ReadTimeout: + if use_ollama and allow_fallback and FALLBACK_MODEL and chosen != FALLBACK_MODEL: + log.warning( + "Ollama timeout for %s after %ss, retrying with %s", + chosen, timeout, FALLBACK_MODEL, + ) + return _call_api( + messages, api_key, use_tools=use_tools, + model=FALLBACK_MODEL, max_tokens=max_tokens, + allow_fallback=False, + ) + raise + + +def ask(question: str, context: str) -> str: + """Legacy-Funktion fuer /commands die bereits Kontext mitbringen.""" + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + messages = [ + {"role": "system", "content": _full_prompt}, + {"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"}, + ] + try: + data = _call_api(messages, api_key, use_tools=False) + return data["choices"][0]["message"]["content"] + except Exception as e: + return f"LLM-Fehler: {e}" + + +def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None, document_mode: bool = False, model_override: str = None) -> str: + """Freitext-Frage mit automatischem Routing und Tool-Calling. + + Routing: + - deep_research / tiefenrecherche -> Deep Research Handler direkt + - Web/Preis/Recherche -> Perplexity Sonar (kein Tool-Calling) + - Alles andere -> Lokales Modell mit allen Tools + """ + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + route = _route_model(question) + + if document_mode and route != "deep_research": + route = MODEL_LOCAL + log.info("Betriebsart Unterlagen: lokales Modell, keine Web-Suche") + + # --- Deep Research: Perplexity Sonar Deep Research --- + if route == "deep_research": + log.info("Route: sonar-deep-research") + try: + messages_dr = [ + {"role": "system", "content": "Du bist ein Recherche-Assistent. Antworte auf Deutsch, strukturiert, mit konkreten Zahlen und Quellen."}, + {"role": "user", "content": question}, + ] + data = _call_api(messages_dr, api_key, use_tools=False, + model="perplexity/sonar-deep-research", max_tokens=4000) + return data["choices"][0]["message"].get("content", "Keine Antwort von Sonar Deep Research.") + except Exception as e: + return f"Sonar Deep Research Fehler: {e}" + + log.info("Route: %s", route) + + # --- Memory + Prompt aufbauen --- + try: + import memory_client + memory_items = memory_client.get_relevant_memory(question, top_k=10) + memory_block = memory_client.format_memory_for_prompt(memory_items) + except Exception: + memory_block = "" + + try: + import openmemory_client + openmemory_block = openmemory_client.get_openmemory_for_prompt(question, top_k=8) + except Exception: + openmemory_block = "" + + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block + (("\n" + openmemory_block) if openmemory_block else "") + + messages = [ + {"role": "system", "content": _full_prompt}, + ] + + _RECAP_MARKERS = ["was haben wir", "worüber haben wir", "worüber hatten wir", + "worueber haben wir", "was hatten wir besprochen", "was war heute thema"] + + def _is_recap(text): + t = text.lower() + return any(m in t for m in _RECAP_MARKERS) + + if session_id: + try: + import memory_client + history = memory_client.get_session_messages(session_id, limit=10) + skip_next_assistant = False + for msg in history: + role = msg.get("role", "") + content = msg.get("content", "") + if not content: + continue + if role == "user" and _is_recap(content): + skip_next_assistant = True + continue + if role == "assistant" and skip_next_assistant: + skip_next_assistant = False + continue + skip_next_assistant = False + if role in ("user", "assistant"): + messages.append({"role": role, "content": content}) + except Exception: + pass + + messages.append({"role": "user", "content": question}) + + # --- RAG-Pflicht: Bei Doc-Keywords rag_search DIREKT aufrufen --- + _DOC_KW = [ + "versicherung", "vertrag", "vertraege", "dokument", "rente", + "finanzamt", "steuer", "grundsteuer", "familienbuch", "urkunde", + "bescheid", "police", "beitrag", "mietvertrag", "arbeitsvertrag", + "kindergeld", "rechnung", "haftpflicht", "rechtsschutz", + "lebensversicherung", "bauspar", "reisepass", "personalausweis", + "lvm", "allianz", "ergo", "huk", "nuernberger", + "jahreskosten", "jährlichen", "jährliche", "jaehrlichen", "jaehrliche", + "monatliche kosten", "versicherungskosten", "beitragsrechnung", + "wohnung", "wohnungen", "immobilie", "immobilien", "mietwohnung", + "condo", "apartment", "eigentumswohnung", "häuser", "haeuser", + "haus ", " haus", + "grundstueck", "grundstück", "kambodscha", "cambodia", "takeo", + "phnom", "sihanouk", "siem reap", + ] + _q_low = question.lower() + if route == MODEL_LOCAL and (document_mode or any(k in _q_low for k in _DOC_KW)): + _rag_fn = tool_handlers.get("rag_search") + if _rag_fn: + try: + log.info("RAG-Pflicht: forciere rag_search fuer: %s", question[:80]) + _rag_q = question + if any( + x in _q_low + for x in ( + "kosten", + "jähr", + "jaehr", + "jahreskosten", + "monatliche kosten", + ) + ): + _rag_q = ( + question + + " Versicherung Beitrag Beitragsrechnung Jahresbetrag" + ) + elif any( + x in _q_low + for x in ( + "wohnung", + "immobilie", + "condo", + "apartment", + "grundstueck", + "grundstück", + "kambodscha", + "cambodia", + "takeo", + "phnom", + ) + ): + _rag_q = question + " Wohnung Immobilie Mietvertrag Kambodscha" + _rag_finance_focus = any( + x in _q_low + for x in ( + "kosten", + "beitrag", + "€", + " eur", + "eur,", + "zahlung", + "versicherung", + "jähr", + "jaehr", + "jahreskosten", + "monatliche kosten", + "beitragsrechnung", + "preis", + "gebühr", + "gebuehr", + ) + ) + _rag_res = _rag_fn(query=_rag_q, top_k=60) + if _rag_res and not _rag_res.startswith("Keine"): + log.info("RAG-Pflicht: %d Zeichen — loesche Session-History", len(str(_rag_res))) + if _rag_finance_focus: + _rag_extra = ( + "\n\nWICHTIG: Ignoriere fruehere Antworten. " + + "STIL-OVERRIDE: Trotz globalem Hausmeister-Stil (kurz): diese Antwort bewusst etwas ausfuehrlicher " + + "(mehrere Saetze, Aufzaehlungen), damit Kosten und Zeitraeume ohne Nachfrage klar sind. " + + "Die Dokumentensuche unten ist die einzige Wahrheit. " + + "Beantworte die Frage NUR basierend auf diesen Suchergebnissen. " + + "Struktur: (1) Kurzfassung 1-2 Saetze: welche Jahre/Rechnungen vorliegen und was sich daraus fuer " + + "einen Jahresbetrag ableiten laesst oder warum nicht. " + + "(2) Danach je relevantem Treffer: Dateiname, Betrag falls im Snippet, Zeitraum falls erkennbar. " + + "Wenn mehrere Treffer zur gleichen Gesellschaft gehoeren: " + + "liste jede erkannte Sparte bzw. jeden Dokumenttyp separat " + + "(z.B. Kfz/Auto, Rechtsschutz, Haftpflicht, Sach, Ausland, Kranken) " + + "mit Beleg (Dateiname oder Ordner aus den Treffern). " + + "Nicht nur den ersten Treffer nennen. " + + "Bei Kosten/Beitraegen: je Treffer Betrag und Zeitraum nennen wenn im Snippet erkennbar; " + + "wenn nicht erkennbar, ausdruecklich sagen. " + + "VERBOTEN: Antwort nur als nackte Zahl (z.B. nur eine EUR-Zeile ohne Kontext). " + + "Zu JEDEM Betrag mindestens einen Dateinamen aus den Treffern nennen. " + + "Bei Kfz/Fahrzeug: sagen welches Dokument sich darauf bezieht oder dass die Zuordnung unsicher ist." + ) + _rag_user_suffix = ( + "\n\n[Strukturiert: Kurzfassung zu Jahr/jaehrlich, dann je Dokument Dateiname mit Betrag " + + "und Zeitraum aus den Treffern; keine reine Ein-Zahl-Antwort.]" + ) + else: + _rag_extra = ( + "\n\nWICHTIG: Ignoriere fruehere Antworten und Priorisiere die Dokumentensuche unten " + + "ueber Kurzinfos aus dem Gedaechtnis. " + + "STIL-OVERRIDE: etwas ausfuehrlicher als sonst (Aufzaehlungen ok). " + + "Die Treffer sind die einzige belastbare Grundlage. " + + "Struktur: (1) Kurzfassung: was in den Treffern zur Frage passt. " + + "(2) Je relevantem Treffer: Dateiname/Ordnerpfad und erkennbare Fakten aus dem Snippet " + + "(Objekt, Ort, Datum, Parteien) — nichts erfinden. " + + "Alle plausibel passenden Treffer nennen, nicht nur den ersten. " + + "Wenn nichts passt: klar sagen dass die Suche nichts Relevantes lieferte." + ) + _rag_user_suffix = ( + "\n\n[Strukturiert: Kurzfassung, dann Liste je Treffer mit Dateiname und Fakten nur aus dem Snippet; " + + "keine erfundenen Details.]" + ) + messages = [ + {"role": "system", "content": _full_prompt + _rag_extra}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "forced_rag", "type": "function", + "function": {"name": "rag_search", + "arguments": json.dumps({"query": _rag_q, "top_k": 60})}}]}, + {"role": "tool", "tool_call_id": "forced_rag", + "content": str(_rag_res)[:100000]}, + {"role": "user", "content": question + _rag_user_suffix}, + ] + except Exception as e: + log.warning("RAG-Pflicht Fehler: %s", e) + + # --- Online (Sonar): kein Tool-Calling, Sonar sucht selbst --- + if route == MODEL_ONLINE: + try: + data = _call_api(messages, api_key, use_tools=False, model=MODEL_ONLINE) + content = data["choices"][0]["message"].get("content", "") + if session_id: + try: + memory_client.log_message(session_id, "user", question) + memory_client.log_message(session_id, "assistant", content) + except Exception: + pass + return content or "Keine Antwort von Sonar." + except Exception as e: + return f"Online-Suche Fehler: {e}" + + # --- Lokal: Tool-Calling mit allen Tools --- + local_model = model_override or MODEL_LOCAL + passthrough_result = None + + try: + for _round in range(MAX_TOOL_ROUNDS): + data = _call_api(messages, api_key, use_tools=True, model=local_model) + choice = data["choices"][0] + msg = choice["message"] + + tool_calls = msg.get("tool_calls") + if not tool_calls: + content = msg.get("content") or "" + if not content and msg.get("reasoning"): + content = msg.get("reasoning", "") + if passthrough_result: + return passthrough_result + return content or "Keine Antwort vom LLM." + + messages.append(msg) + + for tc in tool_calls: + fn_name = tc["function"]["name"] + try: + fn_args = json.loads(tc["function"]["arguments"]) + except (json.JSONDecodeError, KeyError): + fn_args = {} + + log.info("Tool-Call: %s args=%s", fn_name, str(fn_args)[:200]) + handler = tool_handlers.get(fn_name) + if handler: + try: + result = handler(**fn_args) + except Exception as e: + result = f"Fehler bei {fn_name}: {e}" + else: + result = f"Unbekanntes Tool: {fn_name}" + + result_str = str(result)[:3000] + + if fn_name in PASSTHROUGH_TOOLS and not result_str.startswith(("Fehler", "Keine")): + log.info("Passthrough-Tool %s", fn_name) + passthrough_result = result_str + + messages.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": result_str, + }) + + if passthrough_result: + return passthrough_result + data = _call_api(messages, api_key, use_tools=False, model=local_model) + return data["choices"][0]["message"]["content"] + + except Exception as e: + return f"LLM-Fehler: {e}" + + +def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session_id: str = None) -> str: + """Bild-Analyse via lokalem Vision-Modell mit Tool-Calling.""" + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + try: + import memory_client + query = caption if caption else "Bild-Analyse" + memory_items = memory_client.get_relevant_memory(query, top_k=10) + memory_block = memory_client.format_memory_for_prompt(memory_items) + except Exception: + memory_block = "" + + try: + import openmemory_client + q = caption if caption else "Bild-Analyse" + openmemory_block = openmemory_client.get_openmemory_for_prompt(q, top_k=8) + except Exception: + openmemory_block = "" + + default_prompt = ( + "Analysiere dieses Bild. " + "Wenn es ein Dokument ist (Ticket, Rechnung, Beleg, Buchung): " + "Wende die DOKUMENTENEXTRAKTION-Regeln an — feldweise, konservativ, mit Confidence pro Feld. " + "Markiere unsichere Felder explizit. Erfinde NICHTS. " + "Lies Nummern und Daten Zeichen fuer Zeichen. " + "Wenn es ein normales Bild ist: Beschreibe strukturiert was du siehst." + ) + prompt_text = caption if caption else default_prompt + user_content = [ + {"type": "text", "text": prompt_text}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}", "detail": "high"}}, + ] + + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block + (("\n" + openmemory_block) if openmemory_block else "") + + messages = [ + {"role": "system", "content": _full_prompt}, + ] + + if session_id: + try: + import memory_client + history = memory_client.get_session_messages(session_id, limit=6) + for msg in history: + role = msg.get("role", "") + content = msg.get("content", "") + if content and role in ("user", "assistant"): + messages.append({"role": role, "content": content}) + except Exception: + pass + + messages.append({"role": "user", "content": user_content}) + + try: + for _round in range(MAX_TOOL_ROUNDS): + data = _call_api(messages, api_key, use_tools=True, + model=MODEL_VISION, max_tokens=4000, + allow_fallback=False) + choice = data["choices"][0] + msg = choice["message"] + + tool_calls = msg.get("tool_calls") + if not tool_calls: + content = msg.get("content") or "" + if not content and msg.get("reasoning"): + content = msg.get("reasoning", "") + return content or "Keine Antwort vom LLM." + + messages.append(msg) + + for tc in tool_calls: + fn_name = tc["function"]["name"] + try: + fn_args = json.loads(tc["function"]["arguments"]) + except (json.JSONDecodeError, KeyError): + fn_args = {} + + handler = tool_handlers.get(fn_name) + if handler: + try: + result = handler(**fn_args) + except Exception as e: + result = f"Fehler bei {fn_name}: {e}" + else: + result = f"Unbekanntes Tool: {fn_name}" + + messages.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": str(result)[:3000], + }) + + data = _call_api(messages, api_key, use_tools=False, + model=MODEL_VISION, max_tokens=4000, + allow_fallback=False) + return data["choices"][0]["message"]["content"] + + except requests.exceptions.ReadTimeout: + return ( + "Das Vision-Modell antwortet nicht (Timeout). " + "Bitte in 1-2 Min erneut versuchen — das Modell wird gerade geladen." + ) + except Exception as e: + return f"Vision-LLM-Fehler: {e}" diff --git a/homelab-ai-bot/llm.py.bak.20260605_before-gemini b/homelab-ai-bot/llm.py.bak.20260605_before-gemini new file mode 100644 index 000000000..301526563 --- /dev/null +++ b/homelab-ai-bot/llm.py.bak.20260605_before-gemini @@ -0,0 +1,740 @@ +"""OpenRouter LLM-Wrapper mit Tool-Calling. + +Neue Datenquelle = eine Datei in tools/ anlegen. Fertig. +""" + +import json +import logging +import requests +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config +import tool_loader + +log = logging.getLogger('llm') + +OLLAMA_BASE = "http://100.84.255.83:11434" +OPENROUTER_BASE = "https://openrouter.ai/api/v1" + +MODEL_LOCAL = "x-ai/grok-4.3" +MODEL_VISION = "openai/gpt-4o-mini" +MODEL_ONLINE = "perplexity/sonar" +FALLBACK_MODEL = None +MAX_TOOL_ROUNDS = 3 +OLLAMA_MODELS = set() + + +def warmup_ollama(): + """No-Op: Text-Modell laeuft ueber OpenRouter (x-ai/grok-4.3), kein Ollama-Warmup noetig.""" + log.info('Ollama warmup uebersprungen — Text laeuft ueber OpenRouter (x-ai/grok-4.3)') +PASSTHROUGH_TOOLS = {"get_temperaturen", "get_energie", "get_heizung"} + +_LOCAL_OVERRIDES = [ + "api kosten", "api-kosten", "guthaben", "openrouter", + "container", "status", "fehler", "logs", "service", + "backup", "feed", "memory", "gedaechtnis", "gedächtnis", + "mail", "seafile", "forgejo", "grafana", "prometheus", + "savetv", "save.tv", "filmtipp", "aufnahme", + "wordpress", "matomo", "tailscale", + "unsere api", "meine api", "die api", + "vorhersage", "prognose", "health forecast", "was bahnt", "systemstatus", + "system check", "system-check", "wie gehts dem system", "wie geht es dem system", + "versicherung", "vertrag", "verträge", "vertraege", "dokument", + "rente", "rentenversicherung", "finanzamt", "steuer", "grundsteuer", + "familienbuch", "urkunde", "bescheid", "police", "beitrag", + "meine unterlagen", "meine dokumente", "meine dateien", + "habe ich", "welche habe", "was habe ich", + "jahreskosten", "jährlichen", "jährliche", "jaehrlichen", "jaehrliche", + "monatliche kosten", "versicherungskosten", "beitragsrechnung", + # Possessiv/Persoenlich (2026-04-16) + "meine", "mein ", "meiner", "meines", "meinem", "meinen", + "wohnung", "wohnungen", "apartment", "apartments", + "condo", "condos", "kondo", "kondos", "immobilie", "immobilien", + "kambodscha", "cambodia", "phnom penh", "arakawa", + "gekostet", "gekauft", "kaufpreis", "kaufpreise", "bezahlt", + "ausgegeben", "ueberweisung", "überweisung", + "was haben", "wieviel habe ich", "wie viel habe ich", + "fuer die wohnung", "für die wohnung", + "ich fuer", "ich für", + # Chatlogger / Telegram-Mitschnitt (2026-05-01) — verhindert Sonar-Routing bei "aktueller Stand" + "concierge", "conciergerie", "bnb", "airbnb", "listing", "listings", + "vermietung", "vermieten", "vermietet", + "kaution", "mieter", "mieterin", "miete", + "g2010b", "g 2010 b", "g 2010b", "g210", "g 210", + "d1603", "d 1603", "d 16 03", + "telegram-gruppe", "telegram gruppe", "chatverlauf", "chat verlauf", + "wer hat zugesagt", "was ist offen", "muss ich reagieren", + "letzte nachricht", "im chat", "in der gruppe", +] +_WEB_TRIGGERS = [ + "recherche", "recherchiere", "suche im internet", "web search", + "preis", "preise", "kostet", "price", + "news", "nachrichten", "aktuell", "aktuelle", + "google", "finde heraus", "finde raus", + "gold", "silber", "kurs", "kurse", + "vergleich", "vergleiche", + "was kostet", "wie teuer", "wie viel", +] +_DEEP_TRIGGERS = ["deep research", "tiefenrecherche", "tiefensuche", "tiefe suche", "detailrecherche", "ausfuehrliche recherche", "ausführliche recherche", "vollstaendige recherche", "vollständige recherche", "recherchiere genau", "analysiere genau"] + +import datetime as _dt +_TODAY = _dt.date.today() +_3M_AGO = (_TODAY - _dt.timedelta(days=90)) +_DATE_LINE = f'Heutiges Datum: {_TODAY.strftime("%d. %B %Y")}. Wir sind im Jahr {_TODAY.year}. Letzte 3 Monate = {_3M_AGO.strftime("%B %Y")} bis {_TODAY.strftime("%B %Y")}.' + +SYSTEM_PROMPT = _DATE_LINE + """ +Du bist der Hausmeister-Bot fuer ein Homelab. Deutsch, kurz, direkt, operativ. + +STIL: +- So wenig Worte wie moeglich, solange nichts Wichtiges fehlt. +- KEINE Abschlussformeln ("Wenn du weitere Informationen benoetigst..."). +- KEINE kuenstlichen Wuensche ("Guten Flug!", "Viel Erfolg!"). +- KEINE Rueckfragen ob der User mehr wissen will. +- Emojis nur wenn sie Information tragen. Telegram-Format (kein Markdown). + +GEDAECHTNIS LESEN: +Am Ende dieses System-Prompts findest du eine Sektion "=== GEDAECHTNIS ===" mit Fakten die du dir ueber den User und die Umgebung gemerkt hast. +WICHTIG: Beantworte Fragen IMMER anhand dieser Fakten! Wenn der User z.B. fragt "Wo fliege ich hin?" und im Gedaechtnis steht "Ist naechste Woche in Frankfurt", dann antworte direkt mit dieser Info. +Das Gedaechtnis ist deine primaere Wissensquelle ueber den User. + +GEDAECHTNIS — memory_suggest: +Du MUSST memory_suggest aufrufen wenn der User etwas Nuetzliches sagt. +memory_suggest speichert DIREKT. Duplikate werden automatisch erkannt. Widersprueche werden automatisch aufgeloest (altes Item wird ersetzt). + +MEMORY_TYPE — immer angeben: +- "preference" → Vorlieben ("Lieblingskaffee ist Flat White", "bevorzuge dunkles Theme") +- "relationship" → Beziehungen/Rollen ("Ali ist Ansprechpartner fuer Wohnung") +- "fact" → Stabile Fakten ("Server IP geaendert", "Jarvis aktiv seit...") +- "plan" → Konkrete Vorhaben mit Zeitbezug ("Reise nach Frankfurt am 18.03.") +- "temporary" → Kurzfristige Zustaende ("bin bis Mittwoch in Berlin", "morgen Zahnarzt") +- "uncertain" → Vage Aussagen ("vielleicht fliege ich frueher", "glaube Ali kuemmert sich") + +CONFIDENCE — immer angeben: +- "high" → Klare Aussage ("Mein Lieblingskaffee ist Flat White") +- "medium" → Wahrscheinlich aber nicht 100% ("Ali kuemmert sich wohl darum") +- "low" → Sehr vage ("vielleicht aendere ich das noch") + +REGELN: +- Bei plan/temporary: IMMER expires_at mit Zeitausdruck angeben ("naechste Woche", "morgen") +- Bei uncertain mit low confidence: NUR speichern wenn es trotzdem nuetzlich sein koennte +- Vages Gerede, Smalltalk, emotionale Kurzaeusserungen: NICHT speichern +- Passwoerter, Tokens: NIE via memory_suggest speichern. ABER: Wenn der User nach Zugangsdaten fragt, nutze get_service_directory — dort sind alle URLs und Logins hinterlegt. +- Bei Widerspruch zu bestehendem Wissen: NUR den neuen Fakt speichern (z.B. "Lieblingskaffee ist Americano"). NICHT den alten Fakt umformulieren oder als "war..." speichern. Das System erkennt Widersprueche automatisch und ersetzt den alten Eintrag. + +SESSION-RUECKBLICK: +- "Was haben wir besprochen?" → session_summary OHNE topic +- "Was haben wir ueber X besprochen?" → session_summary MIT topic="X" +- NUR echte Gespraechsinhalte aus den Treffern wiedergeben. +- KEINE Negativ-Aussagen hinzufuegen. Nichts ueber Dinge sagen die NICHT besprochen wurden. + Verboten: "wurde nicht thematisiert", "keine weiteren Details", "nicht besprochen", "nicht erwaehnt". +- Wenn es nur 1 Treffer gibt, gib nur diesen 1 Treffer wieder. Fuege nichts hinzu. +- Optional kurz erwaehnen was sonst noch Thema war. +- session_search nur fuer Stichwort-Suche in ALTEN Sessions (nicht aktuelle). + +BILDERKENNUNG — ALLGEMEIN: +Wenn der User ein Bild schickt das KEIN kritisches Dokument ist (z.B. Foto, Screenshot, Landschaft): +- Beschreibe strukturiert was du siehst. +- Bei Screenshots von Fehlern/Logs: Identifiziere das Problem, ordne es einem Container/Service zu, schlage Loesung vor. +- Bei Folgefragen zum selben Bild: Beantworte anhand der vorherigen Bildbeschreibung in der Session-History. +- Speichere erkannte Termine/Plaene via memory_suggest. + +DOKUMENTENEXTRAKTION — HARTE REGELN: +Gilt fuer: Flugtickets, Buchungen, Belege, Rechnungen, Formulare, Behoerdendokumente. +Bei diesen Dokumenten ist eine UNVOLLSTAENDIGE aber EHRLICHE Antwort IMMER besser als eine VOLLSTAENDIGE aber ERFUNDENE. + +1. NIEMALS RATEN: +- Wenn ein Feld nicht sicher lesbar ist: NICHT erfinden. +- Keine plausible Uhrzeit ergaenzen. Keine Codes aus Weltwissen ergaenzen. +- Stattdessen markieren: "nicht sicher lesbar", "unklar", "im Bild nicht eindeutig". + +2. BILDWISSEN VOR WELTWISSEN: +- NUR extrahieren was im Bild steht. KEIN externes Wissen um fehlende Felder zu "reparieren". +- Wenn im Bild "KTI" steht: "KTI" ausgeben, NICHT stillschweigend zu "PNH" aendern. +- Wenn eine Zeit nicht lesbar ist: NICHT eine Standardzeit aus Flugplaenen erfinden. +- Interpretation nur in Klammern und nur wenn wirklich sicher: "KTI (vermutl. Phnom Penh Techo International)". + +3. ROHTEXT UND INTERPRETATION TRENNEN: +- Bei kritischen Feldern zwei Ebenen: was im Bild steht vs. was interpretiert wird. +- Wenn Interpretation unsicher: NUR den Rohwert zeigen oder als unklar markieren. + +4. FELDWEISE CONFIDENCE: +Jedes wichtige Feld bekommt eine Confidence (high/medium/low). +Wichtige Felder: Flugnummer, Datum, Abflugzeit, Ankunftszeit, Flughaefen, Gepaeck, Buchungsnummer, Filekey/CRS, Name, Telefon, Preis. +- Unsichere Felder NIEMALS mit hoher Sicherheit formulieren. +- Low-confidence Felder IMMER kennzeichnen. + +5. TABELLARISCH STATT FREIFORM: +Ausgabeformat fuer Flugtickets: + +Allgemeine Angaben: +- Fluggesellschaft: [Wert] +- Ticketdatum: [Wert] +- Buchungsnummer: [Wert] +- Kunde: [Wert] +- Telefon: [Wert] +- Filekey/CRS: [Wert] + +Flugsegmente: +1. [Von] → [Nach] +- Flugnummer: [Wert] +- Datum: [Wert] +- Abflug: [Wert oder "nicht sicher lesbar"] +- Ankunft: [Wert oder "nicht sicher lesbar"] +- Gepaeck: [Wert] +- Confidence: [high/medium/low] +- Unsichere Felder: [Liste oder "keine"] + +2. ... + +6. ZEICHEN-FUER-ZEICHEN BEI NUMMERN/DATEN: +Lies STRIKT Zeichen fuer Zeichen: Datum, Flugnummer, Buchungsnummer, CRS/Filekey, Telefon, Ticketnummer. +NICHT glaetten, NICHT umdeuten, NICHT kreativ korrigieren. +Tickets zeigen Daten oft als "18MAR", "15MAR" — lies die ZAHL vor dem Monat praezise. Verwechsle NICHT 18 mit 19, 13 mit 15. + +7. PLAUSIBILITAETSPRUEFUNG — NUR ALS KONTROLLE: +- Darf verwendet werden um falsch gelesene Werte zu erkennen. +- Darf NIEMALS fehlende Werte erfinden. +- Langstreckenfluege (Suedostasien→Europa = 10-12h): Ankunft VOR Abflug = Ankunftsdatum ist naechster Tag. +- Zeitzonen: Suedostasien UTC+7, Mitteleuropa CET/UTC+1 = 6h Differenz. +- RECHNE IMMER SELBST NACH. Kopiere NIEMALS Zeitberechnungen aus frueheren Antworten. + +ANSCHLUSSFLUG-PLAUSIBILITAET (PFLICHT bei aufeinanderfolgenden Segmenten): +Pruefe fuer JEDES Segmentpaar auf demselben Ticket: +a) Berechne: Differenz zwischen Ankunftszeit Segment N und Abflugzeit Segment N+1. +b) Wenn die UHRZEITEN weniger als 3 Stunden auseinander liegen (z.B. 23:30→23:35 oder 20:55→23:25), die DATEN aber einen Tag auseinander: + → Das Datum des zweiten Segments hat vermutlich Confidence MEDIUM. + → Markiere: "Datum moeglicherweise falsch gelesen — bei gleichem Tag waere Umsteigezeit [X Min/Stunden], bei Tagessprung [~24h]. Bitte pruefen." +c) Typische Umsteigezeiten auf einem Ticket: 1-6h. Wenn die berechnete Umsteigezeit >20h ist, ist das ein Warnsignal fuer ein falsch gelesenes Datum. +d) Bei Anschlussfluegen mit fast identischen Uhrzeiten (Differenz <30 Min) und genau 1 Tag Abstand: Das Datum ist mit HOHER Wahrscheinlichkeit falsch gelesen → MEDIUM confidence, expliziter Hinweis. + +8. KONSERVATIV FORMULIEREN: +Bei Reisen, Geld, Behoerden, Rechnungen, Buchungen: +- Lieber unvollstaendig aber ehrlich. +- NIEMALS praezise falsche Angaben machen. +- Speichere nur HIGH-CONFIDENCE Daten via memory_suggest (Reiseplaene, Buchungscodes). + +PREISRECHERCHE (PFLICHT): +Wenn der User nach Preisen, Kosten oder Preisentwicklung fragt: +- Nutze IMMER Tools statt Allgemeinwissen. +- Fuer schnelle Preisabfrage: web_search. +- Mache 2-3 gezielte web_search Aufrufe mit verschiedenen Suchbegriffen. +- deep_research NUR wenn User explizit 'deep research' oder 'tiefenrecherche' sagt. +- Gib konkrete Zahlen aus (EUR), nicht nur Tendenzen. +- Nenne 3-5 Quellen-Links. +- Wenn keine belastbaren Zahlen gefunden werden: klar sagen "keine belastbaren Preisdaten gefunden". + +FORMAT bei Preisantworten: +1) Zeitraum +2) Preis damals -> Preis heute (Delta in %) +3) Kurzfazit (steigend/fallend/stabil) +4) Quellen + +TOOLS: +Nutze Tools fuer Live-Daten. Wenn alles OK: kurz sagen. Bei Problemen: erklaeren + Loesung.""" + +TOOLS = tool_loader.get_tools() + + +def _get_api_key() -> str: + cfg = config.parse_config() + return cfg.api_keys.get("openrouter_key", "") + + +def _route_model(question: str) -> str: + """Entscheidet ob lokal, online (Sonar) oder deep_research.""" + q = question.lower() + if any(t in q for t in _DEEP_TRIGGERS): + return "deep_research" + if any(t in q for t in _LOCAL_OVERRIDES): + return MODEL_LOCAL + if any(t in q for t in _WEB_TRIGGERS): + return MODEL_ONLINE + return MODEL_LOCAL + + +def _ollama_timeout_for(model: str) -> int: + if model == MODEL_VISION: + return 240 + if model == FALLBACK_MODEL: + return 90 + return 180 + + +def _add_no_think(messages: list) -> None: + """Haengt /no_think an die letzte User-Nachricht fuer Ollama.""" + for msg in reversed(messages): + if msg.get("role") != "user": + continue + content = msg.get("content", "") + if isinstance(content, str) and "/no_think" not in content: + msg["content"] = content + " /no_think" + elif isinstance(content, list): + for part in content: + if part.get("type") == "text" and "/no_think" not in part.get("text", ""): + part["text"] = part["text"] + " /no_think" + break + break + + +def _call_api(messages: list, api_key: str, use_tools: bool = True, + model: str = None, max_tokens: int = 4000, + allow_fallback: bool = True) -> dict: + chosen = model or MODEL_LOCAL + use_ollama = chosen in OLLAMA_MODELS + log.info("LLM-Call: model=%s ollama=%s max_tokens=%d", chosen, use_ollama, max_tokens) + + payload = { + "model": chosen, + "messages": messages, + "max_tokens": max_tokens, + } + if use_tools: + payload["tools"] = TOOLS + payload["tool_choice"] = "auto" + + if use_ollama: + url = f"{OLLAMA_BASE}/v1/chat/completions" + headers = {"Content-Type": "application/json"} + timeout = _ollama_timeout_for(chosen) + _add_no_think(payload.get("messages", [])) + else: + url = f"{OPENROUTER_BASE}/chat/completions" + headers = {"Authorization": f"Bearer {api_key}"} + timeout = 90 + + try: + r = requests.post(url, headers=headers, json=payload, timeout=timeout) + r.raise_for_status() + return r.json() + except requests.exceptions.ReadTimeout: + if use_ollama and allow_fallback and FALLBACK_MODEL and chosen != FALLBACK_MODEL: + log.warning( + "Ollama timeout for %s after %ss, retrying with %s", + chosen, timeout, FALLBACK_MODEL, + ) + return _call_api( + messages, api_key, use_tools=use_tools, + model=FALLBACK_MODEL, max_tokens=max_tokens, + allow_fallback=False, + ) + raise + + +def ask(question: str, context: str) -> str: + """Legacy-Funktion fuer /commands die bereits Kontext mitbringen.""" + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + messages = [ + {"role": "system", "content": _full_prompt}, + {"role": "user", "content": f"Kontext (Live-Daten):\n{context}\n\nFrage: {question}"}, + ] + try: + data = _call_api(messages, api_key, use_tools=False) + return data["choices"][0]["message"]["content"] + except Exception as e: + return f"LLM-Fehler: {e}" + + +def ask_with_tools(question: str, tool_handlers: dict, session_id: str = None, document_mode: bool = False, model_override: str = None) -> str: + """Freitext-Frage mit automatischem Routing und Tool-Calling. + + Routing: + - deep_research / tiefenrecherche -> Deep Research Handler direkt + - Web/Preis/Recherche -> Perplexity Sonar (kein Tool-Calling) + - Alles andere -> Lokales Modell mit allen Tools + """ + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + route = _route_model(question) + + if document_mode and route != "deep_research": + route = MODEL_LOCAL + log.info("Betriebsart Unterlagen: lokales Modell, keine Web-Suche") + + # --- Deep Research: Perplexity Sonar Deep Research --- + if route == "deep_research": + log.info("Route: sonar-deep-research") + try: + messages_dr = [ + {"role": "system", "content": "Du bist ein Recherche-Assistent. Antworte auf Deutsch, strukturiert, mit konkreten Zahlen und Quellen."}, + {"role": "user", "content": question}, + ] + data = _call_api(messages_dr, api_key, use_tools=False, + model="perplexity/sonar-deep-research", max_tokens=4000) + return data["choices"][0]["message"].get("content", "Keine Antwort von Sonar Deep Research.") + except Exception as e: + return f"Sonar Deep Research Fehler: {e}" + + log.info("Route: %s", route) + + # --- Memory + Prompt aufbauen --- + try: + import memory_client + memory_items = memory_client.get_relevant_memory(question, top_k=10) + memory_block = memory_client.format_memory_for_prompt(memory_items) + except Exception: + memory_block = "" + + try: + import openmemory_client + openmemory_block = openmemory_client.get_openmemory_for_prompt(question, top_k=8) + except Exception: + openmemory_block = "" + + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block + (("\n" + openmemory_block) if openmemory_block else "") + + messages = [ + {"role": "system", "content": _full_prompt}, + ] + + _RECAP_MARKERS = ["was haben wir", "worüber haben wir", "worüber hatten wir", + "worueber haben wir", "was hatten wir besprochen", "was war heute thema"] + + def _is_recap(text): + t = text.lower() + return any(m in t for m in _RECAP_MARKERS) + + if session_id: + try: + import memory_client + history = memory_client.get_session_messages(session_id, limit=10) + skip_next_assistant = False + for msg in history: + role = msg.get("role", "") + content = msg.get("content", "") + if not content: + continue + if role == "user" and _is_recap(content): + skip_next_assistant = True + continue + if role == "assistant" and skip_next_assistant: + skip_next_assistant = False + continue + skip_next_assistant = False + if role in ("user", "assistant"): + messages.append({"role": role, "content": content}) + except Exception: + pass + + messages.append({"role": "user", "content": question}) + + # --- RAG-Pflicht: Bei Doc-Keywords rag_search DIREKT aufrufen --- + _DOC_KW = [ + "versicherung", "vertrag", "vertraege", "dokument", "rente", + "finanzamt", "steuer", "grundsteuer", "familienbuch", "urkunde", + "bescheid", "police", "beitrag", "mietvertrag", "arbeitsvertrag", + "kindergeld", "rechnung", "haftpflicht", "rechtsschutz", + "lebensversicherung", "bauspar", "reisepass", "personalausweis", + "lvm", "allianz", "ergo", "huk", "nuernberger", + "jahreskosten", "jährlichen", "jährliche", "jaehrlichen", "jaehrliche", + "monatliche kosten", "versicherungskosten", "beitragsrechnung", + "wohnung", "wohnungen", "immobilie", "immobilien", "mietwohnung", + "condo", "apartment", "eigentumswohnung", "häuser", "haeuser", + "haus ", " haus", + "grundstueck", "grundstück", "kambodscha", "cambodia", "takeo", + "phnom", "sihanouk", "siem reap", + ] + _q_low = question.lower() + if route == MODEL_LOCAL and (document_mode or any(k in _q_low for k in _DOC_KW)): + _rag_fn = tool_handlers.get("rag_search") + if _rag_fn: + try: + log.info("RAG-Pflicht: forciere rag_search fuer: %s", question[:80]) + _rag_q = question + if any( + x in _q_low + for x in ( + "kosten", + "jähr", + "jaehr", + "jahreskosten", + "monatliche kosten", + ) + ): + _rag_q = ( + question + + " Versicherung Beitrag Beitragsrechnung Jahresbetrag" + ) + elif any( + x in _q_low + for x in ( + "wohnung", + "immobilie", + "condo", + "apartment", + "grundstueck", + "grundstück", + "kambodscha", + "cambodia", + "takeo", + "phnom", + ) + ): + _rag_q = question + " Wohnung Immobilie Mietvertrag Kambodscha" + _rag_finance_focus = any( + x in _q_low + for x in ( + "kosten", + "beitrag", + "€", + " eur", + "eur,", + "zahlung", + "versicherung", + "jähr", + "jaehr", + "jahreskosten", + "monatliche kosten", + "beitragsrechnung", + "preis", + "gebühr", + "gebuehr", + ) + ) + _rag_res = _rag_fn(query=_rag_q, top_k=60) + if _rag_res and not _rag_res.startswith("Keine"): + log.info("RAG-Pflicht: %d Zeichen — loesche Session-History", len(str(_rag_res))) + if _rag_finance_focus: + _rag_extra = ( + "\n\nWICHTIG: Ignoriere fruehere Antworten. " + + "STIL-OVERRIDE: Trotz globalem Hausmeister-Stil (kurz): diese Antwort bewusst etwas ausfuehrlicher " + + "(mehrere Saetze, Aufzaehlungen), damit Kosten und Zeitraeume ohne Nachfrage klar sind. " + + "Die Dokumentensuche unten ist die einzige Wahrheit. " + + "Beantworte die Frage NUR basierend auf diesen Suchergebnissen. " + + "Struktur: (1) Kurzfassung 1-2 Saetze: welche Jahre/Rechnungen vorliegen und was sich daraus fuer " + + "einen Jahresbetrag ableiten laesst oder warum nicht. " + + "(2) Danach je relevantem Treffer: Dateiname, Betrag falls im Snippet, Zeitraum falls erkennbar. " + + "Wenn mehrere Treffer zur gleichen Gesellschaft gehoeren: " + + "liste jede erkannte Sparte bzw. jeden Dokumenttyp separat " + + "(z.B. Kfz/Auto, Rechtsschutz, Haftpflicht, Sach, Ausland, Kranken) " + + "mit Beleg (Dateiname oder Ordner aus den Treffern). " + + "Nicht nur den ersten Treffer nennen. " + + "Bei Kosten/Beitraegen: je Treffer Betrag und Zeitraum nennen wenn im Snippet erkennbar; " + + "wenn nicht erkennbar, ausdruecklich sagen. " + + "VERBOTEN: Antwort nur als nackte Zahl (z.B. nur eine EUR-Zeile ohne Kontext). " + + "Zu JEDEM Betrag mindestens einen Dateinamen aus den Treffern nennen. " + + "Bei Kfz/Fahrzeug: sagen welches Dokument sich darauf bezieht oder dass die Zuordnung unsicher ist." + ) + _rag_user_suffix = ( + "\n\n[Strukturiert: Kurzfassung zu Jahr/jaehrlich, dann je Dokument Dateiname mit Betrag " + + "und Zeitraum aus den Treffern; keine reine Ein-Zahl-Antwort.]" + ) + else: + _rag_extra = ( + "\n\nWICHTIG: Ignoriere fruehere Antworten und Priorisiere die Dokumentensuche unten " + + "ueber Kurzinfos aus dem Gedaechtnis. " + + "STIL-OVERRIDE: etwas ausfuehrlicher als sonst (Aufzaehlungen ok). " + + "Die Treffer sind die einzige belastbare Grundlage. " + + "Struktur: (1) Kurzfassung: was in den Treffern zur Frage passt. " + + "(2) Je relevantem Treffer: Dateiname/Ordnerpfad und erkennbare Fakten aus dem Snippet " + + "(Objekt, Ort, Datum, Parteien) — nichts erfinden. " + + "Alle plausibel passenden Treffer nennen, nicht nur den ersten. " + + "Wenn nichts passt: klar sagen dass die Suche nichts Relevantes lieferte." + ) + _rag_user_suffix = ( + "\n\n[Strukturiert: Kurzfassung, dann Liste je Treffer mit Dateiname und Fakten nur aus dem Snippet; " + + "keine erfundenen Details.]" + ) + messages = [ + {"role": "system", "content": _full_prompt + _rag_extra}, + {"role": "assistant", "content": None, + "tool_calls": [{"id": "forced_rag", "type": "function", + "function": {"name": "rag_search", + "arguments": json.dumps({"query": _rag_q, "top_k": 60})}}]}, + {"role": "tool", "tool_call_id": "forced_rag", + "content": str(_rag_res)[:100000]}, + {"role": "user", "content": question + _rag_user_suffix}, + ] + except Exception as e: + log.warning("RAG-Pflicht Fehler: %s", e) + + # --- Online (Sonar): kein Tool-Calling, Sonar sucht selbst --- + if route == MODEL_ONLINE: + try: + data = _call_api(messages, api_key, use_tools=False, model=MODEL_ONLINE) + content = data["choices"][0]["message"].get("content", "") + if session_id: + try: + memory_client.log_message(session_id, "user", question) + memory_client.log_message(session_id, "assistant", content) + except Exception: + pass + return content or "Keine Antwort von Sonar." + except Exception as e: + return f"Online-Suche Fehler: {e}" + + # --- Lokal: Tool-Calling mit allen Tools --- + local_model = model_override or MODEL_LOCAL + passthrough_result = None + + try: + for _round in range(MAX_TOOL_ROUNDS): + data = _call_api(messages, api_key, use_tools=True, model=local_model) + choice = data["choices"][0] + msg = choice["message"] + + tool_calls = msg.get("tool_calls") + if not tool_calls: + content = msg.get("content") or "" + if not content and msg.get("reasoning"): + content = msg.get("reasoning", "") + if passthrough_result: + return passthrough_result + return content or "Keine Antwort vom LLM." + + messages.append(msg) + + for tc in tool_calls: + fn_name = tc["function"]["name"] + try: + fn_args = json.loads(tc["function"]["arguments"]) + except (json.JSONDecodeError, KeyError): + fn_args = {} + + log.info("Tool-Call: %s args=%s", fn_name, str(fn_args)[:200]) + handler = tool_handlers.get(fn_name) + if handler: + try: + result = handler(**fn_args) + except Exception as e: + result = f"Fehler bei {fn_name}: {e}" + else: + result = f"Unbekanntes Tool: {fn_name}" + + result_str = str(result)[:3000] + + if fn_name in PASSTHROUGH_TOOLS and not result_str.startswith(("Fehler", "Keine")): + log.info("Passthrough-Tool %s", fn_name) + passthrough_result = result_str + + messages.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": result_str, + }) + + if passthrough_result: + return passthrough_result + data = _call_api(messages, api_key, use_tools=False, model=local_model) + return data["choices"][0]["message"]["content"] + + except Exception as e: + return f"LLM-Fehler: {e}" + + +def ask_with_image(image_base64: str, caption: str, tool_handlers: dict, session_id: str = None) -> str: + """Bild-Analyse via lokalem Vision-Modell mit Tool-Calling.""" + api_key = _get_api_key() + if not api_key: + return "OpenRouter API Key fehlt in homelab.conf" + + try: + import memory_client + query = caption if caption else "Bild-Analyse" + memory_items = memory_client.get_relevant_memory(query, top_k=10) + memory_block = memory_client.format_memory_for_prompt(memory_items) + except Exception: + memory_block = "" + + try: + import openmemory_client + q = caption if caption else "Bild-Analyse" + openmemory_block = openmemory_client.get_openmemory_for_prompt(q, top_k=8) + except Exception: + openmemory_block = "" + + default_prompt = ( + "Analysiere dieses Bild. " + "Wenn es ein Dokument ist (Ticket, Rechnung, Beleg, Buchung): " + "Wende die DOKUMENTENEXTRAKTION-Regeln an — feldweise, konservativ, mit Confidence pro Feld. " + "Markiere unsichere Felder explizit. Erfinde NICHTS. " + "Lies Nummern und Daten Zeichen fuer Zeichen. " + "Wenn es ein normales Bild ist: Beschreibe strukturiert was du siehst." + ) + prompt_text = caption if caption else default_prompt + user_content = [ + {"type": "text", "text": prompt_text}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}", "detail": "high"}}, + ] + + _extra = tool_loader.get_extra_prompt() + _full_prompt = SYSTEM_PROMPT + ("\n\n" + _extra if _extra else "") + memory_block + (("\n" + openmemory_block) if openmemory_block else "") + + messages = [ + {"role": "system", "content": _full_prompt}, + ] + + if session_id: + try: + import memory_client + history = memory_client.get_session_messages(session_id, limit=6) + for msg in history: + role = msg.get("role", "") + content = msg.get("content", "") + if content and role in ("user", "assistant"): + messages.append({"role": role, "content": content}) + except Exception: + pass + + messages.append({"role": "user", "content": user_content}) + + try: + for _round in range(MAX_TOOL_ROUNDS): + data = _call_api(messages, api_key, use_tools=True, + model=MODEL_VISION, max_tokens=4000, + allow_fallback=False) + choice = data["choices"][0] + msg = choice["message"] + + tool_calls = msg.get("tool_calls") + if not tool_calls: + content = msg.get("content") or "" + if not content and msg.get("reasoning"): + content = msg.get("reasoning", "") + return content or "Keine Antwort vom LLM." + + messages.append(msg) + + for tc in tool_calls: + fn_name = tc["function"]["name"] + try: + fn_args = json.loads(tc["function"]["arguments"]) + except (json.JSONDecodeError, KeyError): + fn_args = {} + + handler = tool_handlers.get(fn_name) + if handler: + try: + result = handler(**fn_args) + except Exception as e: + result = f"Fehler bei {fn_name}: {e}" + else: + result = f"Unbekanntes Tool: {fn_name}" + + messages.append({ + "role": "tool", + "tool_call_id": tc["id"], + "content": str(result)[:3000], + }) + + data = _call_api(messages, api_key, use_tools=False, + model=MODEL_VISION, max_tokens=4000, + allow_fallback=False) + return data["choices"][0]["message"]["content"] + + except requests.exceptions.ReadTimeout: + return ( + "Das Vision-Modell antwortet nicht (Timeout). " + "Bitte in 1-2 Min erneut versuchen — das Modell wird gerade geladen." + ) + except Exception as e: + return f"Vision-LLM-Fehler: {e}" diff --git a/homelab-ai-bot/monitor.py b/homelab-ai-bot/monitor.py index 80a1ff6e6..b90e7fefd 100644 --- a/homelab-ai-bot/monitor.py +++ b/homelab-ai-bot/monitor.py @@ -4,6 +4,7 @@ import sys import os import json import hashlib +import re import requests import time from datetime import datetime, timezone @@ -22,6 +23,8 @@ ALERT_COOLDOWN_SECONDS = { "memory_expiry": 43200, "default": 3600, "error_rate": 1800, + "hermes": 7200, + "backup": 21600, } @@ -45,10 +48,18 @@ HTTP_HEALTH_CHECKS = [ {"name": "WordPress (CT 101)", "url": "http://10.10.10.101/robots.txt"}, {"name": "Matomo (CT 109)", "url": "http://10.10.10.109"}, {"name": "Grafana (CT 110)", "url": "http://10.10.10.110:3000/api/health"}, - {"name": "Flugscanner-Agent (pve-pp-1)", "url": "http://100.126.26.46:5010/status", - "retries": 5, "timeout": 25, "retry_delay": 6, "host": "pve-pp-1"}, ] +HERMES_HEALTH_CHECKS = [ + {"name": "Hermes Promtail", "url": "http://100.109.174.120:9080/ready", "timeout": 5}, + {"name": "Hermes node_exporter", "url": "http://100.109.174.120:9100/metrics", "timeout": 5}, + {"name": "Hermes Websuche CT121", "url": "http://100.74.196.29:8080/search?q=hausmeister-health&format=json", "timeout": 8}, + {"name": "Loki", "url": "http://100.109.206.43:3100/ready", "timeout": 5}, + {"name": "PBS Muldenstein", "url": "https://100.99.139.22:8007", "timeout": 5, "allow_status": {200, 401}}, +] + +HERMES_LOG_HOST = "hermes-mu" + EXPECTED_STOPPED = { (115, "pve-ka-1"), # flugscanner-asia-old (gestoppt, Cluster pve1) — Live CT auf pve-pp-1 (115, "pve-ka-3"), # dieselbe CT, zweite API-Sicht (Cluster) @@ -57,6 +68,9 @@ EXPECTED_STOPPED = { (504, "pve-ka-2"), # Shop-Template — stopped (8000, "pve-ka-2"), # Kunde0-Shop — stopped (8010, "pve-ka-2"), # Kunde1-Shop — stopped + (121, "pve-ka-1"), # Schawarma-Shop — gestoppt + (121, "pve-ka-2"), # Schawarma-Shop — gestoppt + (121, "pve-ka-3"), # Schawarma-Shop — gestoppt } # VMIDs, die auf jedem Proxmox-Host in CONFIG ok sind, solange status == stopped @@ -83,12 +97,60 @@ def _is_host_suppressed(host: str, suppressed_hosts: set) -> bool: return False +def check_hermes() -> list[str]: + """Prueft Hermes (CT151) als kritischen Dienst ueber HTTP und Loki.""" + alerts = [] + headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 hermes-check)"} + for check in HERMES_HEALTH_CHECKS: + allow_status = check.get("allow_status") or set(range(200, 400)) + try: + r = requests.get( + check["url"], + timeout=check.get("timeout", 5), + allow_redirects=False, + headers=headers, + verify=False, + ) + if r.status_code not in allow_status: + alerts.append(f"🔴 Hermes: {check['name']} HTTP {r.status_code}") + except requests.RequestException as e: + alerts.append(f"🔴 Hermes: {check['name']} nicht erreichbar: {str(e)[:80]}") + + health = loki_client.get_health(HERMES_LOG_HOST, hours=1) + if health.get("status") == "silent": + alerts.append("⚠️ Hermes: keine aktuellen Loki-Logs von hermes-mu") + elif health.get("status") == "critical": + cnt = health.get("error_count", health.get("errors_last_1h", "?")) + alerts.append(f"🔴 Hermes: {cnt} Fehlerlogs in Loki (1h)") + + errors = loki_client.get_errors(container=HERMES_LOG_HOST, hours=0.5, limit=20) + for e in errors: + if "error" in e: + continue + line = (e.get("line") or "").lower() + if "diskstats_linux.go" in line and "disabling udev device properties" in line: + continue + if "collector failed" in line and "node_exporter" in line: + continue + if "beellama-tunnel.service" in line or "beellama-expose.service" in line: + if "main process exited" in line: + continue + if "health(warnable=mapresponse-timeout)" in line: + continue + if any(term in line for term in ["traceback", "exception", "failed", "timeout", "tool_call", "connection refused"]): + alerts.append(f"🔴 Hermes Log: {(e.get('line') or '')[:140]}") + break + + return alerts + + def check_all() -> list[str]: """Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück.""" cfg = config.parse_config() suppressed_hosts = config.get_suppressed_hosts(cfg) suppressed_names = config.get_suppressed_container_names(cfg) alerts = [] + alerts.extend(check_hermes()) containers = proxmox_client.get_all_containers( _get_passwords(cfg), _get_tokens(cfg) @@ -322,6 +384,19 @@ def _save_alert_state(state: dict): def _alert_key(alert_text: str) -> str: + # Volatile Metrik-Alarme (error_rate) ueber einen stabilen Schluessel + # deduplizieren: nur Host, nicht der schwankende Zaehlwert. Sonst + # erzeugt jede neue Zahl einen neuen Hash und der Cooldown greift nie. + if "Fehler in 30 Min" in alert_text: + m = re.search(r"([\w.\-]+):\s*\d+\s+Fehler in 30 Min", alert_text) + if m: + return hashlib.md5(("error_rate|" + m.group(1)).encode()).hexdigest() + if "Hermes:" in alert_text and "Fehlerlogs in Loki" in alert_text: + return hashlib.md5(b"hermes|loki_errors").hexdigest() + if alert_text.startswith("🔴 Hermes Log:"): + m = re.search(r"([\w.\-]+\.service)", alert_text) + unit = m.group(1) if m else "generic" + return hashlib.md5(("hermes|log|" + unit).encode()).hexdigest() return hashlib.md5(alert_text.encode()).hexdigest() @@ -340,6 +415,10 @@ def _alert_category(alert_text: str) -> str: return "http" if "Service-Neustart" in alert_text: return "restart" + if "Hermes" in alert_text: + return "hermes" + if "Backup" in alert_text or "PBS" in alert_text: + return "backup" if "Memory läuft ab" in alert_text: return "memory_expiry" return "default" diff --git a/homelab-ai-bot/monitor.py.bak-hermes-monitor b/homelab-ai-bot/monitor.py.bak-hermes-monitor new file mode 100644 index 000000000..4bf04657b --- /dev/null +++ b/homelab-ai-bot/monitor.py.bak-hermes-monitor @@ -0,0 +1,403 @@ +"""Proaktives Monitoring — regelbasiert (Stufe 1) + KI (Stufe 2).""" + +import sys +import os +import json +import hashlib +import requests +import time +from datetime import datetime, timezone + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config, loki_client, proxmox_client, mail_client + +ALERT_STATE_FILE = "/var/cache/hausmeister-alert-state.json" +ALERT_COOLDOWN_SECONDS = { + "container": 1800, + "ram": 1800, + "panic": 3600, + "silence": 3600, + "http": 1800, + "restart": 900, + "memory_expiry": 43200, + "default": 3600, + "error_rate": 1800, +} + + +def _get_tokens(cfg): + tokens = {} + tn = cfg.raw.get("PVE_TOKEN_HETZNER_NAME", "") + tv = cfg.raw.get("PVE_TOKEN_HETZNER_VALUE", "") + if tn and tv: + tokens["pve-hetzner"] = {"name": tn, "value": tv} + return tokens + + +def _get_passwords(cfg): + pw = cfg.passwords.get("default", "") + return {host: pw for host in proxmox_client.PROXMOX_HOSTS} + + +CRITICAL_CONTAINERS = [101, 109, 111, 112, 113, 115] + +HTTP_HEALTH_CHECKS = [ + {"name": "WordPress (CT 101)", "url": "http://10.10.10.101/robots.txt"}, + {"name": "Matomo (CT 109)", "url": "http://10.10.10.109"}, + {"name": "Grafana (CT 110)", "url": "http://10.10.10.110:3000/api/health"}, +] + +EXPECTED_STOPPED = { + (115, "pve-ka-1"), # flugscanner-asia-old (gestoppt, Cluster pve1) — Live CT auf pve-pp-1 + (115, "pve-ka-3"), # dieselbe CT, zweite API-Sicht (Cluster) + (101, "pp1"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (101, "pp2"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (504, "pve-ka-2"), # Shop-Template — stopped + (8000, "pve-ka-2"), # Kunde0-Shop — stopped + (8010, "pve-ka-2"), # Kunde1-Shop — stopped + (121, "pve-ka-1"), # Schawarma-Shop — gestoppt + (121, "pve-ka-2"), # Schawarma-Shop — gestoppt + (121, "pve-ka-3"), # Schawarma-Shop — gestoppt +} + +# VMIDs, die auf jedem Proxmox-Host in CONFIG ok sind, solange status == stopped +# (115 erscheint je nach API-Zuordnung auch als pve-hetzner o.ä., nicht nur ka-1/ka-3) +EXPECTED_STOPPED_VMIDS = {115, 504, 8000, 8010} + +IGNORED_HOSTS = {"${HOSTNAME}", ""} + +SILENCE_IGNORED_HOSTS = { + "ct-600-webcam", # kein rsyslog, Stream läuft aber + "ct-103-Intercity-Taxi", # absichtlich gestoppt + "ct-101-freshrss", # auf pve-ka-3, lokale Loki (nicht zentral) +} + + +def _is_host_suppressed(host: str, suppressed_hosts: set) -> bool: + if not host or not suppressed_hosts: + return False + h = host.lower() + for s in suppressed_hosts: + s = s.lower() + if h == s or h.startswith(s + ".") or h.startswith(s + "-"): + return True + return False + + +def check_all() -> list[str]: + """Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + alerts = [] + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + for ct in containers: + if "error" in ct: + continue + host = ct.get("_host", "") + if _is_host_suppressed(host, suppressed_hosts): + continue + vmid = ct.get("vmid", 0) + name = ct.get("name", "?") + status = ct.get("status", "unknown") + if vmid in CRITICAL_CONTAINERS and status != "running": + ok_stopped = vmid in EXPECTED_STOPPED_VMIDS and status == "stopped" + if (vmid, host) not in EXPECTED_STOPPED and not ok_stopped: + alerts.append(f"🔴 CT {vmid} ({name}) ist {status}!") + + mem = ct.get("mem", 0) + maxmem = ct.get("maxmem", 1) + if maxmem > 0 and mem / maxmem > 0.90: + pct = int(mem / maxmem * 100) + alerts.append(f"⚠️ CT {vmid} ({name}) RAM bei {pct}%") + + errors = loki_client.get_errors(hours=0.5, limit=50) + error_lines = [e for e in errors if "error" not in e] + panic_lines = [] + for e in error_lines: + line = e.get("line", "") + ll = line.lower() + if not any(w in ll for w in ["panic", "fatal", "oom", "out of memory"]): + continue + if "query=" in line or "caller=metrics" in line: + continue + if "HTTP/1." in line and ('" 200 ' in line or '" 301 ' in line or '" 302 ' in line or '" 304 ' in line): + continue + if "GET /" in line or "POST /" in line or "HEAD /" in line: + continue + panic_lines.append(e) + if panic_lines: + hosts = set(e.get("host", "?") for e in panic_lines) + hosts -= IGNORED_HOSTS + hosts = {h for h in hosts if h.lower() not in suppressed_names} + if hosts: + alerts.append(f"🔴 Kritische Fehler (panic/fatal/OOM) auf: {', '.join(hosts)}") + + error_rates = loki_client.check_error_rate(minutes=30) + for er in error_rates: + if er.get("host", "").lower() in suppressed_names: + continue + alerts.append( + f"🔴 {er['host']}: {er['count']} Fehler in 30 Min (Schwelle: {er['threshold']})" + ) + + running_names = { + ct.get("name", "").lower() + for ct in containers + if "error" not in ct and ct.get("status") == "running" + } + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in silent[0]: + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s.get("host") not in SILENCE_IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + and s["host"].lower() in running_names + ] + if names: + alerts.append(f"⚠️ Keine Logs seit 35+ Min: {', '.join(names)}") + + _headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 health-check)"} + for check in HTTP_HEALTH_CHECKS: + if _is_host_suppressed(check.get("host", ""), suppressed_hosts): + continue + timeout = check.get("timeout", 15) + retries = check.get("retries", 1) + retry_delay = check.get("retry_delay", 3) + msg = None + for attempt in range(retries): + try: + r = requests.get( + check["url"], timeout=timeout, allow_redirects=True, headers=_headers + ) + if r.status_code < 400: + msg = None + break + msg = f"🔴 {check['name']} antwortet mit HTTP {r.status_code}" + except requests.RequestException as e: + msg = f"🔴 {check['name']} nicht erreichbar: {str(e)[:80]}" + if attempt < retries - 1: + time.sleep(retry_delay) + if msg: + alerts.append(msg) + + restarts = loki_client.check_service_restarts(minutes=35) + for r in restarts: + if r.get("host", "").lower() in suppressed_names: + continue + alerts.append(f"🔄 Service-Neustart: {r['service']} auf {r['host']} ({r['count']}x in 35 Min)") + + try: + import memory_client + import time as _time + now_ts = int(_time.time()) + mem_items = memory_client.get_active_memory() + for item in mem_items: + exp = item.get("expires_at") + if exp and 0 < exp - now_ts < 86400: + from datetime import datetime as _dt + exp_str = _dt.fromtimestamp(exp).strftime("%d.%m. %H:%M") + alerts.append(f"⏰ Memory läuft ab ({exp_str}): {item['content'][:80]}") + except Exception: + pass + + try: + mail_client.init(cfg) + important = mail_client.get_important_mails(hours=1) + if important and "error" not in important[0]: + state = _load_alert_state() + seen = state.get("seen_mails", {}) + now = datetime.now(timezone.utc).timestamp() + + new_mails = [] + for m in important: + fp = hashlib.md5( + f"{m.get('date_str','')}{m.get('from','')}{m.get('subject','')}".encode() + ).hexdigest() + if fp not in seen: + new_mails.append(m) + seen[fp] = now + + seen = {k: v for k, v in seen.items() if now - v < 172800} + state["seen_mails"] = seen + _save_alert_state(state) + + if new_mails: + senders = [m["from"][:30] for m in new_mails] + alerts.append(f"📧 {len(new_mails)} neue wichtige Mail(s): {', '.join(senders)}") + except Exception: + pass + + return alerts + + +def format_report() -> str: + """Tagesbericht: Gesamtstatus aller Systeme.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + lines = ["📋 Tagesbericht Homelab\n"] + if suppressed_hosts: + lines.append(f"🔕 Stumm: {', '.join(sorted(suppressed_hosts))}\n") + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + containers = [ + c for c in containers + if not _is_host_suppressed(c.get("_host", ""), suppressed_hosts) + ] + running = [c for c in containers if c.get("status") == "running"] + stopped = [c for c in containers if c.get("status") == "stopped"] + errors_ct = [c for c in containers if "error" in c] + lines.append(f"Container: {len(running)} running, {len(stopped)} stopped, {len(errors_ct)} nicht erreichbar") + + errors = loki_client.get_errors(hours=24, limit=100) + error_count = len([ + e for e in errors + if "error" not in e and e.get("host", "").lower() not in suppressed_names + ]) + lines.append(f"Fehler (24h): {error_count}") + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in (silent[0] if silent else {}): + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + ] + if names: + lines.append(f"Stille Hosts: {', '.join(names)}") + else: + lines.append("Stille Hosts: keine") + else: + lines.append("Stille Hosts: keine") + + try: + import memory_client + mem_items = memory_client.get_active_memory() + perm = [i for i in mem_items if i.get("memory_type") != "temporary"] + temp = [i for i in mem_items if i.get("memory_type") == "temporary"] + candidates = memory_client.get_candidates() + mem_line = f"Memory: {len(perm)} dauerhaft, {len(temp)} temporär" + import time as _time + now_ts = int(_time.time()) + soon = [i for i in temp if i.get("expires_at") and i["expires_at"] - now_ts < 86400] + if soon: + mem_line += f", {len(soon)} laufen in 24h ab" + if candidates: + mem_line += f", {len(candidates)} Kandidaten offen" + lines.append(mem_line) + except Exception: + pass + + alerts = check_all() + if alerts: + lines.append(f"\n⚠️ {len(alerts)} aktive Alarme:") + lines.extend(alerts) + else: + lines.append("\n✅ Keine Alarme — alles läuft.") + + return "\n".join(lines) + + +def _load_alert_state() -> dict: + try: + with open(ALERT_STATE_FILE, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_alert_state(state: dict): + try: + with open(ALERT_STATE_FILE, "w") as f: + json.dump(state, f) + except Exception: + pass + + +def _alert_key(alert_text: str) -> str: + return hashlib.md5(alert_text.encode()).hexdigest() + + +def _alert_category(alert_text: str) -> str: + if "CT " in alert_text and "ist " in alert_text: + return "container" + if "RAM " in alert_text: + return "ram" + if "panic" in alert_text.lower() or "fatal" in alert_text.lower(): + return "panic" + if "Fehler in 30 Min" in alert_text: + return "error_rate" + if "Keine Logs" in alert_text: + return "silence" + if "antwortet mit HTTP" in alert_text or "nicht erreichbar" in alert_text: + return "http" + if "Service-Neustart" in alert_text: + return "restart" + if "Memory läuft ab" in alert_text: + return "memory_expiry" + return "default" + + +def _filter_new_alerts(alerts: list[str]) -> list[str]: + """Filtert bereits gemeldete Infra-Alerts per Cooldown. Mails werden separat in check_all() dedupliziert.""" + state = _load_alert_state() + now = datetime.now(timezone.utc).timestamp() + cooldowns = state.get("alert_cooldowns", {}) + new_alerts = [] + + for alert in alerts: + key = _alert_key(alert) + cat = _alert_category(alert) + cooldown = ALERT_COOLDOWN_SECONDS.get(cat, 3600) + + last_sent = cooldowns.get(key, {}).get("ts", 0) + if now - last_sent > cooldown: + new_alerts.append(alert) + cooldowns[key] = {"ts": now, "text": alert[:80], "cat": cat} + + cutoff = now - 86400 + cooldowns = {k: v for k, v in cooldowns.items() if v.get("ts", 0) > cutoff} + + state["alert_cooldowns"] = cooldowns + _save_alert_state(state) + return new_alerts + + +def send_alert(token: str, chat_id: str, message: str): + """Sendet eine Nachricht via Telegram.""" + requests.post( + f"https://api.telegram.org/bot{token}/sendMessage", + data={"chat_id": chat_id, "text": message}, + timeout=10, + ) + + +def run_check_and_alert(): + """Hauptfunktion für Cron: prüft und sendet Alerts falls nötig.""" + cfg = config.parse_config() + token = cfg.raw.get("TG_HAUSMEISTER_TOKEN", "") + chat_id = cfg.raw.get("TG_CHAT_ID", "") + if not token or not chat_id: + return + + alerts = check_all() + new_alerts = _filter_new_alerts(alerts) + if new_alerts: + msg = "🔧 Hausmeister-Check\n\n" + "\n".join(new_alerts) + send_alert(token, chat_id, msg) + + +if __name__ == "__main__": + import sys as _sys + if len(_sys.argv) > 1 and _sys.argv[1] == "report": + print(format_report()) + else: + run_check_and_alert() diff --git a/homelab-ai-bot/monitor.py.bak.20260601_163058 b/homelab-ai-bot/monitor.py.bak.20260601_163058 new file mode 100644 index 000000000..ce74136f9 --- /dev/null +++ b/homelab-ai-bot/monitor.py.bak.20260601_163058 @@ -0,0 +1,461 @@ +"""Proaktives Monitoring — regelbasiert (Stufe 1) + KI (Stufe 2).""" + +import sys +import os +import json +import hashlib +import requests +import time +from datetime import datetime, timezone + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config, loki_client, proxmox_client, mail_client + +ALERT_STATE_FILE = "/var/cache/hausmeister-alert-state.json" +ALERT_COOLDOWN_SECONDS = { + "container": 1800, + "ram": 1800, + "panic": 3600, + "silence": 3600, + "http": 1800, + "restart": 900, + "memory_expiry": 43200, + "default": 3600, + "error_rate": 1800, + "hermes": 900, + "backup": 21600, +} + + +def _get_tokens(cfg): + tokens = {} + tn = cfg.raw.get("PVE_TOKEN_HETZNER_NAME", "") + tv = cfg.raw.get("PVE_TOKEN_HETZNER_VALUE", "") + if tn and tv: + tokens["pve-hetzner"] = {"name": tn, "value": tv} + return tokens + + +def _get_passwords(cfg): + pw = cfg.passwords.get("default", "") + return {host: pw for host in proxmox_client.PROXMOX_HOSTS} + + +CRITICAL_CONTAINERS = [101, 109, 111, 112, 113, 115] + +HTTP_HEALTH_CHECKS = [ + {"name": "WordPress (CT 101)", "url": "http://10.10.10.101/robots.txt"}, + {"name": "Matomo (CT 109)", "url": "http://10.10.10.109"}, + {"name": "Grafana (CT 110)", "url": "http://10.10.10.110:3000/api/health"}, +] + +HERMES_HEALTH_CHECKS = [ + {"name": "Hermes Promtail", "url": "http://100.109.174.120:9080/ready", "timeout": 5}, + {"name": "Hermes node_exporter", "url": "http://100.109.174.120:9100/metrics", "timeout": 5}, + {"name": "Hermes Websuche CT121", "url": "http://100.74.196.29:8080/search?q=hausmeister-health&format=json", "timeout": 8}, + {"name": "Loki", "url": "http://100.109.206.43:3100/ready", "timeout": 5}, + {"name": "PBS Muldenstein", "url": "https://100.99.139.22:8007", "timeout": 5, "allow_status": {200, 401}}, +] + +HERMES_LOG_HOST = "hermes-mu" + +EXPECTED_STOPPED = { + (115, "pve-ka-1"), # flugscanner-asia-old (gestoppt, Cluster pve1) — Live CT auf pve-pp-1 + (115, "pve-ka-3"), # dieselbe CT, zweite API-Sicht (Cluster) + (101, "pp1"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (101, "pp2"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (504, "pve-ka-2"), # Shop-Template — stopped + (8000, "pve-ka-2"), # Kunde0-Shop — stopped + (8010, "pve-ka-2"), # Kunde1-Shop — stopped + (121, "pve-ka-1"), # Schawarma-Shop — gestoppt + (121, "pve-ka-2"), # Schawarma-Shop — gestoppt + (121, "pve-ka-3"), # Schawarma-Shop — gestoppt +} + +# VMIDs, die auf jedem Proxmox-Host in CONFIG ok sind, solange status == stopped +# (115 erscheint je nach API-Zuordnung auch als pve-hetzner o.ä., nicht nur ka-1/ka-3) +EXPECTED_STOPPED_VMIDS = {115, 504, 8000, 8010} + +IGNORED_HOSTS = {"${HOSTNAME}", ""} + +SILENCE_IGNORED_HOSTS = { + "ct-600-webcam", # kein rsyslog, Stream läuft aber + "ct-103-Intercity-Taxi", # absichtlich gestoppt + "ct-101-freshrss", # auf pve-ka-3, lokale Loki (nicht zentral) +} + + +def _is_host_suppressed(host: str, suppressed_hosts: set) -> bool: + if not host or not suppressed_hosts: + return False + h = host.lower() + for s in suppressed_hosts: + s = s.lower() + if h == s or h.startswith(s + ".") or h.startswith(s + "-"): + return True + return False + + +def check_hermes() -> list[str]: + """Prueft Hermes (CT151) als kritischen Dienst ueber HTTP und Loki.""" + alerts = [] + headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 hermes-check)"} + for check in HERMES_HEALTH_CHECKS: + allow_status = check.get("allow_status") or set(range(200, 400)) + try: + r = requests.get( + check["url"], + timeout=check.get("timeout", 5), + allow_redirects=False, + headers=headers, + verify=False, + ) + if r.status_code not in allow_status: + alerts.append(f"🔴 Hermes: {check['name']} HTTP {r.status_code}") + except requests.RequestException as e: + alerts.append(f"🔴 Hermes: {check['name']} nicht erreichbar: {str(e)[:80]}") + + health = loki_client.get_health(HERMES_LOG_HOST, hours=1) + if health.get("status") == "silent": + alerts.append("⚠️ Hermes: keine aktuellen Loki-Logs von hermes-mu") + elif health.get("status") == "critical": + alerts.append(f"🔴 Hermes: {health.get('error_count', '?')} Fehlerlogs in Loki (1h)") + + errors = loki_client.get_errors(container=HERMES_LOG_HOST, hours=0.5, limit=20) + for e in errors: + if "error" in e: + continue + line = (e.get("line") or "").lower() + if "diskstats_linux.go" in line and "disabling udev device properties" in line: + continue + if "collector failed" in line and "node_exporter" in line: + continue + if any(term in line for term in ["traceback", "exception", "failed", "timeout", "tool_call", "connection refused"]): + alerts.append(f"🔴 Hermes Log: {(e.get('line') or '')[:140]}") + break + + return alerts + + +def check_all() -> list[str]: + """Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + alerts = [] + alerts.extend(check_hermes()) + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + for ct in containers: + if "error" in ct: + continue + host = ct.get("_host", "") + if _is_host_suppressed(host, suppressed_hosts): + continue + vmid = ct.get("vmid", 0) + name = ct.get("name", "?") + status = ct.get("status", "unknown") + if vmid in CRITICAL_CONTAINERS and status != "running": + ok_stopped = vmid in EXPECTED_STOPPED_VMIDS and status == "stopped" + if (vmid, host) not in EXPECTED_STOPPED and not ok_stopped: + alerts.append(f"🔴 CT {vmid} ({name}) ist {status}!") + + mem = ct.get("mem", 0) + maxmem = ct.get("maxmem", 1) + if maxmem > 0 and mem / maxmem > 0.90: + pct = int(mem / maxmem * 100) + alerts.append(f"⚠️ CT {vmid} ({name}) RAM bei {pct}%") + + errors = loki_client.get_errors(hours=0.5, limit=50) + error_lines = [e for e in errors if "error" not in e] + panic_lines = [] + for e in error_lines: + line = e.get("line", "") + ll = line.lower() + if not any(w in ll for w in ["panic", "fatal", "oom", "out of memory"]): + continue + if "query=" in line or "caller=metrics" in line: + continue + if "HTTP/1." in line and ('" 200 ' in line or '" 301 ' in line or '" 302 ' in line or '" 304 ' in line): + continue + if "GET /" in line or "POST /" in line or "HEAD /" in line: + continue + panic_lines.append(e) + if panic_lines: + hosts = set(e.get("host", "?") for e in panic_lines) + hosts -= IGNORED_HOSTS + hosts = {h for h in hosts if h.lower() not in suppressed_names} + if hosts: + alerts.append(f"🔴 Kritische Fehler (panic/fatal/OOM) auf: {', '.join(hosts)}") + + error_rates = loki_client.check_error_rate(minutes=30) + for er in error_rates: + if er.get("host", "").lower() in suppressed_names: + continue + alerts.append( + f"🔴 {er['host']}: {er['count']} Fehler in 30 Min (Schwelle: {er['threshold']})" + ) + + running_names = { + ct.get("name", "").lower() + for ct in containers + if "error" not in ct and ct.get("status") == "running" + } + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in silent[0]: + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s.get("host") not in SILENCE_IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + and s["host"].lower() in running_names + ] + if names: + alerts.append(f"⚠️ Keine Logs seit 35+ Min: {', '.join(names)}") + + _headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 health-check)"} + for check in HTTP_HEALTH_CHECKS: + if _is_host_suppressed(check.get("host", ""), suppressed_hosts): + continue + timeout = check.get("timeout", 15) + retries = check.get("retries", 1) + retry_delay = check.get("retry_delay", 3) + msg = None + for attempt in range(retries): + try: + r = requests.get( + check["url"], timeout=timeout, allow_redirects=True, headers=_headers + ) + if r.status_code < 400: + msg = None + break + msg = f"🔴 {check['name']} antwortet mit HTTP {r.status_code}" + except requests.RequestException as e: + msg = f"🔴 {check['name']} nicht erreichbar: {str(e)[:80]}" + if attempt < retries - 1: + time.sleep(retry_delay) + if msg: + alerts.append(msg) + + restarts = loki_client.check_service_restarts(minutes=35) + for r in restarts: + if r.get("host", "").lower() in suppressed_names: + continue + alerts.append(f"🔄 Service-Neustart: {r['service']} auf {r['host']} ({r['count']}x in 35 Min)") + + try: + import memory_client + import time as _time + now_ts = int(_time.time()) + mem_items = memory_client.get_active_memory() + for item in mem_items: + exp = item.get("expires_at") + if exp and 0 < exp - now_ts < 86400: + from datetime import datetime as _dt + exp_str = _dt.fromtimestamp(exp).strftime("%d.%m. %H:%M") + alerts.append(f"⏰ Memory läuft ab ({exp_str}): {item['content'][:80]}") + except Exception: + pass + + try: + mail_client.init(cfg) + important = mail_client.get_important_mails(hours=1) + if important and "error" not in important[0]: + state = _load_alert_state() + seen = state.get("seen_mails", {}) + now = datetime.now(timezone.utc).timestamp() + + new_mails = [] + for m in important: + fp = hashlib.md5( + f"{m.get('date_str','')}{m.get('from','')}{m.get('subject','')}".encode() + ).hexdigest() + if fp not in seen: + new_mails.append(m) + seen[fp] = now + + seen = {k: v for k, v in seen.items() if now - v < 172800} + state["seen_mails"] = seen + _save_alert_state(state) + + if new_mails: + senders = [m["from"][:30] for m in new_mails] + alerts.append(f"📧 {len(new_mails)} neue wichtige Mail(s): {', '.join(senders)}") + except Exception: + pass + + return alerts + + +def format_report() -> str: + """Tagesbericht: Gesamtstatus aller Systeme.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + lines = ["📋 Tagesbericht Homelab\n"] + if suppressed_hosts: + lines.append(f"🔕 Stumm: {', '.join(sorted(suppressed_hosts))}\n") + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + containers = [ + c for c in containers + if not _is_host_suppressed(c.get("_host", ""), suppressed_hosts) + ] + running = [c for c in containers if c.get("status") == "running"] + stopped = [c for c in containers if c.get("status") == "stopped"] + errors_ct = [c for c in containers if "error" in c] + lines.append(f"Container: {len(running)} running, {len(stopped)} stopped, {len(errors_ct)} nicht erreichbar") + + errors = loki_client.get_errors(hours=24, limit=100) + error_count = len([ + e for e in errors + if "error" not in e and e.get("host", "").lower() not in suppressed_names + ]) + lines.append(f"Fehler (24h): {error_count}") + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in (silent[0] if silent else {}): + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + ] + if names: + lines.append(f"Stille Hosts: {', '.join(names)}") + else: + lines.append("Stille Hosts: keine") + else: + lines.append("Stille Hosts: keine") + + try: + import memory_client + mem_items = memory_client.get_active_memory() + perm = [i for i in mem_items if i.get("memory_type") != "temporary"] + temp = [i for i in mem_items if i.get("memory_type") == "temporary"] + candidates = memory_client.get_candidates() + mem_line = f"Memory: {len(perm)} dauerhaft, {len(temp)} temporär" + import time as _time + now_ts = int(_time.time()) + soon = [i for i in temp if i.get("expires_at") and i["expires_at"] - now_ts < 86400] + if soon: + mem_line += f", {len(soon)} laufen in 24h ab" + if candidates: + mem_line += f", {len(candidates)} Kandidaten offen" + lines.append(mem_line) + except Exception: + pass + + alerts = check_all() + if alerts: + lines.append(f"\n⚠️ {len(alerts)} aktive Alarme:") + lines.extend(alerts) + else: + lines.append("\n✅ Keine Alarme — alles läuft.") + + return "\n".join(lines) + + +def _load_alert_state() -> dict: + try: + with open(ALERT_STATE_FILE, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_alert_state(state: dict): + try: + with open(ALERT_STATE_FILE, "w") as f: + json.dump(state, f) + except Exception: + pass + + +def _alert_key(alert_text: str) -> str: + return hashlib.md5(alert_text.encode()).hexdigest() + + +def _alert_category(alert_text: str) -> str: + if "CT " in alert_text and "ist " in alert_text: + return "container" + if "RAM " in alert_text: + return "ram" + if "panic" in alert_text.lower() or "fatal" in alert_text.lower(): + return "panic" + if "Fehler in 30 Min" in alert_text: + return "error_rate" + if "Keine Logs" in alert_text: + return "silence" + if "antwortet mit HTTP" in alert_text or "nicht erreichbar" in alert_text: + return "http" + if "Service-Neustart" in alert_text: + return "restart" + if "Hermes" in alert_text: + return "hermes" + if "Backup" in alert_text or "PBS" in alert_text: + return "backup" + if "Memory läuft ab" in alert_text: + return "memory_expiry" + return "default" + + +def _filter_new_alerts(alerts: list[str]) -> list[str]: + """Filtert bereits gemeldete Infra-Alerts per Cooldown. Mails werden separat in check_all() dedupliziert.""" + state = _load_alert_state() + now = datetime.now(timezone.utc).timestamp() + cooldowns = state.get("alert_cooldowns", {}) + new_alerts = [] + + for alert in alerts: + key = _alert_key(alert) + cat = _alert_category(alert) + cooldown = ALERT_COOLDOWN_SECONDS.get(cat, 3600) + + last_sent = cooldowns.get(key, {}).get("ts", 0) + if now - last_sent > cooldown: + new_alerts.append(alert) + cooldowns[key] = {"ts": now, "text": alert[:80], "cat": cat} + + cutoff = now - 86400 + cooldowns = {k: v for k, v in cooldowns.items() if v.get("ts", 0) > cutoff} + + state["alert_cooldowns"] = cooldowns + _save_alert_state(state) + return new_alerts + + +def send_alert(token: str, chat_id: str, message: str): + """Sendet eine Nachricht via Telegram.""" + requests.post( + f"https://api.telegram.org/bot{token}/sendMessage", + data={"chat_id": chat_id, "text": message}, + timeout=10, + ) + + +def run_check_and_alert(): + """Hauptfunktion für Cron: prüft und sendet Alerts falls nötig.""" + cfg = config.parse_config() + token = cfg.raw.get("TG_HAUSMEISTER_TOKEN", "") + chat_id = cfg.raw.get("TG_CHAT_ID", "") + if not token or not chat_id: + return + + alerts = check_all() + new_alerts = _filter_new_alerts(alerts) + if new_alerts: + msg = "🔧 Hausmeister-Check\n\n" + "\n".join(new_alerts) + send_alert(token, chat_id, msg) + + +if __name__ == "__main__": + import sys as _sys + if len(_sys.argv) > 1 and _sys.argv[1] == "report": + print(format_report()) + else: + run_check_and_alert() diff --git a/homelab-ai-bot/monitor.py.bak.20260603_hermes b/homelab-ai-bot/monitor.py.bak.20260603_hermes new file mode 100644 index 000000000..3c9dc7686 --- /dev/null +++ b/homelab-ai-bot/monitor.py.bak.20260603_hermes @@ -0,0 +1,469 @@ +"""Proaktives Monitoring — regelbasiert (Stufe 1) + KI (Stufe 2).""" + +import sys +import os +import json +import hashlib +import re +import requests +import time +from datetime import datetime, timezone + +sys.path.insert(0, os.path.dirname(__file__)) +from core import config, loki_client, proxmox_client, mail_client + +ALERT_STATE_FILE = "/var/cache/hausmeister-alert-state.json" +ALERT_COOLDOWN_SECONDS = { + "container": 1800, + "ram": 1800, + "panic": 3600, + "silence": 3600, + "http": 1800, + "restart": 900, + "memory_expiry": 43200, + "default": 3600, + "error_rate": 1800, + "hermes": 900, + "backup": 21600, +} + + +def _get_tokens(cfg): + tokens = {} + tn = cfg.raw.get("PVE_TOKEN_HETZNER_NAME", "") + tv = cfg.raw.get("PVE_TOKEN_HETZNER_VALUE", "") + if tn and tv: + tokens["pve-hetzner"] = {"name": tn, "value": tv} + return tokens + + +def _get_passwords(cfg): + pw = cfg.passwords.get("default", "") + return {host: pw for host in proxmox_client.PROXMOX_HOSTS} + + +CRITICAL_CONTAINERS = [101, 109, 111, 112, 113, 115] + +HTTP_HEALTH_CHECKS = [ + {"name": "WordPress (CT 101)", "url": "http://10.10.10.101/robots.txt"}, + {"name": "Matomo (CT 109)", "url": "http://10.10.10.109"}, + {"name": "Grafana (CT 110)", "url": "http://10.10.10.110:3000/api/health"}, +] + +HERMES_HEALTH_CHECKS = [ + {"name": "Hermes Promtail", "url": "http://100.109.174.120:9080/ready", "timeout": 5}, + {"name": "Hermes node_exporter", "url": "http://100.109.174.120:9100/metrics", "timeout": 5}, + {"name": "Hermes Websuche CT121", "url": "http://100.74.196.29:8080/search?q=hausmeister-health&format=json", "timeout": 8}, + {"name": "Loki", "url": "http://100.109.206.43:3100/ready", "timeout": 5}, + {"name": "PBS Muldenstein", "url": "https://100.99.139.22:8007", "timeout": 5, "allow_status": {200, 401}}, +] + +HERMES_LOG_HOST = "hermes-mu" + +EXPECTED_STOPPED = { + (115, "pve-ka-1"), # flugscanner-asia-old (gestoppt, Cluster pve1) — Live CT auf pve-pp-1 + (115, "pve-ka-3"), # dieselbe CT, zweite API-Sicht (Cluster) + (101, "pp1"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (101, "pp2"), # yt-desktop-standby — Reserve, absichtlich gestoppt (pp-cluster) + (504, "pve-ka-2"), # Shop-Template — stopped + (8000, "pve-ka-2"), # Kunde0-Shop — stopped + (8010, "pve-ka-2"), # Kunde1-Shop — stopped + (121, "pve-ka-1"), # Schawarma-Shop — gestoppt + (121, "pve-ka-2"), # Schawarma-Shop — gestoppt + (121, "pve-ka-3"), # Schawarma-Shop — gestoppt +} + +# VMIDs, die auf jedem Proxmox-Host in CONFIG ok sind, solange status == stopped +# (115 erscheint je nach API-Zuordnung auch als pve-hetzner o.ä., nicht nur ka-1/ka-3) +EXPECTED_STOPPED_VMIDS = {115, 504, 8000, 8010} + +IGNORED_HOSTS = {"${HOSTNAME}", ""} + +SILENCE_IGNORED_HOSTS = { + "ct-600-webcam", # kein rsyslog, Stream läuft aber + "ct-103-Intercity-Taxi", # absichtlich gestoppt + "ct-101-freshrss", # auf pve-ka-3, lokale Loki (nicht zentral) +} + + +def _is_host_suppressed(host: str, suppressed_hosts: set) -> bool: + if not host or not suppressed_hosts: + return False + h = host.lower() + for s in suppressed_hosts: + s = s.lower() + if h == s or h.startswith(s + ".") or h.startswith(s + "-"): + return True + return False + + +def check_hermes() -> list[str]: + """Prueft Hermes (CT151) als kritischen Dienst ueber HTTP und Loki.""" + alerts = [] + headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 hermes-check)"} + for check in HERMES_HEALTH_CHECKS: + allow_status = check.get("allow_status") or set(range(200, 400)) + try: + r = requests.get( + check["url"], + timeout=check.get("timeout", 5), + allow_redirects=False, + headers=headers, + verify=False, + ) + if r.status_code not in allow_status: + alerts.append(f"🔴 Hermes: {check['name']} HTTP {r.status_code}") + except requests.RequestException as e: + alerts.append(f"🔴 Hermes: {check['name']} nicht erreichbar: {str(e)[:80]}") + + health = loki_client.get_health(HERMES_LOG_HOST, hours=1) + if health.get("status") == "silent": + alerts.append("⚠️ Hermes: keine aktuellen Loki-Logs von hermes-mu") + elif health.get("status") == "critical": + alerts.append(f"🔴 Hermes: {health.get('error_count', '?')} Fehlerlogs in Loki (1h)") + + errors = loki_client.get_errors(container=HERMES_LOG_HOST, hours=0.5, limit=20) + for e in errors: + if "error" in e: + continue + line = (e.get("line") or "").lower() + if "diskstats_linux.go" in line and "disabling udev device properties" in line: + continue + if "collector failed" in line and "node_exporter" in line: + continue + if any(term in line for term in ["traceback", "exception", "failed", "timeout", "tool_call", "connection refused"]): + alerts.append(f"🔴 Hermes Log: {(e.get('line') or '')[:140]}") + break + + return alerts + + +def check_all() -> list[str]: + """Regelbasierter Check (Stufe 1). Gibt Liste von Alarmen zurück.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + alerts = [] + alerts.extend(check_hermes()) + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + for ct in containers: + if "error" in ct: + continue + host = ct.get("_host", "") + if _is_host_suppressed(host, suppressed_hosts): + continue + vmid = ct.get("vmid", 0) + name = ct.get("name", "?") + status = ct.get("status", "unknown") + if vmid in CRITICAL_CONTAINERS and status != "running": + ok_stopped = vmid in EXPECTED_STOPPED_VMIDS and status == "stopped" + if (vmid, host) not in EXPECTED_STOPPED and not ok_stopped: + alerts.append(f"🔴 CT {vmid} ({name}) ist {status}!") + + mem = ct.get("mem", 0) + maxmem = ct.get("maxmem", 1) + if maxmem > 0 and mem / maxmem > 0.90: + pct = int(mem / maxmem * 100) + alerts.append(f"⚠️ CT {vmid} ({name}) RAM bei {pct}%") + + errors = loki_client.get_errors(hours=0.5, limit=50) + error_lines = [e for e in errors if "error" not in e] + panic_lines = [] + for e in error_lines: + line = e.get("line", "") + ll = line.lower() + if not any(w in ll for w in ["panic", "fatal", "oom", "out of memory"]): + continue + if "query=" in line or "caller=metrics" in line: + continue + if "HTTP/1." in line and ('" 200 ' in line or '" 301 ' in line or '" 302 ' in line or '" 304 ' in line): + continue + if "GET /" in line or "POST /" in line or "HEAD /" in line: + continue + panic_lines.append(e) + if panic_lines: + hosts = set(e.get("host", "?") for e in panic_lines) + hosts -= IGNORED_HOSTS + hosts = {h for h in hosts if h.lower() not in suppressed_names} + if hosts: + alerts.append(f"🔴 Kritische Fehler (panic/fatal/OOM) auf: {', '.join(hosts)}") + + error_rates = loki_client.check_error_rate(minutes=30) + for er in error_rates: + if er.get("host", "").lower() in suppressed_names: + continue + alerts.append( + f"🔴 {er['host']}: {er['count']} Fehler in 30 Min (Schwelle: {er['threshold']})" + ) + + running_names = { + ct.get("name", "").lower() + for ct in containers + if "error" not in ct and ct.get("status") == "running" + } + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in silent[0]: + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s.get("host") not in SILENCE_IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + and s["host"].lower() in running_names + ] + if names: + alerts.append(f"⚠️ Keine Logs seit 35+ Min: {', '.join(names)}") + + _headers = {"User-Agent": "Mozilla/5.0 (Hausmeister-Bot/1.0 health-check)"} + for check in HTTP_HEALTH_CHECKS: + if _is_host_suppressed(check.get("host", ""), suppressed_hosts): + continue + timeout = check.get("timeout", 15) + retries = check.get("retries", 1) + retry_delay = check.get("retry_delay", 3) + msg = None + for attempt in range(retries): + try: + r = requests.get( + check["url"], timeout=timeout, allow_redirects=True, headers=_headers + ) + if r.status_code < 400: + msg = None + break + msg = f"🔴 {check['name']} antwortet mit HTTP {r.status_code}" + except requests.RequestException as e: + msg = f"🔴 {check['name']} nicht erreichbar: {str(e)[:80]}" + if attempt < retries - 1: + time.sleep(retry_delay) + if msg: + alerts.append(msg) + + restarts = loki_client.check_service_restarts(minutes=35) + for r in restarts: + if r.get("host", "").lower() in suppressed_names: + continue + alerts.append(f"🔄 Service-Neustart: {r['service']} auf {r['host']} ({r['count']}x in 35 Min)") + + try: + import memory_client + import time as _time + now_ts = int(_time.time()) + mem_items = memory_client.get_active_memory() + for item in mem_items: + exp = item.get("expires_at") + if exp and 0 < exp - now_ts < 86400: + from datetime import datetime as _dt + exp_str = _dt.fromtimestamp(exp).strftime("%d.%m. %H:%M") + alerts.append(f"⏰ Memory läuft ab ({exp_str}): {item['content'][:80]}") + except Exception: + pass + + try: + mail_client.init(cfg) + important = mail_client.get_important_mails(hours=1) + if important and "error" not in important[0]: + state = _load_alert_state() + seen = state.get("seen_mails", {}) + now = datetime.now(timezone.utc).timestamp() + + new_mails = [] + for m in important: + fp = hashlib.md5( + f"{m.get('date_str','')}{m.get('from','')}{m.get('subject','')}".encode() + ).hexdigest() + if fp not in seen: + new_mails.append(m) + seen[fp] = now + + seen = {k: v for k, v in seen.items() if now - v < 172800} + state["seen_mails"] = seen + _save_alert_state(state) + + if new_mails: + senders = [m["from"][:30] for m in new_mails] + alerts.append(f"📧 {len(new_mails)} neue wichtige Mail(s): {', '.join(senders)}") + except Exception: + pass + + return alerts + + +def format_report() -> str: + """Tagesbericht: Gesamtstatus aller Systeme.""" + cfg = config.parse_config() + suppressed_hosts = config.get_suppressed_hosts(cfg) + suppressed_names = config.get_suppressed_container_names(cfg) + lines = ["📋 Tagesbericht Homelab\n"] + if suppressed_hosts: + lines.append(f"🔕 Stumm: {', '.join(sorted(suppressed_hosts))}\n") + + containers = proxmox_client.get_all_containers( + _get_passwords(cfg), _get_tokens(cfg) + ) + containers = [ + c for c in containers + if not _is_host_suppressed(c.get("_host", ""), suppressed_hosts) + ] + running = [c for c in containers if c.get("status") == "running"] + stopped = [c for c in containers if c.get("status") == "stopped"] + errors_ct = [c for c in containers if "error" in c] + lines.append(f"Container: {len(running)} running, {len(stopped)} stopped, {len(errors_ct)} nicht erreichbar") + + errors = loki_client.get_errors(hours=24, limit=100) + error_count = len([ + e for e in errors + if "error" not in e and e.get("host", "").lower() not in suppressed_names + ]) + lines.append(f"Fehler (24h): {error_count}") + + silent = loki_client.check_silence(minutes=35) + if silent and "error" not in (silent[0] if silent else {}): + names = [ + s["host"] for s in silent + if s.get("host") not in IGNORED_HOSTS + and s["host"].lower() not in suppressed_names + ] + if names: + lines.append(f"Stille Hosts: {', '.join(names)}") + else: + lines.append("Stille Hosts: keine") + else: + lines.append("Stille Hosts: keine") + + try: + import memory_client + mem_items = memory_client.get_active_memory() + perm = [i for i in mem_items if i.get("memory_type") != "temporary"] + temp = [i for i in mem_items if i.get("memory_type") == "temporary"] + candidates = memory_client.get_candidates() + mem_line = f"Memory: {len(perm)} dauerhaft, {len(temp)} temporär" + import time as _time + now_ts = int(_time.time()) + soon = [i for i in temp if i.get("expires_at") and i["expires_at"] - now_ts < 86400] + if soon: + mem_line += f", {len(soon)} laufen in 24h ab" + if candidates: + mem_line += f", {len(candidates)} Kandidaten offen" + lines.append(mem_line) + except Exception: + pass + + alerts = check_all() + if alerts: + lines.append(f"\n⚠️ {len(alerts)} aktive Alarme:") + lines.extend(alerts) + else: + lines.append("\n✅ Keine Alarme — alles läuft.") + + return "\n".join(lines) + + +def _load_alert_state() -> dict: + try: + with open(ALERT_STATE_FILE, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_alert_state(state: dict): + try: + with open(ALERT_STATE_FILE, "w") as f: + json.dump(state, f) + except Exception: + pass + + +def _alert_key(alert_text: str) -> str: + # Volatile Metrik-Alarme (error_rate) ueber einen stabilen Schluessel + # deduplizieren: nur Host, nicht der schwankende Zaehlwert. Sonst + # erzeugt jede neue Zahl einen neuen Hash und der Cooldown greift nie. + if "Fehler in 30 Min" in alert_text: + m = re.search(r"([\w.\-]+):\s*\d+\s+Fehler in 30 Min", alert_text) + if m: + return hashlib.md5(("error_rate|" + m.group(1)).encode()).hexdigest() + return hashlib.md5(alert_text.encode()).hexdigest() + + +def _alert_category(alert_text: str) -> str: + if "CT " in alert_text and "ist " in alert_text: + return "container" + if "RAM " in alert_text: + return "ram" + if "panic" in alert_text.lower() or "fatal" in alert_text.lower(): + return "panic" + if "Fehler in 30 Min" in alert_text: + return "error_rate" + if "Keine Logs" in alert_text: + return "silence" + if "antwortet mit HTTP" in alert_text or "nicht erreichbar" in alert_text: + return "http" + if "Service-Neustart" in alert_text: + return "restart" + if "Hermes" in alert_text: + return "hermes" + if "Backup" in alert_text or "PBS" in alert_text: + return "backup" + if "Memory läuft ab" in alert_text: + return "memory_expiry" + return "default" + + +def _filter_new_alerts(alerts: list[str]) -> list[str]: + """Filtert bereits gemeldete Infra-Alerts per Cooldown. Mails werden separat in check_all() dedupliziert.""" + state = _load_alert_state() + now = datetime.now(timezone.utc).timestamp() + cooldowns = state.get("alert_cooldowns", {}) + new_alerts = [] + + for alert in alerts: + key = _alert_key(alert) + cat = _alert_category(alert) + cooldown = ALERT_COOLDOWN_SECONDS.get(cat, 3600) + + last_sent = cooldowns.get(key, {}).get("ts", 0) + if now - last_sent > cooldown: + new_alerts.append(alert) + cooldowns[key] = {"ts": now, "text": alert[:80], "cat": cat} + + cutoff = now - 86400 + cooldowns = {k: v for k, v in cooldowns.items() if v.get("ts", 0) > cutoff} + + state["alert_cooldowns"] = cooldowns + _save_alert_state(state) + return new_alerts + + +def send_alert(token: str, chat_id: str, message: str): + """Sendet eine Nachricht via Telegram.""" + requests.post( + f"https://api.telegram.org/bot{token}/sendMessage", + data={"chat_id": chat_id, "text": message}, + timeout=10, + ) + + +def run_check_and_alert(): + """Hauptfunktion für Cron: prüft und sendet Alerts falls nötig.""" + cfg = config.parse_config() + token = cfg.raw.get("TG_HAUSMEISTER_TOKEN", "") + chat_id = cfg.raw.get("TG_CHAT_ID", "") + if not token or not chat_id: + return + + alerts = check_all() + new_alerts = _filter_new_alerts(alerts) + if new_alerts: + msg = "🔧 Hausmeister-Check\n\n" + "\n".join(new_alerts) + send_alert(token, chat_id, msg) + + +if __name__ == "__main__": + import sys as _sys + if len(_sys.argv) > 1 and _sys.argv[1] == "report": + print(format_report()) + else: + run_check_and_alert() diff --git a/homelab-ai-bot/savetv_enrich.py.v1-ollama-bak b/homelab-ai-bot/savetv_enrich.py.v1-ollama-bak new file mode 100644 index 000000000..6f9ce23bf --- /dev/null +++ b/homelab-ai-bot/savetv_enrich.py.v1-ollama-bak @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Save.TV Film-Enricher — reichert Archiv-Filme per KI an. + +Läuft als Cronjob (z.B. alle 3h) auf CT 116. +Nutzt Ollama auf dem KI-Server (gleicher Endpunkt wie llm.py). +Schreibt in /mnt/savetv/.filminfo_cache.json — dieselbe Datei +die savetv_web.py und savetv_extra_routes.py verwenden. + +Ergebnis: 3-6 Sätze Beschreibung, Hauptdarsteller, Land, Jahr, Genre. +""" + +import fcntl +import json +import logging +import os +import re +import sys +import time +import requests +from pathlib import Path + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, "/opt") + +from tools import savetv + +log = logging.getLogger("savetv_enrich") +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", +) + +OLLAMA_BASE = "http://100.84.255.83:11434" +MODEL = "qwen2.5:14b" +FALLBACK_MODEL = "qwen3:30b-a3b" + +FILMINFO_CACHE = Path("/mnt/savetv/.filminfo_cache.json") +LOCKFILE = Path("/tmp/savetv_enrich.lock") +BATCH_SIZE = 8 +SLEEP_BETWEEN = 0.5 + + +def _load_cache() -> dict: + if FILMINFO_CACHE.exists(): + try: + return json.loads(FILMINFO_CACHE.read_text()) + except Exception: + pass + return {} + + +def _save_cache(cache: dict): + tmp = FILMINFO_CACHE.with_suffix(".tmp") + tmp.write_text(json.dumps(cache, ensure_ascii=False, indent=1)) + tmp.rename(FILMINFO_CACHE) + + +def _is_enriched(entry: dict) -> bool: + return bool(entry.get("description")) + + +def _call_ollama(prompt: str, model: str = MODEL) -> str: + payload = { + "model": model, + "messages": [ + {"role": "system", "content": ( + "Du bist eine Filmdatenbank. Antworte AUSSCHLIESSLICH mit validem JSON. " + "Kein Markdown, keine Erklärungen, kein Denken. Nur JSON. Sprache: Deutsch." + )}, + {"role": "user", "content": prompt}, + ], + "stream": False, + "think": False, + "options": {"num_predict": 1024}, + } + try: + r = requests.post( + f"{OLLAMA_BASE}/api/chat", + json=payload, timeout=180, + ) + r.raise_for_status() + data = r.json() + text = data.get("message", {}).get("content", "").strip() + if text.startswith("```"): + text = re.sub(r"^```\w*\n?", "", text) + text = re.sub(r"\n?```$", "", text) + return text.strip() + except requests.exceptions.ReadTimeout: + if model != FALLBACK_MODEL: + log.warning("Timeout mit %s, Fallback auf %s", model, FALLBACK_MODEL) + return _call_ollama(prompt, model=FALLBACK_MODEL) + raise + except Exception as e: + log.error("Ollama-Fehler: %s", e) + return "" + + +def _normalize_actors(actors_raw) -> list: + if not actors_raw or not isinstance(actors_raw, list): + return [] + result = [] + for a in actors_raw[:4]: + if isinstance(a, str): + result.append(a) + elif isinstance(a, dict): + name = a.get("name") or a.get("actor") or "" + if name: + result.append(name) + return result + + +def _enrich_film(title: str) -> dict: + clean_title = re.sub(r"\s*[-\u2013\u2014]\s*.+$", "", title).strip() + + prompt = f"""Gib mir Informationen zum Film "{clean_title}". +Antworte als JSON mit exakt diesen Feldern (alle Texte auf Deutsch): +{{ + "year": "Erscheinungsjahr als String", + "countries": ["Produktionsländer als Strings"], + "genres": ["bis zu 3 Genres als Strings"], + "actors": ["bis zu 4 Hauptdarsteller als Strings"], + "director": "Regisseur als String", + "description": "3-5 Sätze auf Deutsch: Worum geht es, für wen geeignet. Keine Spoiler." +}} +Wichtig: Alle Werte müssen Strings sein. Schreibe die description komplett auf Deutsch. +Falls du den Film nicht kennst, setze description auf leeren String.""" + + raw = _call_ollama(prompt) + if not raw: + log.warning(" Leere Antwort von Ollama für '%s'", title) + return {"year": "", "countries": [], "genres": [], "actors": [], + "director": "", "description": ""} + + try: + data = json.loads(raw) + except json.JSONDecodeError: + match = re.search(r"\{[\s\S]*\}", raw) + if match: + try: + data = json.loads(match.group()) + except json.JSONDecodeError: + log.warning("JSON-Parse fehlgeschlagen für '%s': %.200s", title, raw) + return {"year": "", "countries": [], "genres": [], "actors": [], + "director": "", "description": ""} + else: + log.warning("Kein JSON gefunden für '%s': %.200s", title, raw) + return {"year": "", "countries": [], "genres": [], "actors": [], + "director": "", "description": ""} + + desc = str(data.get("description", ""))[:600] + if desc and not _is_mostly_latin(desc): + log.info(" Nicht-lateinische Beschreibung gefiltert: %.80s", desc) + desc = "" + if not desc: + log.info(" Beschreibung leer, raw year=%s actors=%s", + data.get("year"), str(data.get("actors", []))[:80]) + + return { + "year": str(data.get("year", ""))[:4], + "countries": [str(c) for c in (data.get("countries") or [])[:3]], + "genres": [str(g) for g in (data.get("genres") or [])[:3]], + "actors": _normalize_actors(data.get("actors")), + "director": str(data.get("director", ""))[:60], + "description": desc, + } + + +def _is_mostly_latin(text: str) -> bool: + if not text: + return False + latin = sum(1 for c in text if c.isascii() or '\u00C0' <= c <= '\u024F') + return latin / max(len(text), 1) > 0.7 + + +def run(): + log.info("Starte Film-Enrichment...") + + entries = savetv._get_full_archive() + if not entries: + log.warning("Keine Archiv-Einträge von Save.TV erhalten") + return + + titles = set() + for e in entries: + tc = e.get("STRTELECASTENTRY", {}) + if tc.get("SFOLGE", ""): + continue + title = tc.get("STITLE", "") + if title and not savetv._is_excluded(title): + titles.add(title) + + cache = _load_cache() + missing = [t for t in titles if not _is_enriched(cache.get(t, {}))] + + if not missing: + log.info("Alle %d Filme bereits angereichert", len(titles)) + return + + log.info("%d Filme im Archiv, %d davon noch ohne KI-Beschreibung", + len(titles), len(missing)) + + enriched = 0 + for i, title in enumerate(missing): + log.info("[%d/%d] Anreichern: %s", i + 1, len(missing), title) + try: + info = _enrich_film(title) + if info.get("description"): + cache[title] = info + enriched += 1 + log.info(" OK: %s (%s)", info.get("year", "?"), + ", ".join(info.get("actors", [])[:2])) + if enriched % BATCH_SIZE == 0: + _save_cache(cache) + log.info(" Cache gespeichert (%d angereichert)", enriched) + else: + old = cache.get(title, {}) + for k in ("year", "countries", "genres"): + if info.get(k) and not old.get(k): + old[k] = info[k] + if info.get("actors"): + old["actors"] = info["actors"] + if info.get("director"): + old["director"] = info["director"] + cache[title] = old + log.info(" Keine Beschreibung erhalten, Basisdaten übernommen") + except Exception as e: + log.error(" Fehler bei '%s': %s", title, e) + + time.sleep(SLEEP_BETWEEN) + + _save_cache(cache) + log.info("Fertig: %d von %d Filmen neu angereichert", enriched, len(missing)) + + +if __name__ == "__main__": + lock_fd = open(LOCKFILE, "w") + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + print("Enricher läuft bereits — Abbruch.") + sys.exit(0) + + try: + run() + finally: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + lock_fd.close() diff --git a/homelab-ai-bot/telegram_bot.py b/homelab-ai-bot/telegram_bot.py index b8d6540f9..9f9f72b8e 100644 --- a/homelab-ai-bot/telegram_bot.py +++ b/homelab-ai-bot/telegram_bot.py @@ -447,18 +447,14 @@ async def _run_freitext_llm_pipeline( document_mode=document_mode, ) ) - waited = 0 - while not llm_task.done(): - await asyncio.sleep(10) - waited += 10 - if not llm_task.done(): - try: - await update.message.reply_text( - "⏳ Noch dran (" + str(waited) + "s) — Save.TV/Modell kann etwas brauchen…" - ) - except Exception as te: - log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te) - answer = await llm_task + try: + answer = await asyncio.wait_for(asyncio.shield(llm_task), timeout=15.0) + except asyncio.TimeoutError: + try: + await update.message.reply_text("⏳ Noch dran — dauert etwas länger…") + except Exception as te: + log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te) + answer = await llm_task if session_id: memory_client.log_message(session_id, "user", text) @@ -506,18 +502,14 @@ async def _run_voice_llm_pipeline( document_mode=document_mode, ) ) - waited = 0 - while not llm_task.done(): - await asyncio.sleep(10) - waited += 10 - if not llm_task.done(): - try: - await update.message.reply_text( - "⏳ Noch dran (" + str(waited) + "s) — Save.TV/Modell kann etwas brauchen…" - ) - except Exception as te: - log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te) - answer = await llm_task + try: + answer = await asyncio.wait_for(asyncio.shield(llm_task), timeout=15.0) + except asyncio.TimeoutError: + try: + await update.message.reply_text("⏳ Noch dran — dauert etwas länger…") + except Exception as te: + log.warning("Fortschritts-Nachricht fehlgeschlagen: %s", te) + answer = await llm_task if session_id: memory_client.log_message(session_id, "user", text) diff --git a/infrastructure/STATE.md b/infrastructure/STATE.md index 84ab9e92e..981ab11a5 100644 --- a/infrastructure/STATE.md +++ b/infrastructure/STATE.md @@ -48,3 +48,110 @@ |---|---| | @MutterbotAI_bot | Watchdog-Alerts | | @Orbitalo_Hausmeister_bot | Homelab AI-Bot | + +## Hermes CT151 Monitoring und Backup + +Stand: 2026-05-16 + +Hermes (`hermes-mu`, CT151) ist als kritischer Dienst eingestuft und wird separat abgesichert. + +### Proxmox / PBS Backup + +Backup-Job auf `pve3` / `pve-mu-3`: + +| Feld | Wert | +|------|------| +| Job-ID | `hermes-mu-backup` | +| VMID | `151` | +| Container | `hermes-mu` | +| Node | `pve3` | +| Ziel | `pbs-nvme` / Muldenstein PBS (`100.99.139.22:nvme-pool`) | +| Modus | `snapshot` | +| Kompression | `zstd` | +| Zeitplan | täglich `03:20` | +| Nachholen | `repeat-missed=1` | +| Prune | `keep-daily=7`, `keep-weekly=4`, `keep-monthly=3` | + +Manuelles Safety-Backup wurde am 2026-05-16 erfolgreich erstellt: + +```text +ct/151/2026-05-16T10:51:53Z +Dauer: 56s +Gesichert: ca. 6.15 GiB +Komprimiert übertragen: ca. 3.98 GiB +Ergebnis: Backup job finished successfully +``` + +PBS-Auslastung danach: + +```text +pbs-nvme active, ca. 5.63% belegt +``` + +Schnelltest: + +```bash +# Auf pve3 / pve-mu-3 +pvesh get /cluster/backup/hermes-mu-backup --output-format json +pvesm status --storage pbs-nvme +``` + +### Prometheus / node_exporter + +Auf CT151 ist `prometheus-node-exporter` installiert und aktiv. + +```bash +systemctl is-active prometheus-node-exporter +curl -sS http://127.0.0.1:9100/metrics | grep -m1 '^node_uname_info' +``` + +Von CT116 / Hausmeister erreichbar über: + +```bash +curl -sS http://100.109.174.120:9100/metrics +``` + +### Loki / Promtail + +Auf CT151 läuft `promtail` und schickt Logs mit Host-Label `hermes-mu` an Loki. + +Wichtige Checks: + +```bash +systemctl is-active promtail +curl -sS http://100.109.174.120:9080/ready +``` + +### Hermes-Service-Checks + +Aktive Dienste auf CT151: + +```bash +systemctl is-active hermes-gateway beellama-tunnel promtail prometheus-node-exporter +curl -sS http://127.0.0.1:11435/v1/models +``` + +Der lokale BeeLlama/OpenAI-kompatible Tunnel lauscht in CT151 auf `127.0.0.1:11435`; er ist deshalb von CT116 nicht direkt per HTTP erreichbar. Der Health-Check erfolgt lokal auf CT151 bzw. indirekt über Hermes/Hausmeister. + +### Hausmeister-Integration + +Hausmeister (`CT116`) enthält in `/opt/homelab-ai-bot/monitor.py` einen `check_hermes()`. + +Der Check prüft: + +- Hermes Promtail: `http://100.109.174.120:9080/ready` +- Hermes node_exporter: `http://100.109.174.120:9100/metrics` +- Websuche/SearXNG CT121: `http://100.74.196.29:8080/search?...` +- Loki: `http://100.109.206.43:3100/ready` +- PBS Muldenstein: `https://100.99.139.22:8007` +- Loki-Health für Host `hermes-mu` +- auffällige Hermes-Logs der letzten 30 Minuten + +Harmloser `node_exporter`-Diskstats-Logspam (`diskstats_linux.go`, `disabling udev device properties`) wird gefiltert. + +Finaler Test am 2026-05-16: + +```text +monitor.check_hermes() -> OK +hausmeister-bot.service -> active +``` diff --git a/sessions/opencode-20260420-qwen36-workflow.md b/sessions/opencode-20260420-qwen36-workflow.md index 46c59bc38..751117474 100644 --- a/sessions/opencode-20260420-qwen36-workflow.md +++ b/sessions/opencode-20260420-qwen36-workflow.md @@ -1093,3 +1093,38 @@ _(reasoning: Der Memory wurde erfolgreich gespeichert. Ich bestätige das dem Us Willst du noch etwas anderes speichern? 🔥 +## Entscheidungsnotiz: Qwen3.6 + BeeLlama fuer opencode-Tests + +Stand: 2026-05-18 + +Qwen3.6 ueber BeeLlama ist fuer einfache, kontrollierte `opencode`-Tests ausreichend: + +- Git-Repos klonen +- README, `package.json` und Setup-Hinweise lesen +- Projektstruktur erkunden +- Shell-/Git-Kommandos ausfuehren +- Tests finden und starten +- kleine, klar begrenzte Codeaenderungen vorbereiten + +Wichtig: BeeLlama macht das Modell schneller und lokal praktikabler, aber nicht automatisch intelligenter. Fuer komplexe Coding-Agent-Aufgaben wie grosse Refactorings, viele Dateien gleichzeitig, schwierige Debug-Schleifen oder produktive Aenderungen bleibt ein spezialisiertes Coding-Modell zuverlaessiger. + +Empfohlener erster Testauftrag: + +```text +Klon dieses Repo nach /tmp/opencode-test, lies README und package.json, aber aendere nichts. Sag danach, wie man es startet und welche Tests definiert sind. +``` + +## Entscheidungsnotiz: zweite RTX 3090 + +Eine zweite RTX 3090 verbessert die Qualitaet desselben Modells nicht direkt. Dasselbe Modell mit derselben Quantisierung liefert grundsaetzlich dieselbe Antwortqualitaet. + +Eine zweite GPU hilft indirekt durch: + +- groessere Modelle, die vorher nicht in den VRAM passten +- bessere Quantisierung, z.B. Q5/Q6 statt Q4 +- mehr Kontext/KV-Cache +- weniger CPU-Offload +- hoehere Tokenrate bei gutem Layer-/Tensor-Splitting +- mehr parallele Requests + +Fuer `opencode` waere der echte Qualitaetsgewinn daher nicht die zweite GPU selbst, sondern dass dadurch eventuell ein besseres oder groesseres Coding-Modell lokal betrieben werden kann.