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
+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]