Lucy-Proaktivität (A3): Melde-Briefkasten + Health-Wächter

- services/announce.py: persistenter Briefkasten (/srv/models/mc2-announce.json),
  POST /api/voice/announce + GET /api/voice/announcements (Cursor-Polling)
- services/sentry.py: Health-Wächter (Engine/Hirn/Hermes/Mem0/Voice/Platte),
  flankenerkannt (Alarm nach 3 Fehl-Ticks, Entwarnung, 6h-Erinnerung),
  meldet in Briefkasten + Telegram; Hirn-Verdrängung durch IDE-Last = kein Alarm
- notify.sh spiegelt jede Telegram-Meldung in den Briefkasten (Updates/Radar
  erreichen damit auch die Desktop-Lucy)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-03 13:45:07 +02:00
parent a23f4c0652
commit 019bd6d18b
5 changed files with 281 additions and 5 deletions
+82
View File
@@ -0,0 +1,82 @@
"""
Melde-Briefkasten der Box (Lucy-Proaktivität, Faden A3).
Alles, was die Box dem Commander aktiv sagen will (Health-Wächter, Auto-Updates,
Radar, Hermes-cron via notify.sh), landet als Eintrag hier. Die Lucy-Desktop-App
pollt `/api/voice/announcements` und SPRICHT neue Einträge von sich aus.
Persistenz als JSON neben den Modellen (übersteht Deploys/Neustarts, wie der
Discover-Cache). Bewusst klein: fortlaufende IDs als Cursor, Ring der letzten
MAX_ITEMS Einträge, ein Lock für die FastAPI-Threadpool-Worker.
"""
import json
import logging
import os
import threading
import time
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json")))
MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200"))
_lock = threading.Lock()
_state: dict | None = None # {"next_id": int, "items": [...]}
def _load() -> dict:
global _state
if _state is None:
try:
_state = json.loads(STORE_PATH.read_text(encoding="utf-8"))
assert isinstance(_state.get("next_id"), int) and isinstance(_state.get("items"), list)
except Exception:
_state = {"next_id": 1, "items": []}
return _state
def _save(state: dict) -> None:
try:
tmp = STORE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
tmp.replace(STORE_PATH)
except OSError:
# Briefkasten darf den Absender nie blockieren — dann eben nur in-memory.
log.warning("announce: Store %s nicht schreibbar", STORE_PATH, exc_info=True)
def add(text: str, subject: str = "", source: str = "", priority: str = "normal") -> dict:
"""Eintrag anhängen. priority: 'normal' (sprechen) | 'silent' (nur Verlauf/Panel)."""
text = (text or "").strip()
if not text:
raise ValueError("Leere Meldung.")
with _lock:
state = _load()
item = {
"id": state["next_id"],
"ts": time.time(),
"subject": (subject or "").strip()[:120],
"text": text[:4000],
"source": (source or "").strip()[:60],
"priority": priority if priority in ("normal", "silent") else "normal",
}
state["next_id"] += 1
state["items"].append(item)
del state["items"][:-MAX_ITEMS]
_save(state)
log.info("announce #%s [%s] %s: %.80s", item["id"], item["source"] or "-", item["subject"] or "-", text)
return item
def list_after(after: int | None, limit: int = 20) -> dict:
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
with _lock:
state = _load()
latest = state["next_id"] - 1
items = [] if after is None else [i for i in state["items"] if i["id"] > after][: max(1, min(limit, 100))]
return {"latest": latest, "items": items}