a91d70d9b7
Queue-UI gruppiert verkettete Karten zu Projekten: Fortschritt, laufende Karte mit Heartbeat-Notiz, 'gleich dran', Rest eingeklappt mit 'wartet auf'. Backend reichert die CLI-Sicht read-only aus kanban.db an (task_links + Heartbeats). Neuer Digest-Waechter meldet per Telegram: Fragen sofort, Projekt-Abschluss sofort, sonst 60-min-Puls (User-Wahl 20.07.). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
"""
|
|
Ketten-Digest — Telegram-Status für lange Aufgaben-Ketten (User-Wunsch 20.07.2026).
|
|
|
|
Wenn die Werkstatt eine Karten-Familie abarbeitet (z. B. „Homelab-Dashboard 2.0" mit
|
|
19 verketteten Karten), soll der Commander nicht raten müssen: dieser Wächter liest
|
|
die Queue-Sicht (services/ideen.py, inkl. Ketten-Anreicherung) und meldet per Telegram
|
|
|
|
• SOFORT, wenn eine Karte hängt und eine Frage an den Commander hat (blocked) —
|
|
einmal pro Karte, mit der Frage im Wortlaut,
|
|
• bei MEILENSTEINEN (Projekt komplett fertig) sofort,
|
|
• sonst höchstens einmal pro PULS_SEKUNDEN (Default 60 min) einen Zwischenstand
|
|
je aktivem Projekt („5/19 fertig · läuft: … · als Nächstes: …").
|
|
|
|
Taktung war explizite User-Wahl („Meilensteine + 60-min-Puls, Fragen immer sofort").
|
|
Zustand (was wurde wann gemeldet) liegt als JSON neben den Modellen und übersteht
|
|
Neustarts — sonst käme nach jedem Deploy ein Duplikat-Schwall. Auf Windows (Dev) No-op.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
from config import MODELS_DIR
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
ENABLED = os.name == "posix" and os.environ.get("MC_KETTEN_DIGEST", "1") != "0"
|
|
INTERVAL = int(os.environ.get("MC_KETTEN_DIGEST_INTERVAL", "300")) # Prüf-Tick: 5 min
|
|
PULS_SEKUNDEN = int(os.environ.get("MC_KETTEN_DIGEST_PULS", "3600")) # Zwischenstand: 60 min
|
|
STATE_PATH = Path(os.environ.get("MC_KETTEN_DIGEST_STATE", str(MODELS_DIR / "mc2-ketten-digest.json")))
|
|
|
|
|
|
def _load_state() -> dict:
|
|
try:
|
|
d = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
|
return d if isinstance(d, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _save_state(state: dict) -> None:
|
|
try:
|
|
tmp = STATE_PATH.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
|
tmp.replace(STATE_PATH)
|
|
except OSError:
|
|
log.warning("ketten-digest: Zustand %s nicht schreibbar", STATE_PATH, exc_info=True)
|
|
|
|
|
|
def _melden(subject: str, text: str) -> None:
|
|
"""Aufs Handy UND in den Briefkasten (silent — Lucy muss den Status nicht sprechen)."""
|
|
from services import announce
|
|
announce.notify_telegram(subject, text)
|
|
try:
|
|
announce.add(text, subject, "ketten-digest", "silent")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _tick() -> None:
|
|
from services import ideen
|
|
|
|
data = ideen.list_queue()
|
|
if not data.get("available"):
|
|
return
|
|
items = data.get("items") or []
|
|
projekte = data.get("projekte") or []
|
|
by_id = {i["id"]: i for i in items if i.get("id")}
|
|
state = _load_state()
|
|
gemeldete_fragen: dict = state.setdefault("fragen", {})
|
|
projekt_state: dict = state.setdefault("projekte", {})
|
|
dirty = False
|
|
now = int(time.time())
|
|
|
|
# 1) Hängende Karten mit Frage → sofort, einmal pro Karte. Der Schlüssel enthält den
|
|
# Fragen-Text-Hash: hängt dieselbe Karte später mit NEUER Frage, wird wieder gemeldet.
|
|
for it in items:
|
|
if it.get("status") != "blocked":
|
|
continue
|
|
frage = (it.get("frage") or "").strip()
|
|
key = f"{it['id']}:{zlib.crc32(frage.encode('utf-8')):x}"
|
|
if key in gemeldete_fragen:
|
|
continue
|
|
text = (f"Aufgabe hängt und wartet auf dich: „{(it.get('titel') or '')[:120]}“\n"
|
|
+ (f"Frage: {frage[:500]}\n" if frage else "")
|
|
+ "Antworten geht im Auftragsbuch (Zentrale) — die Box macht dann weiter.")
|
|
_melden("[Werkstatt]", text)
|
|
gemeldete_fragen[key] = now
|
|
dirty = True
|
|
|
|
# Fragen-Gedächtnis klein halten (Karten verschwinden irgendwann ins Archiv).
|
|
if len(gemeldete_fragen) > 200:
|
|
for k in sorted(gemeldete_fragen, key=gemeldete_fragen.get)[:100]:
|
|
gemeldete_fragen.pop(k, None)
|
|
dirty = True
|
|
|
|
# 2) Projekt-Status: Abschluss sofort, sonst gedrosselter Puls solange gearbeitet wird.
|
|
for p in projekte:
|
|
ps = projekt_state.setdefault(p["key"], {})
|
|
fertig, gesamt = p.get("fertig", 0), p.get("gesamt", 0)
|
|
|
|
if gesamt > 0 and fertig >= gesamt and not ps.get("abschluss_gemeldet"):
|
|
_melden("[Werkstatt]", f"Projekt fertig: „{p['titel']}“ — alle {gesamt} Karten erledigt. "
|
|
"Ergebnisse liegen im Auftragsbuch.")
|
|
ps.update(abschluss_gemeldet=True, letzter_puls=now, letzter_stand=fertig)
|
|
dirty = True
|
|
continue
|
|
|
|
aktiv = p.get("laeuft", 0) > 0
|
|
if not aktiv:
|
|
continue
|
|
if now - int(ps.get("letzter_puls") or 0) < PULS_SEKUNDEN:
|
|
continue
|
|
laufende = [i for i in items if i.get("projekt") == p["key"] and i["status"] == "running"]
|
|
naechste = [i for i in items if i.get("projekt") == p["key"]
|
|
and i["status"] in ("ready", "todo") and not i.get("wartet_auf")]
|
|
zeilen = [f"Werkstatt-Status „{p['titel']}“: {fertig}/{gesamt} fertig."]
|
|
for l in laufende[:2]:
|
|
note = f" — {l['notiz']}" if l.get("notiz") else ""
|
|
zeilen.append(f"Läuft: {l['titel'][:90]}{note}")
|
|
if naechste:
|
|
zeilen.append(f"Als Nächstes: {naechste[0]['titel'][:90]}")
|
|
haengt = p.get("haengt", 0)
|
|
if haengt:
|
|
zeilen.append(f"{haengt} Karte(n) warten auf deine Antwort im Auftragsbuch.")
|
|
_melden("[Werkstatt]", "\n".join(zeilen))
|
|
ps.update(letzter_puls=now, letzter_stand=fertig)
|
|
dirty = True
|
|
|
|
if dirty:
|
|
_save_state(state)
|
|
|
|
|
|
async def digest_loop() -> None:
|
|
"""Hintergrund-Task im MC2-Lifespan (Muster: reminders_loop)."""
|
|
log.info("ketten-digest: aktiv (Tick %ss, Puls %ss)", INTERVAL, PULS_SEKUNDEN)
|
|
while True:
|
|
try:
|
|
await asyncio.to_thread(_tick)
|
|
except Exception:
|
|
log.debug("ketten-digest: Tick fehlgeschlagen", exc_info=True)
|
|
await asyncio.sleep(INTERVAL)
|