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
@@ -53,6 +53,7 @@ ZEITPLAENE: tuple[Zeitplan, ...] = (
|
||||
Zeitplan("mc2-radar", "Modell-Radar", TAG + 3 * STUNDE, "täglich 00:30"),
|
||||
Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"),
|
||||
Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"),
|
||||
Zeitplan("mc2-wochenbericht", "Wochenbericht", 7 * TAG + 6 * STUNDE, "sonntags 07:30"),
|
||||
)
|
||||
HERMES_KARENZ_S = STUNDE
|
||||
CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell
|
||||
|
||||
@@ -99,14 +99,19 @@ def add(text: str, subject: str = "", source: str = "", priority: str = "normal"
|
||||
return item
|
||||
|
||||
|
||||
def notify_telegram(subject: str, text: str) -> None:
|
||||
def notify_telegram(subject: str, text: str, dringend: bool = False, knoepfe: list[str] | None = None) -> None:
|
||||
"""Best-effort auch auf Telegram (User ist evtl. nicht am PC). MC_NOTIFY_NO_ANNOUNCE=1
|
||||
verhindert, dass notify.sh die Meldung ZURÜCK in den Briefkasten spiegelt — der Absender
|
||||
(Wächter/Erinnerung) hat sie dort schon selbst abgelegt. Auf Windows (Dev) ein No-op."""
|
||||
(Wächter/Erinnerung) hat sie dort schon selbst abgelegt. Auf Windows (Dev) ein No-op.
|
||||
dringend: geht auch nachts sofort raus. knoepfe (seit 25.09.2026, Rückfragen): Antworttasten wie „ja 7“ —
|
||||
ein Tipp schickt genau diesen Text an Lucy."""
|
||||
if not (os.name == "posix" and shutil.which("bash")):
|
||||
return
|
||||
befehl = ["bash", NOTIFY_SH] + (["-d"] if dringend else [])
|
||||
if knoepfe:
|
||||
befehl += ["-k", "|".join(k.replace("|", " ") for k in knoepfe)]
|
||||
try:
|
||||
subprocess.run(["bash", NOTIFY_SH, "-s", subject, text],
|
||||
subprocess.run([*befehl, "-s", subject, text],
|
||||
timeout=30, capture_output=True, env={**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1"})
|
||||
except Exception:
|
||||
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Lucy untersucht rote Hinweise (Ausbauplan Welle 3, Punkt 14 „Lucy ermittelt, schlägt vor, prüft nach“, seit
|
||||
25.09.2026).
|
||||
|
||||
Der Wächter beider Teile meldet jeden roten Hinweis, bevor er ihn nach Telegram schickt, hierher
|
||||
(POST /api/lucy/befund → befund_gemeldet()). Zurück bekommt er die Zeilen, die er an seine Nachricht hängt:
|
||||
• Hat der Hinweis einen Knopf (Neustart, Job wiederholen, Freigeben), wird er zur Rückfrage: „Antworte „ja 7“, dann
|
||||
starte ich … neu.“ (services/rueckfragen.py)
|
||||
• Je nach Stufe der Aktionsart „ermitteln“ (services/lucy_autonomie.py):
|
||||
selbst Lucy sieht sofort nach — über ihren eigenen Zugang (Hermes-api_server: dieselbe Lucy wie in Telegram,
|
||||
mit allen Werkzeugen) — und schickt Ursache und Lösungsweg als eine Nachricht. Rückfragen und Meldungen
|
||||
danach, die sie dabei auslöst, hängen an genau dieser Nachricht (rueckfragen.gesammelt()).
|
||||
fragen „Antworte „ja 8“, dann sehe ich nach“ (nur, wenn der Hinweis selbst keinen Knopf hat)
|
||||
lesen nichts; sie erklärt, wenn der Commander fragt
|
||||
Bremsen: je Hinweis höchstens einmal in sechs Stunden, höchstens acht Untersuchungen am Tag, eine nach der anderen.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL
|
||||
|
||||
from services import lucy_autonomie, rueckfragen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BETREFF = "[Lucy ermittelt]"
|
||||
ZEITLIMIT_S = 600.0
|
||||
JE_HINWEIS_S = 6 * 3600
|
||||
JE_TAG = 8
|
||||
_ANGEBOT = ("neustart", "job-wiederholen", "freigeben")
|
||||
|
||||
AUFTRAG = """Der Wächter des Orchestrators meldet einen roten Hinweis ({wo}):
|
||||
„{titel}“ — {text}
|
||||
(Kennung {id})
|
||||
|
||||
Sieh nach, was dahintersteckt: lage(), protokoll() und {logs}. Belege, statt zu raten.
|
||||
Danach antworte dem Commander in höchstens sechs Sätzen; die Antwort geht so, wie sie ist, an Telegram: was los ist, \
|
||||
woran es liegt, was betroffen ist und welcher Weg es löst.
|
||||
Ist eine Aktion nötig, nimm das passende Werkzeug mit auf_bitte=false — deine Freiheiten (freiheiten()) entscheiden, ob \
|
||||
du selbst handelst oder den Commander fragst; die Antwortzeile einer Rückfrage hängt das System an, schreib sie nicht \
|
||||
selbst. Einen bewusst gestoppten Dienst startest du nicht selbst. Hast du selbst gehandelt, prüfe mit lage() nach, ob \
|
||||
der Hinweis weg ist, und sag es."""
|
||||
|
||||
_lock = threading.Lock() # eine Untersuchung nach der anderen
|
||||
_zaehler_lock = threading.Lock()
|
||||
_zuletzt: dict[str, float] = {} # Hinweis → Beginn der letzten Untersuchung (nach einem Neustart von vorn: harmlos)
|
||||
_heute: list[float] = []
|
||||
|
||||
|
||||
def angebot(aktion: dict) -> str | None:
|
||||
"""Was ein Knopf des Hinweises tut, als Satzende nach „dann …“."""
|
||||
aid, label = aktion.get("id"), str(aktion.get("label") or "")
|
||||
if aid == "neustart":
|
||||
dienst = aktion.get("dienst") or "den Dienst"
|
||||
if label.startswith("Jetzt erneut"):
|
||||
return f"lasse ich {dienst} jetzt noch einmal laufen"
|
||||
return f"starte ich {dienst}" if label == "Starten" else f"starte ich {dienst} neu"
|
||||
if aid == "job-wiederholen":
|
||||
return "führe ich den Job noch einmal aus"
|
||||
if aid == "freigeben":
|
||||
return f"gebe ich {aktion.get('baustein') or 'den Baustein'} wieder für Updates frei"
|
||||
return None
|
||||
|
||||
|
||||
def _hinweis_pfad(bereich: str, hid: str, aid: str) -> str:
|
||||
basis = "/api/hinweise" if bereich == "box" else f"{rueckfragen.HL}/hinweise"
|
||||
return f"{basis}/{quote(hid, safe=':@-._')}/aktion/{aid}"
|
||||
|
||||
|
||||
def befund_gemeldet(b: object, jetzt: float | None = None) -> dict:
|
||||
"""POST /api/lucy/befund — vom Wächter bei einem roten Hinweis (neu oder als Erinnerung). Antwort: Zeilen und
|
||||
Antwort-Knöpfe für seine Nachricht. Bei einer Erinnerung bietet er nur den Knopf wieder an (dieselbe Nummer)."""
|
||||
d = b if isinstance(b, dict) else {}
|
||||
bereich, hid = d.get("bereich"), str(d.get("id") or "")
|
||||
if bereich not in ("box", "homelab") or not hid:
|
||||
return {"ok": False, "detail": "bereich (box/homelab) und id fehlen."}
|
||||
titel = " ".join(str(d.get("titel") or hid).split())[:200]
|
||||
text = " ".join(str(d.get("text") or "").split())[:600]
|
||||
befund = {"bereich": bereich, "id": hid, "titel": titel}
|
||||
zeilen: list[str] = []
|
||||
knoepfe: list[str] = []
|
||||
aktion = next((a for a in d.get("aktionen") or [] if isinstance(a, dict) and a.get("id") in _ANGEBOT), None)
|
||||
if aktion and (tut := angebot(aktion)):
|
||||
r = rueckfragen.stellen(f"{titel}: Soll ich das beheben? Dann {tut}.", _hinweis_pfad(bereich, hid, aktion["id"]),
|
||||
von="waechter", still=True, befund=befund, jetzt=jetzt,
|
||||
schluessel=f"hinweis {bereich} {hid} {aktion['id']}")
|
||||
if r["ok"]:
|
||||
nr = r["rueckfrage"]["nr"]
|
||||
zeilen.append(f"Antworte „ja {nr}“, dann {tut}.")
|
||||
knoepfe += rueckfragen.knoepfe(nr)
|
||||
if d.get("erinnerung"):
|
||||
return {"ok": True, "zeilen": zeilen, "knoepfe": knoepfe}
|
||||
stufe = lucy_autonomie.stufe("ermitteln")
|
||||
if stufe == "selbst":
|
||||
if starten({**befund, "text": text}, jetzt=jetzt)["ok"]:
|
||||
zeilen.append("Ich sehe nach und melde mich mit Ursache und Weg.")
|
||||
elif stufe == "fragen" and not knoepfe:
|
||||
r = rueckfragen.stellen(f"Soll ich nachsehen, was hinter „{titel}“ steckt?", "/api/lucy/ermitteln",
|
||||
{**befund, "text": text}, von="waechter", still=True, befund=befund, jetzt=jetzt,
|
||||
schluessel=f"ermitteln {bereich} {hid}")
|
||||
if r["ok"]:
|
||||
nr = r["rueckfrage"]["nr"]
|
||||
zeilen.append(f"Antworte „ja {nr}“, dann sehe ich nach, was dahintersteckt.")
|
||||
knoepfe += rueckfragen.knoepfe(nr)
|
||||
return {"ok": True, "zeilen": zeilen, "knoepfe": knoepfe}
|
||||
|
||||
|
||||
def starten(befund: object, jetzt: float | None = None) -> dict:
|
||||
"""Eine Untersuchung im Hintergrund (auch POST /api/lucy/ermitteln, das „ja“ einer Rückfrage). Das Ergebnis kommt
|
||||
als Nachricht; hier nur, ob sie beginnt."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
d = befund if isinstance(befund, dict) else {}
|
||||
if d.get("bereich") not in ("box", "homelab") or not d.get("id"):
|
||||
return {"ok": False, "detail": "bereich (box/homelab) und id fehlen."}
|
||||
if not HERMES_API_KEY:
|
||||
return {"ok": False, "detail": "Lucys eigener Zugang (API_SERVER_KEY in ~/.hermes/.env) fehlt."}
|
||||
schluessel = f"{d['bereich']}:{d['id']}"
|
||||
with _zaehler_lock:
|
||||
if jetzt - _zuletzt.get(schluessel, 0.0) < JE_HINWEIS_S:
|
||||
return {"ok": False, "detail": "Diesen Hinweis habe ich vor Kurzem schon untersucht."}
|
||||
_heute[:] = [t for t in _heute if jetzt - t < 86400]
|
||||
if len(_heute) >= JE_TAG:
|
||||
return {"ok": False, "detail": f"Heute habe ich schon {JE_TAG} Hinweise untersucht; weitere erst morgen."}
|
||||
_zuletzt[schluessel] = jetzt
|
||||
_heute.append(jetzt)
|
||||
befund = {"bereich": d["bereich"], "id": str(d["id"])[:200], "titel": str(d.get("titel") or d["id"])[:200],
|
||||
"text": str(d.get("text") or "")[:600]}
|
||||
threading.Thread(target=_untersuchen, args=(befund,), name=f"ermittlung-{befund['id']}", daemon=True).start()
|
||||
return {"ok": True, "text": "Lucy sieht nach; Ursache und Lösungsweg kommen als Nachricht."}
|
||||
|
||||
|
||||
def auftrag(befund: dict) -> str:
|
||||
if befund["bereich"] == "box":
|
||||
wo, logs = "auf der KI-Box", "die Protokolle des Dienstes (dienst_logs() oder dein Terminal)"
|
||||
else:
|
||||
wo, logs = "im Homelab", "die Protokolle des Geräts (geraet_logs())"
|
||||
return AUFTRAG.format(wo=wo, titel=befund["titel"], text=befund["text"] or "ohne weiteren Text",
|
||||
id=befund["id"], logs=logs)
|
||||
|
||||
|
||||
def _frag_lucy(text: str) -> str | None:
|
||||
"""Eine Anfrage an Lucy über den api_server von Hermes (dieselbe Persona, alle Werkzeuge). None = keine Antwort."""
|
||||
try:
|
||||
antwort = httpx.post(f"{HERMES_API_URL}/v1/chat/completions",
|
||||
json={"model": HERMES_API_MODEL, "stream": False,
|
||||
"messages": [{"role": "user", "content": text}]},
|
||||
headers={"Authorization": f"Bearer {HERMES_API_KEY}"}, timeout=ZEITLIMIT_S)
|
||||
if antwort.status_code != 200:
|
||||
log.warning("ermittlung: api_server antwortete mit HTTP %s", antwort.status_code)
|
||||
return None
|
||||
inhalt = antwort.json()["choices"][0]["message"]["content"]
|
||||
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError):
|
||||
log.warning("ermittlung: api_server nicht erreichbar", exc_info=True)
|
||||
return None
|
||||
inhalt = re.sub(r"<think>.*?</think>", "", str(inhalt or ""), flags=re.DOTALL).strip()
|
||||
return inhalt[:3000] or None
|
||||
|
||||
|
||||
def nachricht(befund: dict, antwort: str | None, gesammelt: list[dict]) -> tuple[str, list[str]]:
|
||||
"""Lucys Antwort und alles, was sie dabei ausgelöst hat, als eine Nachricht mit den Antwort-Knöpfen."""
|
||||
teile = [antwort or f"„{befund['titel']}“: Ich konnte gerade nicht nachsehen — mein eigener Zugang antwortete "
|
||||
"nicht. Frag mich im Chat, dann versuche ich es dort."]
|
||||
teile += [m["text"] for m in gesammelt]
|
||||
knoepfe = [k for m in gesammelt for k in m.get("knoepfe") or []][-6:]
|
||||
return "\n\n".join(teile), knoepfe
|
||||
|
||||
|
||||
def _untersuchen(befund: dict) -> None:
|
||||
with _lock:
|
||||
log.info("ermittlung: %s:%s beginnt", befund["bereich"], befund["id"])
|
||||
with rueckfragen.gesammelt() as gesammelt:
|
||||
antwort = _frag_lucy(auftrag(befund))
|
||||
text, knoepfe = nachricht(befund, antwort, gesammelt)
|
||||
rueckfragen.melden(BETREFF, text, knoepfe=knoepfe)
|
||||
log.info("ermittlung: %s:%s fertig (%s)", befund["bereich"], befund["id"], "Antwort" if antwort else "ohne")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Das Protokoll eines Containers für Lucy (seit 25.09.2026, Ausbauplan Welle 3, Punkt 14: „Lucy liest Logs und
|
||||
Messwerte, erklärt den Befund“).
|
||||
|
||||
Über den Ausführer (Aktion journal, nur lesend): Warnungen und Fehler der letzten 24 Stunden aus dem Journal, ohne
|
||||
systemd (Alpine) die letzten Zeilen von /var/log/messages. Nur für freigegebene Container — in eine VM kommt der
|
||||
Ausführer nicht hinein. Lucys Werkzeug geraet_logs() liest es über GET /api/homelab/ziele/{id}/journal.
|
||||
"""
|
||||
|
||||
from services.homelab import inventar, kanal
|
||||
|
||||
ZEITLIMIT_S = 90
|
||||
|
||||
|
||||
def lesen(ziel_id: str, zeilen: int = 80) -> dict:
|
||||
gast = inventar.gast(ziel_id)
|
||||
if not gast:
|
||||
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
|
||||
if gast.get("art") != "lxc":
|
||||
return {"ok": False, "detail": "Protokolle gibt es nur von Containern; in eine VM kommt der Ausführer nicht."}
|
||||
if not gast.get("erlaubt"):
|
||||
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
|
||||
auftrag = kanal.anlegen("journal", {"vmid": gast["vmid"], "zeilen": max(20, min(int(zeilen), 300))})
|
||||
ergebnis = kanal.warten(auftrag, ZEITLIMIT_S)
|
||||
if not ergebnis:
|
||||
return {"ok": False, "detail": "Der Ausführer hat nicht rechtzeitig geantwortet."}
|
||||
if ergebnis.get("status") != "fertig":
|
||||
return {"ok": False, "detail": str(ergebnis.get("text") or "Das Protokoll ließ sich nicht lesen.")[:500]}
|
||||
return {"ok": True, "text": str(ergebnis.get("text") or "").strip() or "(keine Warnungen oder Fehler)"}
|
||||
@@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
|
||||
|
||||
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
|
||||
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
|
||||
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog"}
|
||||
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog", "journal"}
|
||||
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
|
||||
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
|
||||
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
|
||||
|
||||
@@ -72,6 +72,8 @@ _AKTION = {
|
||||
"etikett_setzen": ("Etikett gesetzt", "Etikett setzen gescheitert", "Etikett wird gesetzt", "Etikett setzen"),
|
||||
"changelog": ("Änderungslisten gelesen", "Änderungslisten nicht gelesen", "Änderungslisten werden gelesen",
|
||||
"Änderungslisten"),
|
||||
# Seit 25.09.2026: Lucy liest das Protokoll eines Containers, wenn sie einen Hinweis untersucht.
|
||||
"journal": ("Protokoll gelesen (Lucy)", "Protokoll lesen gescheitert", "Protokoll wird gelesen", "Protokoll lesen"),
|
||||
}
|
||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog"}
|
||||
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
|
||||
|
||||
@@ -35,6 +35,8 @@ import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -44,6 +46,11 @@ from kern.zeit import LOCAL_TZ
|
||||
from services import announce, update_verlauf
|
||||
from services.homelab import apps, arcane, inventar, kanal, karenz, sicherheit
|
||||
|
||||
try:
|
||||
import fcntl # Linux: Sperre über Prozessgrenzen
|
||||
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
|
||||
fcntl = None
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WARTEN_NACH_UPDATE_S = 20
|
||||
@@ -74,8 +81,25 @@ def _laeufe() -> list[dict]:
|
||||
return daten if isinstance(daten, list) else []
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _datei_sperre() -> Iterator[None]:
|
||||
"""Seit 25.09.2026 schreiben zwei Prozesse Läufe: die Oberfläche (Knöpfe) und der Steward (Wartungsfenster). Ohne
|
||||
flock könnte einer den Lauf des anderen mit einer älteren Liste überschreiben."""
|
||||
if fcntl is None:
|
||||
yield
|
||||
return
|
||||
pfad = _pfad().with_suffix(".lock")
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(pfad, "a", encoding="utf-8") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _merken(lauf: dict) -> None:
|
||||
with _lock:
|
||||
with _lock, _datei_sperre():
|
||||
laeufe = [x for x in _laeufe() if x["id"] != lauf["id"]] + [lauf]
|
||||
_pfad().parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _pfad().with_suffix(".tmp")
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Wie selbstständig Lucy handelt — je Aktionsart (Ausbauplan Welle 3, Punkt 14, seit 25.09.2026).
|
||||
|
||||
User-Entscheid 25.09.2026 „Mehr Freiheit“: Lucy untersucht und erklärt von selbst und startet Dienste selbst neu; sie
|
||||
darf zusätzlich Sicherheitsupdates einspielen und bei Rot auf den Snapshot zurückgehen — mit Meldung danach. Andere
|
||||
Updates, das Aufräumen, Modell-Tests und den Neustart des Proxmox-Hosts gibt es erst nach dem Ja des Commanders.
|
||||
Je Aktionsart umstellbar (Einstellungen → Lucy):
|
||||
|
||||
lesen nur lesen und erklären: Sie handelt nicht und fragt nicht — außer der Commander bittet sie ausdrücklich
|
||||
fragen erst fragen: eine Rückfrage (services/rueckfragen.py); sein „ja“ löst genau diese Aktion aus
|
||||
selbst selbst handeln, Meldung danach (gehandelt())
|
||||
|
||||
Bittet der Commander Lucy im Gespräch ausdrücklich um etwas, ist das sein Ja. Die Stufen gelten für alles, was sie aus
|
||||
eigenem Antrieb tut — auch in geplanten Läufen und wenn sie einen roten Hinweis untersucht. Gelesen werden sie von
|
||||
Lucys Werkzeugen (mcp/orchestrator_werkzeuge.py) und beim Untersuchen roter Hinweise (services/ermittlung.py).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
STUFEN = {"lesen": "Nur lesen und erklären", "fragen": "Erst fragen", "selbst": "Selbst handeln, Meldung danach"}
|
||||
STUFE_KURZ = {"lesen": "nur lesen", "fragen": "erst fragen", "selbst": "selbst handeln"}
|
||||
BETREFF_GEHANDELT = "[Lucy selbst]"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Art:
|
||||
id: str
|
||||
name: str
|
||||
text: str
|
||||
standard: str
|
||||
|
||||
|
||||
ARTEN: tuple[Art, ...] = (
|
||||
Art("ermitteln", "Rote Hinweise untersuchen",
|
||||
"Lucy liest Protokolle und Messwerte und schickt dir Ursache und Lösungsweg. Selbst: sobald ein Hinweis rot "
|
||||
"wird. Erst fragen: nach deinem Ja. Nur lesen: wenn du sie fragst.", "selbst"),
|
||||
Art("neustart", "Dienste neu starten",
|
||||
"Dienste der KI-Box (Hermes, Motor, Steuerpult …), die hängen oder abgestürzt sind. Bewusst gestoppte startet "
|
||||
"sie nie von selbst.", "selbst"),
|
||||
Art("sicherheit", "Sicherheitsupdates einspielen",
|
||||
"Nur Pakete, die eine Sicherheitslücke schließen, mit Snapshot vorher und Rückweg bei Rot. Der Proxmox-Host "
|
||||
"startet dabei nicht neu.", "selbst"),
|
||||
Art("rueckweg", "Bei Rot auf den Snapshot zurück",
|
||||
"Nur für ein Gerät, das gerade rot ist. Was seit dem Snapshot dort geschrieben wurde, geht verloren.", "selbst"),
|
||||
Art("suchen", "Nach Updates und Modellen suchen", "Fragt nur nach neuen Versionen, ändert nichts.", "selbst"),
|
||||
Art("updates", "Andere Updates einspielen",
|
||||
"Apps, Pakete, Docker, Motor, llama-swap, Hermes und „Alle aktualisieren“.", "fragen"),
|
||||
Art("aufraeumen", "Alte Kernel entfernen", "Auf dem Proxmox-Host; der laufende und der nächste Kernel bleiben.",
|
||||
"fragen"),
|
||||
Art("radar", "Modelle im Radar testen", "Ein Test belegt die KI-Box einige Minuten; Lucy antwortet dann langsamer.",
|
||||
"fragen"),
|
||||
Art("host_neustart", "Proxmox-Host neu starten",
|
||||
"Alle Container und die Arcane-VM sind ein paar Minuten weg, auch AdGuard (DNS im Heimnetz).", "fragen"),
|
||||
)
|
||||
_ARTEN = {a.id: a for a in ARTEN}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "lucy-autonomie.json"
|
||||
|
||||
|
||||
def _gespeichert() -> dict[str, str]:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
werte = daten.get("stufen") if isinstance(daten, dict) else None
|
||||
return {k: v for k, v in (werte or {}).items() if k in _ARTEN and v in STUFEN}
|
||||
|
||||
|
||||
def stufen() -> dict[str, str]:
|
||||
return {a.id: a.standard for a in ARTEN} | _gespeichert()
|
||||
|
||||
|
||||
def stufe(art: str) -> str:
|
||||
"""Die Stufe einer Aktionsart. Eine unbekannte Art gilt als „fragen“: im Zweifel nie ungefragt."""
|
||||
return stufen().get(art, "fragen")
|
||||
|
||||
|
||||
def stand() -> dict:
|
||||
"""Für GET /api/lucy/autonomie (Einstellungen → Lucy) und Lucys Werkzeug freiheiten()."""
|
||||
aktuell = stufen()
|
||||
return {"arten": [{"id": a.id, "name": a.name, "text": a.text, "stufe": aktuell[a.id], "standard": a.standard}
|
||||
for a in ARTEN],
|
||||
"stufen": STUFEN}
|
||||
|
||||
|
||||
def setzen(neu: object) -> dict:
|
||||
"""POST /api/lucy/autonomie {"stufen": {art: stufe}} — nur bekannte Arten und Stufen. Gespeichert wird, was der
|
||||
Commander gewählt hat, auch wenn es dem Standard entspricht (ändert sich ein Standard, bleibt seine Wahl)."""
|
||||
werte = neu.get("stufen") if isinstance(neu, dict) else None
|
||||
if not isinstance(werte, dict) or not werte:
|
||||
return {"ok": False, "detail": "Es fehlt, was sich ändern soll."}
|
||||
for art, s in werte.items():
|
||||
if art not in _ARTEN:
|
||||
return {"ok": False, "detail": f"Die Aktionsart „{art}“ gibt es nicht."}
|
||||
if s not in STUFEN:
|
||||
return {"ok": False, "detail": f"„{s}“ ist keine Stufe (lesen, fragen oder selbst)."}
|
||||
with _lock:
|
||||
gespeichert = _gespeichert() | werte
|
||||
pfad = _pfad()
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({"stufen": gespeichert}, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
teile = [f"{_ARTEN[a].name}: {STUFE_KURZ[s]}" for a, s in werte.items()]
|
||||
log.info("lucy-autonomie: %s", "; ".join(teile))
|
||||
return {"ok": True, "text": "Gespeichert — " + "; ".join(teile) + "."}
|
||||
|
||||
|
||||
def gehandelt(daten: object) -> dict:
|
||||
"""POST /api/lucy/gehandelt — die Meldung danach: Lucys Werkzeuge melden hier, was sie auf der Stufe „selbst“ aus
|
||||
eigenem Antrieb getan hat (was, warum, Ergebnis). Nachts kommt das mit der Morgenmeldung."""
|
||||
from services import rueckfragen
|
||||
|
||||
d = daten if isinstance(daten, dict) else {}
|
||||
was = " ".join(str(d.get("was") or "").split())[:300].rstrip(".")
|
||||
if not was:
|
||||
return {"ok": False, "detail": "Es fehlt, was Lucy getan hat."}
|
||||
grund = " ".join(str(d.get("grund") or "").split())[:500]
|
||||
ergebnis = " ".join(str(d.get("ergebnis") or "").split())[:500]
|
||||
text = f"{was}." + (f" Warum: {grund}" if grund else "") + (f"\n{ergebnis}" if ergebnis else "")
|
||||
rueckfragen.melden(BETREFF_GEHANDELT, text)
|
||||
log.info("lucy-autonomie: selbst gehandelt — %s", was)
|
||||
return {"ok": True, "text": "Gemeldet."}
|
||||
@@ -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.")
|
||||
@@ -51,7 +51,7 @@ from kern import partner, prognose
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce, llamaswap, maintenance, probe_wiederherstellung, update_verlauf
|
||||
from services import announce, llamaswap, maintenance, probe_wiederherstellung, rueckfragen, update_verlauf
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
ROLLE = einstellungen().rolle
|
||||
@@ -83,6 +83,9 @@ VERLAUF_MAX = 200
|
||||
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber
|
||||
# nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter.
|
||||
TROCKEN = os.environ.get("MC_WAECHTER_TROCKEN", "") == "1"
|
||||
# Rote Hinweise an Lucy auf der KI-Box (seit 25.09.2026, _an_lucy). Die Tests schalten das ab (conftest.py) — auf der
|
||||
# Box liefe es sonst beim Prüftor gegen das echte MC2.
|
||||
LUCY_BEFUND = os.environ.get("MC_LUCY_BEFUND", "1") != "0"
|
||||
|
||||
# Im Steward-Modus beobachtet er auch MC2 selbst und den Gateway (wie der alte Wächter).
|
||||
WATCH_MC2 = os.environ.get("MC_SENTRY_WATCH_MC2", os.environ.get("MC_WAECHTER_WATCH_MC2", "")) == "1"
|
||||
@@ -112,6 +115,8 @@ TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
|
||||
# Seit 24.09.2026 im Repo (vom 21.08. an aus, weil sie rot lief und niemand es sah). Gelb: Die tägliche
|
||||
# Sicherung (mc2-backup) bleibt die erste Schicht, der PBS ist die Kopie auf dem QNAP. Ausgeschaltet kein Befund.
|
||||
"pbs-backup": ("Sicherung auf den PBS", False),
|
||||
# Seit 25.09.2026 (Ausbauplan Welle 3): sonntags 07:30 der Wochenbericht in Lucys Stimme.
|
||||
"mc2-wochenbericht": ("Wochenbericht", False),
|
||||
}
|
||||
|
||||
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
|
||||
@@ -674,14 +679,34 @@ def _verlauf(art: str, hinweis_id: str, text: str, stufe: str | None = None, que
|
||||
del _stand["verlauf"][:-VERLAUF_MAX]
|
||||
|
||||
|
||||
def _telegram(betreff: str, text: str) -> None:
|
||||
def _telegram(betreff: str, text: str, knoepfe: list[str] | None = None) -> None:
|
||||
if TROCKEN:
|
||||
log.info("waechter (trocken): würde melden %s %s", betreff, text)
|
||||
return
|
||||
announce.add(text, subject=betreff, source="waechter")
|
||||
# Bis 25.09.2026 hing hier „(Diese Meldung kam auch an Lucy.)“ an — als wäre Lucy jemand anderes als der Absender.
|
||||
# Sie IST der Absender (ihr Bot, ihre Stimme, siehe notify.sh); im Briefkasten oben liegt die Meldung trotzdem.
|
||||
announce.notify_telegram(betreff, text)
|
||||
announce.notify_telegram(betreff, text, knoepfe=knoepfe)
|
||||
|
||||
|
||||
def _an_lucy(b: Befund, erinnerung: bool) -> tuple[list[str], list[str]]:
|
||||
"""Roter Hinweis (seit 25.09.2026, Ausbauplan Welle 3): Auf der KI-Box entscheidet Lucy, ob sie nachsieht
|
||||
(services/ermittlung.py), und der Knopf des Hinweises wird zur Rückfrage. Zurück kommen die Zeilen für diese
|
||||
Nachricht („Antworte „ja 7“, dann …“) und die Antworttasten. Ist die Box nicht erreichbar, bleibt es wie bisher."""
|
||||
if TROCKEN or not LUCY_BEFUND:
|
||||
return [], []
|
||||
basis = rueckfragen.box_url()
|
||||
if not basis:
|
||||
return [], []
|
||||
try:
|
||||
antwort = httpx.post(f"{basis}/api/lucy/befund", timeout=8, json={
|
||||
"bereich": ROLLE, "id": b.id, "titel": b.titel, "text": b.text, "stufe": b.stufe, "quelle": b.quelle,
|
||||
"aktionen": b.aktionen, "erinnerung": erinnerung})
|
||||
daten = antwort.json() if antwort.status_code == 200 else {}
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return [], []
|
||||
daten = daten if isinstance(daten, dict) else {}
|
||||
return [str(z) for z in daten.get("zeilen") or []][:3], [str(k) for k in daten.get("knoepfe") or []][:6]
|
||||
|
||||
|
||||
def _auto_erlaubt(schluessel: str, jetzt: float) -> bool:
|
||||
@@ -750,7 +775,9 @@ def takt() -> None:
|
||||
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S) \
|
||||
and not ist_stumm(zustaende.get(b.id), b.stufe, jetzt):
|
||||
vorsatz = "" if neu else "Immer noch: "
|
||||
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}")
|
||||
zeilen, knoepfe = _an_lucy(b, erinnerung=not neu)
|
||||
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}" + "".join(f"\n{z}" for z in zeilen),
|
||||
knoepfe or None)
|
||||
h["gemeldet"] = jetzt
|
||||
|
||||
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Wochenbericht in Lucys Stimme (Ausbauplan Welle 3, Punkt 16, seit 25.09.2026).
|
||||
|
||||
Sonntags 07:30 (mc2-wochenbericht.timer — nach dem Wartungsfenster des Homelabs, den Sonntags-Updates der KI-Box und der
|
||||
Morgenmeldung) eine Nachricht statt vieler: was diese Woche eingespielt wurde, was offen ist, Platten, Sicherungen,
|
||||
Zertifikate, Lücken, das Modell-Radar und was Lucy selbst getan hat.
|
||||
|
||||
„Fakten vom Skript, Prosa vom Modell“: fakten() liest über die REST-API beider Teile (dieselben Zahlen wie die
|
||||
Oberfläche), zeilen() macht daraus feste Sätze. Nur der kurze Absatz obenauf kommt vom Modell (`fast`, ohne Denkpause)
|
||||
und darf nur umschreiben, was in den Zeilen steht; antwortet es nicht, steht dort ein fester Satz. Offene App-Updates
|
||||
des Homelabs werden zu Rückfragen (services/rueckfragen.py): „ja 9“ spielt Gitea ein, „ja 11“ startet „Alle
|
||||
aktualisieren“ für den Rest.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern import prognose
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce, rueckfragen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BETREFF = "[Wochenbericht]"
|
||||
WOCHE_S = 7 * 86400
|
||||
MAX_ZEICHEN = 3800 # Telegram nimmt 4096 Zeichen je Nachricht
|
||||
ANGEBOTE_MAX = 4
|
||||
TAG, UHR = 6, (7, 30) # sonntags 07:30 — wie deploy/mc2-wochenbericht.timer
|
||||
LUCY_LOG = Path(os.environ.get("MC_ORCH_LOG", str(Path.home() / ".hermes" / "logs" / "orchestrator-werkzeuge.log")))
|
||||
BAUSTEIN = {"app": "App", "os": "Pakete", "pakete": "Pakete", "docker": "Docker", "sicherheit": "Sicherheitsupdates",
|
||||
"kernel": "alte Kernel", "neustart": "Neustart"}
|
||||
NICHT_GLATT = {"zurueckgerollt": "zurückgerollt", "festgehalten": "festgehalten", "fehler": "gescheitert"}
|
||||
PROSA = """Du bist Lucy, die Systemadministratorin dieses Homelabs, und schreibst dem Commander seinen Wochenbericht.
|
||||
Schreib drei bis fünf Sätze Fließtext: wie die Woche lief, was wichtig ist und was er tun sollte. Beginne mit \
|
||||
„Commander,“. Knapp, sachlich, ohne Emojis, ohne Ausrufezeichen, ohne Aufzählung. Nimm nur, was in den Fakten steht — \
|
||||
keine Zahl, kein Name, kein Ereignis, das dort fehlt. Die Fakten und die Rückfragen stehen unter deinem Absatz ohnehin \
|
||||
noch einmal; wiederhole sie nicht Punkt für Punkt.
|
||||
|
||||
Fakten dieser Woche:
|
||||
{fakten}"""
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "wochenbericht.json"
|
||||
|
||||
|
||||
def _get(pfad: str, **params: object) -> dict | None:
|
||||
try:
|
||||
antwort = httpx.get(f"{rueckfragen.EIGENE_URL}{pfad}", params=params or None, timeout=30)
|
||||
daten = antwort.json() if antwort.status_code == 200 else None
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return None
|
||||
return daten if isinstance(daten, dict) else None
|
||||
|
||||
|
||||
def _zeit(wert: object) -> float | None:
|
||||
if isinstance(wert, (int, float)):
|
||||
return float(wert)
|
||||
try:
|
||||
zeit = datetime.fromisoformat(str(wert))
|
||||
except ValueError:
|
||||
return None
|
||||
return (zeit if zeit.tzinfo else zeit.replace(tzinfo=LOCAL_TZ)).timestamp()
|
||||
|
||||
|
||||
def _liste(namen: list[str], leer: str = "") -> str:
|
||||
namen = list(dict.fromkeys(n for n in namen if n))
|
||||
if not namen:
|
||||
return leer
|
||||
return namen[0] if len(namen) == 1 else ", ".join(namen[:-1]) + " und " + namen[-1]
|
||||
|
||||
|
||||
# --- Fakten ----------------------------------------------------------------------------------------------------
|
||||
|
||||
def fakten(jetzt: float | None = None) -> dict:
|
||||
"""Alles für den Bericht, wie es die Oberfläche auch zeigt. Was nicht antwortet, fehlt (None)."""
|
||||
hl = rueckfragen.HL
|
||||
return {
|
||||
"box_laeufe": (_get("/api/updates/verlauf", anzahl=12) or {}).get("laeufe"),
|
||||
"homelab_laeufe": (_get(f"{hl}/laeufe", anzahl=100) or {}).get("laeufe"),
|
||||
"ziele_box": (_get("/api/ziele") or {}).get("ziele"),
|
||||
"ziele_homelab": (_get(f"{hl}/ziele") or {}).get("ziele"),
|
||||
"hinweise_box": (_get("/api/hinweise") or {}).get("hinweise"),
|
||||
"hinweise_homelab": (_get(f"{hl}/hinweise") or {}).get("hinweise"),
|
||||
"speicher": _get(f"{hl}/speicher"),
|
||||
"box_platte": ((_get("/api/start") or {}).get("box") or {}).get("platte"),
|
||||
"zertifikate": _get(f"{hl}/zertifikate"),
|
||||
"docker": _get(f"{hl}/docker"),
|
||||
"radar": (_get("/api/radar") or {}).get("kandidaten"),
|
||||
"rueckfragen": rueckfragen.liste(100, jetzt=jetzt),
|
||||
"lucy": _lucy_log(),
|
||||
}
|
||||
|
||||
|
||||
def _lucy_log() -> list[dict]:
|
||||
"""Was Lucy über ihre Werkzeuge getan hat (mcp/orchestrator_werkzeuge.py schreibt je Aktion eine Zeile)."""
|
||||
try:
|
||||
zeilen = LUCY_LOG.read_text(encoding="utf-8").splitlines()[-500:]
|
||||
except OSError:
|
||||
return []
|
||||
eintraege = []
|
||||
for zeile in zeilen:
|
||||
try:
|
||||
e = json.loads(zeile)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(e, dict):
|
||||
eintraege.append(e)
|
||||
return eintraege
|
||||
|
||||
|
||||
# --- Feste Sätze ---------------------------------------------------------------------------------------------------
|
||||
|
||||
def _eingespielt(f: dict, seit: float) -> tuple[list[str], list[str]]:
|
||||
"""(eingespielt, nicht gut gegangen) dieser Woche, KI-Box und Homelab."""
|
||||
gut, schlecht = [], []
|
||||
for lauf in f.get("homelab_laeufe") or []:
|
||||
ende = _zeit(lauf.get("ende"))
|
||||
if lauf.get("status") != "fertig" or lauf.get("baustein") == "probe" or ende is None or ende < seit:
|
||||
continue
|
||||
was = f"{lauf.get('name')} ({BAUSTEIN.get(lauf.get('baustein'), lauf.get('baustein'))})"
|
||||
if lauf.get("ergebnis") == "eingespielt":
|
||||
gut.append(was)
|
||||
elif lauf.get("ergebnis") in NICHT_GLATT:
|
||||
schlecht.append(f"{was} {NICHT_GLATT[lauf['ergebnis']]}")
|
||||
for lauf in f.get("box_laeufe") or []:
|
||||
start = _zeit(lauf.get("start"))
|
||||
if start is None or start < seit:
|
||||
continue
|
||||
for z in lauf.get("zeilen") or []:
|
||||
was = f"KI-Box {z.get('baustein')}"
|
||||
if z.get("ergebnis") == "eingespielt":
|
||||
gut.append(was)
|
||||
elif z.get("ergebnis") in NICHT_GLATT:
|
||||
schlecht.append(f"{was} {NICHT_GLATT[z['ergebnis']]}")
|
||||
return gut, schlecht
|
||||
|
||||
|
||||
def _offen(f: dict) -> tuple[str, list[dict]]:
|
||||
"""Offene Updates als Satz und die Kandidaten für Rückfragen (App-Updates des Homelabs mit Knopf)."""
|
||||
apps, geraete_pakete, sicherheit, angebote = [], 0, 0, []
|
||||
for quelle in ("ziele_box", "ziele_homelab"):
|
||||
for z in f.get(quelle) or []:
|
||||
for b in z.get("bausteine") or []:
|
||||
if b.get("zustand") != "neu":
|
||||
continue
|
||||
if b.get("id") in ("os", "pakete"):
|
||||
geraete_pakete += 1
|
||||
sicherheit += int(b.get("sicherheit") or 0)
|
||||
continue
|
||||
version = b.get("verfuegbar") or b.get("kurz") or ""
|
||||
apps.append(f"{z.get('name')} {version}".strip() if b.get("id") == "app" else
|
||||
f"{z.get('name')}: {b.get('name')}" + (f" {version}" if version else ""))
|
||||
pfad = (b.get("aktion") or {}).get("pfad") or ""
|
||||
if quelle == "ziele_homelab" and b.get("id") == "app" and pfad.startswith("/api/homelab/"):
|
||||
angebote.append({"frage": f"Soll ich {z.get('name')} {version} einspielen?".replace(" ", " "),
|
||||
"was": f"{z.get('name')} {version}".strip(), "pfad": pfad})
|
||||
teile = []
|
||||
if apps:
|
||||
teile.append(_liste(apps))
|
||||
if geraete_pakete:
|
||||
teile.append(f"Paket-Updates auf {geraete_pakete} {'Gerät' if geraete_pakete == 1 else 'Geräten'}"
|
||||
+ (f", davon {sicherheit} Sicherheitsupdates" if sicherheit else ""))
|
||||
return ("; ".join(teile) if teile else "nichts"), angebote
|
||||
|
||||
|
||||
def _speicher(f: dict) -> str:
|
||||
s = f.get("speicher") or {}
|
||||
teile = []
|
||||
|
||||
def satz(name: str, anteil: object, v: dict | None) -> str:
|
||||
text = f"{name} {round(float(anteil or 0) * 100)} %"
|
||||
if v and v.get("tage") is not None and v["tage"] < 120:
|
||||
text += f" (voll {prognose.in_tagen(float(v['tage']))})"
|
||||
return text
|
||||
|
||||
for p in s.get("pools") or []:
|
||||
teile.append(satz(f"Pool {p.get('speicher')}", p.get("anteil"), p.get("vorhersage")))
|
||||
if sp := s.get("systemplatte"):
|
||||
teile.append(satz("Host-Systemplatte", sp.get("anteil"), sp.get("vorhersage")))
|
||||
for n in s.get("nas") or []:
|
||||
teile.append(f"NAS: {str(n['problem']).rstrip('.')}" if n.get("problem")
|
||||
else satz("NAS", n.get("anteil"), n.get("vorhersage")))
|
||||
if bp := f.get("box_platte"):
|
||||
teile.append(satz("KI-Box", float(bp.get("percent") or 0) / 100, bp.get("vorhersage")))
|
||||
return "; ".join(teile) or "keine Angaben"
|
||||
|
||||
|
||||
def _sicherungen(f: dict) -> str:
|
||||
si = (f.get("speicher") or {}).get("sicherungen") or {}
|
||||
if not si or si.get("stufe") in (None, "keine"):
|
||||
return "keine Angaben"
|
||||
teile = ["alle aktuell" if si.get("stufe") == "ok" else f"Stufe {si.get('stufe')}"]
|
||||
if fehlend := [x.get("name") for x in si.get("fehlend") or []]:
|
||||
teile.append(f"zu alt oder fehlend: {_liste(fehlend)}")
|
||||
if gescheitert := [g.get("name") for g in si.get("geraete") or [] if (g.get("probe") or {}).get("ok") is False]:
|
||||
teile.append(f"Probe-Wiederherstellung gescheitert: {_liste(gescheitert)}")
|
||||
return "; ".join(teile)
|
||||
|
||||
|
||||
def _zertifikate(f: dict) -> str:
|
||||
z = f.get("zertifikate") or {}
|
||||
liste = z.get("zertifikate") or []
|
||||
if not liste:
|
||||
return "keine Angaben"
|
||||
probleme = [f"{x.get('name')}: {x.get('problem')}" for x in liste if x.get("stufe") != "ok" and x.get("problem")]
|
||||
return _liste(probleme) if probleme else f"alle {len(liste)} gültig"
|
||||
|
||||
|
||||
def _luecken(f: dict) -> str | None:
|
||||
d = f.get("docker") or {}
|
||||
if not d.get("verfuegbar"):
|
||||
return None
|
||||
gescannt = [c for s in d.get("stacks") or [] for c in s.get("container") or [] if c.get("luecken")]
|
||||
if not gescannt:
|
||||
return None
|
||||
schlimm = [f"{c['name']} ({c['luecken'].get('kritisch', 0)} kritisch)" for c in gescannt
|
||||
if c["luecken"].get("kritisch")]
|
||||
return _liste(schlimm) if schlimm else f"{len(gescannt)} Images gescannt, nichts Kritisches"
|
||||
|
||||
|
||||
def _radar(f: dict) -> str | None:
|
||||
kandidaten = f.get("radar") or []
|
||||
bestanden = [k.get("name") for k in kandidaten if k.get("status") == "bestanden"]
|
||||
wartend = sum(1 for k in kandidaten if k.get("status") in ("neu", "wartet"))
|
||||
teile = []
|
||||
if bestanden:
|
||||
teile.append(f"bestanden und wartet auf deine Entscheidung: {_liste(bestanden)}")
|
||||
if wartend:
|
||||
teile.append(f"{wartend} {'Kandidat wartet' if wartend == 1 else 'Kandidaten warten'} auf den Test")
|
||||
return "; ".join(teile) or None
|
||||
|
||||
|
||||
def _hinweise(f: dict) -> str:
|
||||
alle = [(wo, h) for wo in ("box", "homelab") for h in f.get(f"hinweise_{wo}") or []]
|
||||
rot = [h.get("titel") for _, h in alle if h.get("stufe") == "rot"]
|
||||
gelb = [h.get("titel") for _, h in alle if h.get("stufe") != "rot"]
|
||||
if not alle:
|
||||
return "keine"
|
||||
teile = []
|
||||
if rot:
|
||||
teile.append(f"rot: {_liste(rot)}")
|
||||
if gelb:
|
||||
teile.append(f"gelb: {_liste(gelb)}")
|
||||
return "; ".join(teile)
|
||||
|
||||
|
||||
def _lucy(f: dict, seit: float) -> str | None:
|
||||
selbst, bitte = [], 0
|
||||
for e in f.get("lucy") or []:
|
||||
zeit = _zeit(e.get("zeit"))
|
||||
if zeit is None or zeit < seit or str(e.get("ergebnis") or "").startswith("Nicht ausgeführt"):
|
||||
continue
|
||||
if e.get("stufe") == "selbst":
|
||||
selbst.append(str(e.get("werkzeug")))
|
||||
elif e.get("stufe") in (None, "auf_bitte"):
|
||||
bitte += 1
|
||||
fragen = [r for r in (f.get("rueckfragen") or {}).get("zuletzt") or []
|
||||
if float(r.get("zeit") or 0) >= seit]
|
||||
ja = sum(1 for r in fragen if (r.get("antwort") or {}).get("ja"))
|
||||
teile = []
|
||||
if selbst:
|
||||
teile.append(f"{len(selbst)}× selbst gehandelt")
|
||||
if bitte:
|
||||
teile.append(f"{bitte}× auf deine Bitte")
|
||||
if fragen:
|
||||
teile.append(f"{len(fragen)} Rückfragen beantwortet oder abgelaufen, davon {ja}× ja")
|
||||
return "; ".join(teile) or None
|
||||
|
||||
|
||||
def zeilen(f: dict, jetzt: float | None = None) -> list[str]:
|
||||
"""Die festen Sätze des Berichts, in dieser Reihenfolge."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
seit = jetzt - WOCHE_S
|
||||
gut, schlecht = _eingespielt(f, seit)
|
||||
offen, _ = _offen(f)
|
||||
ergebnis = [f"Eingespielt: {_liste(gut, 'nichts')}."]
|
||||
if schlecht:
|
||||
ergebnis.append(f"Nicht glatt gelaufen: {_liste(schlecht)}.")
|
||||
ergebnis += [f"Offen: {offen}.", f"Hinweise: {_hinweise(f)}.", f"Speicher: {_speicher(f)}.",
|
||||
f"Sicherungen: {_sicherungen(f)}.", f"Zertifikate: {_zertifikate(f)}."]
|
||||
if luecken := _luecken(f):
|
||||
ergebnis.append(f"Lücken-Scan: {luecken}.")
|
||||
if radar := _radar(f):
|
||||
ergebnis.append(f"Modell-Radar: {radar}.")
|
||||
if lucy := _lucy(f, seit):
|
||||
ergebnis.append(f"Lucy: {lucy}.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Absatz vom Modell, Rückfragen, Senden ---------------------------------------------------------------------------
|
||||
|
||||
def prosa(fakten_zeilen: list[str]) -> str | None:
|
||||
"""Lucys Absatz obenauf (Modell `fast` über /v1 der KI-Box). None, wenn es nicht antwortet."""
|
||||
try:
|
||||
antwort = httpx.post(f"{rueckfragen.EIGENE_URL}/v1/chat/completions", timeout=120, json={
|
||||
"model": "fast", "temperature": 0.3, "max_tokens": 400,
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
"messages": [{"role": "user", "content": PROSA.format(fakten="\n".join(fakten_zeilen))}]})
|
||||
text = antwort.json()["choices"][0]["message"]["content"] if antwort.status_code == 200 else ""
|
||||
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError):
|
||||
log.warning("wochenbericht: Modell antwortete nicht", exc_info=True)
|
||||
return None
|
||||
text = re.sub(r"<think>.*?</think>", "", str(text or ""), flags=re.DOTALL).strip()
|
||||
return text[:1200] or None
|
||||
|
||||
|
||||
def angebote(f: dict, jetzt: float | None = None) -> list[tuple[str, int]]:
|
||||
"""Offene App-Updates des Homelabs als Rückfragen (still — die Antwortzeilen stehen im Bericht), dazu eine für
|
||||
„Alle aktualisieren“, wenn außer den Apps noch etwas ansteht (das nimmt dann auch die Apps mit). Rückgabe: (Text
|
||||
der Zeile, Nummer)."""
|
||||
_, kandidaten = _offen(f)
|
||||
zeilen_ = []
|
||||
for k in kandidaten[:ANGEBOTE_MAX]:
|
||||
r = rueckfragen.stellen(k["frage"], k["pfad"], text="Snapshot vorher, Prüfung danach, bei Rot zurück.",
|
||||
von="wochenbericht", still=True, jetzt=jetzt)
|
||||
if r["ok"]:
|
||||
zeilen_.append((f"{k['was']} einspielen", r["rueckfrage"]["nr"]))
|
||||
rest = [b for z in f.get("ziele_homelab") or [] for b in z.get("bausteine") or []
|
||||
if b.get("zustand") == "neu" and b.get("aktion") and b.get("id") != "app"]
|
||||
if rest or len(kandidaten) > ANGEBOTE_MAX:
|
||||
r = rueckfragen.stellen("Soll ich im Homelab alles aktualisieren (Gäste nacheinander, Host zuletzt, ohne "
|
||||
"Neustart)?", f"{rueckfragen.HL}/alle", von="wochenbericht", still=True, jetzt=jetzt)
|
||||
if r["ok"]:
|
||||
zeilen_.append(("alles auf einmal („Alle aktualisieren“)", r["rueckfrage"]["nr"]))
|
||||
return zeilen_
|
||||
|
||||
|
||||
def bauen(jetzt: float | None = None, mit_angeboten: bool = True) -> dict:
|
||||
"""Der Bericht als Text (ohne zu senden): {"text", "knoepfe", "zeilen"}."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
f = fakten(jetzt)
|
||||
feste = zeilen(f, jetzt)
|
||||
absatz = prosa(feste) or "Commander, hier ist dein Wochenbericht."
|
||||
text = absatz + "\n\nDie Woche in Stichpunkten:\n" + "\n".join(f"• {z}" for z in feste)
|
||||
knoepfe: list[str] = []
|
||||
if mit_angeboten and (liste := angebote(f, jetzt)):
|
||||
text += "\n\nAuf Zuruf:\n" + "\n".join(f"• {was} — „ja {nr}“" for was, nr in liste)
|
||||
knoepfe = [f"ja {nr}" for _, nr in liste]
|
||||
if len(text) > MAX_ZEICHEN:
|
||||
text = text[:MAX_ZEICHEN - 2].rstrip() + " …"
|
||||
return {"text": text, "knoepfe": knoepfe, "zeilen": feste}
|
||||
|
||||
|
||||
def senden(jetzt: float | None = None) -> dict:
|
||||
"""POST /api/lucy/wochenbericht (Timer sonntags 07:30 und Knopf „Jetzt schicken“)."""
|
||||
if not _lock.acquire(blocking=False):
|
||||
return {"ok": False, "detail": "Der Wochenbericht wird gerade schon geschrieben."}
|
||||
try:
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
bericht = bauen(jetzt)
|
||||
# Direkt statt über rueckfragen.melden(): Untersucht Lucy gerade etwas, hinge der Bericht sonst an ihrer
|
||||
# Nachricht. Im Briefkasten leise — die Desktop-Lucy soll nicht drei Minuten vorlesen.
|
||||
announce.add(bericht["text"], subject=BETREFF, source="wochenbericht", priority="silent")
|
||||
announce.notify_telegram(BETREFF, bericht["text"], knoepfe=bericht["knoepfe"] or None)
|
||||
pfad = _pfad()
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({"zeit": jetzt, "text": bericht["text"]}, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
log.info("wochenbericht: verschickt (%d Zeichen)", len(bericht["text"]))
|
||||
return {"ok": True, "text": "Der Wochenbericht ist unterwegs."}
|
||||
finally:
|
||||
_lock.release()
|
||||
|
||||
|
||||
def naechster(jetzt: float | None = None) -> float:
|
||||
jetzt_dt = datetime.fromtimestamp(time.time() if jetzt is None else jetzt, LOCAL_TZ)
|
||||
ziel = jetzt_dt.replace(hour=UHR[0], minute=UHR[1], second=0, microsecond=0) + timedelta(
|
||||
days=(TAG - jetzt_dt.weekday()) % 7)
|
||||
if ziel <= jetzt_dt:
|
||||
ziel += timedelta(days=7)
|
||||
return ziel.timestamp()
|
||||
|
||||
|
||||
def stand(jetzt: float | None = None) -> dict:
|
||||
"""GET /api/lucy/wochenbericht: der letzte Bericht und wann der nächste kommt."""
|
||||
try:
|
||||
zuletzt = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
zuletzt = None
|
||||
return {"zuletzt": zuletzt if isinstance(zuletzt, dict) else None, "naechster": naechster(jetzt)}
|
||||
Reference in New Issue
Block a user