MC2 Final (D16 a/b/d): verständliche Updates + ehrliche Speicher-Zahlen

a) Update-Meldungen: generischer Release-Summarizer in Lucys Stimme mit
   strukturiertem Aktions-Verdikt ("Musst du etwas tun? NEIN/JA") für
   Hermes, Engine (llama.cpp) und llama-swap; Fangnetz-Hinweis verheiratet
   Breaking-Change-Sorge mit dem Postcheck.
b) OS ehrlich: zurückgestellte Pakete (Phasen-Rollout / kept back) werden
   ausgewiesen statt scheinbar zu hängen.
d) Ehrliche Speicher-Zahlen: KV-Cache aus echten GGUF-Architektur-Daten
   (Layer × KV-Köpfe × head_dim) + KV-Quant aus dem cmd statt params-blinder
   Schätzung — footprint_gb als eine Zahlensprache (Zentrale, Modell-Manager,
   fits-Check auf warmset+largest). Auto-Rewarm-Nudge nach Config-Reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-03 19:40:59 +02:00
parent 612ce127fa
commit 4e5a7eed35
12 changed files with 578 additions and 225 deletions
+115 -29
View File
@@ -298,8 +298,42 @@ def updates() -> dict:
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
def _os_held_back() -> list[dict]:
"""Pakete, die apt aktuell NICHT einspielt, obwohl es sie gäbe — mit ehrlichem Grund.
Zwei Fälle, aus `apt-get -s upgrade` (Simulation) gelesen:
• Phasen-Rollout (Ubuntu staffelt Updates prozentual aus) → 'deferred due to phasing'.
• zurückgehalten wegen neuer Abhängigkeiten → 'kept back'.
Beides ist normal und löst sich von selbst — verhindert nur das 'hängt fest'-Gefühl,
wenn nach 'Fertig' noch aktualisierbare Pakete übrig scheinen."""
held: list[dict] = []
sections = {
"The following upgrades have been deferred due to phasing:": "phasing",
"The following packages have been kept back:": "kept_back",
}
try:
out = subprocess.run(
["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"],
capture_output=True, text=True, timeout=25)
reason: str | None = None
for line in (out.stdout or "").splitlines():
hdr = sections.get(line.strip())
if hdr: # Abschnitts-Kopf → folgende Zeilen sammeln
reason = hdr
continue
if reason and line.startswith((" ", "\t")): # eingerückt = Paketnamen des Abschnitts
for name in line.split():
held.append({"name": name, "reason": reason})
elif reason: # nicht eingerückt → Abschnitt zu Ende
reason = None
except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker
pass
held.sort(key=lambda p: p["name"])
return held
def os_update_details() -> dict:
"""Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat)."""
"""Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat) + ehrliche
Anzeige der vom System zurückgestellten Pakete (Phasen-Rollout / kept back)."""
out_pkgs: list[dict] = []
try:
# LC_ALL=C → englische Ausgabe, damit der Regex "[upgradable from: ...]" greift
@@ -314,7 +348,8 @@ def os_update_details() -> dict:
out_pkgs.append({"name": m.group(1), "candidate": m.group(2),
"current": m.group(3).strip()})
out_pkgs.sort(key=lambda p: p["name"])
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs}
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
"held_back": _os_held_back()}
except Exception as exc: # noqa: BLE001
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
@@ -334,6 +369,13 @@ def engine_update_details() -> dict:
info["url"] = rel.get("html_url")
body = (rel.get("body") or "").strip()
info["body"] = body[:2000] if body else None
# Nur zusammenfassen, wenn wirklich ein neuerer Build ansteht (spart einen LLM-Call,
# wenn das Modal bei aktuellem Stand geöffnet wird).
if body and info["latest_build"] and info["installed_build"] \
and info["latest_build"] > info["installed_build"]:
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
except Exception as exc: # noqa: BLE001
info["error"] = str(exc)
return info
@@ -354,31 +396,54 @@ def swap_update_details() -> dict:
info["url"] = rel.get("html_url")
body = (rel.get("body") or "").strip()
info["body"] = body[:2000] if body else None
if body and info["latest_build"] and info["installed_build"] \
and info["latest_build"] > info["installed_build"]:
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
"verteilt und Sprachmodelle heiß nachlädt).")
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
except Exception as exc: # noqa: BLE001
info["error"] = str(exc)
return info
# LLM-Zusammenfassung der anstehenden Hermes-Commits (die Box liest ihre Release-Notes selbst).
# Gecacht auf den neuesten Commit-Hash — das Modal darf beliebig oft geöffnet werden.
_relnotes_cache: dict = {"key": "", "summary": ""}
# LLM-Zusammenfassung anstehender Updates in Lucys Stimme (die Box liest ihre Release-Notes
# selbst). Jede Zusammenfassung beginnt mit einem klaren Aktions-Verdikt für den Besitzer
# (kein Entwickler): "Musst du etwas tun? NEIN — das Fangnetz regelt das / JA: …".
# Gecacht je Komponente auf den neuesten Commit-Hash/Release-Tag — das Modal darf beliebig
# oft geöffnet werden, ohne das Modell jedes Mal neu zu befragen.
_relnotes_caches: dict = {"hermes": {}, "engine": {}, "swap": {}}
# Erste Zeile jeder Modell-Antwort: "AKTION: NEIN" oder "AKTION: JA — <grund>".
# Toleriert Markdown-Deko (**fett**, Bullet, Überschrift), die 'fast' gern einstreut.
_ACTION_RX = re.compile(
r"^[\s*_>#\-]*AKTION:?\s*\**\s*(JA|NEIN)\b[\s—:,.\-*_]*(.*?)[\s*_]*$", re.IGNORECASE)
def _summarize_hermes_commits(commits: list[dict]) -> str:
key = commits[0]["hash"] if commits else ""
def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
"""Fasst Release-Notes/Commits in Lucys Stimme zusammen und trennt das Aktions-Verdikt
ab. Rückgabe: {summary, action_needed, action_text}. Cache je Komponente auf `key`."""
empty = {"summary": "", "action_needed": None, "action_text": ""}
if not key:
return ""
if _relnotes_cache["key"] == key and _relnotes_cache["summary"]:
return _relnotes_cache["summary"]
return empty
cache = _relnotes_caches.setdefault(kind, {})
if cache.get("key") == key and cache.get("data"):
return cache["data"]
from config import LLAMA_SWAP_URL
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
prompt = (
"Du bist der Update-Berater einer lokalen AI-Box (Hermes-Agent auf Strix Halo; genutzt werden: "
"api_server/Gateway, memory-provider-Plugin 'mc2-memory', terminal-/web-Tools, approvals, cron). "
"Hier die Commit-Titel des anstehenden Hermes-Updates:\n\n" + subjects + "\n\n"
"Fasse auf DEUTSCH in maximal 6 Stichpunkten zusammen: (1) Breaking Changes oder geänderte/"
"entfernte Config-Schlüssel (WICHTIGSTES zuerst, explizit warnen), (2) was unser Setup betrifft, "
"(3) welche neuen Features sich für uns lohnen. Keine Einleitung, nur die Punkte."
"Du bist Lucy, die Stimme einer lokalen AI-Box, und erklärst dem Besitzer (KEIN Entwickler, "
"fasst nie eine Konsole an) ein anstehendes Update ruhig und verständlich.\n"
f"{context}\n\n"
"Anstehende Änderungen:\n" + changes + "\n\n"
"Antworte auf DEUTSCH, knapp und klar. HALTE DICH GENAU an dieses Format:\n"
"Zeile 1 ist das Aktions-Verdikt und beginnt mit 'AKTION: ':\n"
"'AKTION: NEIN' — wenn der Besitzer nichts tun muss (die Box spielt es selbst ein, das "
"Fangnetz prüft danach automatisch und rollt bei Problemen von allein zurück). Das ist der "
"Normalfall.\n"
"'AKTION: JA — <was er konkret tun/entscheiden muss>' — NUR wenn er wirklich selbst "
"handeln muss.\n"
"Danach maximal 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Breaking Changes oder "
"geänderte/entfernte Config-Schlüssel ZUERST und mit '⚠️' markiert, dann was unser Setup "
"betrifft, dann lohnende neue Features. Keine Einleitung, keine Überschrift."
)
try:
r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={
@@ -389,21 +454,42 @@ def _summarize_hermes_commits(commits: list[dict]) -> str:
r.raise_for_status()
resp = r.json()
choice = (resp.get("choices") or [{}])[0]
summary = (choice.get("message") or {}).get("content") or ""
text = ((choice.get("message") or {}).get("content") or "").strip()
finish_reason = choice.get("finish_reason") or ""
summary = summary.strip()
# finish_reason == "length" → Antwort wurde wegen Token-Limits abgeschnitten →
# letzten (unvollständigen) Stichpunkt entfernen.
# Bei "stop" oder None → Antwort ist vollständig → unverändert lassen.
if finish_reason == "length" and summary:
lines = summary.rsplit("\n", 1)
summary = lines[0] if len(lines) > 1 else ""
if summary:
_relnotes_cache.update(key=key, summary=summary)
return summary
# letzten (unvollständigen) Stichpunkt entfernen. Bei "stop"/None → vollständig.
if finish_reason == "length" and text:
head, _, _tail = text.rpartition("\n")
text = head if head else ""
# Aktions-Verdikt herauslösen (strukturiertes Feld fürs UI). Normalerweise Zeile 1,
# aber tolerant: erste passende Zeile suchen (Modell startet manchmal mit Leerzeile).
action_needed: bool | None = None
action_text = ""
lines = text.splitlines()
for idx, ln in enumerate(lines):
if m := _ACTION_RX.match(ln):
action_needed = m.group(1).upper() == "JA"
action_text = (m.group(2) or "").strip()
text = "\n".join(lines[:idx] + lines[idx + 1:]).strip()
break
data = {"summary": text, "action_needed": action_needed, "action_text": action_text}
if text:
cache.update(key=key, data=data)
return data
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
return f"(Zusammenfassung nicht verfügbar: {exc})"
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
"action_needed": None, "action_text": ""}
def _summarize_hermes_commits(commits: list[dict]) -> dict:
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf "
"Strix Halo; genutzt werden: api_server/Gateway, memory-provider-Plugin 'mc2-memory', "
"terminal-/web-Tools, approvals, cron).")
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
def hermes_update_details() -> dict:
@@ -430,7 +516,7 @@ def hermes_update_details() -> dict:
info["commits"] = commits
info["behind"] = len(commits)
if commits:
info["summary"] = _summarize_hermes_commits(commits)
info.update(_summarize_hermes_commits(commits))
except Exception as exc: # noqa: BLE001
info["error"] = str(exc)
return info