- 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>
180 lines
9.7 KiB
Python
180 lines
9.7 KiB
Python
"""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")
|