"""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".*?", "", 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")