"""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".*?", "", 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)}