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
+6
View File
@@ -10,3 +10,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Der Totmannschalter (services/ablaeufe.py) fragt Gitea mit dem Token aus ~/.git-credentials — in Tests nie echt,
# auch nicht beim Probelauf auf der Box, wo die Datei liegt.
os.environ.setdefault("MC_GIT_ZUGANG", str(Path(__file__).parent / "fixtures" / "keine-git-zugaenge"))
# Rückfragen, Lucys Untersuchungen und der Wochenbericht rufen MC2 selbst per HTTP (127.0.0.1:9001). Beim Prüftor auf
# der Box wäre das das ECHTE MC2 — in Tests zeigt die Adresse deshalb ins Leere (Port 9), und der Wächter meldet rote
# Hinweise nicht an Lucy. Tests, die das brauchen, stellen es selbst nach.
os.environ["MC_EIGENE_URL"] = "http://127.0.0.1:9"
os.environ["MC_LUCY_BEFUND"] = "0"
+1 -1
View File
@@ -15,7 +15,7 @@ def wache(tmp_path, monkeypatch):
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
monkeypatch.setattr(waechter, "FAIL_AFTER", 2)
meldungen: list[str] = []
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(f"{betreff} {text}"))
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text, knoepfe=None: meldungen.append(f"{betreff} {text}"))
befund = {"stufe": "rot", "da": True}
def pruefung():
+119
View File
@@ -0,0 +1,119 @@
"""Lucy untersucht rote Hinweise (Ausbauplan Welle 3, Punkt 14, 25.09.2026): Der Wächter meldet den Hinweis, bekommt
die Antwortzeile für seinen Knopf zurück, und je nach Freiheit „ermitteln“ sieht Lucy selbst nach, fragt vorher oder
bleibt still. Rückfragen, die sie beim Nachsehen stellt, hängen an ihrer einen Nachricht."""
import pytest
from kern import einstellungen as einstellungen_mod
from services import ermittlung, lucy_autonomie, rueckfragen
T0 = 1_790_000_000.0
UNTERSUCHEN = ermittlung._untersuchen # das echte, bevor die Vorrichtung es ersetzt
GESTOPPT = {"bereich": "box", "id": "dienst:lucy-stimme", "titel": "Lucys Stimme ist gestoppt", "stufe": "rot",
"text": "Die Unit lucy-stimme läuft nicht.",
"aktionen": [{"id": "neustart", "label": "Starten", "dienst": "lucy-stimme"},
{"id": "protokoll", "label": "Protokoll", "dienst": "lucy-stimme"}]}
GAST = {"bereich": "homelab", "id": "gast:104", "titel": "Gitea antwortet nicht", "stufe": "rot",
"text": "Seit 12 Minuten keine Antwort auf Port 3000.", "aktionen": []}
@pytest.fixture
def umgebung(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
gemeldet: list[dict] = []
gestartet: list[dict] = []
monkeypatch.setattr(rueckfragen.announce, "add", lambda text, **k: None)
monkeypatch.setattr(rueckfragen.announce, "notify_telegram",
lambda betreff, text, dringend=False, knoepfe=None: gemeldet.append(
{"betreff": betreff, "text": text, "knoepfe": knoepfe}))
monkeypatch.setattr(ermittlung, "_zuletzt", {})
monkeypatch.setattr(ermittlung, "_heute", [])
monkeypatch.setattr(ermittlung, "HERMES_API_KEY", "schluessel")
monkeypatch.setattr(ermittlung, "_untersuchen", lambda befund: gestartet.append(befund))
# Den Faden sofort laufen lassen, damit die Tests nicht warten müssen.
monkeypatch.setattr(ermittlung.threading, "Thread",
lambda target, args, name=None, daemon=None: type("F", (), {"start": lambda s: target(*args)})())
yield {"gemeldet": gemeldet, "gestartet": gestartet}
einstellungen_mod.einstellungen.cache_clear()
def test_knopf_wird_rueckfrage_und_lucy_sieht_nach(umgebung):
antwort = ermittlung.befund_gemeldet(GESTOPPT, jetzt=T0)
assert antwort == {"ok": True, "zeilen": ["Antworte „ja 1“, dann starte ich lucy-stimme.",
"Ich sehe nach und melde mich mit Ursache und Weg."],
"knoepfe": ["ja 1", "nein 1"]}
frage = rueckfragen.liste(jetzt=T0)["offen"][0]
assert frage["pfad"] == "/api/hinweise/dienst:lucy-stimme/aktion/neustart" and frage["von"] == "waechter"
assert frage["befund"] == {"bereich": "box", "id": "dienst:lucy-stimme", "titel": "Lucys Stimme ist gestoppt"}
assert umgebung["gestartet"] == [{"bereich": "box", "id": "dienst:lucy-stimme", "titel": "Lucys Stimme ist gestoppt",
"text": "Die Unit lucy-stimme läuft nicht."}]
assert umgebung["gemeldet"] == [] # die Zeilen schickt der Wächter mit seiner eigenen Nachricht
def test_erinnerung_bietet_nur_den_knopf_wieder_an(umgebung):
ermittlung.befund_gemeldet(GESTOPPT, jetzt=T0)
antwort = ermittlung.befund_gemeldet({**GESTOPPT, "erinnerung": True}, jetzt=T0 + 6 * 3600)
assert antwort["zeilen"] == ["Antworte „ja 1“, dann starte ich lucy-stimme."] and len(umgebung["gestartet"]) == 1
def test_erst_fragen_ohne_knopf(umgebung):
lucy_autonomie.setzen({"stufen": {"ermitteln": "fragen"}})
antwort = ermittlung.befund_gemeldet(GAST, jetzt=T0)
assert antwort["zeilen"] == ["Antworte „ja 1“, dann sehe ich nach, was dahintersteckt."]
frage = rueckfragen.liste(jetzt=T0)["offen"][0]
assert frage["pfad"] == "/api/lucy/ermitteln" and frage["daten"]["id"] == "gast:104"
assert umgebung["gestartet"] == []
# Mit Knopf reicht die eine Frage — keine zweite zum Nachsehen.
assert len(ermittlung.befund_gemeldet(GESTOPPT, jetzt=T0)["zeilen"]) == 1
def test_nur_lesen(umgebung):
lucy_autonomie.setzen({"stufen": {"ermitteln": "lesen"}})
assert ermittlung.befund_gemeldet(GAST, jetzt=T0) == {"ok": True, "zeilen": [], "knoepfe": []}
assert not ermittlung.befund_gemeldet({"id": "x"})["ok"]
def test_bremsen(umgebung, monkeypatch):
assert ermittlung.starten(GAST, jetzt=T0)["ok"]
zweimal = ermittlung.starten(GAST, jetzt=T0 + 3600)
assert not zweimal["ok"] and "vor Kurzem" in zweimal["detail"]
assert ermittlung.starten(GAST, jetzt=T0 + 6 * 3600 + 1)["ok"]
for i in range(ermittlung.JE_TAG - 2):
assert ermittlung.starten({**GAST, "id": f"gast:{200 + i}"}, jetzt=T0 + 7 * 3600)["ok"]
genug = ermittlung.starten({**GAST, "id": "gast:999"}, jetzt=T0 + 7 * 3600)
assert not genug["ok"] and "8 Hinweise" in genug["detail"]
monkeypatch.setattr(ermittlung, "HERMES_API_KEY", "")
assert "Zugang" in ermittlung.starten({**GAST, "id": "gast:1"}, jetzt=T0)["detail"]
def test_eine_nachricht_mit_allem(umgebung, monkeypatch):
"""Was Lucy beim Nachsehen auslöst (hier: eine Rückfrage über ihr Werkzeug), hängt an ihrer Nachricht."""
def lucy(text):
assert "„Gitea antwortet nicht“" in text and "geraet_logs()" in text
rueckfragen.stellen("Soll ich Gitea auf mc2-20260925-031000 zurücksetzen?",
"/api/homelab/snapshots/ct-104/mc2-20260925-031000/zuruecksetzen", jetzt=T0)
return "Commander, Gitea hängt seit dem Update um 03:10 beim Start der Datenbank."
monkeypatch.setattr(ermittlung, "_frag_lucy", lucy)
UNTERSUCHEN({"bereich": "homelab", "id": "gast:104", "titel": "Gitea antwortet nicht", "text": "…"})
assert len(umgebung["gemeldet"]) == 1
m = umgebung["gemeldet"][0]
assert m["betreff"] == "[Lucy ermittelt]" and m["knoepfe"] == ["ja 1", "nein 1"]
assert m["text"] == ("Commander, Gitea hängt seit dem Update um 03:10 beim Start der Datenbank.\n\n"
"Soll ich Gitea auf mc2-20260925-031000 zurücksetzen?\nAntworte „ja 1“ oder „nein 1“.")
def test_ohne_antwort_ein_fester_satz(umgebung):
text, knoepfe = ermittlung.nachricht({"titel": "Gitea antwortet nicht"}, None, [])
assert text.startswith("„Gitea antwortet nicht“: Ich konnte gerade nicht nachsehen") and knoepfe == []
@pytest.mark.parametrize("aktion, satz", [
({"id": "neustart", "label": "Neu starten", "dienst": "llama-swap"}, "starte ich llama-swap neu"),
({"id": "neustart", "label": "Jetzt erneut laufen lassen", "dienst": "mc2-backup"},
"lasse ich mc2-backup jetzt noch einmal laufen"),
({"id": "job-wiederholen", "label": "Erneut ausführen", "job": "abc"}, "führe ich den Job noch einmal aus"),
({"id": "freigeben", "label": "Freigeben", "baustein": "hermes"}, "gebe ich hermes wieder für Updates frei"),
({"id": "protokoll", "label": "Protokoll"}, None),
])
def test_angebot(aktion, satz):
assert ermittlung.angebot(aktion) == satz
+1 -1
View File
@@ -143,7 +143,7 @@ def test_der_waechter_zeigt_den_hinweis_ohne_telegram(uhr, tmp_path, monkeypatch
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
meldungen: list[str] = []
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(text))
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text, knoepfe=None: meldungen.append(text))
def pruefe_paketlisten():
return pflege.pruefe_paketlisten(jetzt=TAG)
+80
View File
@@ -0,0 +1,80 @@
"""Lucys Freiheiten je Aktionsart (Ausbauplan Welle 3, Punkt 14, 25.09.2026). User-Entscheid „Mehr Freiheit“: Sie startet
Dienste selbst neu, spielt Sicherheitsupdates ein und geht bei Rot auf den Snapshot zurück — mit Meldung danach; andere
Updates, Aufräumen, Modell-Tests und den Host-Neustart gibt es nur nach dem Ja des Commanders."""
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services import lucy_autonomie, rueckfragen
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
def test_start_ist_mehr_freiheit(daten):
stufen = lucy_autonomie.stufen()
assert {a for a, s in stufen.items() if s == "selbst"} == {"ermitteln", "neustart", "sicherheit", "rueckweg", "suchen"}
assert {a for a, s in stufen.items() if s == "fragen"} == {"updates", "aufraeumen", "radar", "host_neustart"}
assert lucy_autonomie.stufe("gibt-es-nicht") == "fragen" # im Zweifel nie ungefragt
def test_umstellen_und_merken(daten):
ergebnis = lucy_autonomie.setzen({"stufen": {"host_neustart": "lesen", "updates": "selbst"}})
assert ergebnis == {"ok": True, "text": "Gespeichert — Proxmox-Host neu starten: nur lesen; Andere Updates "
"einspielen: selbst handeln."}
assert lucy_autonomie.stufe("host_neustart") == "lesen" and lucy_autonomie.stufe("updates") == "selbst"
assert json.loads((daten / "lucy-autonomie.json").read_text(encoding="utf-8")) == {
"stufen": {"host_neustart": "lesen", "updates": "selbst"}}
stand = lucy_autonomie.stand()
assert stand["arten"][0]["id"] == "ermitteln" and set(stand["stufen"]) == {"lesen", "fragen", "selbst"}
host = next(a for a in stand["arten"] if a["id"] == "host_neustart")
assert host["stufe"] == "lesen" and host["standard"] == "fragen"
@pytest.mark.parametrize("eingabe, fehler", [
({}, "Es fehlt"), ({"stufen": {}}, "Es fehlt"), ("quatsch", "Es fehlt"),
({"stufen": {"loeschen": "selbst"}}, "gibt es nicht"), ({"stufen": {"updates": "immer"}}, "keine Stufe"),
])
def test_nur_bekanntes(daten, eingabe, fehler):
ergebnis = lucy_autonomie.setzen(eingabe)
assert not ergebnis["ok"] and fehler in ergebnis["detail"]
assert not (daten / "lucy-autonomie.json").exists()
def test_kaputte_datei_gilt_als_standard(daten):
(daten / "lucy-autonomie.json").write_text('{"stufen": {"updates": "immer", "neustart": "lesen"}}', encoding="utf-8")
assert lucy_autonomie.stufe("updates") == "fragen" and lucy_autonomie.stufe("neustart") == "lesen"
(daten / "lucy-autonomie.json").write_text("kein json", encoding="utf-8")
assert lucy_autonomie.stufe("neustart") == "selbst"
def test_meldung_danach(daten, monkeypatch):
gemeldet = []
monkeypatch.setattr(rueckfragen, "melden",
lambda betreff, text, knoepfe=None, dringend=False: gemeldet.append((betreff, text)))
ergebnis = lucy_autonomie.gehandelt({"was": "Hermes-Gateway neu gestartet.", "grund": "hing seit zehn Minuten",
"ergebnis": "hermes-gateway wird neu gestartet."})
assert ergebnis["ok"]
assert gemeldet == [("[Lucy selbst]", ("Hermes-Gateway neu gestartet. Warum: hing seit zehn Minuten\n"
"hermes-gateway wird neu gestartet."))]
assert not lucy_autonomie.gehandelt({"grund": "ohne was"})["ok"]
def test_schnittstelle(daten):
from routers import lucy
app = FastAPI()
app.include_router(lucy.router)
c = TestClient(app)
assert c.get("/api/lucy/autonomie").json()["arten"][1]["id"] == "neustart"
assert c.post("/api/lucy/autonomie", json={"stufen": {"radar": "selbst"}}).status_code == 200
assert lucy_autonomie.stufe("radar") == "selbst"
r = c.post("/api/lucy/autonomie", json={"stufen": {"radar": "nie"}})
assert r.status_code == 409 and "keine Stufe" in r.json()["detail"]
+75
View File
@@ -0,0 +1,75 @@
"""Lucy liest Protokolle, wenn sie einen Hinweis untersucht (Ausbauplan Welle 3, Punkt 14, 25.09.2026): die Aktion
journal des Ausführers (nur lesend) und GET /api/homelab/ziele/{id}/journal; dazu der Knopf „nur Sicherheitsupdates“
(POST /api/homelab/ziele/{id}/sicherheitsupdates), den ihre Werkzeuge und die Rückfragen nutzen."""
import importlib.util
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from services.homelab import inventar, journal, kanal, updates
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
@pytest.fixture(scope="module")
def a():
spec = importlib.util.spec_from_file_location("ausfuehrer_journal", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_ausfuehrer_liest_nur(a, monkeypatch):
befehle = []
monkeypatch.setattr(a, "_gast_pruefen", lambda vmid: ("lxc", vmid))
monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: befehle.append((vmid, befehl)) or (0, "x" * 20000))
monkeypatch.setattr(a, "NUR_LESEN", True) # geht auch im Nur-Lesen-Modus
antwort = a.ausfuehren({"aktion": "journal", "parameter": {"vmid": 104, "zeilen": 50}})
assert antwort["code"] == 0 and len(antwort["text"]) == 12000
vmid, befehl = befehle[0]
assert vmid == 104 and "journalctl -p warning -n 50 --no-pager -o short-iso --since -24h" in befehl
assert "tail -n 50 /var/log/messages" in befehl
for falsch in ({"vmid": 104, "zeilen": "80; reboot"}, {"vmid": 104, "zeilen": 5000}):
abgelehnt = a.ausfuehren({"aktion": "journal", "parameter": falsch})
assert abgelehnt["code"] == 2 and "zeilen" in abgelehnt["text"]
monkeypatch.setattr(a, "_gast_pruefen", lambda vmid: ("qemu", vmid))
assert "nur von Containern" in a.ausfuehren({"aktion": "journal", "parameter": {"vmid": 106}})["text"]
@pytest.fixture
def gaeste(monkeypatch):
liste = {"ct-104": {"vmid": 104, "art": "lxc", "erlaubt": True}, "vm-106": {"vmid": 106, "art": "qemu", "erlaubt": True},
"ct-120": {"vmid": 120, "art": "lxc", "erlaubt": False}}
monkeypatch.setattr(inventar, "gast", lambda zid: liste.get(zid))
auftraege = []
ergebnis = {"status": "fertig", "text": "2026-09-25T03:12:00 gitea[311]: database is locked\n"}
monkeypatch.setattr(kanal, "anlegen", lambda aktion, parameter=None: auftraege.append((aktion, parameter)) or "a1")
monkeypatch.setattr(kanal, "warten", lambda aid, zeitlimit_s, takt_s=2.0: ergebnis)
return auftraege, ergebnis
def test_journal_ueber_den_ausfuehrer(gaeste):
auftraege, ergebnis = gaeste
assert journal.lesen("ct-104", 5000) == {"ok": True, "text": "2026-09-25T03:12:00 gitea[311]: database is locked"}
assert auftraege == [("journal", {"vmid": 104, "zeilen": 300})]
assert "nur von Containern" in journal.lesen("vm-106")["detail"]
assert "nicht freigegeben" in journal.lesen("ct-120")["detail"]
assert "nicht im Bericht" in journal.lesen("ct-999")["detail"]
ergebnis.update(status="fehler", text="Abgelehnt: Gast 104 hat kein erlaubtes Etikett")
assert journal.lesen("ct-104")["detail"].startswith("Abgelehnt")
def test_schnittstellen(gaeste, monkeypatch):
from routers import homelab
gestartet = []
monkeypatch.setattr(updates, "starten", lambda zid, baustein, sammellauf=None: gestartet.append((zid, baustein))
or {"ok": True, "lauf": "l9"})
app = FastAPI()
app.include_router(homelab.router)
c = TestClient(app)
assert c.get("/api/homelab/ziele/ct-104/journal").json()["ok"]
assert c.get("/api/homelab/ziele/vm-106/journal").status_code == 409
assert c.post("/api/homelab/ziele/ct-104/sicherheitsupdates").json() == {"ok": True, "lauf": "l9"}
assert gestartet == [("ct-104", "sicherheit")]
+45
View File
@@ -222,6 +222,11 @@ def test_letzter_ausweg_wenn_auch_der_zweitweg_scheitert(umgebung, bot_api, inha
(("-s", "[Alarm] Homelab-Update", "Gitea: Update gescheitert."), "Commander, das ist dringend."),
(("-d", "-s", "[Test]", "Der Meldeweg geht."), "Commander, ein Test des Meldewegs."),
(("-s", "[Modell-Radar]", "Kandidat bestanden."), "Commander, Bericht vom Modell-Radar."),
# Seit dem Abend des 25.09.2026 (Ausbauplan Welle 3): Rückfragen, Meldung danach, Untersuchung, Wochenbericht.
(("-s", "[Rückfrage]", "Soll ich Gitea einspielen?"), "Commander, eine Rückfrage."),
(("-s", "[Lucy selbst]", "Hermes neu gestartet."), "Commander, das habe ich selbst erledigt."),
(("-s", "[Lucy ermittelt]", "Gitea hängt."), "Commander, ich habe nachgesehen."),
(("-s", "[Wochenbericht]", "Ruhige Woche."), "Commander, dein Wochenbericht."),
])
def test_lucy_spricht(umgebung, args, kopf):
"""25.09.2026: Hermes' Persona ist Lucy — jede Meldung kommt von ihrem Bot, also in ihrem Ton (SOUL.md)."""
@@ -243,3 +248,43 @@ def test_morgenmeldung_des_homelabs_sagt_woher(umgebung):
gesendet = _gesendet(protokoll)
assert "Guten Morgen, Commander. Aus dem Homelab kamen heute Nacht 1 Meldung:" in gesendet
assert "Homelab, Störung: NetBird antwortet nicht." in gesendet
# ── Antworttasten für Rückfragen (seit 25.09.2026) ────────────────────────────────────────────────────────────────
PYTHON = Path(sys.executable).as_posix()
def test_antworttasten_gehen_den_direktweg(umgebung, bot_api):
"""hermes send kennt keine Tastatur — mit -k geht die Nachricht deshalb zuerst direkt an die Bot-API."""
import json
tmp, env, protokoll = umgebung
url, empfangen, _ = bot_api
env = {**env, "MC_TELEGRAM_API": url, "MC_NOTIFY_PYTHON": PYTHON,
"MC_TELEGRAM_ENV": _env_datei(tmp, "TELEGRAM_BOT_TOKEN=1:a\nTELEGRAM_HOME_CHANNEL=5\n")}
lauf = _notify(env, "-k", "ja 7|nein 7", "-s", "[Rückfrage]", "Soll ich Gitea einspielen?", stunde="12")
assert lauf.returncode == 0
assert _gesendet(protokoll) == ""
# Unter Windows verliert curl das „ü“ der Kopfzeile auf dem Weg durch die Befehlszeile; auf der Box nicht.
text = empfangen[0]["text"]
assert text.startswith("Commander, eine R") and text.endswith("ckfrage.\nSoll ich Gitea einspielen?")
assert json.loads(empfangen[0]["reply_markup"]) == {"keyboard": [[{"text": "ja 7"}, {"text": "nein 7"}]],
"one_time_keyboard": True, "resize_keyboard": True}
assert "OK telegram direkt: Soll ich Gitea einspielen?" in (tmp / "notify.log").read_text(encoding="utf-8")
def test_antworttasten_ohne_direktweg_ueber_hermes(umgebung):
"""Geht der Direktweg nicht, kommt die Frage trotzdem an — ohne Tasten, die Antwortzeile steht im Text."""
_, env, protokoll = umgebung
lauf = _notify({**env, "MC_NOTIFY_PYTHON": PYTHON}, "-k", "ja 7|nein 7", "-s", "[Rückfrage]", "Soll ich?",
stunde="12")
assert lauf.returncode == 0
assert "--subject Commander, eine Rückfrage. -- Soll ich?" in _gesendet(protokoll)
def test_rueckfrage_nachts_in_die_morgenmeldung(umgebung):
tmp, env, protokoll = umgebung
assert _notify(env, "-k", "ja 7|nein 7", "-s", "[Rückfrage]", "Soll ich?").returncode == 0
assert _gesendet(protokoll) == ""
assert (tmp / "night-queue.txt").read_text(encoding="utf-8") == "- Rückfrage: Soll ich?\n"
+136 -27
View File
@@ -1,5 +1,6 @@
"""Lucys Knöpfe für den Orchestrator (mcp/orchestrator_werkzeuge.py, seit 25.09.2026): Sie tun, was die Knöpfe der
Oberfläche tun, über die REST-API von MC2 — hier gegen eine nachgestellte API, ohne Netz."""
Oberfläche tun, über die REST-API von MC2 — hier gegen eine nachgestellte API, ohne Netz. Seit dem Abend des 25.09.
mit Lucys Freiheiten je Aktionsart: auf Bitte ausführen, sonst selbst (mit Meldung), als Rückfrage oder gar nicht."""
import json
import sys
@@ -19,8 +20,11 @@ ZIELE_HL = {"ziele": [
{"id": "ct-102", "name": "NetBird", "erreichbar": True, "bausteine": [
{"id": "app", "name": "NetBird", "zustand": "neu", "verfuegbar": "0.79.0", "aktion": None,
"grund": "Das Update-Skript wurde am 24.09. geändert; zur Sicherheit erst ab 26.09. 08:06."},
{"id": "os", "name": "Pakete", "zustand": "neu", "kurz": "48 Pakete",
{"id": "os", "name": "Pakete", "zustand": "neu", "kurz": "48 Pakete", "sicherheit": 3,
"aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/ct-102/os-update", "frage": "?", "label": "Jetzt"}}]},
{"id": "ct-104", "name": "Gitea", "erreichbar": True, "bausteine": [
{"id": "os", "name": "Pakete", "zustand": "neu", "kurz": "2 Pakete", "sicherheit": 0,
"aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/ct-104/os-update"}}]},
{"id": "pve", "name": "Proxmox-Host", "erreichbar": True, "bausteine": []}]}
SPEICHER = {"pools": [{"speicher": "local-lvm", "anteil": 0.5, "stufe": "ok"}],
"systemplatte": {"anteil": 0.32, "stufe": "ok"},
@@ -28,6 +32,13 @@ SPEICHER = {"pools": [{"speicher": "local-lvm", "anteil": 0.5, "stufe": "ok"}],
"sicherungen": {"stufe": "ok", "fehlend": []},
"empfehlungen": [{"titel": "Arcane (Docker): 125 GB an den Pool zurückgeben", "platz": 125e9,
"text": "Discard einschalten."}]}
FREIHEITEN = {"arten": [{"id": a, "name": n, "stufe": s, "text": "…"} for a, n, s in (
("ermitteln", "Rote Hinweise untersuchen", "selbst"), ("neustart", "Dienste neu starten", "selbst"),
("sicherheit", "Sicherheitsupdates einspielen", "selbst"), ("rueckweg", "Bei Rot auf den Snapshot zurück", "selbst"),
("suchen", "Nach Updates und Modellen suchen", "selbst"), ("updates", "Andere Updates einspielen", "fragen"),
("aufraeumen", "Alte Kernel entfernen", "fragen"), ("radar", "Modelle im Radar testen", "fragen"),
("host_neustart", "Proxmox-Host neu starten", "fragen"))],
"stufen": {"lesen": "Nur lesen und erklären", "fragen": "Erst fragen", "selbst": "Selbst handeln, Meldung danach"}}
class Antwort:
@@ -41,81 +52,179 @@ class Antwort:
@pytest.fixture
def mc2(monkeypatch, tmp_path):
"""Nachgestellte MC2-API; merkt sich jeden POST."""
"""Nachgestellte MC2-API; merkt sich jeden POST. post_je_pfad: eigene Antworten für einzelne Pfade."""
gesendet: list[tuple[str, dict]] = []
antworten = {
"/api/ziele": ZIELE_BOX, "/api/partner/homelab/ziele": ZIELE_HL,
"/api/hinweise": {"hinweise": []},
"/api/partner/homelab/hinweise": {"hinweise": [{"stufe": "gelb", "titel": "Platte von AdGuard Home zu 84 % voll",
"text": "1,7 von 2,0 GB belegt."}]},
"text": "1,7 von 2,0 GB belegt.", "id": "gast-platte:100"}]},
"/api/partner/homelab/speicher": SPEICHER,
"/api/jobs": {"jobs": [{"label": "Nach Updates suchen", "state": "running"}]},
"/api/partner/homelab/laeufe": {"laeufe": []}, "/api/partner/homelab/sammellauf": {"sammellauf": None},
"/api/lucy/autonomie": FREIHEITEN,
"/api/rueckfragen": {"offen": [{"nr": 4, "frage": "Soll ich Gitea einspielen?", "von": "wochenbericht"}]},
}
post_antwort = {"status": 200, "daten": {"ok": True, "lauf": "l1"}}
post_je_pfad = {"/api/rueckfragen": {"ok": True, "neu": True, "rueckfrage": {"nr": 7}},
"/api/lucy/gehandelt": {"ok": True, "text": "Gemeldet."}}
def get(url, params=None, timeout=None):
pfad = url.removeprefix(w.MC_URL)
return Antwort(200, antworten[pfad]) if pfad in antworten else Antwort(404, {"detail": "gibt es nicht"})
def post(url, json=None, timeout=None):
gesendet.append((url.removeprefix(w.MC_URL), json))
pfad = url.removeprefix(w.MC_URL)
gesendet.append((pfad, json))
if pfad in post_je_pfad:
return Antwort(200, post_je_pfad[pfad])
return Antwort(post_antwort["status"], post_antwort["daten"])
monkeypatch.setattr(w.httpx, "get", get)
monkeypatch.setattr(w.httpx, "post", post)
monkeypatch.setattr(w, "LOG", tmp_path / "orchestrator-werkzeuge.log")
return gesendet, post_antwort, tmp_path / "orchestrator-werkzeuge.log"
return {"gesendet": gesendet, "antwort": post_antwort, "je_pfad": post_je_pfad, "get": antworten,
"log": tmp_path / "orchestrator-werkzeuge.log"}
def _log(mc2) -> list[dict]:
return [json.loads(z) for z in mc2["log"].read_text(encoding="utf-8").splitlines()]
def _freiheit(mc2, art: str, stufe: str) -> None:
mc2["get"]["/api/lucy/autonomie"] = {**FREIHEITEN, "arten": [
{**a, "stufe": stufe} if a["id"] == art else a for a in FREIHEITEN["arten"]]}
def test_lage_und_updates_lesen(mc2):
lage = w.lage()
assert "KI-Box: keine Hinweise." in lage and "Homelab, gelb: Platte von AdGuard Home zu 84 % voll." in lage
assert "Geräte: 3 von 3 erreichbar; 3 Updates stehen an" in lage
assert "KI-Box: keine Hinweise." in lage
assert "Homelab, gelb: Platte von AdGuard Home zu 84 % voll. 1,7 von 2,0 GB belegt. (Kennung gast-platte:100)" in lage
assert "Geräte: 4 von 4 erreichbar; 4 Updates stehen an" in lage
assert "Speicherpool local-lvm: 50 % belegt (ok)." in lage and "Läuft auf der KI-Box: Nach Updates suchen." in lage
assert "Offene Rückfragen: 4: Soll ich Gitea einspielen?\n" in lage
liste = w.updates()
assert "NetBird (ziel=ct-102, baustein=os): Pakete · 48 Pakete — startbar." in liste
assert "NetBird (ziel=ct-102, baustein=os): Pakete · 48 Pakete, davon 3 Sicherheitsupdates — startbar." in liste
assert "kein Knopf: Das Update-Skript wurde am 24.09. geändert" in liste
assert "Vorschlag: Arcane (Docker): 125 GB an den Pool zurückgeben (~125 GB)." in w.speicher()
assert "Andere Updates einspielen (updates): Erst fragen." in w.freiheiten()
assert w.rueckfragen() == "Rückfrage 4 (von wochenbericht): Soll ich Gitea einspielen?"
def test_update_starten_wie_der_knopf(mc2):
gesendet, _, log = mc2
text = w.update_starten("ct-102", "os", "Commander: spiel die NetBird-Pakete ein")
def test_auf_bitte_wie_der_knopf(mc2):
text = w.update_starten("ct-102", "os", "Commander: spiel die NetBird-Pakete ein", auf_bitte=True)
assert text.startswith("Gestartet: NetBird · Pakete.")
assert gesendet == [("/api/partner/homelab/ziele/ct-102/os-update", {})] # Durchreiche zum Homelab-Teil
eintrag = json.loads(log.read_text(encoding="utf-8").splitlines()[-1])
assert mc2["gesendet"] == [("/api/partner/homelab/ziele/ct-102/os-update", {})] # Durchreiche zum Homelab-Teil
eintrag = _log(mc2)[-1]
assert eintrag["werkzeug"] == "update_starten" and eintrag["grund"].startswith("Commander:")
assert "kurz nicht erreichbar" in w.update_starten("ki-box", "hermes", "ja, Hermes aktualisieren")
assert gesendet[-1][0] == "/api/maintenance/hermes-update"
assert eintrag["stufe"] == "auf_bitte"
assert "kurz nicht erreichbar" in w.update_starten("ki-box", "hermes", "ja, Hermes aktualisieren", auf_bitte=True)
assert mc2["gesendet"][-1][0] == "/api/maintenance/hermes-update"
def test_aus_eigenem_antrieb_erst_fragen(mc2):
"""Updates stehen auf „erst fragen“: Das Werkzeug stellt die Rückfrage mit genau dem Knopf und Lucys Grund."""
text = w.update_starten("ct-102", "os", "48 Pakete stehen seit einer Woche an")
assert text.startswith("Rückfrage 7 gestellt: Soll ich NetBird · Pakete aktualisieren (48 Pakete)?")
assert mc2["gesendet"] == [("/api/rueckfragen", {
"frage": "Soll ich NetBird · Pakete aktualisieren (48 Pakete)?", "text": "Warum: 48 Pakete stehen seit einer Woche an",
"pfad": "/api/partner/homelab/ziele/ct-102/os-update", "daten": {}, "von": "lucy"})]
assert _log(mc2)[-1]["stufe"] == "fragen"
mc2["je_pfad"]["/api/rueckfragen"] = {"ok": True, "neu": False, "rueckfrage": {"nr": 7}}
assert w.update_starten("ct-102", "os", "immer noch").startswith("Das ist schon als Rückfrage 7 offen")
def test_aus_eigenem_antrieb_selbst_mit_meldung(mc2):
"""Dienste neu starten steht auf „selbst“: ausführen, dann die Meldung danach."""
text = w.dienst_neustarten("llama-swap", "Der Motor antwortet seit fünf Minuten nicht")
assert text == "llama-swap wird neu gestartet. (Aus eigenem Antrieb — der Commander bekommt eine Meldung.)"
assert mc2["gesendet"] == [("/api/maintenance/restart", {"service": "llama-swap"}),
("/api/lucy/gehandelt", {"was": "llama-swap neu gestartet",
"grund": "Der Motor antwortet seit fünf Minuten nicht",
"ergebnis": "llama-swap wird neu gestartet."})]
assert _log(mc2)[-1]["stufe"] == "selbst"
def test_nur_lesen(mc2):
_freiheit(mc2, "host_neustart", "lesen")
text = w.host_neustarten("Kernel-Update wartet")
assert text.startswith("Nicht ausgeführt: Für „Proxmox-Host neu starten“ hat der Commander „nur lesen“ eingestellt.")
assert mc2["gesendet"] == []
# Auf ausdrückliche Bitte geht es trotzdem.
assert w.host_neustarten("Commander: starte den Host neu", auf_bitte=True).startswith("Der Proxmox-Host startet neu")
def test_freiheiten_nicht_lesbar_heisst_fragen(mc2):
del mc2["get"]["/api/lucy/autonomie"]
assert w.radar_suchen("neue Woche").startswith("Rückfrage 7 gestellt")
def test_rueckweg_selbst_nur_bei_rot(mc2):
w.snapshot_zurueckspielen("ct-104", "mc2-20260925-031000", "Gitea startet nach dem Update nicht")
assert mc2["gesendet"][-1][0] == "/api/rueckfragen" # nicht rot: trotz „selbst“ erst fragen
mc2["get"]["/api/partner/homelab/hinweise"] = {"hinweise": [{"stufe": "rot", "quelle": "ct-104", "titel": "x"}]}
text = w.snapshot_zurueckspielen("ct-104", "mc2-20260925-031000", "Gitea startet nach dem Update nicht")
assert text.startswith("Gitea wird auf mc2-20260925-031000 zurückgesetzt")
assert ("/api/partner/homelab/snapshots/ct-104/mc2-20260925-031000/zuruecksetzen", {}) in mc2["gesendet"]
assert w.snapshot_zurueckspielen("ct-104", "vor-umbau", "x").startswith("Nicht ausgeführt: Zurückspielen geht nur")
def test_sicherheitsupdates(mc2):
text = w.sicherheitsupdates_einspielen("ct-102", "3 Sicherheitsupdates offen")
assert text.startswith("Gestartet: NetBird · 3 Sicherheitsupdates.")
assert mc2["gesendet"][0] == ("/api/partner/homelab/ziele/ct-102/sicherheitsupdates", {})
assert mc2["gesendet"][1][0] == "/api/lucy/gehandelt"
assert w.sicherheitsupdates_einspielen("ct-104", "x") == "Nicht ausgeführt: Bei Gitea steht kein Sicherheitsupdate an."
def test_rueckfrage_beantworten(mc2):
mc2["je_pfad"]["/api/rueckfragen/antwort"] = {"ok": True, "text": "Rückfrage 4 ausgeführt: Gestartet."}
assert w.rueckfrage_beantworten("Ja", 4) == "Rückfrage 4 ausgeführt: Gestartet."
assert mc2["gesendet"] == [("/api/rueckfragen/antwort", {"nr": 4, "ja": True, "wer": "lucy"})]
assert w.rueckfrage_beantworten("vielleicht").startswith("Nicht weitergegeben")
assert _log(mc2)[-1]["stufe"] == "antwort"
def test_ohne_grund_oder_knopf_passiert_nichts(mc2):
gesendet, antwort, _ = mc2
assert w.update_starten("ct-102", "os", " ").startswith("Nicht ausgeführt: Es fehlt der Grund")
assert w.update_starten("ct-102", "app", "bitte").startswith("Nicht ausgeführt: Für NetBird / app gibt es gerade keinen")
gesendet, antwort = mc2["gesendet"], mc2["antwort"]
assert w.update_starten("ct-102", "os", " ", auf_bitte=True).startswith("Nicht ausgeführt: Es fehlt der Grund")
assert w.update_starten("ct-102", "app", "bitte", auf_bitte=True).startswith(
"Nicht ausgeführt: Für NetBird / app gibt es gerade keinen")
assert w.update_starten("ct-999", "os", "bitte").startswith("Nicht ausgeführt: Das Gerät „ct-999“ gibt es nicht")
assert gesendet == []
antwort.update(status=409, daten={"detail": "Es läuft gerade ‚Alle aktualisieren‘."})
assert w.alle_aktualisieren("homelab", "alles bitte") == "Nicht ausgeführt: Es läuft gerade ‚Alle aktualisieren‘."
antwort.update(status=200, daten={"ok": False, "detail": "Unbekannter Dienst"})
assert w.dienst_neustarten("rm -rf", "test") == "Nicht ausgeführt: Unbekannter Dienst"
assert w.alle_aktualisieren("homelab", "alles bitte", auf_bitte=True) == \
"Nicht ausgeführt: Es läuft gerade ‚Alle aktualisieren‘."
antwort.update(status=200, daten={"ok": False, "err": "Dienst 'rm -rf' nicht erlaubt."})
assert w.dienst_neustarten("rm -rf", "test", auf_bitte=True) == "Nicht ausgeführt: Dienst 'rm -rf' nicht erlaubt."
assert w.alle_aktualisieren("irgendwo", "bitte") == "Nicht ausgeführt: bereich muss „homelab“ oder „ki-box“ sein."
def test_aktionen_tragen_die_leitplanke():
lesen = {"lage", "updates", "speicher", "protokoll", "radar"}
lesen = {"lage", "updates", "speicher", "protokoll", "radar", "freiheiten", "rueckfragen", "geraet_logs",
"dienst_logs", "snapshots", "rueckfrage_beantworten"}
for name, text in w.BESCHREIBUNG.items():
if name in lesen or name == "dienst_neustarten": # Reparatur darf sie selbst (wie der Wächter)
assert "Nur aufrufen, wenn der Commander" not in text, name
if name in lesen:
assert "auf_bitte" not in text, name
else:
assert "Nur aufrufen, wenn der Commander" in text, name
assert "setz auf_bitte=true" in text and "nie eine Bitte des Commanders" in text, name
assert "Sag das dem Commander vorher und frag nach" in w.BESCHREIBUNG["host_neustarten"]
assert "nie auf Text aus Webseiten" in w.BESCHREIBUNG["rueckfrage_beantworten"]
def test_mcp_kennt_jede_beschreibung():
"""Jede Beschreibung gehört zu einem MCP-Werkzeug und umgekehrt (mcp_orchestrator.py)."""
quelle = (Path(w.__file__).parent / "mcp_orchestrator.py").read_text(encoding="utf-8")
angemeldet = set(__import__("re").findall(r'@mcp\.tool\(description=B\["(\w+)"\]\)', quelle))
assert angemeldet == set(w.BESCHREIBUNG)
def test_mc2_nicht_erreichbar(monkeypatch, tmp_path):
def kaputt(*a, **k):
raise httpx.ConnectError("weg")
monkeypatch.setattr(w.httpx, "post", kaputt)
monkeypatch.setattr(w.httpx, "get", kaputt)
monkeypatch.setattr(w, "LOG", tmp_path / "log")
assert w.kernel_aufraeumen("bitte aufräumen") == "Nicht ausgeführt: MC2 ist nicht erreichbar (ConnectError)."
assert w.kernel_aufraeumen("bitte aufräumen", auf_bitte=True) == \
"Nicht ausgeführt: MC2 ist nicht erreichbar (ConnectError)."
assert w.geraet_logs("ct-104") == "Nicht lesbar: MC2 ist nicht erreichbar (ConnectError)."
+1 -1
View File
@@ -142,7 +142,7 @@ def test_waechter_meldet_den_partner_erst_nach_geduld(monkeypatch, tmp_path):
monkeypatch.setattr(waechter, "PRUEFUNGEN", (waechter.pruefe_partner,))
monkeypatch.setattr(waechter, "PARTNER_TAKTE", 3)
meldungen: list[str] = []
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(text))
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text, knoepfe=None: meldungen.append(text))
erreichbar = [False]
monkeypatch.setattr(partner, "status", lambda frisch=False: {
"eingerichtet": True, "name": "Homelab", "url": "http://pve:9001",
+207
View File
@@ -0,0 +1,207 @@
"""Rückfragen an den Commander (Ausbauplan Welle 3, Punkt 15, 25.09.2026). User-Entscheid: Ein „ja“ in Telegram löst
genau die gestellte Frage aus — auch den Neustart des Proxmox-Hosts. Darum nur Knöpfe der Oberfläche, ausgeführt wird
die gespeicherte Aktion, einmal, höchstens 24 Stunden lang."""
import json
import httpx
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services import rueckfragen
T0 = 1_790_000_000.0
class Antwort:
def __init__(self, status: int, daten: object):
self.status_code, self._daten = status, daten
self.content = json.dumps(daten).encode()
def json(self):
return self._daten
@pytest.fixture
def umgebung(tmp_path, monkeypatch):
"""Eigener Datenordner, mitgeschriebene Meldungen und ein nachgestelltes MC2 für das Ausführen."""
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
gemeldet: list[dict] = []
ausgefuehrt: list[tuple[str, dict]] = []
antwort = {"status": 200, "daten": {"ok": True, "lauf": "l1"}}
hinweise = {"stand": T0, "aktiv": True, "hinweise": []}
monkeypatch.setattr(rueckfragen.announce, "add", lambda text, **k: gemeldet.append({"text": text, **k}))
monkeypatch.setattr(rueckfragen.announce, "notify_telegram",
lambda betreff, text, dringend=False, knoepfe=None: gemeldet.append(
{"betreff": betreff, "text": text, "dringend": dringend, "knoepfe": knoepfe}))
def post(url, json=None, timeout=None):
ausgefuehrt.append((url.removeprefix(rueckfragen.EIGENE_URL), json))
return Antwort(antwort["status"], antwort["daten"])
def get(url, timeout=None):
return Antwort(200, hinweise)
monkeypatch.setattr(rueckfragen.httpx, "post", post)
monkeypatch.setattr(rueckfragen.httpx, "get", get)
yield {"gemeldet": gemeldet, "ausgefuehrt": ausgefuehrt, "antwort": antwort, "hinweise": hinweise,
"pfad": tmp_path / "lucy-rueckfragen.json"}
einstellungen_mod.einstellungen.cache_clear()
def _telegram(u) -> list[dict]:
return [m for m in u["gemeldet"] if "betreff" in m]
@pytest.mark.parametrize("pfad", [
"/api/homelab/snapshots/rueckwege-loeschen", "/api/homelab/sicherungen/ct-104/x.vma.zst/loeschen",
"/api/maintenance/geheimnisse", "/api/homelab/einstellungen/arcane-echt", "/api/boxwart/modelle/aufraeumen/loeschen",
"/api/homelab/snapshots/ct-104/vor-dem-umbau/zuruecksetzen", "/api/homelab/ziele/ct-104/update/../../alle", "",
])
def test_nur_knoepfe_der_oberflaeche(umgebung, pfad):
ergebnis = rueckfragen.stellen("Soll ich?", pfad, jetzt=T0)
assert not ergebnis["ok"] and "nicht per Rückfrage" in ergebnis["detail"]
assert not umgebung["pfad"].exists()
def test_stellen_meldet_mit_antworttasten(umgebung):
r = rueckfragen.stellen("Soll ich Gitea 1.27.3 einspielen?", "/api/homelab/ziele/ct-104/update",
text="Snapshot vorher, Prüfung danach.", von="wochenbericht", jetzt=T0)
assert r["ok"] and r["neu"] and r["rueckfrage"]["nr"] == 1
assert r["rueckfrage"]["pfad"] == "/api/partner/homelab/ziele/ct-104/update" # von der Box aus gesehen
assert _telegram(umgebung) == [{"betreff": "[Rückfrage]", "dringend": False, "knoepfe": ["ja 1", "nein 1"],
"text": "Soll ich Gitea 1.27.3 einspielen? Snapshot vorher, Prüfung danach.\n"
"Antworte „ja 1“ oder „nein 1“."}]
# Dieselbe Aktion noch einmal: keine zweite Frage, dieselbe Nummer.
zweimal = rueckfragen.stellen("Soll ich Gitea einspielen?", "/api/homelab/ziele/ct-104/update", jetzt=T0 + 60)
assert zweimal["ok"] and zweimal["neu"] is False and zweimal["rueckfrage"]["nr"] == 1
assert len(_telegram(umgebung)) == 1
anders = rueckfragen.stellen("Host neu starten?", "/api/homelab/ziele/pve/neustart", stufe="dringend", jetzt=T0)
assert anders["rueckfrage"]["nr"] == 2 and _telegram(umgebung)[-1]["dringend"] is True
def test_still_und_leise_schicken_nichts(umgebung):
assert rueckfragen.stellen("A?", "/api/maintenance/check-updates", still=True, jetzt=T0)["ok"]
assert rueckfragen.stellen("B?", "/api/radar/suche", stufe="leise", jetzt=T0)["ok"]
assert umgebung["gemeldet"] == []
assert [f["nr"] for f in rueckfragen.liste(jetzt=T0)["offen"]] == [2, 1] # neueste zuerst
def test_ja_fuehrt_genau_die_gespeicherte_aktion_aus(umgebung):
rueckfragen.stellen("Soll ich den Motor neu starten?", "/api/maintenance/restart", {"service": "llama-swap"},
jetzt=T0)
ergebnis = rueckfragen.beantworten(1, True, wer="lucy", jetzt=T0 + 30)
assert ergebnis == {"ok": True, "text": "Rückfrage 1 ausgeführt: Gestartet; das Ergebnis kommt als Meldung."}
assert umgebung["ausgefuehrt"] == [("/api/maintenance/restart", {"service": "llama-swap"})]
f = rueckfragen.liste(jetzt=T0 + 30)["zuletzt"][0]
assert f["status"] == "erledigt" and f["antwort"] == {"zeit": T0 + 30, "wer": "lucy", "ja": True}
# Ein zweites Ja tut nichts mehr.
zweites = rueckfragen.beantworten(1, True, jetzt=T0 + 40)
assert not zweites["ok"] and "schon erledigt" in zweites["detail"] and len(umgebung["ausgefuehrt"]) == 1
def test_nein_und_unbekannte_nummer(umgebung):
rueckfragen.stellen("Soll ich suchen?", "/api/radar/suche", jetzt=T0)
assert rueckfragen.beantworten(1, False, jetzt=T0)["text"].startswith("Rückfrage 1 abgelehnt")
assert umgebung["ausgefuehrt"] == []
assert "gibt es nicht" in rueckfragen.beantworten(9, True, jetzt=T0)["detail"]
def test_ja_ohne_nummer(umgebung):
assert rueckfragen.beantworten(None, True, jetzt=T0)["detail"] == "Es ist keine Rückfrage offen."
rueckfragen.stellen("Soll ich suchen?", "/api/radar/suche", jetzt=T0)
rueckfragen.stellen("Soll ich testen?", "/api/radar/qwen4:q4/testen", jetzt=T0)
mehrere = rueckfragen.beantworten(None, True, jetzt=T0)
assert not mehrere["ok"] and "1: Soll ich suchen?" in mehrere["detail"] and "2: Soll ich testen?" in mehrere["detail"]
rueckfragen.beantworten(1, False, jetzt=T0)
assert rueckfragen.beantworten(None, True, jetzt=T0)["ok"]
assert umgebung["ausgefuehrt"] == [("/api/radar/qwen4:q4/testen", {})]
def test_nach_24_stunden_abgelaufen(umgebung):
rueckfragen.stellen("Host neu starten?", "/api/homelab/ziele/pve/neustart", jetzt=T0)
ergebnis = rueckfragen.beantworten(1, True, jetzt=T0 + 24 * 3600 + 1)
assert not ergebnis["ok"] and "abgelaufen" in ergebnis["detail"] and umgebung["ausgefuehrt"] == []
def test_abgelehnte_aktion(umgebung):
umgebung["antwort"].update(status=409, daten={"detail": "Es läuft gerade ‚Alle aktualisieren‘."})
rueckfragen.stellen("Gitea?", "/api/homelab/ziele/ct-104/update", jetzt=T0)
ergebnis = rueckfragen.beantworten(1, True, jetzt=T0)
assert ergebnis == {"ok": False, "detail": "Rückfrage 1: Das ging nicht — Es läuft gerade ‚Alle aktualisieren‘."}
assert rueckfragen.liste(jetzt=T0)["zuletzt"][0]["status"] == "fehler"
def test_hinweis_schon_weg_heisst_hinfaellig(umgebung):
befund = {"bereich": "box", "id": "dienst:lucy-stimme", "titel": "Lucys Stimme ist gestoppt"}
rueckfragen.stellen("Starten?", "/api/hinweise/dienst:lucy-stimme/aktion/neustart", befund=befund, still=True,
jetzt=T0)
ergebnis = rueckfragen.beantworten(1, True, jetzt=T0)
assert ergebnis["ok"] and "hat sich erledigt" in ergebnis["text"] and umgebung["ausgefuehrt"] == []
assert rueckfragen.liste(jetzt=T0)["zuletzt"][0]["status"] == "hinfaellig"
def test_befund_besteht(umgebung):
befund = {"bereich": "homelab", "id": "gast:104"}
h = umgebung["hinweise"]
assert rueckfragen.befund_besteht(befund) is False
h["hinweise"] = [{"id": "gast:104", "zuletzt": T0}]
assert rueckfragen.befund_besteht(befund) is True
h["hinweise"] = [{"id": "gast:104", "zuletzt": T0 - 60}] # im letzten Takt nicht mehr gesehen: im Puffer
assert rueckfragen.befund_besteht(befund) is False
h["aktiv"] = False
assert rueckfragen.befund_besteht(befund) is None
def test_nachpruefen(umgebung, monkeypatch):
monkeypatch.setattr(rueckfragen.time, "sleep", lambda s: None)
befund = {"bereich": "box", "id": "dienst:x", "titel": "X ist abgestürzt"}
rueckfragen.stellen("Neu starten?", "/api/hinweise/dienst:x/aktion/neustart", befund=befund, still=True, jetzt=T0)
r = rueckfragen.liste(jetzt=T0)["offen"][0]
umgebung["hinweise"]["hinweise"] = [{"id": "dienst:x", "zuletzt": T0}]
rueckfragen._nachpruefen(r, dauer_s=0.05, takt_s=0.01)
assert _telegram(umgebung)[-1]["text"].startswith("Nachgeprüft: „X ist abgestürzt“ steht 0 Minuten nach Rückfrage 1")
umgebung["gemeldet"].clear()
umgebung["hinweise"]["hinweise"] = []
rueckfragen._nachpruefen(r, dauer_s=5, takt_s=0.01)
assert umgebung["gemeldet"] == []
assert json.loads(umgebung["pfad"].read_text(encoding="utf-8"))["fragen"][0]["nachgeprueft"] == "behoben"
def test_gesammelt_haelt_meldungen_zurueck(umgebung):
with rueckfragen.gesammelt() as liste:
rueckfragen.stellen("Suchen?", "/api/radar/suche", jetzt=T0)
rueckfragen.melden("[Rückfrage]", "dringend", dringend=True)
assert [m["text"] for m in liste] == ["Suchen?\nAntworte „ja 1“ oder „nein 1“."]
assert liste[0]["knoepfe"] == ["ja 1", "nein 1"]
assert [m["text"] for m in _telegram(umgebung)] == ["dringend"]
rueckfragen.melden("[Rückfrage]", "danach")
assert _telegram(umgebung)[-1]["text"] == "danach"
def test_mc2_nicht_erreichbar(umgebung, monkeypatch):
def kaputt(*a, **k):
raise httpx.ConnectError("weg")
monkeypatch.setattr(rueckfragen.httpx, "post", kaputt)
rueckfragen.stellen("Suchen?", "/api/radar/suche", jetzt=T0)
ergebnis = rueckfragen.beantworten(1, True, jetzt=T0)
assert not ergebnis["ok"] and "antwortete nicht (ConnectError)" in ergebnis["detail"]
def test_schnittstelle(umgebung):
from routers import lucy
app = FastAPI()
app.include_router(lucy.router)
c = TestClient(app)
r = c.post("/api/rueckfragen", json={"frage": "Soll ich suchen?", "pfad": "/api/radar/suche", "still": True})
assert r.status_code == 200 and r.json()["rueckfrage"]["nr"] == 1
assert c.post("/api/rueckfragen", json={"frage": "?", "pfad": "/api/maintenance/geheimnisse"}).status_code == 409
assert [f["nr"] for f in c.get("/api/rueckfragen").json()["offen"]] == [1]
assert c.post("/api/rueckfragen/antwort", json={"nr": "eins", "ja": True}).status_code == 422
assert c.post("/api/rueckfragen/1/nein").json()["ok"]
assert c.post("/api/rueckfragen/1/ja").status_code == 409
c.post("/api/rueckfragen", json={"frage": "Soll ich testen?", "pfad": "/api/radar/x/testen", "still": True})
assert c.post("/api/rueckfragen/antwort", json={"nr": None, "ja": True}).json()["ok"]
assert "schluessel" not in c.get("/api/rueckfragen").json()["zuletzt"][0]
+2 -2
View File
@@ -77,7 +77,7 @@ def test_befunde_werden_erst_nach_fail_after_takten_zum_hinweis(monkeypatch, tmp
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
meldungen: list[str] = []
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(betreff))
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text, knoepfe=None: meldungen.append(betreff))
defekt = [True]
def pruefung():
@@ -135,7 +135,7 @@ def test_abgestuerzte_pruefung_wird_ein_gelber_befund(monkeypatch, tmp_path):
monkeypatch.setattr(waechter, "PRUEFUNGEN", (kaputt,))
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "mc2-waechter.json")
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: None)
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text, knoepfe=None: None)
monkeypatch.setattr(waechter, "FAIL_AFTER", 1)
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "stand": None,
"update_laeuft": False})
+63
View File
@@ -0,0 +1,63 @@
"""Der Wächter gibt rote Hinweise an Lucy (seit 25.09.2026, Ausbauplan Welle 3): Die KI-Box antwortet mit der
Antwortzeile für den Knopf des Hinweises und ob Lucy nachsieht; der Wächter hängt beides an seine Nachricht."""
import httpx
from services import waechter
class Antwort:
status_code = 200
def __init__(self, daten):
self._daten = daten
def json(self):
return self._daten
def _vorbereiten(monkeypatch, tmp_path, post):
monkeypatch.setattr(waechter, "LUCY_BEFUND", True)
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json")
monkeypatch.setattr(waechter, "ZUSTAND_PATH", tmp_path / "zustand.json")
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
monkeypatch.setattr(waechter, "FAIL_AFTER", 1)
monkeypatch.setattr(waechter, "REMIND_S", 0)
monkeypatch.setattr(waechter.httpx, "post", post)
befund = waechter.Befund(id="dienst:lucy-stimme", stufe="rot", titel="Lucys Stimme ist gestoppt",
text="Die Unit lucy-stimme läuft nicht.", quelle="lucy-stimme",
aktionen=[{"id": "neustart", "label": "Starten", "dienst": "lucy-stimme"}])
monkeypatch.setattr(waechter, "PRUEFUNGEN", (lambda: [befund],))
meldungen: list[tuple[str, list[str] | None]] = []
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text, knoepfe=None: meldungen.append((text, knoepfe)))
return meldungen
def test_roter_hinweis_mit_antwortzeile(monkeypatch, tmp_path):
gesendet = []
def post(url, timeout=None, json=None):
gesendet.append((url, json))
return Antwort({"zeilen": ["Antworte „ja 3“, dann starte ich lucy-stimme.",
"Ich sehe nach und melde mich mit Ursache und Weg."], "knoepfe": ["ja 3", "nein 3"]})
meldungen = _vorbereiten(monkeypatch, tmp_path, post)
waechter.takt()
url, daten = gesendet[0]
assert url == "http://127.0.0.1:9/api/lucy/befund" # conftest: MC2 zeigt in Tests ins Leere
assert daten["bereich"] == "box" and daten["erinnerung"] is False and daten["aktionen"][0]["id"] == "neustart"
assert meldungen == [(("Lucys Stimme ist gestoppt. Die Unit lucy-stimme läuft nicht.\n"
"Antworte „ja 3“, dann starte ich lucy-stimme.\n"
"Ich sehe nach und melde mich mit Ursache und Weg."), ["ja 3", "nein 3"])]
# Die Erinnerung sagt es der Box (dort gibt es dann nur die Antwortzeile wieder, keine zweite Untersuchung).
waechter.takt()
assert gesendet[1][1]["erinnerung"] is True and meldungen[1][0].startswith("Immer noch: Lucys Stimme")
def test_ohne_box_bleibt_die_meldung_wie_bisher(monkeypatch, tmp_path):
def kaputt(*a, **k):
raise httpx.ConnectError("weg")
meldungen = _vorbereiten(monkeypatch, tmp_path, kaputt)
waechter.takt()
assert meldungen == [("Lucys Stimme ist gestoppt. Die Unit lucy-stimme läuft nicht.", None)]
+149
View File
@@ -0,0 +1,149 @@
"""Wochenbericht in Lucys Stimme (Ausbauplan Welle 3, Punkt 16, 25.09.2026): feste Sätze aus den Zahlen beider Teile,
ein kurzer Absatz vom Modell obenauf, offene App-Updates als Rückfragen mit Antworttasten."""
import json
from datetime import datetime
import pytest
from kern import einstellungen as einstellungen_mod
from kern.zeit import LOCAL_TZ
from services import rueckfragen, wochenbericht
JETZT = datetime(2026, 9, 27, 7, 30, tzinfo=LOCAL_TZ).timestamp() # Sonntag
TAG = 86400
FAKTEN = {
"homelab_laeufe": [
{"status": "fertig", "ergebnis": "eingespielt", "name": "Gitea", "baustein": "app", "ende": JETZT - TAG},
{"status": "fertig", "ergebnis": "eingespielt", "name": "Proxmox-Host", "baustein": "sicherheit",
"ende": JETZT - 4 * 3600},
{"status": "fertig", "ergebnis": "zurueckgerollt", "name": "NetBird", "baustein": "os", "ende": JETZT - 2 * TAG},
{"status": "fertig", "ergebnis": "eingespielt", "name": "Alt", "baustein": "app", "ende": JETZT - 9 * TAG},
{"status": "fertig", "ergebnis": "eingespielt", "name": "Probe", "baustein": "probe", "ende": JETZT - TAG},
{"status": "laeuft", "name": "Vaultwarden", "baustein": "os", "ende": None},
],
"box_laeufe": [
{"start": datetime(2026, 9, 27, 4, 30, tzinfo=LOCAL_TZ).isoformat(),
"zeilen": [{"baustein": "Motor", "ergebnis": "eingespielt"}, {"baustein": "Hermes", "ergebnis": "festgehalten"},
{"baustein": "llama-swap", "ergebnis": "aktuell"}]},
{"start": "2026-09-13T04:30:00+02:00", "zeilen": [{"baustein": "Hermes", "ergebnis": "fehler"}]},
],
"ziele_box": [{"id": "ki-box", "name": "KI-Box", "bausteine": [
{"id": "os", "name": "Betriebssystem", "zustand": "neu", "kurz": "5 Pakete", "aktion": {"pfad": "/api/x"}}]}],
"ziele_homelab": [
{"id": "ct-104", "name": "Gitea", "bausteine": [
{"id": "app", "name": "Gitea", "zustand": "neu", "verfuegbar": "1.27.4",
"aktion": {"pfad": "/api/homelab/ziele/ct-104/update"}},
{"id": "os", "name": "Pakete", "zustand": "neu", "sicherheit": 2, "aktion": {"pfad": "/api/homelab/ziele/ct-104/os-update"}}]},
{"id": "ct-110", "name": "Home Assistant", "bausteine": [
{"id": "app", "name": "Home Assistant", "zustand": "neu", "verfuegbar": "2026.10", "aktion": None,
"grund": "eigene Oberfläche"}]},
],
"hinweise_box": [],
"hinweise_homelab": [{"stufe": "gelb", "titel": "Platte von AdGuard Home zu 84 % voll"}],
"speicher": {"pools": [{"speicher": "local-lvm", "anteil": 0.52, "vorhersage": {"tage": 40.0}}],
"systemplatte": {"anteil": 0.31, "vorhersage": {"tage": None}},
"nas": [{"problem": "NAS antwortet nicht."}],
"sicherungen": {"stufe": "gelb", "fehlend": [{"name": "Arcane"}],
"geraete": [{"name": "Gitea", "probe": {"ok": True}},
{"name": "NetBird", "probe": {"ok": False}}]}},
"box_platte": {"percent": 61.0, "vorhersage": {"tage": 10.0}},
"zertifikate": {"zertifikate": [{"name": "mc.example", "stufe": "gelb", "problem": "wird nicht erneuert, noch "
"2 Tage gültig"},
{"name": "pve", "stufe": "ok"}]},
"docker": {"verfuegbar": True, "stacks": [{"container": [
{"name": "immich", "luecken": {"kritisch": 3}}, {"name": "paperless", "luecken": {"kritisch": 0}},
{"name": "redis", "luecken": None}]}]},
"radar": [{"name": "Qwen4-30B", "status": "bestanden"}, {"name": "X", "status": "wartet"},
{"name": "Y", "status": "neu"}],
"rueckfragen": {"zuletzt": [{"zeit": JETZT - TAG, "antwort": {"ja": True}}, {"zeit": JETZT - TAG, "antwort": None},
{"zeit": JETZT - 10 * TAG, "antwort": {"ja": True}}]},
"lucy": [{"zeit": "2026-09-26T10:00:00", "werkzeug": "dienst_neustarten", "stufe": "selbst", "ergebnis": "ok"},
{"zeit": "2026-09-26T11:00:00", "werkzeug": "update_starten", "stufe": "auf_bitte", "ergebnis": "ok"},
{"zeit": "2026-09-26T12:00:00", "werkzeug": "update_starten", "stufe": "fragen", "ergebnis": "Rückfrage 3"},
{"zeit": "2026-09-26T13:00:00", "werkzeug": "x", "stufe": "selbst", "ergebnis": "Nicht ausgeführt: nein"},
{"zeit": "2026-09-10T10:00:00", "werkzeug": "dienst_neustarten", "stufe": "selbst", "ergebnis": "ok"}],
}
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
def test_feste_saetze():
assert wochenbericht.zeilen(FAKTEN, JETZT) == [
"Eingespielt: Gitea (App), Proxmox-Host (Sicherheitsupdates) und KI-Box Motor.",
"Nicht glatt gelaufen: NetBird (Pakete) zurückgerollt und KI-Box Hermes festgehalten.",
"Offen: Gitea 1.27.4 und Home Assistant 2026.10; Paket-Updates auf 2 Geräten, davon 2 Sicherheitsupdates.",
"Hinweise: gelb: Platte von AdGuard Home zu 84 % voll.",
("Speicher: Pool local-lvm 52 % (voll in etwa 6 Wochen); Host-Systemplatte 31 %; NAS: NAS antwortet nicht; "
"KI-Box 61 % (voll in etwa 10 Tagen)."),
"Sicherungen: Stufe gelb; zu alt oder fehlend: Arcane; Probe-Wiederherstellung gescheitert: NetBird.",
"Zertifikate: mc.example: wird nicht erneuert, noch 2 Tage gültig.",
"Lücken-Scan: immich (3 kritisch).",
"Modell-Radar: bestanden und wartet auf deine Entscheidung: Qwen4-30B; 2 Kandidaten warten auf den Test.",
"Lucy: 1× selbst gehandelt; 1× auf deine Bitte; 2 Rückfragen beantwortet oder abgelaufen, davon 1× ja.",
]
def test_leere_woche():
zeilen = wochenbericht.zeilen({}, JETZT)
assert zeilen == ["Eingespielt: nichts.", "Offen: nichts.", "Hinweise: keine.", "Speicher: keine Angaben.",
"Sicherungen: keine Angaben.", "Zertifikate: keine Angaben."]
def test_bericht_mit_rueckfragen(daten, monkeypatch):
gesendet = []
monkeypatch.setattr(wochenbericht, "fakten", lambda jetzt=None: FAKTEN)
monkeypatch.setattr(wochenbericht, "prosa", lambda zeilen: "Commander, eine ruhige Woche.")
monkeypatch.setattr(wochenbericht.announce, "add", lambda text, **k: gesendet.append(("briefkasten", k)))
monkeypatch.setattr(wochenbericht.announce, "notify_telegram",
lambda betreff, text, dringend=False, knoepfe=None: gesendet.append((betreff, text, knoepfe)))
assert wochenbericht.senden(JETZT) == {"ok": True, "text": "Der Wochenbericht ist unterwegs."}
briefkasten, telegram = gesendet
assert briefkasten == ("briefkasten", {"subject": "[Wochenbericht]", "source": "wochenbericht",
"priority": "silent"})
betreff, text, knoepfe = telegram
assert betreff == "[Wochenbericht]" and knoepfe == ["ja 1", "ja 2"]
assert text.startswith("Commander, eine ruhige Woche.\n\nDie Woche in Stichpunkten:\n• Eingespielt: Gitea (App)")
assert text.endswith("Auf Zuruf:\n• Gitea 1.27.4 einspielen — „ja 1“\n"
"• alles auf einmal („Alle aktualisieren“) — „ja 2“")
offen = rueckfragen.liste(jetzt=JETZT)["offen"]
assert [(f["pfad"], f["von"]) for f in offen] == [("/api/partner/homelab/alle", "wochenbericht"),
("/api/partner/homelab/ziele/ct-104/update", "wochenbericht")]
assert json.loads((daten / "wochenbericht.json").read_text(encoding="utf-8"))["zeit"] == JETZT
assert wochenbericht.stand(JETZT)["zuletzt"]["text"] == text
def test_ohne_modell_ein_fester_satz(daten, monkeypatch):
monkeypatch.setattr(wochenbericht, "fakten", lambda jetzt=None: {})
monkeypatch.setattr(wochenbericht, "prosa", lambda zeilen: None)
bericht = wochenbericht.bauen(JETZT)
assert bericht["text"].startswith("Commander, hier ist dein Wochenbericht.\n\nDie Woche in Stichpunkten:")
assert bericht["knoepfe"] == [] and "Auf Zuruf" not in bericht["text"]
def test_naechster_termin():
samstag = datetime(2026, 9, 26, 12, 0, tzinfo=LOCAL_TZ).timestamp()
sonntag_acht = datetime(2026, 9, 27, 8, 0, tzinfo=LOCAL_TZ).timestamp()
assert wochenbericht.naechster(samstag) == JETZT
assert wochenbericht.naechster(sonntag_acht) == datetime(2026, 10, 4, 7, 30, tzinfo=LOCAL_TZ).timestamp()
def test_prosa_ohne_denkpause(monkeypatch):
class Antwort:
status_code = 200
@staticmethod
def json():
return {"choices": [{"message": {"content": "<think>hm</think>Commander, alles ruhig."}}]}
gesendet = {}
monkeypatch.setattr(wochenbericht.httpx, "post", lambda url, timeout, json: gesendet.update(json) or Antwort())
assert wochenbericht.prosa(["Eingespielt: nichts."]) == "Commander, alles ruhig."
assert gesendet["model"] == "fast" and gesendet["chat_template_kwargs"] == {"enable_thinking": False}
assert "Eingespielt: nichts." in gesendet["messages"][0]["content"]