Versionen je Thema
Ein Thema hat Versionen storage/<Thema>/v<N>/ mit eigenem Lauf, Buch und Leseposition; bestehende Ablagen wandern beim Start nach v1/. Intern ist „Thema/vN“ der Schlüssel aller Zustandsfunktionen. API: ?v=N an allen Themen-Endpunkten, POST /version, DELETE ?v=N. Oberfläche: Versionsliste unter dem Thema (Klick auf Thema → Lauf der neuesten, auf Version → Buch), Knopf „Neue Version“ mit gleichen Eingaben. make loeschen kennt V=<Nr>. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
110
backend/main.py
110
backend/main.py
@@ -16,6 +16,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
config.STORAGE.mkdir(exist_ok=True)
|
||||
state.migrieren()
|
||||
yield
|
||||
for name in list(pipeline._laeufe):
|
||||
await pipeline.pausieren(name)
|
||||
@@ -35,19 +36,34 @@ class Position(BaseModel):
|
||||
lesemodus: str | int | None = None # neu: key des Bildschirms, alt: Index
|
||||
|
||||
|
||||
def _pruefen(name: str) -> dict:
|
||||
if not state.name_ok(name) or not (t := state.thema(name)):
|
||||
def _key(name: str, v: int | None = None) -> str:
|
||||
"""Themenname + optionale Version → Schlüssel „Thema/vN“ (ohne v: neueste)."""
|
||||
if not state.name_ok(name) or not state.thema(name):
|
||||
raise HTTPException(404, "Thema unbekannt")
|
||||
return t
|
||||
key = state.schluessel(name, v)
|
||||
if not key:
|
||||
raise HTTPException(404, "Version unbekannt")
|
||||
return key
|
||||
|
||||
|
||||
def _version_info(name: str, n: int) -> dict:
|
||||
key = f"{name}/v{n}"
|
||||
st = state.zustand(key)
|
||||
angelegt = st.get("angelegt") or (state.thema(name) or {}).get("angelegt", "") # migrierte v1 hat kein eigenes Datum
|
||||
return {"v": n, "status": st["status"], "laeuft": pipeline.laeuft(key), "stufe": st["stufe"],
|
||||
"angelegt": angelegt, "kosten_usd": st.get("kosten_usd", 0)}
|
||||
|
||||
|
||||
@app.get("/api/topics")
|
||||
async def topics():
|
||||
aus = []
|
||||
for t in state.themen():
|
||||
st = state.zustand(t["name"])
|
||||
aus.append({"name": t["name"], "titel": t["titel"], "status": st["status"],
|
||||
"laeuft": pipeline.laeuft(t["name"]), "stufe": st["stufe"]})
|
||||
vs = [_version_info(t["name"], n) for n in state.versionen(t["name"])]
|
||||
if not vs:
|
||||
continue
|
||||
neueste = vs[-1]
|
||||
aus.append({"name": t["name"], "titel": t["titel"], "status": neueste["status"],
|
||||
"laeuft": neueste["laeuft"], "stufe": neueste["stufe"], "versionen": vs})
|
||||
return aus
|
||||
|
||||
|
||||
@@ -62,77 +78,87 @@ async def topic_anlegen(body: ThemaNeu):
|
||||
raise HTTPException(409, str(e))
|
||||
|
||||
|
||||
@app.post("/api/topics/{name}/version")
|
||||
async def version_anlegen(name: str):
|
||||
"""Neue Version: gleiche Eingaben, frischer Lauf, ohne Wissen über die Vorgänger."""
|
||||
_key(name)
|
||||
key = state.version_anlegen(name)
|
||||
return {"v": int(key.rsplit("/v", 1)[1])}
|
||||
|
||||
|
||||
@app.delete("/api/topics/{name}")
|
||||
async def topic_loeschen(name: str):
|
||||
_pruefen(name)
|
||||
await pipeline.pausieren(name)
|
||||
state.loeschen(name)
|
||||
async def topic_loeschen(name: str, v: int | None = None):
|
||||
if v is None:
|
||||
_key(name)
|
||||
for n in state.versionen(name):
|
||||
await pipeline.pausieren(f"{name}/v{n}")
|
||||
state.loeschen(name)
|
||||
else:
|
||||
key = _key(name, v)
|
||||
await pipeline.pausieren(key)
|
||||
state.version_loeschen(key)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/topics/{name}/start")
|
||||
async def start(name: str):
|
||||
_pruefen(name)
|
||||
if state.zustand(name)["status"] == "fertig":
|
||||
async def start(name: str, v: int | None = None):
|
||||
key = _key(name, v)
|
||||
if state.zustand(key)["status"] == "fertig":
|
||||
raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen")
|
||||
pipeline.starten(name)
|
||||
pipeline.starten(key)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/topics/{name}/pause")
|
||||
async def pause(name: str):
|
||||
_pruefen(name)
|
||||
await pipeline.pausieren(name)
|
||||
async def pause(name: str, v: int | None = None):
|
||||
await pipeline.pausieren(_key(name, v))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/topics/{name}/reset")
|
||||
async def reset(name: str):
|
||||
_pruefen(name)
|
||||
await pipeline.pausieren(name)
|
||||
state.zuruecksetzen(name)
|
||||
async def reset(name: str, v: int | None = None):
|
||||
key = _key(name, v)
|
||||
await pipeline.pausieren(key)
|
||||
state.zuruecksetzen(key)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/topics/{name}/state")
|
||||
async def zustand(name: str):
|
||||
t = _pruefen(name)
|
||||
st = state.zustand(name)
|
||||
return {"name": name, "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st,
|
||||
"deutung": st.get("deutung") or state.deutung(name), "ereignisse": st["ereignisse"][-100:]}
|
||||
async def zustand(name: str, v: int | None = None):
|
||||
key = _key(name, v)
|
||||
t = state.thema(name)
|
||||
st = state.zustand(key)
|
||||
return {"name": name, "version": int(key.rsplit("/v", 1)[1]), "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st,
|
||||
"deutung": st.get("deutung") or state.deutung(key), "ereignisse": st["ereignisse"][-100:]}
|
||||
|
||||
|
||||
@app.get("/api/topics/{name}/position")
|
||||
async def position_lesen(name: str):
|
||||
_pruefen(name)
|
||||
return state.position(name)
|
||||
async def position_lesen(name: str, v: int | None = None):
|
||||
return state.position(_key(name, v))
|
||||
|
||||
|
||||
@app.put("/api/topics/{name}/position")
|
||||
async def position_schreiben(name: str, body: Position):
|
||||
_pruefen(name)
|
||||
return state.position_setzen(name, {k: v for k, v in body.model_dump().items() if v is not None})
|
||||
async def position_schreiben(name: str, body: Position, v: int | None = None):
|
||||
return state.position_setzen(_key(name, v), {k: val for k, val in body.model_dump().items() if val is not None})
|
||||
|
||||
|
||||
@app.get("/api/topics/{name}/guide")
|
||||
async def guide(name: str):
|
||||
_pruefen(name)
|
||||
return montage.guide(name)
|
||||
async def guide(name: str, v: int | None = None):
|
||||
return montage.guide(_key(name, v))
|
||||
|
||||
|
||||
@app.get("/api/topics/{name}/guide.md", response_class=PlainTextResponse)
|
||||
async def guide_md(name: str):
|
||||
_pruefen(name)
|
||||
return montage.markdown(name)
|
||||
async def guide_md(name: str, v: int | None = None):
|
||||
return montage.markdown(_key(name, v))
|
||||
|
||||
|
||||
@app.get("/api/topics/{name}/buch.pdf")
|
||||
async def buch_pdf(name: str):
|
||||
_pruefen(name)
|
||||
p = state.verzeichnis(name) / "buch" / "buch.pdf"
|
||||
async def buch_pdf(name: str, v: int | None = None):
|
||||
key = _key(name, v)
|
||||
p = state.verzeichnis(key) / "buch" / "buch.pdf"
|
||||
if not p.exists():
|
||||
raise HTTPException(404, "PDF noch nicht gebaut")
|
||||
return FileResponse(p, media_type="application/pdf", filename=f"{name}.pdf")
|
||||
return FileResponse(p, media_type="application/pdf", filename=f"{name} v{key.rsplit('/v', 1)[1]}.pdf")
|
||||
|
||||
|
||||
if config.FRONTEND_DIST.exists():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Zustand je Thema: storage/<name>/{thema.json,state.json,log.jsonl}. Alles im Speicher, bei Änderung auf Platte."""
|
||||
"""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
|
||||
@@ -18,8 +19,56 @@ 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(name: str) -> Path:
|
||||
return config.STORAGE / name
|
||||
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 version_anlegen(name: str) -> str:
|
||||
n = (versionen(name) or [0])[-1] + 1
|
||||
key = f"{name}/v{n}"
|
||||
v = verzeichnis(key)
|
||||
for u in ("quellen", "buch", "berichte", "arbeit"):
|
||||
(v / u).mkdir(parents=True)
|
||||
_schreiben(v / "state.json", leerer_zustand())
|
||||
return 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."""
|
||||
for d in config.STORAGE.glob("*/"):
|
||||
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):
|
||||
@@ -37,7 +86,7 @@ def _schreiben(pfad: Path, daten) -> None:
|
||||
|
||||
|
||||
def leerer_zustand() -> dict:
|
||||
return {"status": "neu", "grund": "", "stufe": "", "deutung": None,
|
||||
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": []}
|
||||
@@ -53,38 +102,36 @@ def themen() -> list[dict]:
|
||||
|
||||
|
||||
def anlegen(name: str, hinweise: str) -> dict:
|
||||
v = verzeichnis(name)
|
||||
if v.exists():
|
||||
d = config.STORAGE / name
|
||||
if d.exists():
|
||||
raise ValueError("Thema existiert schon")
|
||||
for u in ("quellen", "buch", "berichte", "arbeit"):
|
||||
(v / u).mkdir(parents=True)
|
||||
d.mkdir(parents=True)
|
||||
t = {"name": name, "titel": name, "hinweise": hinweise.strip(), "angelegt": jetzt()}
|
||||
_schreiben(v / "thema.json", t)
|
||||
_schreiben(v / "state.json", leerer_zustand())
|
||||
_schreiben(d / "thema.json", t)
|
||||
version_anlegen(name)
|
||||
return t
|
||||
|
||||
|
||||
def loeschen(name: str) -> None:
|
||||
_cache.pop(name, None)
|
||||
shutil.rmtree(verzeichnis(name), ignore_errors=True)
|
||||
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(name: str) -> None:
|
||||
v = verzeichnis(name)
|
||||
t = _lesen(v / "thema.json", None)
|
||||
_cache.pop(name, None)
|
||||
def zuruecksetzen(key: str) -> None:
|
||||
v = verzeichnis(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"):
|
||||
for f in ("deutung.json", "log.jsonl", "state.json", "position.json"):
|
||||
(v / f).unlink(missing_ok=True)
|
||||
_schreiben(v / "state.json", leerer_zustand())
|
||||
if t:
|
||||
_schreiben(v / "thema.json", t)
|
||||
|
||||
|
||||
def thema(name: str) -> dict | None:
|
||||
return _lesen(verzeichnis(name) / "thema.json", None)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user