"""Was ein Update bringt, vor dem Knopf (Ausbauplan Welle 2, Punkt 9, seit 25.09.2026). App (Community-Script mit GitHub-Projekt) die Release Notes aller Versionen zwischen installiert und neu (GitHub-API, höchstens zehn, je auf 4 000 Zeichen gekürzt) Proxmox-Host (Pakete) die Debian-Änderungslisten der wichtigsten Pakete (Proxmox, Kernel, QEMU, LXC, ZFS …, höchstens acht) — der Ausführer holt sie nur lesend (Aktion changelog), jeweils bis zur installierten Version Lucy fasst das auf Deutsch zusammen (Modell `fast` der KI-Box über deren /v1, Aufbau wie bei den Updates der Box): eine erste Zeile, ob der User selbst etwas tun muss („Handarbeit“), dann höchstens fünf Stichpunkte, Brüche und geänderte Einstellungen zuerst und mit ⚠️. Fakten kommen aus den Notes, die Worte vom Modell. Antwortet die Box nicht, stehen die Notes ohne Kurzfassung da. Ergebnisse werden je (Gerät, Baustein, von, nach) gemerkt (/neuerungen.json) — dieselbe Version fasst Lucy nur einmal zusammen. """ import json import logging import re import threading import time from pathlib import Path import httpx from kern.einstellungen import einstellungen from kern.github import github_json from services.homelab import apps, inventar, kanal log = logging.getLogger(__name__) MAX_VERSIONEN = 10 MAX_ZEICHEN = 4000 MAX_PAKETE = 8 WICHTIG = re.compile(r"pve|proxmox|qemu|lxc|kernel|zfs|ceph|corosync|spiceterm|novnc|libpve") ZEITLIMIT_LLM = httpx.Timeout(120.0, connect=5.0) FEHLER_MERKEN_S = 600 _AKTION = re.compile(r"^\s*AKTION:\s*(JA|NEIN)\b\s*[—–-]?\s*(.*)$", re.IGNORECASE) _lock = threading.Lock() def _pfad() -> Path: return einstellungen().daten_dir / "neuerungen.json" def _gemerkt() -> dict: try: daten = json.loads(_pfad().read_text(encoding="utf-8")) except (OSError, ValueError): return {} return daten if isinstance(daten, dict) else {} def _merken(schluessel: str, ergebnis: dict) -> None: with _lock: daten = _gemerkt() daten[schluessel] = {"zeit": time.time(), "ergebnis": ergebnis} # Nur die jüngsten 100 Einträge: alte Versionen braucht niemand mehr. daten = dict(sorted(daten.items(), key=lambda t: t[1].get("zeit", 0))[-100:]) pfad = _pfad() pfad.parent.mkdir(parents=True, exist_ok=True) tmp = pfad.with_suffix(".tmp") tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8") tmp.replace(pfad) # --- Quellen ----------------------------------------------------------------------------------------------------- def zwischen(releases: object, installiert: str, neu: str) -> list[dict]: """Die Releases mit installiert < Version <= neu, neueste zuerst (ohne Entwürfe und Vorabversionen).""" von, bis = inventar._teile(installiert), inventar._teile(neu) if not von or not bis: return [] auswahl = [] for r in releases if isinstance(releases, list) else []: if not isinstance(r, dict) or r.get("draft") or r.get("prerelease"): continue version = inventar._teile(str(r.get("tag_name") or "")) if version and von < version <= bis: auswahl.append((version, {"version": str(r.get("tag_name")), "titel": r.get("name") or r.get("tag_name"), "datum": r.get("published_at"), "url": r.get("html_url"), "text": str(r.get("body") or "").strip()[:MAX_ZEICHEN]})) auswahl.sort(key=lambda t: t[0], reverse=True) return [r for _, r in auswahl[:MAX_VERSIONEN]] def wichtige_pakete(updates: list[dict]) -> list[str]: """Die Pakete, deren Änderungsliste zählt: Proxmox, Kernel und Co. zuerst, höchstens MAX_PAKETE.""" namen = [str(u.get("paket")) for u in updates if isinstance(u, dict) and u.get("paket")] return sorted(namen, key=lambda n: (not WICHTIG.search(n), n))[:MAX_PAKETE] def _changelogs(pakete: list[str]) -> dict[str, str]: """Über den Ausführer (Aktion changelog, nur lesend). Leer, wenn er nicht antwortet.""" auftrag = kanal.anlegen("changelog", {"pakete": pakete}) ergebnis = kanal.warten(auftrag, 120) if not ergebnis or ergebnis.get("status") != "fertig": return {} try: daten = json.loads(ergebnis.get("text") or "{}") except ValueError: return {} return {k: str(v) for k, v in daten.items()} if isinstance(daten, dict) else {} # --- Lucys Kurzfassung --------------------------------------------------------------------------------------------- def auswerten(text: str) -> dict: """Die Antwort des Modells: {"aktion": bool|None, "aktion_text", "punkte": […]}. Tolerant: Die AKTION-Zeile darf irgendwo stehen, Stichpunkte beginnen mit „-“ oder „•“.""" aktion, aktion_text, punkte = None, "", [] for zeile in text.splitlines(): if aktion is None and (m := _AKTION.match(zeile)): aktion, aktion_text = m.group(1).upper() == "JA", m.group(2).strip() elif zeile.strip().startswith(("-", "•", "*")): punkte.append(zeile.strip().lstrip("-•* ").strip()) return {"aktion": aktion, "aktion_text": aktion_text, "punkte": [p for p in punkte if p][:5]} def zusammenfassen(was: str, von: str, nach: str, quellen: str) -> dict | None: """Lucy fasst zusammen (Modell `fast` der KI-Box). None, wenn es keine Box gibt oder sie nicht antwortet.""" box = einstellungen().partner_url if not box or not quellen.strip(): return None prompt = ( f"Du bist Lucy und betreust ein Homelab. Vor einem Update von {was} ({von} → {nach}) liest du die " "Änderungen und sagst dem Besitzer, was ihn betrifft. Die Änderungen:\n\n" + quellen[:14000] + "\n\n" "Antworte auf DEUTSCH, knapp und klar, GENAU in diesem Format:\n" "Zeile 1 beginnt mit 'AKTION: ':\n" " • 'AKTION: NEIN' — wenn er nichts selbst tun muss (der Orchestrator legt vorher einen Snapshot an und geht " "bei Problemen von allein zurück). Das ist der Normalfall.\n" " • 'AKTION: JA — ' — NUR bei Handarbeit: Migration, geänderte oder entfernte " "Einstellungen, neue Pflichtangaben, Brüche.\n" "Danach höchstens 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Brüche und geänderte Einstellungen " "ZUERST und mit '⚠️', dann Sicherheitskorrekturen, dann lohnende Neuerungen. Keine Einleitung, keine " "Überschrift, nichts erfinden, was nicht in den Änderungen steht.") try: r = httpx.post(f"{box}/v1/chat/completions", timeout=ZEITLIMIT_LLM, json={ "model": "fast", "max_tokens": 600, "temperature": 0.2, "chat_template_kwargs": {"enable_thinking": False}, "messages": [{"role": "user", "content": prompt}]}) r.raise_for_status() wahl = (r.json().get("choices") or [{}])[0] text = ((wahl.get("message") or {}).get("content") or "").strip() except (httpx.HTTPError, ValueError, AttributeError): log.warning("neuerungen: Lucy war nicht zu erreichen", exc_info=True) return None if wahl.get("finish_reason") == "length": text = text.rpartition("\n")[0] ergebnis = auswerten(text) return ergebnis if ergebnis["aktion"] is not None or ergebnis["punkte"] else None # --- Für die Oberfläche -------------------------------------------------------------------------------------------- def _baustein(zid: str, baustein: str) -> tuple[dict | None, dict | None]: ziel = next((z for z in inventar.ziele()["ziele"] if z["id"] == zid), None) b = next((x for x in (ziel or {}).get("bausteine") or [] if x["id"] == baustein), None) return ziel, b def fuer(zid: str, baustein: str) -> dict: """GET /api/homelab/ziele/{zid}/neuerungen?baustein=…: {"verfuegbar", "grund", "von", "nach", "versionen" | "pakete", "zusammenfassung", "quelle_url"}.""" ziel, b = _baustein(zid, baustein) if not ziel or not b or b.get("zustand") != "neu": return {"verfuegbar": False, "grund": "Für diesen Baustein steht kein Update an."} von, nach = str(b.get("installiert") or ""), str(b.get("verfuegbar") or b.get("kurz") or "") schluessel = f"{zid}:{baustein}:{von}:{nach}" alt = _gemerkt().get(schluessel) if alt and (alt["ergebnis"].get("zusammenfassung") or time.time() - alt.get("zeit", 0) < FEHLER_MERKEN_S): return alt["ergebnis"] if zid == "pve" and baustein == "pakete": ergebnis = _host(von, nach) elif baustein == "app": ergebnis = _app(ziel, von, nach) else: return {"verfuegbar": False, "grund": "Für Paket-Updates im Gast und Docker-Images gibt es keine Release Notes."} if ergebnis.get("verfuegbar"): _merken(schluessel, ergebnis) return ergebnis def _app(ziel: dict, von: str, nach: str) -> dict: gast = inventar.gast(ziel["id"]) or {} app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name")) if not app or not app.quelle: return {"verfuegbar": False, "grund": "Zu dieser App kennt der Orchestrator kein GitHub-Projekt."} url = f"https://github.com/{app.quelle}/releases" try: versionen = zwischen(github_json(f"repos/{app.quelle}/releases?per_page=40"), von, nach) except Exception: return {"verfuegbar": False, "grund": "GitHub antwortet gerade nicht.", "quelle_url": url} if not versionen: return {"verfuegbar": False, "grund": "Zu diesen Versionen stehen auf GitHub keine Release Notes.", "quelle_url": url} quellen = "\n\n".join(f"## {v['titel']} ({v['version']})\n{v['text']}" for v in versionen) return {"verfuegbar": True, "grund": None, "von": von, "nach": nach, "versionen": versionen, "quelle_url": url, "zusammenfassung": zusammenfassen(app.name, von, nach, quellen)} def _host(von: str, nach: str) -> dict: host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {} pakete = wichtige_pakete(host.get("updates") or []) if not pakete: return {"verfuegbar": False, "grund": "Der Bericht nennt keine Pakete."} logs = _changelogs(pakete) if not logs: return {"verfuegbar": False, "grund": "Der Ausführer hat die Änderungslisten nicht geliefert."} alle = len(host.get("updates") or []) quellen = "\n\n".join(f"## {name}\n{text}" for name, text in logs.items()) return {"verfuegbar": True, "grund": None, "von": von, "nach": nach, "pakete": [{"name": n, "text": t} for n, t in logs.items()], "weitere": max(0, alle - len(logs)), "zusammenfassung": zusammenfassen(f"Proxmox VE ({alle} Pakete)", von or "installiert", nach or "neu", quellen)}