Files
mission-control-v2/backend/services/rueckfragen.py
T
HitonabiandClaude Opus 5.5 12cefb427a 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>
2026-09-25 19:12:50 +02:00

350 lines
16 KiB
Python

"""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.")