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