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>
This commit is contained in:
Hitonabi
2026-09-25 19:12:50 +02:00
co-authored by Claude Opus 5.5
parent dd23f3fb4c
commit 12cefb427a
37 changed files with 2649 additions and 169 deletions
+1
View File
@@ -53,6 +53,7 @@ ZEITPLAENE: tuple[Zeitplan, ...] = (
Zeitplan("mc2-radar", "Modell-Radar", TAG + 3 * STUNDE, "täglich 00:30"),
Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"),
Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"),
Zeitplan("mc2-wochenbericht", "Wochenbericht", 7 * TAG + 6 * STUNDE, "sonntags 07:30"),
)
HERMES_KARENZ_S = STUNDE
CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell
+8 -3
View File
@@ -99,14 +99,19 @@ def add(text: str, subject: str = "", source: str = "", priority: str = "normal"
return item
def notify_telegram(subject: str, text: str) -> None:
def notify_telegram(subject: str, text: str, dringend: bool = False, knoepfe: list[str] | None = None) -> None:
"""Best-effort auch auf Telegram (User ist evtl. nicht am PC). MC_NOTIFY_NO_ANNOUNCE=1
verhindert, dass notify.sh die Meldung ZURÜCK in den Briefkasten spiegelt — der Absender
(Wächter/Erinnerung) hat sie dort schon selbst abgelegt. Auf Windows (Dev) ein No-op."""
(Wächter/Erinnerung) hat sie dort schon selbst abgelegt. Auf Windows (Dev) ein No-op.
dringend: geht auch nachts sofort raus. knoepfe (seit 25.09.2026, Rückfragen): Antworttasten wie „ja 7“ —
ein Tipp schickt genau diesen Text an Lucy."""
if not (os.name == "posix" and shutil.which("bash")):
return
befehl = ["bash", NOTIFY_SH] + (["-d"] if dringend else [])
if knoepfe:
befehl += ["-k", "|".join(k.replace("|", " ") for k in knoepfe)]
try:
subprocess.run(["bash", NOTIFY_SH, "-s", subject, text],
subprocess.run([*befehl, "-s", subject, text],
timeout=30, capture_output=True, env={**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1"})
except Exception:
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
+179
View File
@@ -0,0 +1,179 @@
"""Lucy untersucht rote Hinweise (Ausbauplan Welle 3, Punkt 14 „Lucy ermittelt, schlägt vor, prüft nach“, seit
25.09.2026).
Der Wächter beider Teile meldet jeden roten Hinweis, bevor er ihn nach Telegram schickt, hierher
(POST /api/lucy/befund → befund_gemeldet()). Zurück bekommt er die Zeilen, die er an seine Nachricht hängt:
• Hat der Hinweis einen Knopf (Neustart, Job wiederholen, Freigeben), wird er zur Rückfrage: „Antworte „ja 7“, dann
starte ich … neu.“ (services/rueckfragen.py)
• Je nach Stufe der Aktionsart „ermitteln“ (services/lucy_autonomie.py):
selbst Lucy sieht sofort nach — über ihren eigenen Zugang (Hermes-api_server: dieselbe Lucy wie in Telegram,
mit allen Werkzeugen) — und schickt Ursache und Lösungsweg als eine Nachricht. Rückfragen und Meldungen
danach, die sie dabei auslöst, hängen an genau dieser Nachricht (rueckfragen.gesammelt()).
fragen „Antworte „ja 8“, dann sehe ich nach“ (nur, wenn der Hinweis selbst keinen Knopf hat)
lesen nichts; sie erklärt, wenn der Commander fragt
Bremsen: je Hinweis höchstens einmal in sechs Stunden, höchstens acht Untersuchungen am Tag, eine nach der anderen.
"""
import logging
import re
import threading
import time
from urllib.parse import quote
import httpx
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL
from services import lucy_autonomie, rueckfragen
log = logging.getLogger(__name__)
BETREFF = "[Lucy ermittelt]"
ZEITLIMIT_S = 600.0
JE_HINWEIS_S = 6 * 3600
JE_TAG = 8
_ANGEBOT = ("neustart", "job-wiederholen", "freigeben")
AUFTRAG = """Der Wächter des Orchestrators meldet einen roten Hinweis ({wo}):
„{titel}“ — {text}
(Kennung {id})
Sieh nach, was dahintersteckt: lage(), protokoll() und {logs}. Belege, statt zu raten.
Danach antworte dem Commander in höchstens sechs Sätzen; die Antwort geht so, wie sie ist, an Telegram: was los ist, \
woran es liegt, was betroffen ist und welcher Weg es löst.
Ist eine Aktion nötig, nimm das passende Werkzeug mit auf_bitte=false — deine Freiheiten (freiheiten()) entscheiden, ob \
du selbst handelst oder den Commander fragst; die Antwortzeile einer Rückfrage hängt das System an, schreib sie nicht \
selbst. Einen bewusst gestoppten Dienst startest du nicht selbst. Hast du selbst gehandelt, prüfe mit lage() nach, ob \
der Hinweis weg ist, und sag es."""
_lock = threading.Lock() # eine Untersuchung nach der anderen
_zaehler_lock = threading.Lock()
_zuletzt: dict[str, float] = {} # Hinweis → Beginn der letzten Untersuchung (nach einem Neustart von vorn: harmlos)
_heute: list[float] = []
def angebot(aktion: dict) -> str | None:
"""Was ein Knopf des Hinweises tut, als Satzende nach „dann …“."""
aid, label = aktion.get("id"), str(aktion.get("label") or "")
if aid == "neustart":
dienst = aktion.get("dienst") or "den Dienst"
if label.startswith("Jetzt erneut"):
return f"lasse ich {dienst} jetzt noch einmal laufen"
return f"starte ich {dienst}" if label == "Starten" else f"starte ich {dienst} neu"
if aid == "job-wiederholen":
return "führe ich den Job noch einmal aus"
if aid == "freigeben":
return f"gebe ich {aktion.get('baustein') or 'den Baustein'} wieder für Updates frei"
return None
def _hinweis_pfad(bereich: str, hid: str, aid: str) -> str:
basis = "/api/hinweise" if bereich == "box" else f"{rueckfragen.HL}/hinweise"
return f"{basis}/{quote(hid, safe=':@-._')}/aktion/{aid}"
def befund_gemeldet(b: object, jetzt: float | None = None) -> dict:
"""POST /api/lucy/befund — vom Wächter bei einem roten Hinweis (neu oder als Erinnerung). Antwort: Zeilen und
Antwort-Knöpfe für seine Nachricht. Bei einer Erinnerung bietet er nur den Knopf wieder an (dieselbe Nummer)."""
d = b if isinstance(b, dict) else {}
bereich, hid = d.get("bereich"), str(d.get("id") or "")
if bereich not in ("box", "homelab") or not hid:
return {"ok": False, "detail": "bereich (box/homelab) und id fehlen."}
titel = " ".join(str(d.get("titel") or hid).split())[:200]
text = " ".join(str(d.get("text") or "").split())[:600]
befund = {"bereich": bereich, "id": hid, "titel": titel}
zeilen: list[str] = []
knoepfe: list[str] = []
aktion = next((a for a in d.get("aktionen") or [] if isinstance(a, dict) and a.get("id") in _ANGEBOT), None)
if aktion and (tut := angebot(aktion)):
r = rueckfragen.stellen(f"{titel}: Soll ich das beheben? Dann {tut}.", _hinweis_pfad(bereich, hid, aktion["id"]),
von="waechter", still=True, befund=befund, jetzt=jetzt,
schluessel=f"hinweis {bereich} {hid} {aktion['id']}")
if r["ok"]:
nr = r["rueckfrage"]["nr"]
zeilen.append(f"Antworte „ja {nr}“, dann {tut}.")
knoepfe += rueckfragen.knoepfe(nr)
if d.get("erinnerung"):
return {"ok": True, "zeilen": zeilen, "knoepfe": knoepfe}
stufe = lucy_autonomie.stufe("ermitteln")
if stufe == "selbst":
if starten({**befund, "text": text}, jetzt=jetzt)["ok"]:
zeilen.append("Ich sehe nach und melde mich mit Ursache und Weg.")
elif stufe == "fragen" and not knoepfe:
r = rueckfragen.stellen(f"Soll ich nachsehen, was hinter „{titel}“ steckt?", "/api/lucy/ermitteln",
{**befund, "text": text}, von="waechter", still=True, befund=befund, jetzt=jetzt,
schluessel=f"ermitteln {bereich} {hid}")
if r["ok"]:
nr = r["rueckfrage"]["nr"]
zeilen.append(f"Antworte „ja {nr}“, dann sehe ich nach, was dahintersteckt.")
knoepfe += rueckfragen.knoepfe(nr)
return {"ok": True, "zeilen": zeilen, "knoepfe": knoepfe}
def starten(befund: object, jetzt: float | None = None) -> dict:
"""Eine Untersuchung im Hintergrund (auch POST /api/lucy/ermitteln, das „ja“ einer Rückfrage). Das Ergebnis kommt
als Nachricht; hier nur, ob sie beginnt."""
jetzt = time.time() if jetzt is None else jetzt
d = befund if isinstance(befund, dict) else {}
if d.get("bereich") not in ("box", "homelab") or not d.get("id"):
return {"ok": False, "detail": "bereich (box/homelab) und id fehlen."}
if not HERMES_API_KEY:
return {"ok": False, "detail": "Lucys eigener Zugang (API_SERVER_KEY in ~/.hermes/.env) fehlt."}
schluessel = f"{d['bereich']}:{d['id']}"
with _zaehler_lock:
if jetzt - _zuletzt.get(schluessel, 0.0) < JE_HINWEIS_S:
return {"ok": False, "detail": "Diesen Hinweis habe ich vor Kurzem schon untersucht."}
_heute[:] = [t for t in _heute if jetzt - t < 86400]
if len(_heute) >= JE_TAG:
return {"ok": False, "detail": f"Heute habe ich schon {JE_TAG} Hinweise untersucht; weitere erst morgen."}
_zuletzt[schluessel] = jetzt
_heute.append(jetzt)
befund = {"bereich": d["bereich"], "id": str(d["id"])[:200], "titel": str(d.get("titel") or d["id"])[:200],
"text": str(d.get("text") or "")[:600]}
threading.Thread(target=_untersuchen, args=(befund,), name=f"ermittlung-{befund['id']}", daemon=True).start()
return {"ok": True, "text": "Lucy sieht nach; Ursache und Lösungsweg kommen als Nachricht."}
def auftrag(befund: dict) -> str:
if befund["bereich"] == "box":
wo, logs = "auf der KI-Box", "die Protokolle des Dienstes (dienst_logs() oder dein Terminal)"
else:
wo, logs = "im Homelab", "die Protokolle des Geräts (geraet_logs())"
return AUFTRAG.format(wo=wo, titel=befund["titel"], text=befund["text"] or "ohne weiteren Text",
id=befund["id"], logs=logs)
def _frag_lucy(text: str) -> str | None:
"""Eine Anfrage an Lucy über den api_server von Hermes (dieselbe Persona, alle Werkzeuge). None = keine Antwort."""
try:
antwort = httpx.post(f"{HERMES_API_URL}/v1/chat/completions",
json={"model": HERMES_API_MODEL, "stream": False,
"messages": [{"role": "user", "content": text}]},
headers={"Authorization": f"Bearer {HERMES_API_KEY}"}, timeout=ZEITLIMIT_S)
if antwort.status_code != 200:
log.warning("ermittlung: api_server antwortete mit HTTP %s", antwort.status_code)
return None
inhalt = antwort.json()["choices"][0]["message"]["content"]
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError):
log.warning("ermittlung: api_server nicht erreichbar", exc_info=True)
return None
inhalt = re.sub(r"<think>.*?</think>", "", str(inhalt or ""), flags=re.DOTALL).strip()
return inhalt[:3000] or None
def nachricht(befund: dict, antwort: str | None, gesammelt: list[dict]) -> tuple[str, list[str]]:
"""Lucys Antwort und alles, was sie dabei ausgelöst hat, als eine Nachricht mit den Antwort-Knöpfen."""
teile = [antwort or f"„{befund['titel']}“: Ich konnte gerade nicht nachsehen — mein eigener Zugang antwortete "
"nicht. Frag mich im Chat, dann versuche ich es dort."]
teile += [m["text"] for m in gesammelt]
knoepfe = [k for m in gesammelt for k in m.get("knoepfe") or []][-6:]
return "\n\n".join(teile), knoepfe
def _untersuchen(befund: dict) -> None:
with _lock:
log.info("ermittlung: %s:%s beginnt", befund["bereich"], befund["id"])
with rueckfragen.gesammelt() as gesammelt:
antwort = _frag_lucy(auftrag(befund))
text, knoepfe = nachricht(befund, antwort, gesammelt)
rueckfragen.melden(BETREFF, text, knoepfe=knoepfe)
log.info("ermittlung: %s:%s fertig (%s)", befund["bereich"], befund["id"], "Antwort" if antwort else "ohne")
+28
View File
@@ -0,0 +1,28 @@
"""Das Protokoll eines Containers für Lucy (seit 25.09.2026, Ausbauplan Welle 3, Punkt 14: „Lucy liest Logs und
Messwerte, erklärt den Befund“).
Über den Ausführer (Aktion journal, nur lesend): Warnungen und Fehler der letzten 24 Stunden aus dem Journal, ohne
systemd (Alpine) die letzten Zeilen von /var/log/messages. Nur für freigegebene Container — in eine VM kommt der
Ausführer nicht hinein. Lucys Werkzeug geraet_logs() liest es über GET /api/homelab/ziele/{id}/journal.
"""
from services.homelab import inventar, kanal
ZEITLIMIT_S = 90
def lesen(ziel_id: str, zeilen: int = 80) -> dict:
gast = inventar.gast(ziel_id)
if not gast:
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
if gast.get("art") != "lxc":
return {"ok": False, "detail": "Protokolle gibt es nur von Containern; in eine VM kommt der Ausführer nicht."}
if not gast.get("erlaubt"):
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
auftrag = kanal.anlegen("journal", {"vmid": gast["vmid"], "zeilen": max(20, min(int(zeilen), 300))})
ergebnis = kanal.warten(auftrag, ZEITLIMIT_S)
if not ergebnis:
return {"ok": False, "detail": "Der Ausführer hat nicht rechtzeitig geantwortet."}
if ergebnis.get("status") != "fertig":
return {"ok": False, "detail": str(ergebnis.get("text") or "Das Protokoll ließ sich nicht lesen.")[:500]}
return {"ok": True, "text": str(ergebnis.get("text") or "").strip() or "(keine Warnungen oder Fehler)"}
+1 -1
View File
@@ -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", "changelog"}
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog", "journal"}
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 …
+2
View File
@@ -72,6 +72,8 @@ _AKTION = {
"etikett_setzen": ("Etikett gesetzt", "Etikett setzen gescheitert", "Etikett wird gesetzt", "Etikett setzen"),
"changelog": ("Änderungslisten gelesen", "Änderungslisten nicht gelesen", "Änderungslisten werden gelesen",
"Änderungslisten"),
# Seit 25.09.2026: Lucy liest das Protokoll eines Containers, wenn sie einen Hinweis untersucht.
"journal": ("Protokoll gelesen (Lucy)", "Protokoll lesen gescheitert", "Protokoll wird gelesen", "Protokoll lesen"),
}
_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",
+25 -1
View File
@@ -35,6 +35,8 @@ import re
import threading
import time
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
@@ -44,6 +46,11 @@ from kern.zeit import LOCAL_TZ
from services import announce, update_verlauf
from services.homelab import apps, arcane, inventar, kanal, karenz, sicherheit
try:
import fcntl # Linux: Sperre über Prozessgrenzen
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
fcntl = None
log = logging.getLogger(__name__)
WARTEN_NACH_UPDATE_S = 20
@@ -74,8 +81,25 @@ def _laeufe() -> list[dict]:
return daten if isinstance(daten, list) else []
@contextmanager
def _datei_sperre() -> Iterator[None]:
"""Seit 25.09.2026 schreiben zwei Prozesse Läufe: die Oberfläche (Knöpfe) und der Steward (Wartungsfenster). Ohne
flock könnte einer den Lauf des anderen mit einer älteren Liste überschreiben."""
if fcntl is None:
yield
return
pfad = _pfad().with_suffix(".lock")
pfad.parent.mkdir(parents=True, exist_ok=True)
with open(pfad, "a", encoding="utf-8") as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(f, fcntl.LOCK_UN)
def _merken(lauf: dict) -> None:
with _lock:
with _lock, _datei_sperre():
laeufe = [x for x in _laeufe() if x["id"] != lauf["id"]] + [lauf]
_pfad().parent.mkdir(parents=True, exist_ok=True)
tmp = _pfad().with_suffix(".tmp")
+133
View File
@@ -0,0 +1,133 @@
"""Wie selbstständig Lucy handelt — je Aktionsart (Ausbauplan Welle 3, Punkt 14, seit 25.09.2026).
User-Entscheid 25.09.2026 „Mehr Freiheit“: Lucy untersucht und erklärt von selbst und startet Dienste selbst neu; sie
darf zusätzlich Sicherheitsupdates einspielen und bei Rot auf den Snapshot zurückgehen — mit Meldung danach. Andere
Updates, das Aufräumen, Modell-Tests und den Neustart des Proxmox-Hosts gibt es erst nach dem Ja des Commanders.
Je Aktionsart umstellbar (Einstellungen → Lucy):
lesen nur lesen und erklären: Sie handelt nicht und fragt nicht — außer der Commander bittet sie ausdrücklich
fragen erst fragen: eine Rückfrage (services/rueckfragen.py); sein „ja“ löst genau diese Aktion aus
selbst selbst handeln, Meldung danach (gehandelt())
Bittet der Commander Lucy im Gespräch ausdrücklich um etwas, ist das sein Ja. Die Stufen gelten für alles, was sie aus
eigenem Antrieb tut — auch in geplanten Läufen und wenn sie einen roten Hinweis untersucht. Gelesen werden sie von
Lucys Werkzeugen (mcp/orchestrator_werkzeuge.py) und beim Untersuchen roter Hinweise (services/ermittlung.py).
"""
import json
import logging
import threading
from dataclasses import dataclass
from pathlib import Path
from kern.einstellungen import einstellungen
log = logging.getLogger(__name__)
STUFEN = {"lesen": "Nur lesen und erklären", "fragen": "Erst fragen", "selbst": "Selbst handeln, Meldung danach"}
STUFE_KURZ = {"lesen": "nur lesen", "fragen": "erst fragen", "selbst": "selbst handeln"}
BETREFF_GEHANDELT = "[Lucy selbst]"
@dataclass(frozen=True)
class Art:
id: str
name: str
text: str
standard: str
ARTEN: tuple[Art, ...] = (
Art("ermitteln", "Rote Hinweise untersuchen",
"Lucy liest Protokolle und Messwerte und schickt dir Ursache und Lösungsweg. Selbst: sobald ein Hinweis rot "
"wird. Erst fragen: nach deinem Ja. Nur lesen: wenn du sie fragst.", "selbst"),
Art("neustart", "Dienste neu starten",
"Dienste der KI-Box (Hermes, Motor, Steuerpult …), die hängen oder abgestürzt sind. Bewusst gestoppte startet "
"sie nie von selbst.", "selbst"),
Art("sicherheit", "Sicherheitsupdates einspielen",
"Nur Pakete, die eine Sicherheitslücke schließen, mit Snapshot vorher und Rückweg bei Rot. Der Proxmox-Host "
"startet dabei nicht neu.", "selbst"),
Art("rueckweg", "Bei Rot auf den Snapshot zurück",
"Nur für ein Gerät, das gerade rot ist. Was seit dem Snapshot dort geschrieben wurde, geht verloren.", "selbst"),
Art("suchen", "Nach Updates und Modellen suchen", "Fragt nur nach neuen Versionen, ändert nichts.", "selbst"),
Art("updates", "Andere Updates einspielen",
"Apps, Pakete, Docker, Motor, llama-swap, Hermes und „Alle aktualisieren“.", "fragen"),
Art("aufraeumen", "Alte Kernel entfernen", "Auf dem Proxmox-Host; der laufende und der nächste Kernel bleiben.",
"fragen"),
Art("radar", "Modelle im Radar testen", "Ein Test belegt die KI-Box einige Minuten; Lucy antwortet dann langsamer.",
"fragen"),
Art("host_neustart", "Proxmox-Host neu starten",
"Alle Container und die Arcane-VM sind ein paar Minuten weg, auch AdGuard (DNS im Heimnetz).", "fragen"),
)
_ARTEN = {a.id: a for a in ARTEN}
_lock = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "lucy-autonomie.json"
def _gespeichert() -> dict[str, str]:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
werte = daten.get("stufen") if isinstance(daten, dict) else None
return {k: v for k, v in (werte or {}).items() if k in _ARTEN and v in STUFEN}
def stufen() -> dict[str, str]:
return {a.id: a.standard for a in ARTEN} | _gespeichert()
def stufe(art: str) -> str:
"""Die Stufe einer Aktionsart. Eine unbekannte Art gilt als „fragen“: im Zweifel nie ungefragt."""
return stufen().get(art, "fragen")
def stand() -> dict:
"""Für GET /api/lucy/autonomie (Einstellungen → Lucy) und Lucys Werkzeug freiheiten()."""
aktuell = stufen()
return {"arten": [{"id": a.id, "name": a.name, "text": a.text, "stufe": aktuell[a.id], "standard": a.standard}
for a in ARTEN],
"stufen": STUFEN}
def setzen(neu: object) -> dict:
"""POST /api/lucy/autonomie {"stufen": {art: stufe}} — nur bekannte Arten und Stufen. Gespeichert wird, was der
Commander gewählt hat, auch wenn es dem Standard entspricht (ändert sich ein Standard, bleibt seine Wahl)."""
werte = neu.get("stufen") if isinstance(neu, dict) else None
if not isinstance(werte, dict) or not werte:
return {"ok": False, "detail": "Es fehlt, was sich ändern soll."}
for art, s in werte.items():
if art not in _ARTEN:
return {"ok": False, "detail": f"Die Aktionsart „{art}“ gibt es nicht."}
if s not in STUFEN:
return {"ok": False, "detail": f"„{s}“ ist keine Stufe (lesen, fragen oder selbst)."}
with _lock:
gespeichert = _gespeichert() | werte
pfad = _pfad()
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(".tmp")
tmp.write_text(json.dumps({"stufen": gespeichert}, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(pfad)
teile = [f"{_ARTEN[a].name}: {STUFE_KURZ[s]}" for a, s in werte.items()]
log.info("lucy-autonomie: %s", "; ".join(teile))
return {"ok": True, "text": "Gespeichert — " + "; ".join(teile) + "."}
def gehandelt(daten: object) -> dict:
"""POST /api/lucy/gehandelt — die Meldung danach: Lucys Werkzeuge melden hier, was sie auf der Stufe „selbst“ aus
eigenem Antrieb getan hat (was, warum, Ergebnis). Nachts kommt das mit der Morgenmeldung."""
from services import rueckfragen
d = daten if isinstance(daten, dict) else {}
was = " ".join(str(d.get("was") or "").split())[:300].rstrip(".")
if not was:
return {"ok": False, "detail": "Es fehlt, was Lucy getan hat."}
grund = " ".join(str(d.get("grund") or "").split())[:500]
ergebnis = " ".join(str(d.get("ergebnis") or "").split())[:500]
text = f"{was}." + (f" Warum: {grund}" if grund else "") + (f"\n{ergebnis}" if ergebnis else "")
rueckfragen.melden(BETREFF_GEHANDELT, text)
log.info("lucy-autonomie: selbst gehandelt — %s", was)
return {"ok": True, "text": "Gemeldet."}
+349
View File
@@ -0,0 +1,349 @@
"""Rückfragen an den Commander (Ausbauplan Welle 3, Punkt 15, seit 25.09.2026).
Eine Rückfrage ist genau eine Aktion — derselbe Knopf, den auch die Oberfläche hat —, die erst nach dem Ja des
Commanders läuft. Stellen können sie: Lucy (ihre Werkzeuge auf der Stufe „erst fragen“, services/lucy_autonomie.py),
der Wächter beider Teile bei roten Hinweisen mit Knopf (services/ermittlung.py) und der Wochenbericht für offene
Updates. Jede trägt eine Nummer, die nie wieder vergeben wird. Telegram bekommt „Antworte „ja 7“ oder „nein 7““ und
zwei Antwort-Knöpfe: eine Antworttastatur, deren Tipp nur den Text „ja 7“ schickt. Den liest Lucy wie jede Nachricht und
ruft ihr Werkzeug rueckfrage_beantworten — an Hermes' Code ändert das nichts (Vorgabe des Ausbauplans: echte Knöpfe nur
ohne Eingriff in Hermes). Die Oberfläche zeigt offene Rückfragen mit Ja und Nein.
User-Entscheid 25.09.2026: Ein „ja“ gilt für genau die gestellte Frage, auch für den Neustart des Proxmox-Hosts. Darum:
• Stellen lassen sich nur Pfade aus ERLAUBT — die Knöpfe der Oberfläche, nie Löschen, nie Einstellungen.
• Ausgeführt wird die gespeicherte Aktion, nicht, was in der Antwort steht. Vor dem Ausführen steht sie auf „läuft“;
ein zweites „ja“ tut nichts mehr.
• Eine Rückfrage gilt 24 Stunden. Gehört sie zu einem Hinweis, der inzwischen weg ist, passiert nichts („hat sich
erledigt“). Nach dem Ausführen prüft sie nach, ob der Hinweis verschwindet, und meldet sich, wenn nicht.
Dringlichkeit: dringend (sofort, auch nachts), normal (tagsüber sofort, nachts mit der Morgenmeldung), leise (keine
Nachricht, nur Oberfläche und Wochenbericht).
Die Rückfragen gehören der KI-Box (dort wohnt Lucy) und nur MC2 dort schreibt die Datei. Steward und Homelab-Teil
stellen sie per HTTP (box_url()); Pfade des Homelab-Teils (/api/homelab/…) werden dabei zur Durchreiche
(/api/partner/homelab/…), denn ausgeführt wird von der Box aus.
"""
import json
import logging
import os
import re
import threading
import time
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
import httpx
from kern.einstellungen import einstellungen
from services import announce
log = logging.getLogger(__name__)
GUELTIG_S = 24 * 3600
BEHALTEN = 200
STUFEN = ("dringend", "normal", "leise")
BETREFF = "[Rückfrage]"
EIGENE_URL = os.environ.get("MC_EIGENE_URL", "http://127.0.0.1:9001").rstrip("/")
AUSFUEHREN_S = 120.0
NACHPRUEFEN_S = 6 * 60 # der Wächter braucht drei Takte, bis ein Hinweis als erledigt gilt
NACHPRUEF_TAKT_S = 30.0
STATUS_TEXT = {"offen": "offen", "laeuft": "wird gerade ausgeführt", "erledigt": "schon erledigt",
"fehler": "schon beantwortet (die Aktion scheiterte)", "abgelehnt": "abgelehnt",
"abgelaufen": "abgelaufen (älter als 24 Stunden)", "hinfaellig": "hinfällig (der Hinweis war weg)"}
HL = "/api/partner/homelab"
_ZIEL = r"(?:pve|(?:ct|vm)-\d+)"
_HINWEIS_AKTION = r"[^/]+/aktion/(?:neustart|job-wiederholen|freigeben)"
# Die Knöpfe, die ein „ja“ auslösen darf (Pfade von der Box aus gesehen). Nie: Löschen, Einstellungen, Zugänge.
ERLAUBT = tuple(re.compile(m) for m in (
rf"{HL}/ziele/{_ZIEL}/(?:update|os-update|docker|sicherheitsupdates|suchen)",
rf"{HL}/ziele/pve/(?:neustart|kernel-aufraeumen)",
rf"{HL}/alle",
rf"{HL}/snapshots/{_ZIEL}/mc2-[\w.-]+/zuruecksetzen",
rf"{HL}/sicherungen/probe",
rf"{HL}/hinweise/{_HINWEIS_AKTION}",
rf"/api/hinweise/{_HINWEIS_AKTION}",
r"/api/maintenance/(?:check-updates|os-update|engine-update|swap-update|hermes-update|update-all|restart)",
r"/api/updates/festgehalten/[\w-]+/freigeben",
r"/api/radar/suche",
r"/api/radar/[\w.:@-]+/testen",
r"/api/lucy/ermitteln",
))
_lock = threading.Lock()
def erlaubt(pfad: str) -> bool:
return any(m.fullmatch(pfad) for m in ERLAUBT)
def box_pfad(pfad: str) -> str:
"""Pfade des Homelab-Teils (/api/homelab/…) laufen auf der Box über die Durchreiche."""
return HL + pfad[len("/api/homelab"):] if pfad.startswith("/api/homelab/") else pfad
def box_url() -> str:
"""Wo die Rückfragen wohnen (MC2 auf der KI-Box) — für Prozesse außerhalb davon: den Steward der Box (dieselbe
Adresse wie MC2 selbst) und den Homelab-Teil (seine Partner-Adresse). Leer = keine Box bekannt."""
e = einstellungen()
return EIGENE_URL if e.rolle == "box" else e.partner_url
# --- Meldungen (mit Sammler für Lucys Untersuchungen) ------------------------------------------------------------
_sammel_lock = threading.Lock()
_sammler: list[dict] | None = None
def melden(betreff: str, text: str, knoepfe: list[str] | None = None, dringend: bool = False) -> None:
"""Eine Nachricht in Lucys Namen: Briefkasten (die Desktop-Lucy spricht sie) und Telegram. Untersucht Lucy gerade
einen Hinweis (gesammelt()), hängt sie an deren Nachricht — außer sie ist dringend."""
with _sammel_lock:
if _sammler is not None and not dringend:
_sammler.append({"betreff": betreff, "text": text, "knoepfe": list(knoepfe or [])})
return
try:
announce.add(text, subject=betreff, source="lucy")
except ValueError:
return
announce.notify_telegram(betreff, text, dringend=dringend, knoepfe=knoepfe)
@contextmanager
def gesammelt() -> Iterator[list[dict]]:
"""Solange Lucy einen Hinweis untersucht: Rückfragen und Meldungen danach sammeln statt einzeln schicken."""
global _sammler
liste: list[dict] = []
with _sammel_lock:
_sammler = liste
try:
yield liste
finally:
with _sammel_lock:
_sammler = None
def antwortzeile(nr: int) -> str:
return f"Antworte „ja {nr}“ oder „nein {nr}“."
def knoepfe(nr: int) -> list[str]:
return [f"ja {nr}", f"nein {nr}"]
def nachricht(r: dict) -> str:
return r["frage"] + (f" {r['text']}" if r.get("text") else "") + "\n" + antwortzeile(r["nr"])
# --- Ablage ----------------------------------------------------------------------------------------------------
def _pfad() -> Path:
return einstellungen().daten_dir / "lucy-rueckfragen.json"
def _lesen() -> dict:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
if isinstance(daten, dict) and isinstance(daten.get("fragen"), list) and isinstance(daten.get("naechste"), int):
return daten
except (OSError, ValueError):
pass
return {"naechste": 1, "fragen": []}
def _schreiben(daten: dict) -> None:
daten["fragen"] = daten["fragen"][-BEHALTEN:]
pfad = _pfad()
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(pfad)
def _ablaufen(daten: dict, jetzt: float) -> bool:
geaendert = False
for f in daten["fragen"]:
if f.get("status") == "offen" and jetzt >= float(f.get("ablauf") or 0):
f["status"] = "abgelaufen"
geaendert = True
return geaendert
def _befund(b: object) -> dict | None:
if not isinstance(b, dict) or b.get("bereich") not in ("box", "homelab") or not b.get("id"):
return None
return {"bereich": b["bereich"], "id": str(b["id"])[:200], "titel": str(b.get("titel") or b["id"])[:200]}
def _oeffentlich(f: dict) -> dict:
return {k: v for k, v in f.items() if k != "schluessel"}
# --- Stellen, Lesen, Beantworten -----------------------------------------------------------------------------------
def stellen(frage: str, pfad: str, daten: dict | None = None, *, text: str = "", von: str = "lucy",
stufe: str = "normal", schluessel: str | None = None, befund: object = None, still: bool = False,
jetzt: float | None = None) -> dict:
"""Eine Rückfrage anlegen und (außer still oder leise) melden. Dieselbe Aktion zweimal offen gibt es nicht: Dann
kommt die offene zurück („neu“: False) und niemand wird ein zweites Mal gefragt.
still: Wer fragt, schickt die Antwortzeile in seiner eigenen Nachricht mit (der Wächter, der Wochenbericht)."""
jetzt = time.time() if jetzt is None else jetzt
frage = " ".join(str(frage or "").split())[:300]
pfad = box_pfad(str(pfad or "").strip())
daten = {} if daten is None else daten
if not frage:
return {"ok": False, "detail": "Es fehlt die Frage."}
if not erlaubt(pfad):
return {"ok": False, "detail": f"Diese Aktion lässt sich nicht per Rückfrage auslösen ({pfad or 'ohne Pfad'})."}
if stufe not in STUFEN:
return {"ok": False, "detail": "Die Dringlichkeit muss dringend, normal oder leise sein."}
if not isinstance(daten, dict) or len(json.dumps(daten, ensure_ascii=False)) > 2000:
return {"ok": False, "detail": "Die Angaben zur Aktion sind ungültig."}
schluessel = str(schluessel or f"{pfad} {json.dumps(daten, sort_keys=True, ensure_ascii=False)}")[:400]
with _lock:
alle = _lesen()
geaendert = _ablaufen(alle, jetzt)
offen = next((f for f in alle["fragen"] if f["status"] == "offen" and f.get("schluessel") == schluessel), None)
if offen is not None:
if geaendert:
_schreiben(alle)
return {"ok": True, "neu": False, "rueckfrage": _oeffentlich(offen)}
r = {"nr": alle["naechste"], "zeit": jetzt, "frage": frage, "text": " ".join(str(text or "").split())[:1500],
"pfad": pfad, "daten": daten, "von": str(von or "lucy")[:40], "stufe": stufe,
"ablauf": jetzt + GUELTIG_S, "status": "offen", "schluessel": schluessel, "befund": _befund(befund)}
alle["naechste"] += 1
alle["fragen"].append(r)
_schreiben(alle)
log.info("rueckfrage %s von %s: %s → %s", r["nr"], r["von"], frage, pfad)
if not still and stufe != "leise":
melden(BETREFF, nachricht(r), knoepfe=knoepfe(r["nr"]), dringend=stufe == "dringend")
return {"ok": True, "neu": True, "rueckfrage": _oeffentlich(r)}
def liste(grenze: int = 20, jetzt: float | None = None) -> dict:
"""Für GET /api/rueckfragen: die offenen (neueste zuerst) und die zuletzt beantworteten."""
jetzt = time.time() if jetzt is None else jetzt
with _lock:
alle = _lesen()
if _ablaufen(alle, jetzt):
_schreiben(alle)
fragen = [_oeffentlich(f) for f in reversed(alle["fragen"])]
return {"offen": [f for f in fragen if f["status"] == "offen"],
"zuletzt": [f for f in fragen if f["status"] != "offen"][:max(1, min(int(grenze), 100))]}
def offene() -> list[dict]:
return liste(1)["offen"]
def beantworten(nr: int | None, ja: bool, wer: str = "oberflaeche", jetzt: float | None = None,
nachpruefen: bool = True) -> dict:
"""„ja“ führt genau die gespeicherte Aktion aus, „nein“ legt sie ab. Ohne Nummer nur, wenn genau eine offen ist."""
jetzt = time.time() if jetzt is None else jetzt
with _lock:
alle = _lesen()
if _ablaufen(alle, jetzt):
_schreiben(alle)
offen = [f for f in alle["fragen"] if f["status"] == "offen"]
if nr is None:
if not offen:
return {"ok": False, "detail": "Es ist keine Rückfrage offen."}
if len(offen) > 1:
welche = "; ".join(f"{f['nr']}: {f['frage']}" for f in offen[-5:])
return {"ok": False, "detail": f"Es sind mehrere Rückfragen offen — welche meinst du? {welche}"}
r = offen[0]
else:
r = next((f for f in alle["fragen"] if f["nr"] == nr), None)
if r is None:
return {"ok": False, "detail": f"Eine Rückfrage {nr} gibt es nicht."}
if r["status"] != "offen":
return {"ok": False, "detail": f"Rückfrage {nr} ist {STATUS_TEXT.get(r['status'], r['status'])}."}
r["status"] = "laeuft" if ja else "abgelehnt"
r["antwort"] = {"zeit": jetzt, "wer": str(wer or "")[:40], "ja": bool(ja)}
_schreiben(alle)
r = dict(r)
if not ja:
log.info("rueckfrage %s: nein (%s)", r["nr"], wer)
return {"ok": True, "text": f"Rückfrage {r['nr']} abgelehnt, es passiert nichts: {r['frage']}"}
if r.get("befund") and befund_besteht(r["befund"]) is False:
_abschliessen(r["nr"], "hinfaellig", "Der Hinweis war schon weg.")
return {"ok": True, "text": f"Rückfrage {r['nr']} hat sich erledigt: „{r['befund']['titel']}“ ist schon weg. "
"Ich habe nichts ausgeführt."}
ergebnis = _ausfuehren(r)
_abschliessen(r["nr"], "erledigt" if ergebnis["ok"] else "fehler", ergebnis.get("text") or ergebnis.get("detail"))
log.info("rueckfrage %s: ja (%s) → %s", r["nr"], wer, "ok" if ergebnis["ok"] else ergebnis.get("detail"))
if not ergebnis["ok"]:
return {"ok": False, "detail": f"Rückfrage {r['nr']}: Das ging nicht — {ergebnis['detail']}"}
if nachpruefen and r.get("befund"):
threading.Thread(target=_nachpruefen, args=(r,), name=f"rueckfrage-{r['nr']}", daemon=True).start()
return {"ok": True, "text": f"Rückfrage {r['nr']} ausgeführt: {ergebnis['text']}"}
def _abschliessen(nr: int, status: str, text: str | None, **mehr: object) -> None:
with _lock:
alle = _lesen()
r = next((f for f in alle["fragen"] if f["nr"] == nr), None)
if r is not None:
r["status"] = status
r["ergebnis"] = str(text or "")[:500]
r.update(mehr)
_schreiben(alle)
def _vermerken(nr: int, **mehr: object) -> None:
with _lock:
alle = _lesen()
r = next((f for f in alle["fragen"] if f["nr"] == nr), None)
if r is not None:
r.update(mehr)
_schreiben(alle)
def _ausfuehren(r: dict) -> dict:
"""Die gespeicherte Aktion wie ein Knopf der Oberfläche (POST an MC2 selbst). Rückgabe ok + text oder detail."""
if not erlaubt(r["pfad"]): # doppelt hält besser: Die Datei könnte von Hand geändert worden sein.
return {"ok": False, "detail": "Diese Aktion ist nicht (mehr) erlaubt."}
try:
antwort = httpx.post(f"{EIGENE_URL}{r['pfad']}", json=r.get("daten") or {}, timeout=AUSFUEHREN_S)
except httpx.HTTPError as exc:
return {"ok": False, "detail": f"das Steuerpult antwortete nicht ({exc.__class__.__name__})"}
try:
daten = antwort.json() if antwort.content else {}
except ValueError:
daten = {}
daten = daten if isinstance(daten, dict) else {}
if antwort.status_code >= 400 or daten.get("ok") is False:
return {"ok": False, "detail": str(daten.get("detail") or daten.get("err") or daten.get("text")
or f"HTTP {antwort.status_code}")}
return {"ok": True, "text": str(daten.get("text") or "Gestartet; das Ergebnis kommt als Meldung.")}
def befund_besteht(befund: dict) -> bool | None:
"""Steht der Hinweis noch? None = unbekannt (Wächter nicht lesbar). Ein Hinweis, den der letzte Takt nicht mehr sah,
läuft nur noch im Puffer des Wächters aus (ERLEDIGT_NACH) und gilt hier schon als weg."""
pfad = "/api/hinweise" if befund.get("bereich") == "box" else f"{HL}/hinweise"
try:
antwort = httpx.get(f"{EIGENE_URL}{pfad}", timeout=10)
stand = antwort.json() if antwort.status_code == 200 else None
except (httpx.HTTPError, ValueError):
return None
if not isinstance(stand, dict) or not stand.get("aktiv", True):
return None
h = next((x for x in stand.get("hinweise") or [] if x.get("id") == befund.get("id")), None)
if h is None:
return False
return float(h.get("zuletzt") or 0) >= float(stand.get("stand") or 0) - 1
def _nachpruefen(r: dict, dauer_s: float = NACHPRUEFEN_S, takt_s: float = NACHPRUEF_TAKT_S) -> None:
"""Nach dem Ausführen: Verschwindet der Hinweis? Dann still (die Entwarnung schickt der Wächter selbst). Steht er
nach sechs Minuten noch, sagt Lucy es — ein „ja“ darf nicht folgenlos im Sand verlaufen."""
ende = time.monotonic() + dauer_s
while time.monotonic() < ende:
time.sleep(takt_s)
if befund_besteht(r["befund"]) is False:
_vermerken(r["nr"], nachgeprueft="behoben")
return
_vermerken(r["nr"], nachgeprueft="besteht")
melden(BETREFF, f"Nachgeprüft: „{r['befund']['titel']}“ steht {round(dauer_s / 60)} Minuten nach Rückfrage "
f"{r['nr']} immer noch an. Das hat nicht gereicht — frag mich, dann sehe ich nach.")
+31 -4
View File
@@ -51,7 +51,7 @@ from kern import partner, prognose
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import announce, llamaswap, maintenance, probe_wiederherstellung, update_verlauf
from services import announce, llamaswap, maintenance, probe_wiederherstellung, rueckfragen, update_verlauf
log = logging.getLogger(__name__)
ROLLE = einstellungen().rolle
@@ -83,6 +83,9 @@ VERLAUF_MAX = 200
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber
# nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter.
TROCKEN = os.environ.get("MC_WAECHTER_TROCKEN", "") == "1"
# Rote Hinweise an Lucy auf der KI-Box (seit 25.09.2026, _an_lucy). Die Tests schalten das ab (conftest.py) — auf der
# Box liefe es sonst beim Prüftor gegen das echte MC2.
LUCY_BEFUND = os.environ.get("MC_LUCY_BEFUND", "1") != "0"
# Im Steward-Modus beobachtet er auch MC2 selbst und den Gateway (wie der alte Wächter).
WATCH_MC2 = os.environ.get("MC_SENTRY_WATCH_MC2", os.environ.get("MC_WAECHTER_WATCH_MC2", "")) == "1"
@@ -112,6 +115,8 @@ TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
# Seit 24.09.2026 im Repo (vom 21.08. an aus, weil sie rot lief und niemand es sah). Gelb: Die tägliche
# Sicherung (mc2-backup) bleibt die erste Schicht, der PBS ist die Kopie auf dem QNAP. Ausgeschaltet kein Befund.
"pbs-backup": ("Sicherung auf den PBS", False),
# Seit 25.09.2026 (Ausbauplan Welle 3): sonntags 07:30 der Wochenbericht in Lucys Stimme.
"mc2-wochenbericht": ("Wochenbericht", False),
}
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
@@ -674,14 +679,34 @@ def _verlauf(art: str, hinweis_id: str, text: str, stufe: str | None = None, que
del _stand["verlauf"][:-VERLAUF_MAX]
def _telegram(betreff: str, text: str) -> None:
def _telegram(betreff: str, text: str, knoepfe: list[str] | None = None) -> None:
if TROCKEN:
log.info("waechter (trocken): würde melden %s %s", betreff, text)
return
announce.add(text, subject=betreff, source="waechter")
# Bis 25.09.2026 hing hier „(Diese Meldung kam auch an Lucy.)“ an — als wäre Lucy jemand anderes als der Absender.
# Sie IST der Absender (ihr Bot, ihre Stimme, siehe notify.sh); im Briefkasten oben liegt die Meldung trotzdem.
announce.notify_telegram(betreff, text)
announce.notify_telegram(betreff, text, knoepfe=knoepfe)
def _an_lucy(b: Befund, erinnerung: bool) -> tuple[list[str], list[str]]:
"""Roter Hinweis (seit 25.09.2026, Ausbauplan Welle 3): Auf der KI-Box entscheidet Lucy, ob sie nachsieht
(services/ermittlung.py), und der Knopf des Hinweises wird zur Rückfrage. Zurück kommen die Zeilen für diese
Nachricht („Antworte „ja 7“, dann …“) und die Antworttasten. Ist die Box nicht erreichbar, bleibt es wie bisher."""
if TROCKEN or not LUCY_BEFUND:
return [], []
basis = rueckfragen.box_url()
if not basis:
return [], []
try:
antwort = httpx.post(f"{basis}/api/lucy/befund", timeout=8, json={
"bereich": ROLLE, "id": b.id, "titel": b.titel, "text": b.text, "stufe": b.stufe, "quelle": b.quelle,
"aktionen": b.aktionen, "erinnerung": erinnerung})
daten = antwort.json() if antwort.status_code == 200 else {}
except (httpx.HTTPError, ValueError):
return [], []
daten = daten if isinstance(daten, dict) else {}
return [str(z) for z in daten.get("zeilen") or []][:3], [str(k) for k in daten.get("knoepfe") or []][:6]
def _auto_erlaubt(schluessel: str, jetzt: float) -> bool:
@@ -750,7 +775,9 @@ def takt() -> None:
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S) \
and not ist_stumm(zustaende.get(b.id), b.stufe, jetzt):
vorsatz = "" if neu else "Immer noch: "
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}")
zeilen, knoepfe = _an_lucy(b, erinnerung=not neu)
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}" + "".join(f"\n{z}" for z in zeilen),
knoepfe or None)
h["gemeldet"] = jetzt
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
+392
View File
@@ -0,0 +1,392 @@
"""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)}