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
@@ -0,0 +1,349 @@
|
||||
"""Rückfragen an den Commander (Ausbauplan Welle 3, Punkt 15, seit 25.09.2026).
|
||||
|
||||
Eine Rückfrage ist genau eine Aktion — derselbe Knopf, den auch die Oberfläche hat —, die erst nach dem Ja des
|
||||
Commanders läuft. Stellen können sie: Lucy (ihre Werkzeuge auf der Stufe „erst fragen“, services/lucy_autonomie.py),
|
||||
der Wächter beider Teile bei roten Hinweisen mit Knopf (services/ermittlung.py) und der Wochenbericht für offene
|
||||
Updates. Jede trägt eine Nummer, die nie wieder vergeben wird. Telegram bekommt „Antworte „ja 7“ oder „nein 7““ und
|
||||
zwei Antwort-Knöpfe: eine Antworttastatur, deren Tipp nur den Text „ja 7“ schickt. Den liest Lucy wie jede Nachricht und
|
||||
ruft ihr Werkzeug rueckfrage_beantworten — an Hermes' Code ändert das nichts (Vorgabe des Ausbauplans: echte Knöpfe nur
|
||||
ohne Eingriff in Hermes). Die Oberfläche zeigt offene Rückfragen mit Ja und Nein.
|
||||
|
||||
User-Entscheid 25.09.2026: Ein „ja“ gilt für genau die gestellte Frage, auch für den Neustart des Proxmox-Hosts. Darum:
|
||||
• Stellen lassen sich nur Pfade aus ERLAUBT — die Knöpfe der Oberfläche, nie Löschen, nie Einstellungen.
|
||||
• Ausgeführt wird die gespeicherte Aktion, nicht, was in der Antwort steht. Vor dem Ausführen steht sie auf „läuft“;
|
||||
ein zweites „ja“ tut nichts mehr.
|
||||
• Eine Rückfrage gilt 24 Stunden. Gehört sie zu einem Hinweis, der inzwischen weg ist, passiert nichts („hat sich
|
||||
erledigt“). Nach dem Ausführen prüft sie nach, ob der Hinweis verschwindet, und meldet sich, wenn nicht.
|
||||
Dringlichkeit: dringend (sofort, auch nachts), normal (tagsüber sofort, nachts mit der Morgenmeldung), leise (keine
|
||||
Nachricht, nur Oberfläche und Wochenbericht).
|
||||
|
||||
Die Rückfragen gehören der KI-Box (dort wohnt Lucy) und nur MC2 dort schreibt die Datei. Steward und Homelab-Teil
|
||||
stellen sie per HTTP (box_url()); Pfade des Homelab-Teils (/api/homelab/…) werden dabei zur Durchreiche
|
||||
(/api/partner/homelab/…), denn ausgeführt wird von der Box aus.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
from services import announce
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
GUELTIG_S = 24 * 3600
|
||||
BEHALTEN = 200
|
||||
STUFEN = ("dringend", "normal", "leise")
|
||||
BETREFF = "[Rückfrage]"
|
||||
EIGENE_URL = os.environ.get("MC_EIGENE_URL", "http://127.0.0.1:9001").rstrip("/")
|
||||
AUSFUEHREN_S = 120.0
|
||||
NACHPRUEFEN_S = 6 * 60 # der Wächter braucht drei Takte, bis ein Hinweis als erledigt gilt
|
||||
NACHPRUEF_TAKT_S = 30.0
|
||||
STATUS_TEXT = {"offen": "offen", "laeuft": "wird gerade ausgeführt", "erledigt": "schon erledigt",
|
||||
"fehler": "schon beantwortet (die Aktion scheiterte)", "abgelehnt": "abgelehnt",
|
||||
"abgelaufen": "abgelaufen (älter als 24 Stunden)", "hinfaellig": "hinfällig (der Hinweis war weg)"}
|
||||
|
||||
HL = "/api/partner/homelab"
|
||||
_ZIEL = r"(?:pve|(?:ct|vm)-\d+)"
|
||||
_HINWEIS_AKTION = r"[^/]+/aktion/(?:neustart|job-wiederholen|freigeben)"
|
||||
# Die Knöpfe, die ein „ja“ auslösen darf (Pfade von der Box aus gesehen). Nie: Löschen, Einstellungen, Zugänge.
|
||||
ERLAUBT = tuple(re.compile(m) for m in (
|
||||
rf"{HL}/ziele/{_ZIEL}/(?:update|os-update|docker|sicherheitsupdates|suchen)",
|
||||
rf"{HL}/ziele/pve/(?:neustart|kernel-aufraeumen)",
|
||||
rf"{HL}/alle",
|
||||
rf"{HL}/snapshots/{_ZIEL}/mc2-[\w.-]+/zuruecksetzen",
|
||||
rf"{HL}/sicherungen/probe",
|
||||
rf"{HL}/hinweise/{_HINWEIS_AKTION}",
|
||||
rf"/api/hinweise/{_HINWEIS_AKTION}",
|
||||
r"/api/maintenance/(?:check-updates|os-update|engine-update|swap-update|hermes-update|update-all|restart)",
|
||||
r"/api/updates/festgehalten/[\w-]+/freigeben",
|
||||
r"/api/radar/suche",
|
||||
r"/api/radar/[\w.:@-]+/testen",
|
||||
r"/api/lucy/ermitteln",
|
||||
))
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def erlaubt(pfad: str) -> bool:
|
||||
return any(m.fullmatch(pfad) for m in ERLAUBT)
|
||||
|
||||
|
||||
def box_pfad(pfad: str) -> str:
|
||||
"""Pfade des Homelab-Teils (/api/homelab/…) laufen auf der Box über die Durchreiche."""
|
||||
return HL + pfad[len("/api/homelab"):] if pfad.startswith("/api/homelab/") else pfad
|
||||
|
||||
|
||||
def box_url() -> str:
|
||||
"""Wo die Rückfragen wohnen (MC2 auf der KI-Box) — für Prozesse außerhalb davon: den Steward der Box (dieselbe
|
||||
Adresse wie MC2 selbst) und den Homelab-Teil (seine Partner-Adresse). Leer = keine Box bekannt."""
|
||||
e = einstellungen()
|
||||
return EIGENE_URL if e.rolle == "box" else e.partner_url
|
||||
|
||||
|
||||
# --- Meldungen (mit Sammler für Lucys Untersuchungen) ------------------------------------------------------------
|
||||
|
||||
_sammel_lock = threading.Lock()
|
||||
_sammler: list[dict] | None = None
|
||||
|
||||
|
||||
def melden(betreff: str, text: str, knoepfe: list[str] | None = None, dringend: bool = False) -> None:
|
||||
"""Eine Nachricht in Lucys Namen: Briefkasten (die Desktop-Lucy spricht sie) und Telegram. Untersucht Lucy gerade
|
||||
einen Hinweis (gesammelt()), hängt sie an deren Nachricht — außer sie ist dringend."""
|
||||
with _sammel_lock:
|
||||
if _sammler is not None and not dringend:
|
||||
_sammler.append({"betreff": betreff, "text": text, "knoepfe": list(knoepfe or [])})
|
||||
return
|
||||
try:
|
||||
announce.add(text, subject=betreff, source="lucy")
|
||||
except ValueError:
|
||||
return
|
||||
announce.notify_telegram(betreff, text, dringend=dringend, knoepfe=knoepfe)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def gesammelt() -> Iterator[list[dict]]:
|
||||
"""Solange Lucy einen Hinweis untersucht: Rückfragen und Meldungen danach sammeln statt einzeln schicken."""
|
||||
global _sammler
|
||||
liste: list[dict] = []
|
||||
with _sammel_lock:
|
||||
_sammler = liste
|
||||
try:
|
||||
yield liste
|
||||
finally:
|
||||
with _sammel_lock:
|
||||
_sammler = None
|
||||
|
||||
|
||||
def antwortzeile(nr: int) -> str:
|
||||
return f"Antworte „ja {nr}“ oder „nein {nr}“."
|
||||
|
||||
|
||||
def knoepfe(nr: int) -> list[str]:
|
||||
return [f"ja {nr}", f"nein {nr}"]
|
||||
|
||||
|
||||
def nachricht(r: dict) -> str:
|
||||
return r["frage"] + (f" {r['text']}" if r.get("text") else "") + "\n" + antwortzeile(r["nr"])
|
||||
|
||||
|
||||
# --- Ablage ----------------------------------------------------------------------------------------------------
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "lucy-rueckfragen.json"
|
||||
|
||||
|
||||
def _lesen() -> dict:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
if isinstance(daten, dict) and isinstance(daten.get("fragen"), list) and isinstance(daten.get("naechste"), int):
|
||||
return daten
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return {"naechste": 1, "fragen": []}
|
||||
|
||||
|
||||
def _schreiben(daten: dict) -> None:
|
||||
daten["fragen"] = daten["fragen"][-BEHALTEN:]
|
||||
pfad = _pfad()
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
|
||||
|
||||
def _ablaufen(daten: dict, jetzt: float) -> bool:
|
||||
geaendert = False
|
||||
for f in daten["fragen"]:
|
||||
if f.get("status") == "offen" and jetzt >= float(f.get("ablauf") or 0):
|
||||
f["status"] = "abgelaufen"
|
||||
geaendert = True
|
||||
return geaendert
|
||||
|
||||
|
||||
def _befund(b: object) -> dict | None:
|
||||
if not isinstance(b, dict) or b.get("bereich") not in ("box", "homelab") or not b.get("id"):
|
||||
return None
|
||||
return {"bereich": b["bereich"], "id": str(b["id"])[:200], "titel": str(b.get("titel") or b["id"])[:200]}
|
||||
|
||||
|
||||
def _oeffentlich(f: dict) -> dict:
|
||||
return {k: v for k, v in f.items() if k != "schluessel"}
|
||||
|
||||
|
||||
# --- Stellen, Lesen, Beantworten -----------------------------------------------------------------------------------
|
||||
|
||||
def stellen(frage: str, pfad: str, daten: dict | None = None, *, text: str = "", von: str = "lucy",
|
||||
stufe: str = "normal", schluessel: str | None = None, befund: object = None, still: bool = False,
|
||||
jetzt: float | None = None) -> dict:
|
||||
"""Eine Rückfrage anlegen und (außer still oder leise) melden. Dieselbe Aktion zweimal offen gibt es nicht: Dann
|
||||
kommt die offene zurück („neu“: False) und niemand wird ein zweites Mal gefragt.
|
||||
still: Wer fragt, schickt die Antwortzeile in seiner eigenen Nachricht mit (der Wächter, der Wochenbericht)."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
frage = " ".join(str(frage or "").split())[:300]
|
||||
pfad = box_pfad(str(pfad or "").strip())
|
||||
daten = {} if daten is None else daten
|
||||
if not frage:
|
||||
return {"ok": False, "detail": "Es fehlt die Frage."}
|
||||
if not erlaubt(pfad):
|
||||
return {"ok": False, "detail": f"Diese Aktion lässt sich nicht per Rückfrage auslösen ({pfad or 'ohne Pfad'})."}
|
||||
if stufe not in STUFEN:
|
||||
return {"ok": False, "detail": "Die Dringlichkeit muss dringend, normal oder leise sein."}
|
||||
if not isinstance(daten, dict) or len(json.dumps(daten, ensure_ascii=False)) > 2000:
|
||||
return {"ok": False, "detail": "Die Angaben zur Aktion sind ungültig."}
|
||||
schluessel = str(schluessel or f"{pfad} {json.dumps(daten, sort_keys=True, ensure_ascii=False)}")[:400]
|
||||
with _lock:
|
||||
alle = _lesen()
|
||||
geaendert = _ablaufen(alle, jetzt)
|
||||
offen = next((f for f in alle["fragen"] if f["status"] == "offen" and f.get("schluessel") == schluessel), None)
|
||||
if offen is not None:
|
||||
if geaendert:
|
||||
_schreiben(alle)
|
||||
return {"ok": True, "neu": False, "rueckfrage": _oeffentlich(offen)}
|
||||
r = {"nr": alle["naechste"], "zeit": jetzt, "frage": frage, "text": " ".join(str(text or "").split())[:1500],
|
||||
"pfad": pfad, "daten": daten, "von": str(von or "lucy")[:40], "stufe": stufe,
|
||||
"ablauf": jetzt + GUELTIG_S, "status": "offen", "schluessel": schluessel, "befund": _befund(befund)}
|
||||
alle["naechste"] += 1
|
||||
alle["fragen"].append(r)
|
||||
_schreiben(alle)
|
||||
log.info("rueckfrage %s von %s: %s → %s", r["nr"], r["von"], frage, pfad)
|
||||
if not still and stufe != "leise":
|
||||
melden(BETREFF, nachricht(r), knoepfe=knoepfe(r["nr"]), dringend=stufe == "dringend")
|
||||
return {"ok": True, "neu": True, "rueckfrage": _oeffentlich(r)}
|
||||
|
||||
|
||||
def liste(grenze: int = 20, jetzt: float | None = None) -> dict:
|
||||
"""Für GET /api/rueckfragen: die offenen (neueste zuerst) und die zuletzt beantworteten."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
with _lock:
|
||||
alle = _lesen()
|
||||
if _ablaufen(alle, jetzt):
|
||||
_schreiben(alle)
|
||||
fragen = [_oeffentlich(f) for f in reversed(alle["fragen"])]
|
||||
return {"offen": [f for f in fragen if f["status"] == "offen"],
|
||||
"zuletzt": [f for f in fragen if f["status"] != "offen"][:max(1, min(int(grenze), 100))]}
|
||||
|
||||
|
||||
def offene() -> list[dict]:
|
||||
return liste(1)["offen"]
|
||||
|
||||
|
||||
def beantworten(nr: int | None, ja: bool, wer: str = "oberflaeche", jetzt: float | None = None,
|
||||
nachpruefen: bool = True) -> dict:
|
||||
"""„ja“ führt genau die gespeicherte Aktion aus, „nein“ legt sie ab. Ohne Nummer nur, wenn genau eine offen ist."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
with _lock:
|
||||
alle = _lesen()
|
||||
if _ablaufen(alle, jetzt):
|
||||
_schreiben(alle)
|
||||
offen = [f for f in alle["fragen"] if f["status"] == "offen"]
|
||||
if nr is None:
|
||||
if not offen:
|
||||
return {"ok": False, "detail": "Es ist keine Rückfrage offen."}
|
||||
if len(offen) > 1:
|
||||
welche = "; ".join(f"{f['nr']}: {f['frage']}" for f in offen[-5:])
|
||||
return {"ok": False, "detail": f"Es sind mehrere Rückfragen offen — welche meinst du? {welche}"}
|
||||
r = offen[0]
|
||||
else:
|
||||
r = next((f for f in alle["fragen"] if f["nr"] == nr), None)
|
||||
if r is None:
|
||||
return {"ok": False, "detail": f"Eine Rückfrage {nr} gibt es nicht."}
|
||||
if r["status"] != "offen":
|
||||
return {"ok": False, "detail": f"Rückfrage {nr} ist {STATUS_TEXT.get(r['status'], r['status'])}."}
|
||||
r["status"] = "laeuft" if ja else "abgelehnt"
|
||||
r["antwort"] = {"zeit": jetzt, "wer": str(wer or "")[:40], "ja": bool(ja)}
|
||||
_schreiben(alle)
|
||||
r = dict(r)
|
||||
if not ja:
|
||||
log.info("rueckfrage %s: nein (%s)", r["nr"], wer)
|
||||
return {"ok": True, "text": f"Rückfrage {r['nr']} abgelehnt, es passiert nichts: {r['frage']}"}
|
||||
if r.get("befund") and befund_besteht(r["befund"]) is False:
|
||||
_abschliessen(r["nr"], "hinfaellig", "Der Hinweis war schon weg.")
|
||||
return {"ok": True, "text": f"Rückfrage {r['nr']} hat sich erledigt: „{r['befund']['titel']}“ ist schon weg. "
|
||||
"Ich habe nichts ausgeführt."}
|
||||
ergebnis = _ausfuehren(r)
|
||||
_abschliessen(r["nr"], "erledigt" if ergebnis["ok"] else "fehler", ergebnis.get("text") or ergebnis.get("detail"))
|
||||
log.info("rueckfrage %s: ja (%s) → %s", r["nr"], wer, "ok" if ergebnis["ok"] else ergebnis.get("detail"))
|
||||
if not ergebnis["ok"]:
|
||||
return {"ok": False, "detail": f"Rückfrage {r['nr']}: Das ging nicht — {ergebnis['detail']}"}
|
||||
if nachpruefen and r.get("befund"):
|
||||
threading.Thread(target=_nachpruefen, args=(r,), name=f"rueckfrage-{r['nr']}", daemon=True).start()
|
||||
return {"ok": True, "text": f"Rückfrage {r['nr']} ausgeführt: {ergebnis['text']}"}
|
||||
|
||||
|
||||
def _abschliessen(nr: int, status: str, text: str | None, **mehr: object) -> None:
|
||||
with _lock:
|
||||
alle = _lesen()
|
||||
r = next((f for f in alle["fragen"] if f["nr"] == nr), None)
|
||||
if r is not None:
|
||||
r["status"] = status
|
||||
r["ergebnis"] = str(text or "")[:500]
|
||||
r.update(mehr)
|
||||
_schreiben(alle)
|
||||
|
||||
|
||||
def _vermerken(nr: int, **mehr: object) -> None:
|
||||
with _lock:
|
||||
alle = _lesen()
|
||||
r = next((f for f in alle["fragen"] if f["nr"] == nr), None)
|
||||
if r is not None:
|
||||
r.update(mehr)
|
||||
_schreiben(alle)
|
||||
|
||||
|
||||
def _ausfuehren(r: dict) -> dict:
|
||||
"""Die gespeicherte Aktion wie ein Knopf der Oberfläche (POST an MC2 selbst). Rückgabe ok + text oder detail."""
|
||||
if not erlaubt(r["pfad"]): # doppelt hält besser: Die Datei könnte von Hand geändert worden sein.
|
||||
return {"ok": False, "detail": "Diese Aktion ist nicht (mehr) erlaubt."}
|
||||
try:
|
||||
antwort = httpx.post(f"{EIGENE_URL}{r['pfad']}", json=r.get("daten") or {}, timeout=AUSFUEHREN_S)
|
||||
except httpx.HTTPError as exc:
|
||||
return {"ok": False, "detail": f"das Steuerpult antwortete nicht ({exc.__class__.__name__})"}
|
||||
try:
|
||||
daten = antwort.json() if antwort.content else {}
|
||||
except ValueError:
|
||||
daten = {}
|
||||
daten = daten if isinstance(daten, dict) else {}
|
||||
if antwort.status_code >= 400 or daten.get("ok") is False:
|
||||
return {"ok": False, "detail": str(daten.get("detail") or daten.get("err") or daten.get("text")
|
||||
or f"HTTP {antwort.status_code}")}
|
||||
return {"ok": True, "text": str(daten.get("text") or "Gestartet; das Ergebnis kommt als Meldung.")}
|
||||
|
||||
|
||||
def befund_besteht(befund: dict) -> bool | None:
|
||||
"""Steht der Hinweis noch? None = unbekannt (Wächter nicht lesbar). Ein Hinweis, den der letzte Takt nicht mehr sah,
|
||||
läuft nur noch im Puffer des Wächters aus (ERLEDIGT_NACH) und gilt hier schon als weg."""
|
||||
pfad = "/api/hinweise" if befund.get("bereich") == "box" else f"{HL}/hinweise"
|
||||
try:
|
||||
antwort = httpx.get(f"{EIGENE_URL}{pfad}", timeout=10)
|
||||
stand = antwort.json() if antwort.status_code == 200 else None
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return None
|
||||
if not isinstance(stand, dict) or not stand.get("aktiv", True):
|
||||
return None
|
||||
h = next((x for x in stand.get("hinweise") or [] if x.get("id") == befund.get("id")), None)
|
||||
if h is None:
|
||||
return False
|
||||
return float(h.get("zuletzt") or 0) >= float(stand.get("stand") or 0) - 1
|
||||
|
||||
|
||||
def _nachpruefen(r: dict, dauer_s: float = NACHPRUEFEN_S, takt_s: float = NACHPRUEF_TAKT_S) -> None:
|
||||
"""Nach dem Ausführen: Verschwindet der Hinweis? Dann still (die Entwarnung schickt der Wächter selbst). Steht er
|
||||
nach sechs Minuten noch, sagt Lucy es — ein „ja“ darf nicht folgenlos im Sand verlaufen."""
|
||||
ende = time.monotonic() + dauer_s
|
||||
while time.monotonic() < ende:
|
||||
time.sleep(takt_s)
|
||||
if befund_besteht(r["befund"]) is False:
|
||||
_vermerken(r["nr"], nachgeprueft="behoben")
|
||||
return
|
||||
_vermerken(r["nr"], nachgeprueft="besteht")
|
||||
melden(BETREFF, f"Nachgeprüft: „{r['befund']['titel']}“ steht {round(dauer_s / 60)} Minuten nach Rückfrage "
|
||||
f"{r['nr']} immer noch an. Das hat nicht gereicht — frag mich, dann sehe ich nach.")
|
||||
Reference in New Issue
Block a user