Files
buchwerk/backend/state.py
team3 97e465181a Hinweise und Spezialisierung je Version: im Lauf sichtbar, bei „Neue Version“ änderbar
Eingaben liegen jetzt in vN/state.json; alte Versionen ohne eigene
Eingaben nehmen die des Themas. „Neue Version“ öffnet ein Formular mit
den bisherigen Werten, Thema bleibt fest. Zurücksetzen behält die Eingaben.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:29:34 +02:00

273 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Zustand je Themenversion. Ablage: storage/<Thema>/thema.json und storage/<Thema>/v<N>/{state.json, deutung.json, quellen/, buch/, …}.
Intern heißt eine Version „<Thema>/v<N>“ (Schlüssel); alle Funktionen mit `key` erwarten diesen Schlüssel. Alles im Speicher, bei Änderung auf Platte."""
import json
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from . import config
_cache: dict[str, dict] = {}
def jetzt() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def name_ok(name: str) -> bool:
return bool(name) and name.strip() == name and "/" not in name and ".." not in name and len(name) < 120
def verzeichnis(key: str) -> Path:
return config.STORAGE / key
def thema_von(key: str) -> str:
"""„Godot 4/v2“ → „Godot 4“; ein reiner Themenname bleibt unverändert."""
return key.rsplit("/", 1)[0] if "/" in key else key
def versionen(name: str) -> list[int]:
d = config.STORAGE / name
return sorted(int(x.name[1:]) for x in d.glob("v[0-9]*") if x.is_dir() and x.name[1:].isdigit())
def schluessel(name: str, v: int | None = None) -> str | None:
"""Schlüssel einer Version; ohne v die neueste. None, wenn Thema oder Version fehlen."""
vs = versionen(name)
if not vs or (v is not None and v not in vs):
return None
return f"{name}/v{vs[-1] if v is None else v}"
def slug_frei(name: str) -> str:
"""Eindeutiger Slug für die Adresse: „Godot 4“ → „godot-4“; bei Kollision „godot-4-2“."""
basis = slug(name)
vergeben = {t.get("slug") for t in themen()}
k, i = basis, 2
while k in vergeben:
k, i = f"{basis}-{i}", i + 1
return k
def name_von_slug(sl: str) -> str | None:
return next((t["name"] for t in themen() if t.get("slug") == sl), None)
def version_anlegen(name: str, hinweise: str | None = None, spezialisierung: str | None = None) -> str:
"""Neue Version; ohne Angabe übernimmt sie Hinweise und Spezialisierung der neuesten Version (bzw. des Themas)."""
vs = versionen(name)
alt_h, alt_s = eingaben(f"{name}/v{vs[-1]}") if vs else _eingaben_thema(name)
key = f"{name}/v{(vs or [0])[-1] + 1}"
v = verzeichnis(key)
for u in ("quellen", "buch", "berichte", "arbeit"):
(v / u).mkdir(parents=True)
st = leerer_zustand()
st["hinweise"] = (alt_h if hinweise is None else hinweise).strip()
st["spezialisierung"] = (alt_s if spezialisierung is None else spezialisierung).strip()
_schreiben(v / "state.json", st)
return key
def _eingaben_thema(name: str) -> tuple[str, str]:
t = thema(name) or {}
return t.get("hinweise", ""), t.get("spezialisierung", "")
def eingaben(key: str) -> tuple[str, str]:
"""Hinweise und Spezialisierung einer Version; alte Versionen ohne eigene Eingaben nehmen die des Themas."""
st = zustand(key)
if "hinweise" in st:
return st["hinweise"], st.get("spezialisierung", "")
return _eingaben_thema(key)
def version_loeschen(key: str) -> None:
_cache.pop(key, None)
shutil.rmtree(verzeichnis(key), ignore_errors=True)
name = thema_von(key)
if not versionen(name):
shutil.rmtree(config.STORAGE / name, ignore_errors=True)
def migrieren() -> None:
"""Alte Ablage (alles direkt unter storage/<Thema>/) einmalig nach v1/ verschieben; Slugs nachtragen."""
for d in config.STORAGE.glob("*/"):
t = _lesen(d / "thema.json", None)
if t and not t.get("slug"):
t["slug"] = slug_frei(t["name"])
_schreiben(d / "thema.json", t)
if not (d / "thema.json").exists() or not (d / "state.json").exists() or (d / "v1").exists():
continue
v1 = d / "v1"
v1.mkdir()
for kind in list(d.iterdir()):
if kind.name not in ("thema.json", "v1"):
shutil.move(str(kind), str(v1 / kind.name))
def _lesen(pfad: Path, standard):
try:
return json.loads(pfad.read_text())
except (OSError, ValueError):
return standard
def _schreiben(pfad: Path, daten) -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(pfad.suffix + ".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1))
tmp.replace(pfad)
def leerer_zustand() -> dict:
return {"status": "neu", "grund": "", "stufe": "", "deutung": None, "angelegt": jetzt(),
"stufen": [{"id": s, "titel": t, "status": "offen", "fertig": 0, "gesamt": 0,
"kosten_usd": 0.0, "tokens": 0, "gestartet": "", "beendet": ""} for s, t in config.STUFEN],
"kosten_usd": 0.0, "tokens": 0, "agenten": [], "ereignisse": []}
def themen() -> list[dict]:
aus = []
for d in sorted(config.STORAGE.glob("*/thema.json")):
t = _lesen(d, None)
if t:
aus.append(t)
return aus
def anlegen(name: str, hinweise: str, spezialisierung: str = "") -> dict:
d = config.STORAGE / name
if d.exists():
raise ValueError("Thema existiert schon")
d.mkdir(parents=True)
t = {"name": name, "slug": slug_frei(name), "titel": name, "hinweise": hinweise.strip(), "spezialisierung": spezialisierung.strip(), "angelegt": jetzt()}
_schreiben(d / "thema.json", t)
version_anlegen(name)
return t
def loeschen(name: str) -> None:
for k in [k for k in _cache if thema_von(k) == name]:
_cache.pop(k, None)
shutil.rmtree(config.STORAGE / name, ignore_errors=True)
def zuruecksetzen(key: str) -> None:
v = verzeichnis(key)
h, s = eingaben(key)
_cache.pop(key, None)
for u in ("quellen", "buch", "berichte", "arbeit"):
shutil.rmtree(v / u, ignore_errors=True)
(v / u).mkdir()
for f in ("deutung.json", "log.jsonl", "state.json", "position.json"):
(v / f).unlink(missing_ok=True)
_schreiben(v / "state.json", {**leerer_zustand(), "hinweise": h, "spezialisierung": s})
def thema(key: str) -> dict | None:
"""thema.json des Themas – nimmt Schlüssel oder Themenname."""
return _lesen(config.STORAGE / thema_von(key) / "thema.json", None)
def zustand(name: str) -> dict:
if name not in _cache:
st = _lesen(verzeichnis(name) / "state.json", None) or leerer_zustand()
# Ein Lauf, der beim Serverstart noch als „laeuft“ gespeichert ist, ist tot.
if st.get("status") == "laeuft":
st["status"] = "pausiert"
st["grund"] = "Server wurde beendet – mit „Fortsetzen“ weitermachen"
for s in st["stufen"]:
if s["status"] == "laeuft":
s["status"] = "offen"
for a in st["agenten"]:
if a["status"] == "laeuft":
a["status"] = "abgebrochen"
# Später hinzugekommene Stufen (z. B. Lektorat) in alte Zustände einfügen, in der Reihenfolge der Konfiguration
vorhanden = {x["id"] for x in st["stufen"]}
if vorhanden != {sid for sid, _ in config.STUFEN}:
alt = {x["id"]: x for x in st["stufen"]}
fertig = st.get("status") == "fertig"
st["stufen"] = [alt.get(sid) or {"id": sid, "titel": t, "status": "fertig" if fertig else "offen", "fertig": 0, "gesamt": 0,
"kosten_usd": 0.0, "tokens": 0, "gestartet": "", "beendet": ""} for sid, t in config.STUFEN]
_cache[name] = st
return _cache[name]
def speichern(name: str) -> None:
if name in _cache:
_schreiben(verzeichnis(name) / "state.json", _cache[name])
def deutung(name: str) -> dict | None:
return _lesen(verzeichnis(name) / "deutung.json", None)
def stufe(name: str, sid: str) -> dict:
return next(s for s in zustand(name)["stufen"] if s["id"] == sid)
def ereignis(name: str, agent: str, art: str, text: str) -> None:
st = zustand(name)
e = {"zeit": jetzt(), "agent": agent, "art": art, "text": text[:300]}
st["ereignisse"].append(e)
del st["ereignisse"][:-200]
try:
with open(verzeichnis(name) / "log.jsonl", "a") as f:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
except OSError:
pass
def agent_eintrag(name: str, agent_id: str, stufe_id: str, titel: str) -> dict:
st = zustand(name)
st["agenten"] = [a for a in st["agenten"] if a["id"] != agent_id]
a = {"id": agent_id, "stufe": stufe_id, "name": titel, "status": "laeuft", "kosten_usd": 0.0, "tokens": 0,
"werkzeuge": 0, "gestartet": jetzt(), "beendet": "", "bericht": ""}
st["agenten"].append(a)
speichern(name)
return a
def agent_fertig(name: str, a: dict, status: str, bericht: str, kosten: float, tokens: int) -> None:
st = zustand(name)
a.update(status=status, bericht=bericht, kosten_usd=round(kosten, 4), tokens=tokens, beendet=jetzt())
s = stufe(name, a["stufe"])
s["kosten_usd"] = round(s["kosten_usd"] + kosten, 4)
s["tokens"] += tokens
st["kosten_usd"] = round(st["kosten_usd"] + kosten, 4)
st["tokens"] += tokens
speichern(name)
def bericht_schreiben(name: str, agent_id: str, text: str) -> None:
p = verzeichnis(name) / "berichte" / f"{agent_id}.md"
p.parent.mkdir(exist_ok=True)
p.write_text(text.strip() + "\n")
def bericht_lesen(name: str, agent_id: str) -> str:
p = verzeichnis(name) / "berichte" / f"{agent_id}.md"
return p.read_text() if p.exists() else ""
def slug(text: str) -> str:
t = text.lower()
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
t = t.replace(a, b)
t = re.sub(r"[^a-z0-9]+", "-", t).strip("-")
return t[:60] or "thema"
def position(name: str) -> dict:
"""Leseposition (Abschnitt der Buchansicht, Bildschirm des Lesemodus) – geräteübergreifend auf dem Server."""
return _lesen(verzeichnis(name) / "position.json", {})
def position_setzen(name: str, werte: dict) -> dict:
p = position(name)
p.update(werte)
_schreiben(verzeichnis(name) / "position.json", p)
return p