Welle 2 · „Was ist neu?“ vor dem Knopf: Release Notes, Debian-Änderungslisten und Lucys Kurzfassung
Vor einem App-Update sammelt der Orchestrator die Release Notes aller Versionen zwischen installiert und neu (GitHub), beim Proxmox-Host die Debian-Änderungslisten der wichtigsten Pakete (neue Ausführer-Aktion changelog, nur lesend, nur für anstehende Pakete). Lucy fasst sie über das Modell fast der KI-Box auf Deutsch zusammen: Handarbeit ganz oben, höchstens fünf Stichpunkte, Brüche zuerst. Die Update-Tabelle hat „Was ist neu?“ je Gerät, und die Rückfrage vor dem Update zeigt die Kurzfassung gleich mit. Gemerkt je Version; der Ausführer braucht ausfuehrer-einrichten.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0190c25d5a
commit
45f11bc3dc
@@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
|
||||
|
||||
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
|
||||
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
|
||||
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen"}
|
||||
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog"}
|
||||
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
|
||||
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
|
||||
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""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
|
||||
(<Datenordner>/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 — <was er konkret tun muss>' — 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)}
|
||||
@@ -70,8 +70,10 @@ _AKTION = {
|
||||
"sicherung_probe": ("Probe-Wiederherstellung gelungen", "Probe-Wiederherstellung gescheitert",
|
||||
"Probe-Wiederherstellung läuft", "Probe-Wiederherstellung"),
|
||||
"etikett_setzen": ("Etikett gesetzt", "Etikett setzen gescheitert", "Etikett wird gesetzt", "Etikett setzen"),
|
||||
"changelog": ("Änderungslisten gelesen", "Änderungslisten nicht gelesen", "Änderungslisten werden gelesen",
|
||||
"Änderungslisten"),
|
||||
}
|
||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe"}
|
||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog"}
|
||||
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
|
||||
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
|
||||
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
|
||||
|
||||
Reference in New Issue
Block a user