Files
mission-control-v2/backend/services/ermittlung.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

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