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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dd23f3fb4c
commit
12cefb427a
@@ -0,0 +1,392 @@
|
||||
"""Wochenbericht in Lucys Stimme (Ausbauplan Welle 3, Punkt 16, seit 25.09.2026).
|
||||
|
||||
Sonntags 07:30 (mc2-wochenbericht.timer — nach dem Wartungsfenster des Homelabs, den Sonntags-Updates der KI-Box und der
|
||||
Morgenmeldung) eine Nachricht statt vieler: was diese Woche eingespielt wurde, was offen ist, Platten, Sicherungen,
|
||||
Zertifikate, Lücken, das Modell-Radar und was Lucy selbst getan hat.
|
||||
|
||||
„Fakten vom Skript, Prosa vom Modell“: fakten() liest über die REST-API beider Teile (dieselben Zahlen wie die
|
||||
Oberfläche), zeilen() macht daraus feste Sätze. Nur der kurze Absatz obenauf kommt vom Modell (`fast`, ohne Denkpause)
|
||||
und darf nur umschreiben, was in den Zeilen steht; antwortet es nicht, steht dort ein fester Satz. Offene App-Updates
|
||||
des Homelabs werden zu Rückfragen (services/rueckfragen.py): „ja 9“ spielt Gitea ein, „ja 11“ startet „Alle
|
||||
aktualisieren“ für den Rest.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern import prognose
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce, rueckfragen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BETREFF = "[Wochenbericht]"
|
||||
WOCHE_S = 7 * 86400
|
||||
MAX_ZEICHEN = 3800 # Telegram nimmt 4096 Zeichen je Nachricht
|
||||
ANGEBOTE_MAX = 4
|
||||
TAG, UHR = 6, (7, 30) # sonntags 07:30 — wie deploy/mc2-wochenbericht.timer
|
||||
LUCY_LOG = Path(os.environ.get("MC_ORCH_LOG", str(Path.home() / ".hermes" / "logs" / "orchestrator-werkzeuge.log")))
|
||||
BAUSTEIN = {"app": "App", "os": "Pakete", "pakete": "Pakete", "docker": "Docker", "sicherheit": "Sicherheitsupdates",
|
||||
"kernel": "alte Kernel", "neustart": "Neustart"}
|
||||
NICHT_GLATT = {"zurueckgerollt": "zurückgerollt", "festgehalten": "festgehalten", "fehler": "gescheitert"}
|
||||
PROSA = """Du bist Lucy, die Systemadministratorin dieses Homelabs, und schreibst dem Commander seinen Wochenbericht.
|
||||
Schreib drei bis fünf Sätze Fließtext: wie die Woche lief, was wichtig ist und was er tun sollte. Beginne mit \
|
||||
„Commander,“. Knapp, sachlich, ohne Emojis, ohne Ausrufezeichen, ohne Aufzählung. Nimm nur, was in den Fakten steht — \
|
||||
keine Zahl, kein Name, kein Ereignis, das dort fehlt. Die Fakten und die Rückfragen stehen unter deinem Absatz ohnehin \
|
||||
noch einmal; wiederhole sie nicht Punkt für Punkt.
|
||||
|
||||
Fakten dieser Woche:
|
||||
{fakten}"""
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "wochenbericht.json"
|
||||
|
||||
|
||||
def _get(pfad: str, **params: object) -> dict | None:
|
||||
try:
|
||||
antwort = httpx.get(f"{rueckfragen.EIGENE_URL}{pfad}", params=params or None, timeout=30)
|
||||
daten = antwort.json() if antwort.status_code == 200 else None
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return None
|
||||
return daten if isinstance(daten, dict) else None
|
||||
|
||||
|
||||
def _zeit(wert: object) -> float | None:
|
||||
if isinstance(wert, (int, float)):
|
||||
return float(wert)
|
||||
try:
|
||||
zeit = datetime.fromisoformat(str(wert))
|
||||
except ValueError:
|
||||
return None
|
||||
return (zeit if zeit.tzinfo else zeit.replace(tzinfo=LOCAL_TZ)).timestamp()
|
||||
|
||||
|
||||
def _liste(namen: list[str], leer: str = "") -> str:
|
||||
namen = list(dict.fromkeys(n for n in namen if n))
|
||||
if not namen:
|
||||
return leer
|
||||
return namen[0] if len(namen) == 1 else ", ".join(namen[:-1]) + " und " + namen[-1]
|
||||
|
||||
|
||||
# --- Fakten ----------------------------------------------------------------------------------------------------
|
||||
|
||||
def fakten(jetzt: float | None = None) -> dict:
|
||||
"""Alles für den Bericht, wie es die Oberfläche auch zeigt. Was nicht antwortet, fehlt (None)."""
|
||||
hl = rueckfragen.HL
|
||||
return {
|
||||
"box_laeufe": (_get("/api/updates/verlauf", anzahl=12) or {}).get("laeufe"),
|
||||
"homelab_laeufe": (_get(f"{hl}/laeufe", anzahl=100) or {}).get("laeufe"),
|
||||
"ziele_box": (_get("/api/ziele") or {}).get("ziele"),
|
||||
"ziele_homelab": (_get(f"{hl}/ziele") or {}).get("ziele"),
|
||||
"hinweise_box": (_get("/api/hinweise") or {}).get("hinweise"),
|
||||
"hinweise_homelab": (_get(f"{hl}/hinweise") or {}).get("hinweise"),
|
||||
"speicher": _get(f"{hl}/speicher"),
|
||||
"box_platte": ((_get("/api/start") or {}).get("box") or {}).get("platte"),
|
||||
"zertifikate": _get(f"{hl}/zertifikate"),
|
||||
"docker": _get(f"{hl}/docker"),
|
||||
"radar": (_get("/api/radar") or {}).get("kandidaten"),
|
||||
"rueckfragen": rueckfragen.liste(100, jetzt=jetzt),
|
||||
"lucy": _lucy_log(),
|
||||
}
|
||||
|
||||
|
||||
def _lucy_log() -> list[dict]:
|
||||
"""Was Lucy über ihre Werkzeuge getan hat (mcp/orchestrator_werkzeuge.py schreibt je Aktion eine Zeile)."""
|
||||
try:
|
||||
zeilen = LUCY_LOG.read_text(encoding="utf-8").splitlines()[-500:]
|
||||
except OSError:
|
||||
return []
|
||||
eintraege = []
|
||||
for zeile in zeilen:
|
||||
try:
|
||||
e = json.loads(zeile)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(e, dict):
|
||||
eintraege.append(e)
|
||||
return eintraege
|
||||
|
||||
|
||||
# --- Feste Sätze ---------------------------------------------------------------------------------------------------
|
||||
|
||||
def _eingespielt(f: dict, seit: float) -> tuple[list[str], list[str]]:
|
||||
"""(eingespielt, nicht gut gegangen) dieser Woche, KI-Box und Homelab."""
|
||||
gut, schlecht = [], []
|
||||
for lauf in f.get("homelab_laeufe") or []:
|
||||
ende = _zeit(lauf.get("ende"))
|
||||
if lauf.get("status") != "fertig" or lauf.get("baustein") == "probe" or ende is None or ende < seit:
|
||||
continue
|
||||
was = f"{lauf.get('name')} ({BAUSTEIN.get(lauf.get('baustein'), lauf.get('baustein'))})"
|
||||
if lauf.get("ergebnis") == "eingespielt":
|
||||
gut.append(was)
|
||||
elif lauf.get("ergebnis") in NICHT_GLATT:
|
||||
schlecht.append(f"{was} {NICHT_GLATT[lauf['ergebnis']]}")
|
||||
for lauf in f.get("box_laeufe") or []:
|
||||
start = _zeit(lauf.get("start"))
|
||||
if start is None or start < seit:
|
||||
continue
|
||||
for z in lauf.get("zeilen") or []:
|
||||
was = f"KI-Box {z.get('baustein')}"
|
||||
if z.get("ergebnis") == "eingespielt":
|
||||
gut.append(was)
|
||||
elif z.get("ergebnis") in NICHT_GLATT:
|
||||
schlecht.append(f"{was} {NICHT_GLATT[z['ergebnis']]}")
|
||||
return gut, schlecht
|
||||
|
||||
|
||||
def _offen(f: dict) -> tuple[str, list[dict]]:
|
||||
"""Offene Updates als Satz und die Kandidaten für Rückfragen (App-Updates des Homelabs mit Knopf)."""
|
||||
apps, geraete_pakete, sicherheit, angebote = [], 0, 0, []
|
||||
for quelle in ("ziele_box", "ziele_homelab"):
|
||||
for z in f.get(quelle) or []:
|
||||
for b in z.get("bausteine") or []:
|
||||
if b.get("zustand") != "neu":
|
||||
continue
|
||||
if b.get("id") in ("os", "pakete"):
|
||||
geraete_pakete += 1
|
||||
sicherheit += int(b.get("sicherheit") or 0)
|
||||
continue
|
||||
version = b.get("verfuegbar") or b.get("kurz") or ""
|
||||
apps.append(f"{z.get('name')} {version}".strip() if b.get("id") == "app" else
|
||||
f"{z.get('name')}: {b.get('name')}" + (f" {version}" if version else ""))
|
||||
pfad = (b.get("aktion") or {}).get("pfad") or ""
|
||||
if quelle == "ziele_homelab" and b.get("id") == "app" and pfad.startswith("/api/homelab/"):
|
||||
angebote.append({"frage": f"Soll ich {z.get('name')} {version} einspielen?".replace(" ", " "),
|
||||
"was": f"{z.get('name')} {version}".strip(), "pfad": pfad})
|
||||
teile = []
|
||||
if apps:
|
||||
teile.append(_liste(apps))
|
||||
if geraete_pakete:
|
||||
teile.append(f"Paket-Updates auf {geraete_pakete} {'Gerät' if geraete_pakete == 1 else 'Geräten'}"
|
||||
+ (f", davon {sicherheit} Sicherheitsupdates" if sicherheit else ""))
|
||||
return ("; ".join(teile) if teile else "nichts"), angebote
|
||||
|
||||
|
||||
def _speicher(f: dict) -> str:
|
||||
s = f.get("speicher") or {}
|
||||
teile = []
|
||||
|
||||
def satz(name: str, anteil: object, v: dict | None) -> str:
|
||||
text = f"{name} {round(float(anteil or 0) * 100)} %"
|
||||
if v and v.get("tage") is not None and v["tage"] < 120:
|
||||
text += f" (voll {prognose.in_tagen(float(v['tage']))})"
|
||||
return text
|
||||
|
||||
for p in s.get("pools") or []:
|
||||
teile.append(satz(f"Pool {p.get('speicher')}", p.get("anteil"), p.get("vorhersage")))
|
||||
if sp := s.get("systemplatte"):
|
||||
teile.append(satz("Host-Systemplatte", sp.get("anteil"), sp.get("vorhersage")))
|
||||
for n in s.get("nas") or []:
|
||||
teile.append(f"NAS: {str(n['problem']).rstrip('.')}" if n.get("problem")
|
||||
else satz("NAS", n.get("anteil"), n.get("vorhersage")))
|
||||
if bp := f.get("box_platte"):
|
||||
teile.append(satz("KI-Box", float(bp.get("percent") or 0) / 100, bp.get("vorhersage")))
|
||||
return "; ".join(teile) or "keine Angaben"
|
||||
|
||||
|
||||
def _sicherungen(f: dict) -> str:
|
||||
si = (f.get("speicher") or {}).get("sicherungen") or {}
|
||||
if not si or si.get("stufe") in (None, "keine"):
|
||||
return "keine Angaben"
|
||||
teile = ["alle aktuell" if si.get("stufe") == "ok" else f"Stufe {si.get('stufe')}"]
|
||||
if fehlend := [x.get("name") for x in si.get("fehlend") or []]:
|
||||
teile.append(f"zu alt oder fehlend: {_liste(fehlend)}")
|
||||
if gescheitert := [g.get("name") for g in si.get("geraete") or [] if (g.get("probe") or {}).get("ok") is False]:
|
||||
teile.append(f"Probe-Wiederherstellung gescheitert: {_liste(gescheitert)}")
|
||||
return "; ".join(teile)
|
||||
|
||||
|
||||
def _zertifikate(f: dict) -> str:
|
||||
z = f.get("zertifikate") or {}
|
||||
liste = z.get("zertifikate") or []
|
||||
if not liste:
|
||||
return "keine Angaben"
|
||||
probleme = [f"{x.get('name')}: {x.get('problem')}" for x in liste if x.get("stufe") != "ok" and x.get("problem")]
|
||||
return _liste(probleme) if probleme else f"alle {len(liste)} gültig"
|
||||
|
||||
|
||||
def _luecken(f: dict) -> str | None:
|
||||
d = f.get("docker") or {}
|
||||
if not d.get("verfuegbar"):
|
||||
return None
|
||||
gescannt = [c for s in d.get("stacks") or [] for c in s.get("container") or [] if c.get("luecken")]
|
||||
if not gescannt:
|
||||
return None
|
||||
schlimm = [f"{c['name']} ({c['luecken'].get('kritisch', 0)} kritisch)" for c in gescannt
|
||||
if c["luecken"].get("kritisch")]
|
||||
return _liste(schlimm) if schlimm else f"{len(gescannt)} Images gescannt, nichts Kritisches"
|
||||
|
||||
|
||||
def _radar(f: dict) -> str | None:
|
||||
kandidaten = f.get("radar") or []
|
||||
bestanden = [k.get("name") for k in kandidaten if k.get("status") == "bestanden"]
|
||||
wartend = sum(1 for k in kandidaten if k.get("status") in ("neu", "wartet"))
|
||||
teile = []
|
||||
if bestanden:
|
||||
teile.append(f"bestanden und wartet auf deine Entscheidung: {_liste(bestanden)}")
|
||||
if wartend:
|
||||
teile.append(f"{wartend} {'Kandidat wartet' if wartend == 1 else 'Kandidaten warten'} auf den Test")
|
||||
return "; ".join(teile) or None
|
||||
|
||||
|
||||
def _hinweise(f: dict) -> str:
|
||||
alle = [(wo, h) for wo in ("box", "homelab") for h in f.get(f"hinweise_{wo}") or []]
|
||||
rot = [h.get("titel") for _, h in alle if h.get("stufe") == "rot"]
|
||||
gelb = [h.get("titel") for _, h in alle if h.get("stufe") != "rot"]
|
||||
if not alle:
|
||||
return "keine"
|
||||
teile = []
|
||||
if rot:
|
||||
teile.append(f"rot: {_liste(rot)}")
|
||||
if gelb:
|
||||
teile.append(f"gelb: {_liste(gelb)}")
|
||||
return "; ".join(teile)
|
||||
|
||||
|
||||
def _lucy(f: dict, seit: float) -> str | None:
|
||||
selbst, bitte = [], 0
|
||||
for e in f.get("lucy") or []:
|
||||
zeit = _zeit(e.get("zeit"))
|
||||
if zeit is None or zeit < seit or str(e.get("ergebnis") or "").startswith("Nicht ausgeführt"):
|
||||
continue
|
||||
if e.get("stufe") == "selbst":
|
||||
selbst.append(str(e.get("werkzeug")))
|
||||
elif e.get("stufe") in (None, "auf_bitte"):
|
||||
bitte += 1
|
||||
fragen = [r for r in (f.get("rueckfragen") or {}).get("zuletzt") or []
|
||||
if float(r.get("zeit") or 0) >= seit]
|
||||
ja = sum(1 for r in fragen if (r.get("antwort") or {}).get("ja"))
|
||||
teile = []
|
||||
if selbst:
|
||||
teile.append(f"{len(selbst)}× selbst gehandelt")
|
||||
if bitte:
|
||||
teile.append(f"{bitte}× auf deine Bitte")
|
||||
if fragen:
|
||||
teile.append(f"{len(fragen)} Rückfragen beantwortet oder abgelaufen, davon {ja}× ja")
|
||||
return "; ".join(teile) or None
|
||||
|
||||
|
||||
def zeilen(f: dict, jetzt: float | None = None) -> list[str]:
|
||||
"""Die festen Sätze des Berichts, in dieser Reihenfolge."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
seit = jetzt - WOCHE_S
|
||||
gut, schlecht = _eingespielt(f, seit)
|
||||
offen, _ = _offen(f)
|
||||
ergebnis = [f"Eingespielt: {_liste(gut, 'nichts')}."]
|
||||
if schlecht:
|
||||
ergebnis.append(f"Nicht glatt gelaufen: {_liste(schlecht)}.")
|
||||
ergebnis += [f"Offen: {offen}.", f"Hinweise: {_hinweise(f)}.", f"Speicher: {_speicher(f)}.",
|
||||
f"Sicherungen: {_sicherungen(f)}.", f"Zertifikate: {_zertifikate(f)}."]
|
||||
if luecken := _luecken(f):
|
||||
ergebnis.append(f"Lücken-Scan: {luecken}.")
|
||||
if radar := _radar(f):
|
||||
ergebnis.append(f"Modell-Radar: {radar}.")
|
||||
if lucy := _lucy(f, seit):
|
||||
ergebnis.append(f"Lucy: {lucy}.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Absatz vom Modell, Rückfragen, Senden ---------------------------------------------------------------------------
|
||||
|
||||
def prosa(fakten_zeilen: list[str]) -> str | None:
|
||||
"""Lucys Absatz obenauf (Modell `fast` über /v1 der KI-Box). None, wenn es nicht antwortet."""
|
||||
try:
|
||||
antwort = httpx.post(f"{rueckfragen.EIGENE_URL}/v1/chat/completions", timeout=120, json={
|
||||
"model": "fast", "temperature": 0.3, "max_tokens": 400,
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
"messages": [{"role": "user", "content": PROSA.format(fakten="\n".join(fakten_zeilen))}]})
|
||||
text = antwort.json()["choices"][0]["message"]["content"] if antwort.status_code == 200 else ""
|
||||
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError):
|
||||
log.warning("wochenbericht: Modell antwortete nicht", exc_info=True)
|
||||
return None
|
||||
text = re.sub(r"<think>.*?</think>", "", str(text or ""), flags=re.DOTALL).strip()
|
||||
return text[:1200] or None
|
||||
|
||||
|
||||
def angebote(f: dict, jetzt: float | None = None) -> list[tuple[str, int]]:
|
||||
"""Offene App-Updates des Homelabs als Rückfragen (still — die Antwortzeilen stehen im Bericht), dazu eine für
|
||||
„Alle aktualisieren“, wenn außer den Apps noch etwas ansteht (das nimmt dann auch die Apps mit). Rückgabe: (Text
|
||||
der Zeile, Nummer)."""
|
||||
_, kandidaten = _offen(f)
|
||||
zeilen_ = []
|
||||
for k in kandidaten[:ANGEBOTE_MAX]:
|
||||
r = rueckfragen.stellen(k["frage"], k["pfad"], text="Snapshot vorher, Prüfung danach, bei Rot zurück.",
|
||||
von="wochenbericht", still=True, jetzt=jetzt)
|
||||
if r["ok"]:
|
||||
zeilen_.append((f"{k['was']} einspielen", r["rueckfrage"]["nr"]))
|
||||
rest = [b for z in f.get("ziele_homelab") or [] for b in z.get("bausteine") or []
|
||||
if b.get("zustand") == "neu" and b.get("aktion") and b.get("id") != "app"]
|
||||
if rest or len(kandidaten) > ANGEBOTE_MAX:
|
||||
r = rueckfragen.stellen("Soll ich im Homelab alles aktualisieren (Gäste nacheinander, Host zuletzt, ohne "
|
||||
"Neustart)?", f"{rueckfragen.HL}/alle", von="wochenbericht", still=True, jetzt=jetzt)
|
||||
if r["ok"]:
|
||||
zeilen_.append(("alles auf einmal („Alle aktualisieren“)", r["rueckfrage"]["nr"]))
|
||||
return zeilen_
|
||||
|
||||
|
||||
def bauen(jetzt: float | None = None, mit_angeboten: bool = True) -> dict:
|
||||
"""Der Bericht als Text (ohne zu senden): {"text", "knoepfe", "zeilen"}."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
f = fakten(jetzt)
|
||||
feste = zeilen(f, jetzt)
|
||||
absatz = prosa(feste) or "Commander, hier ist dein Wochenbericht."
|
||||
text = absatz + "\n\nDie Woche in Stichpunkten:\n" + "\n".join(f"• {z}" for z in feste)
|
||||
knoepfe: list[str] = []
|
||||
if mit_angeboten and (liste := angebote(f, jetzt)):
|
||||
text += "\n\nAuf Zuruf:\n" + "\n".join(f"• {was} — „ja {nr}“" for was, nr in liste)
|
||||
knoepfe = [f"ja {nr}" for _, nr in liste]
|
||||
if len(text) > MAX_ZEICHEN:
|
||||
text = text[:MAX_ZEICHEN - 2].rstrip() + " …"
|
||||
return {"text": text, "knoepfe": knoepfe, "zeilen": feste}
|
||||
|
||||
|
||||
def senden(jetzt: float | None = None) -> dict:
|
||||
"""POST /api/lucy/wochenbericht (Timer sonntags 07:30 und Knopf „Jetzt schicken“)."""
|
||||
if not _lock.acquire(blocking=False):
|
||||
return {"ok": False, "detail": "Der Wochenbericht wird gerade schon geschrieben."}
|
||||
try:
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
bericht = bauen(jetzt)
|
||||
# Direkt statt über rueckfragen.melden(): Untersucht Lucy gerade etwas, hinge der Bericht sonst an ihrer
|
||||
# Nachricht. Im Briefkasten leise — die Desktop-Lucy soll nicht drei Minuten vorlesen.
|
||||
announce.add(bericht["text"], subject=BETREFF, source="wochenbericht", priority="silent")
|
||||
announce.notify_telegram(BETREFF, bericht["text"], knoepfe=bericht["knoepfe"] or None)
|
||||
pfad = _pfad()
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({"zeit": jetzt, "text": bericht["text"]}, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
log.info("wochenbericht: verschickt (%d Zeichen)", len(bericht["text"]))
|
||||
return {"ok": True, "text": "Der Wochenbericht ist unterwegs."}
|
||||
finally:
|
||||
_lock.release()
|
||||
|
||||
|
||||
def naechster(jetzt: float | None = None) -> float:
|
||||
jetzt_dt = datetime.fromtimestamp(time.time() if jetzt is None else jetzt, LOCAL_TZ)
|
||||
ziel = jetzt_dt.replace(hour=UHR[0], minute=UHR[1], second=0, microsecond=0) + timedelta(
|
||||
days=(TAG - jetzt_dt.weekday()) % 7)
|
||||
if ziel <= jetzt_dt:
|
||||
ziel += timedelta(days=7)
|
||||
return ziel.timestamp()
|
||||
|
||||
|
||||
def stand(jetzt: float | None = None) -> dict:
|
||||
"""GET /api/lucy/wochenbericht: der letzte Bericht und wann der nächste kommt."""
|
||||
try:
|
||||
zuletzt = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
zuletzt = None
|
||||
return {"zuletzt": zuletzt if isinstance(zuletzt, dict) else None, "naechster": naechster(jetzt)}
|
||||
Reference in New Issue
Block a user