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:
co-authored by
Claude Opus 5.5
parent
dd23f3fb4c
commit
12cefb427a
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")]
|
||||
@@ -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"
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
@@ -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})
|
||||
|
||||
@@ -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)]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user