Files
mission-control-v2/backend/services/wochenbericht.py
T
HitonabiandClaude Opus 5.5 12cefb427a Welle 3 · Lucys Freiheiten je Aktionsart, Rückfragen mit „ja 7“, Lucy untersucht rote Hinweise, Wochenbericht
- services/lucy_autonomie.py: Stufe je Aktionsart (nur lesen, erst fragen, selbst handeln), Start „mehr Freiheit“;
  Meldung danach (POST /api/lucy/gehandelt)
- services/rueckfragen.py: nummerierte Rückfragen, nur Knöpfe der Oberfläche (ERLAUBT), „ja“ führt genau die
  gespeicherte Aktion aus, 24 h gültig, hinfällig wenn der Hinweis weg ist, Nachprüfung danach
- notify.sh -k: Antworttasten (Antworttastatur über den Direktweg, ohne Eingriff in Hermes)
- services/ermittlung.py: Wächter meldet rote Hinweise an Lucy; Knopf wird Rückfrage, Lucy sieht über den
  api_server nach und schickt Ursache und Weg als eine Nachricht
- services/wochenbericht.py + mc2-wochenbericht.timer (So 07:30): feste Sätze, Absatz vom Modell, offene
  App-Updates als Rückfragen
- MCP: Werkzeuge fragen die Freiheiten (auf_bitte), neu: freiheiten, rueckfragen, rueckfrage_beantworten,
  sicherheitsupdates_einspielen, snapshot_zurueckspielen, snapshots, geraet_logs, dienst_logs
- Ausführer-Aktion journal (nur lesend), POST /api/homelab/ziele/{id}/sicherheitsupdates
- Läufe des Homelabs mit flock (Oberfläche und Steward schreiben beide)
- Skill homelab-orchestrator, SOUL-Abschnitt als Kopie in deploy/nur-box

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:12:50 +02:00

393 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Wochenbericht in Lucys Stimme (Ausbauplan Welle 3, Punkt 16, seit 25.09.2026).
Sonntags 07:30 (mc2-wochenbericht.timer — nach dem Wartungsfenster des Homelabs, den Sonntags-Updates der KI-Box und der
Morgenmeldung) eine Nachricht statt vieler: was diese Woche eingespielt wurde, was offen ist, Platten, Sicherungen,
Zertifikate, Lücken, das Modell-Radar und was Lucy selbst getan hat.
„Fakten vom Skript, Prosa vom Modell“: fakten() liest über die REST-API beider Teile (dieselben Zahlen wie die
Oberfläche), zeilen() macht daraus feste Sätze. Nur der kurze Absatz obenauf kommt vom Modell (`fast`, ohne Denkpause)
und darf nur umschreiben, was in den Zeilen steht; antwortet es nicht, steht dort ein fester Satz. Offene App-Updates
des Homelabs werden zu Rückfragen (services/rueckfragen.py): „ja 9“ spielt Gitea ein, „ja 11“ startet „Alle
aktualisieren“ für den Rest.
"""
import json
import logging
import os
import re
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
import httpx
from kern import prognose
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import announce, rueckfragen
log = logging.getLogger(__name__)
BETREFF = "[Wochenbericht]"
WOCHE_S = 7 * 86400
MAX_ZEICHEN = 3800 # Telegram nimmt 4096 Zeichen je Nachricht
ANGEBOTE_MAX = 4
TAG, UHR = 6, (7, 30) # sonntags 07:30 — wie deploy/mc2-wochenbericht.timer
LUCY_LOG = Path(os.environ.get("MC_ORCH_LOG", str(Path.home() / ".hermes" / "logs" / "orchestrator-werkzeuge.log")))
BAUSTEIN = {"app": "App", "os": "Pakete", "pakete": "Pakete", "docker": "Docker", "sicherheit": "Sicherheitsupdates",
"kernel": "alte Kernel", "neustart": "Neustart"}
NICHT_GLATT = {"zurueckgerollt": "zurückgerollt", "festgehalten": "festgehalten", "fehler": "gescheitert"}
PROSA = """Du bist Lucy, die Systemadministratorin dieses Homelabs, und schreibst dem Commander seinen Wochenbericht.
Schreib drei bis fünf Sätze Fließtext: wie die Woche lief, was wichtig ist und was er tun sollte. Beginne mit \
„Commander,“. Knapp, sachlich, ohne Emojis, ohne Ausrufezeichen, ohne Aufzählung. Nimm nur, was in den Fakten steht — \
keine Zahl, kein Name, kein Ereignis, das dort fehlt. Die Fakten und die Rückfragen stehen unter deinem Absatz ohnehin \
noch einmal; wiederhole sie nicht Punkt für Punkt.
Fakten dieser Woche:
{fakten}"""
_lock = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "wochenbericht.json"
def _get(pfad: str, **params: object) -> dict | None:
try:
antwort = httpx.get(f"{rueckfragen.EIGENE_URL}{pfad}", params=params or None, timeout=30)
daten = antwort.json() if antwort.status_code == 200 else None
except (httpx.HTTPError, ValueError):
return None
return daten if isinstance(daten, dict) else None
def _zeit(wert: object) -> float | None:
if isinstance(wert, (int, float)):
return float(wert)
try:
zeit = datetime.fromisoformat(str(wert))
except ValueError:
return None
return (zeit if zeit.tzinfo else zeit.replace(tzinfo=LOCAL_TZ)).timestamp()
def _liste(namen: list[str], leer: str = "") -> str:
namen = list(dict.fromkeys(n for n in namen if n))
if not namen:
return leer
return namen[0] if len(namen) == 1 else ", ".join(namen[:-1]) + " und " + namen[-1]
# --- Fakten ----------------------------------------------------------------------------------------------------
def fakten(jetzt: float | None = None) -> dict:
"""Alles für den Bericht, wie es die Oberfläche auch zeigt. Was nicht antwortet, fehlt (None)."""
hl = rueckfragen.HL
return {
"box_laeufe": (_get("/api/updates/verlauf", anzahl=12) or {}).get("laeufe"),
"homelab_laeufe": (_get(f"{hl}/laeufe", anzahl=100) or {}).get("laeufe"),
"ziele_box": (_get("/api/ziele") or {}).get("ziele"),
"ziele_homelab": (_get(f"{hl}/ziele") or {}).get("ziele"),
"hinweise_box": (_get("/api/hinweise") or {}).get("hinweise"),
"hinweise_homelab": (_get(f"{hl}/hinweise") or {}).get("hinweise"),
"speicher": _get(f"{hl}/speicher"),
"box_platte": ((_get("/api/start") or {}).get("box") or {}).get("platte"),
"zertifikate": _get(f"{hl}/zertifikate"),
"docker": _get(f"{hl}/docker"),
"radar": (_get("/api/radar") or {}).get("kandidaten"),
"rueckfragen": rueckfragen.liste(100, jetzt=jetzt),
"lucy": _lucy_log(),
}
def _lucy_log() -> list[dict]:
"""Was Lucy über ihre Werkzeuge getan hat (mcp/orchestrator_werkzeuge.py schreibt je Aktion eine Zeile)."""
try:
zeilen = LUCY_LOG.read_text(encoding="utf-8").splitlines()[-500:]
except OSError:
return []
eintraege = []
for zeile in zeilen:
try:
e = json.loads(zeile)
except ValueError:
continue
if isinstance(e, dict):
eintraege.append(e)
return eintraege
# --- Feste Sätze ---------------------------------------------------------------------------------------------------
def _eingespielt(f: dict, seit: float) -> tuple[list[str], list[str]]:
"""(eingespielt, nicht gut gegangen) dieser Woche, KI-Box und Homelab."""
gut, schlecht = [], []
for lauf in f.get("homelab_laeufe") or []:
ende = _zeit(lauf.get("ende"))
if lauf.get("status") != "fertig" or lauf.get("baustein") == "probe" or ende is None or ende < seit:
continue
was = f"{lauf.get('name')} ({BAUSTEIN.get(lauf.get('baustein'), lauf.get('baustein'))})"
if lauf.get("ergebnis") == "eingespielt":
gut.append(was)
elif lauf.get("ergebnis") in NICHT_GLATT:
schlecht.append(f"{was} {NICHT_GLATT[lauf['ergebnis']]}")
for lauf in f.get("box_laeufe") or []:
start = _zeit(lauf.get("start"))
if start is None or start < seit:
continue
for z in lauf.get("zeilen") or []:
was = f"KI-Box {z.get('baustein')}"
if z.get("ergebnis") == "eingespielt":
gut.append(was)
elif z.get("ergebnis") in NICHT_GLATT:
schlecht.append(f"{was} {NICHT_GLATT[z['ergebnis']]}")
return gut, schlecht
def _offen(f: dict) -> tuple[str, list[dict]]:
"""Offene Updates als Satz und die Kandidaten für Rückfragen (App-Updates des Homelabs mit Knopf)."""
apps, geraete_pakete, sicherheit, angebote = [], 0, 0, []
for quelle in ("ziele_box", "ziele_homelab"):
for z in f.get(quelle) or []:
for b in z.get("bausteine") or []:
if b.get("zustand") != "neu":
continue
if b.get("id") in ("os", "pakete"):
geraete_pakete += 1
sicherheit += int(b.get("sicherheit") or 0)
continue
version = b.get("verfuegbar") or b.get("kurz") or ""
apps.append(f"{z.get('name')} {version}".strip() if b.get("id") == "app" else
f"{z.get('name')}: {b.get('name')}" + (f" {version}" if version else ""))
pfad = (b.get("aktion") or {}).get("pfad") or ""
if quelle == "ziele_homelab" and b.get("id") == "app" and pfad.startswith("/api/homelab/"):
angebote.append({"frage": f"Soll ich {z.get('name')} {version} einspielen?".replace(" ", " "),
"was": f"{z.get('name')} {version}".strip(), "pfad": pfad})
teile = []
if apps:
teile.append(_liste(apps))
if geraete_pakete:
teile.append(f"Paket-Updates auf {geraete_pakete} {'Gerät' if geraete_pakete == 1 else 'Geräten'}"
+ (f", davon {sicherheit} Sicherheitsupdates" if sicherheit else ""))
return ("; ".join(teile) if teile else "nichts"), angebote
def _speicher(f: dict) -> str:
s = f.get("speicher") or {}
teile = []
def satz(name: str, anteil: object, v: dict | None) -> str:
text = f"{name} {round(float(anteil or 0) * 100)} %"
if v and v.get("tage") is not None and v["tage"] < 120:
text += f" (voll {prognose.in_tagen(float(v['tage']))})"
return text
for p in s.get("pools") or []:
teile.append(satz(f"Pool {p.get('speicher')}", p.get("anteil"), p.get("vorhersage")))
if sp := s.get("systemplatte"):
teile.append(satz("Host-Systemplatte", sp.get("anteil"), sp.get("vorhersage")))
for n in s.get("nas") or []:
teile.append(f"NAS: {str(n['problem']).rstrip('.')}" if n.get("problem")
else satz("NAS", n.get("anteil"), n.get("vorhersage")))
if bp := f.get("box_platte"):
teile.append(satz("KI-Box", float(bp.get("percent") or 0) / 100, bp.get("vorhersage")))
return "; ".join(teile) or "keine Angaben"
def _sicherungen(f: dict) -> str:
si = (f.get("speicher") or {}).get("sicherungen") or {}
if not si or si.get("stufe") in (None, "keine"):
return "keine Angaben"
teile = ["alle aktuell" if si.get("stufe") == "ok" else f"Stufe {si.get('stufe')}"]
if fehlend := [x.get("name") for x in si.get("fehlend") or []]:
teile.append(f"zu alt oder fehlend: {_liste(fehlend)}")
if gescheitert := [g.get("name") for g in si.get("geraete") or [] if (g.get("probe") or {}).get("ok") is False]:
teile.append(f"Probe-Wiederherstellung gescheitert: {_liste(gescheitert)}")
return "; ".join(teile)
def _zertifikate(f: dict) -> str:
z = f.get("zertifikate") or {}
liste = z.get("zertifikate") or []
if not liste:
return "keine Angaben"
probleme = [f"{x.get('name')}: {x.get('problem')}" for x in liste if x.get("stufe") != "ok" and x.get("problem")]
return _liste(probleme) if probleme else f"alle {len(liste)} gültig"
def _luecken(f: dict) -> str | None:
d = f.get("docker") or {}
if not d.get("verfuegbar"):
return None
gescannt = [c for s in d.get("stacks") or [] for c in s.get("container") or [] if c.get("luecken")]
if not gescannt:
return None
schlimm = [f"{c['name']} ({c['luecken'].get('kritisch', 0)} kritisch)" for c in gescannt
if c["luecken"].get("kritisch")]
return _liste(schlimm) if schlimm else f"{len(gescannt)} Images gescannt, nichts Kritisches"
def _radar(f: dict) -> str | None:
kandidaten = f.get("radar") or []
bestanden = [k.get("name") for k in kandidaten if k.get("status") == "bestanden"]
wartend = sum(1 for k in kandidaten if k.get("status") in ("neu", "wartet"))
teile = []
if bestanden:
teile.append(f"bestanden und wartet auf deine Entscheidung: {_liste(bestanden)}")
if wartend:
teile.append(f"{wartend} {'Kandidat wartet' if wartend == 1 else 'Kandidaten warten'} auf den Test")
return "; ".join(teile) or None
def _hinweise(f: dict) -> str:
alle = [(wo, h) for wo in ("box", "homelab") for h in f.get(f"hinweise_{wo}") or []]
rot = [h.get("titel") for _, h in alle if h.get("stufe") == "rot"]
gelb = [h.get("titel") for _, h in alle if h.get("stufe") != "rot"]
if not alle:
return "keine"
teile = []
if rot:
teile.append(f"rot: {_liste(rot)}")
if gelb:
teile.append(f"gelb: {_liste(gelb)}")
return "; ".join(teile)
def _lucy(f: dict, seit: float) -> str | None:
selbst, bitte = [], 0
for e in f.get("lucy") or []:
zeit = _zeit(e.get("zeit"))
if zeit is None or zeit < seit or str(e.get("ergebnis") or "").startswith("Nicht ausgeführt"):
continue
if e.get("stufe") == "selbst":
selbst.append(str(e.get("werkzeug")))
elif e.get("stufe") in (None, "auf_bitte"):
bitte += 1
fragen = [r for r in (f.get("rueckfragen") or {}).get("zuletzt") or []
if float(r.get("zeit") or 0) >= seit]
ja = sum(1 for r in fragen if (r.get("antwort") or {}).get("ja"))
teile = []
if selbst:
teile.append(f"{len(selbst)}× selbst gehandelt")
if bitte:
teile.append(f"{bitte}× auf deine Bitte")
if fragen:
teile.append(f"{len(fragen)} Rückfragen beantwortet oder abgelaufen, davon {ja}× ja")
return "; ".join(teile) or None
def zeilen(f: dict, jetzt: float | None = None) -> list[str]:
"""Die festen Sätze des Berichts, in dieser Reihenfolge."""
jetzt = time.time() if jetzt is None else jetzt
seit = jetzt - WOCHE_S
gut, schlecht = _eingespielt(f, seit)
offen, _ = _offen(f)
ergebnis = [f"Eingespielt: {_liste(gut, 'nichts')}."]
if schlecht:
ergebnis.append(f"Nicht glatt gelaufen: {_liste(schlecht)}.")
ergebnis += [f"Offen: {offen}.", f"Hinweise: {_hinweise(f)}.", f"Speicher: {_speicher(f)}.",
f"Sicherungen: {_sicherungen(f)}.", f"Zertifikate: {_zertifikate(f)}."]
if luecken := _luecken(f):
ergebnis.append(f"Lücken-Scan: {luecken}.")
if radar := _radar(f):
ergebnis.append(f"Modell-Radar: {radar}.")
if lucy := _lucy(f, seit):
ergebnis.append(f"Lucy: {lucy}.")
return ergebnis
# --- Absatz vom Modell, Rückfragen, Senden ---------------------------------------------------------------------------
def prosa(fakten_zeilen: list[str]) -> str | None:
"""Lucys Absatz obenauf (Modell `fast` über /v1 der KI-Box). None, wenn es nicht antwortet."""
try:
antwort = httpx.post(f"{rueckfragen.EIGENE_URL}/v1/chat/completions", timeout=120, json={
"model": "fast", "temperature": 0.3, "max_tokens": 400,
"chat_template_kwargs": {"enable_thinking": False},
"messages": [{"role": "user", "content": PROSA.format(fakten="\n".join(fakten_zeilen))}]})
text = antwort.json()["choices"][0]["message"]["content"] if antwort.status_code == 200 else ""
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError):
log.warning("wochenbericht: Modell antwortete nicht", exc_info=True)
return None
text = re.sub(r"<think>.*?</think>", "", str(text or ""), flags=re.DOTALL).strip()
return text[:1200] or None
def angebote(f: dict, jetzt: float | None = None) -> list[tuple[str, int]]:
"""Offene App-Updates des Homelabs als Rückfragen (still — die Antwortzeilen stehen im Bericht), dazu eine für
„Alle aktualisieren“, wenn außer den Apps noch etwas ansteht (das nimmt dann auch die Apps mit). Rückgabe: (Text
der Zeile, Nummer)."""
_, kandidaten = _offen(f)
zeilen_ = []
for k in kandidaten[:ANGEBOTE_MAX]:
r = rueckfragen.stellen(k["frage"], k["pfad"], text="Snapshot vorher, Prüfung danach, bei Rot zurück.",
von="wochenbericht", still=True, jetzt=jetzt)
if r["ok"]:
zeilen_.append((f"{k['was']} einspielen", r["rueckfrage"]["nr"]))
rest = [b for z in f.get("ziele_homelab") or [] for b in z.get("bausteine") or []
if b.get("zustand") == "neu" and b.get("aktion") and b.get("id") != "app"]
if rest or len(kandidaten) > ANGEBOTE_MAX:
r = rueckfragen.stellen("Soll ich im Homelab alles aktualisieren (Gäste nacheinander, Host zuletzt, ohne "
"Neustart)?", f"{rueckfragen.HL}/alle", von="wochenbericht", still=True, jetzt=jetzt)
if r["ok"]:
zeilen_.append(("alles auf einmal („Alle aktualisieren“)", r["rueckfrage"]["nr"]))
return zeilen_
def bauen(jetzt: float | None = None, mit_angeboten: bool = True) -> dict:
"""Der Bericht als Text (ohne zu senden): {"text", "knoepfe", "zeilen"}."""
jetzt = time.time() if jetzt is None else jetzt
f = fakten(jetzt)
feste = zeilen(f, jetzt)
absatz = prosa(feste) or "Commander, hier ist dein Wochenbericht."
text = absatz + "\n\nDie Woche in Stichpunkten:\n" + "\n".join(f"• {z}" for z in feste)
knoepfe: list[str] = []
if mit_angeboten and (liste := angebote(f, jetzt)):
text += "\n\nAuf Zuruf:\n" + "\n".join(f"• {was} — „ja {nr}“" for was, nr in liste)
knoepfe = [f"ja {nr}" for _, nr in liste]
if len(text) > MAX_ZEICHEN:
text = text[:MAX_ZEICHEN - 2].rstrip() + " …"
return {"text": text, "knoepfe": knoepfe, "zeilen": feste}
def senden(jetzt: float | None = None) -> dict:
"""POST /api/lucy/wochenbericht (Timer sonntags 07:30 und Knopf „Jetzt schicken“)."""
if not _lock.acquire(blocking=False):
return {"ok": False, "detail": "Der Wochenbericht wird gerade schon geschrieben."}
try:
jetzt = time.time() if jetzt is None else jetzt
bericht = bauen(jetzt)
# Direkt statt über rueckfragen.melden(): Untersucht Lucy gerade etwas, hinge der Bericht sonst an ihrer
# Nachricht. Im Briefkasten leise — die Desktop-Lucy soll nicht drei Minuten vorlesen.
announce.add(bericht["text"], subject=BETREFF, source="wochenbericht", priority="silent")
announce.notify_telegram(BETREFF, bericht["text"], knoepfe=bericht["knoepfe"] or None)
pfad = _pfad()
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(".tmp")
tmp.write_text(json.dumps({"zeit": jetzt, "text": bericht["text"]}, ensure_ascii=False), encoding="utf-8")
tmp.replace(pfad)
log.info("wochenbericht: verschickt (%d Zeichen)", len(bericht["text"]))
return {"ok": True, "text": "Der Wochenbericht ist unterwegs."}
finally:
_lock.release()
def naechster(jetzt: float | None = None) -> float:
jetzt_dt = datetime.fromtimestamp(time.time() if jetzt is None else jetzt, LOCAL_TZ)
ziel = jetzt_dt.replace(hour=UHR[0], minute=UHR[1], second=0, microsecond=0) + timedelta(
days=(TAG - jetzt_dt.weekday()) % 7)
if ziel <= jetzt_dt:
ziel += timedelta(days=7)
return ziel.timestamp()
def stand(jetzt: float | None = None) -> dict:
"""GET /api/lucy/wochenbericht: der letzte Bericht und wann der nächste kommt."""
try:
zuletzt = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
zuletzt = None
return {"zuletzt": zuletzt if isinstance(zuletzt, dict) else None, "naechster": naechster(jetzt)}