This commit is contained in:
Team3
2026-09-27 21:36:33 +02:00
commit 498fe2220a
34 changed files with 3608 additions and 0 deletions

0
backend/__init__.py Normal file
View File

74
backend/agenten.py Normal file
View File

@@ -0,0 +1,74 @@
"""Ein Agent = eine Claude-Agent-SDK-Query mit dem Themenverzeichnis als Arbeitsverzeichnis."""
import asyncio
import logging
from claude_agent_sdk import (AssistantMessage, ClaudeAgentOptions, ResultMessage, TextBlock, ToolUseBlock, query)
from . import config, state
log = logging.getLogger("buchwerk")
SYSTEM_ZUSATZ = (
"Du bist ein Agent in einer Pipeline, die ein deutschsprachiges Sachbuch erstellt. Das aktuelle Arbeitsverzeichnis "
"ist das Buchprojekt; verlasse es nicht (außer für Klone und Downloads, die du unter quellen/ ablegst). "
"Es gibt keinen menschlichen Ansprechpartner: Rückfragen sind nicht möglich, entscheide selbst. "
"Arbeite gründlich und bis zum Ende der Aufgabe. Antworte auf Deutsch. Deine letzte Nachricht ist der Abschlussbericht."
)
def _kurz(block: ToolUseBlock) -> str:
i = block.input or {}
for k in ("file_path", "command", "pattern", "query", "url", "description"):
if k in i:
return f"{block.name} {str(i[k])[:160]}"
return block.name
async def laufen(name: str, agent_id: str, stufe_id: str, titel: str, prompt: str, *,
effort: str = config.EFFORT, max_turns: int = config.MAX_TURNS) -> dict:
"""Führt einen Agenten aus; schreibt Ereignisse und Bericht. → {"status", "bericht", "kosten", "tokens"}"""
a = state.agent_eintrag(name, agent_id, stufe_id, titel)
state.ereignis(name, agent_id, "start", titel)
options = ClaudeAgentOptions(
model=config.MODELL, effort=effort, cwd=str(state.verzeichnis(name)),
permission_mode="bypassPermissions", max_turns=max_turns,
system_prompt={"type": "preset", "preset": "claude_code", "append": SYSTEM_ZUSATZ},
)
letzter_text, kosten, tokens, status, fehler = "", 0.0, 0, "fehler", ""
try:
async for m in query(prompt=prompt, options=options):
if isinstance(m, AssistantMessage):
for b in m.content:
if isinstance(b, ToolUseBlock):
a["werkzeuge"] += 1
if a["werkzeuge"] % 5 == 1:
state.ereignis(name, agent_id, "tool", _kurz(b))
elif isinstance(b, TextBlock) and b.text.strip():
letzter_text = b.text
elif isinstance(m, ResultMessage):
kosten = m.total_cost_usd or 0.0
u = m.usage or {}
tokens = sum(int(u.get(k) or 0) for k in
("input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
if m.result and m.result.strip():
letzter_text = m.result
if m.is_error or m.subtype != "success":
fehler = f"{m.subtype}: " + "; ".join(str(e) for e in (m.errors or [])[:3])
else:
status = "fertig"
except asyncio.CancelledError:
state.agent_fertig(name, a, "abgebrochen", letzter_text, kosten, tokens)
state.ereignis(name, agent_id, "ende", "abgebrochen")
raise
except Exception as e: # ProcessError, CLIConnectionError, …
fehler = f"{type(e).__name__}: {e}"
log.exception("Agent %s", agent_id)
bericht = letzter_text.strip()
if status != "fertig":
bericht = f"**Fehler:** {fehler}\n\n{bericht}".strip()
state.agent_fertig(name, a, status, bericht, kosten, tokens)
if status == "fertig":
state.bericht_schreiben(name, agent_id, bericht)
state.ereignis(name, agent_id, "ende" if status == "fertig" else "fehler",
f"{status} · {kosten:.2f} USD · {a['werkzeuge']} Werkzeugaufrufe" + (f" · {fehler}" if fehler else ""))
return {"status": status, "bericht": bericht, "kosten": kosten, "tokens": tokens}

23
backend/config.py Normal file
View File

@@ -0,0 +1,23 @@
from pathlib import Path
WURZEL = Path(__file__).resolve().parent.parent
STORAGE = WURZEL / "storage"
PROMPTS = WURZEL / "prompts"
FRONTEND_DIST = WURZEL / "frontend" / "dist"
PORT = 8020
MODELL = "claude-opus-5-5"
EFFORT = "high"
PARALLEL = 5 # gleichzeitige Kapitel-Agenten
MAX_TURNS = 800
KONSISTENZ_GRUPPE = 5 # Kapitel je Querverweis-Agent
LESE_WPM = 180
STUFEN = [
("recherche", "Thema deuten und Quellen beschaffen"),
("gliederung", "Gliederung"),
("schreiben", "Kapitel schreiben"),
("pruefung", "Kapitel prüfen"),
("konsistenz", "Querverweise und Vorwort"),
("montage", "Buch zusammensetzen"),
]

52
backend/gliederung.py Normal file
View File

@@ -0,0 +1,52 @@
"""Parser für buch/GLIEDERUNG.md (Format siehe prompts/gliederung.md)."""
import re
KAPITEL_RE = re.compile(r"^\*\*Kapitel (\d+) – (.+?)\*\* \(`([0-9]{2}-[a-z0-9-]+\.md)`\)\s*$")
TEIL_RE = re.compile(r"^## (Teil .+?)\s*$")
TITEL_RE = re.compile(r"^# Gliederung – (.+?)(?:\. (.+))?$")
def parsen(text: str) -> dict:
"""→ {"titel", "untertitel", "kapitel": [{"n","titel","datei","teil","abschnitte":[{"nr","titel"}]}], "fehler": [...]}"""
aus = {"titel": "", "untertitel": "", "kapitel": [], "fehler": []}
teil = ""
zeilen = text.splitlines()
i = 0
while i < len(zeilen):
z = zeilen[i].rstrip()
m = TITEL_RE.match(z)
if m and not aus["titel"]:
aus["titel"], aus["untertitel"] = m.group(1).strip(), (m.group(2) or "").strip()
elif TEIL_RE.match(z):
teil = TEIL_RE.match(z).group(1)
elif KAPITEL_RE.match(z):
m = KAPITEL_RE.match(z)
n = int(m.group(1))
k = {"n": n, "titel": m.group(2).strip(), "datei": m.group(3), "teil": teil, "abschnitte": []}
# nächste nicht-leere Zeile: Abschnitte
j = i + 1
while j < len(zeilen) and not zeilen[j].strip():
j += 1
if j < len(zeilen):
for teilstueck in re.split(r"\s+·\s+", zeilen[j].strip()):
am = re.match(r"^(\d+)\.(\d+)\s+(.+)$", teilstueck.strip())
if am and int(am.group(1)) == n:
k["abschnitte"].append({"nr": f"{am.group(1)}.{am.group(2)}", "titel": am.group(3).strip()})
else:
aus["fehler"].append(f"Kapitel {n}: Abschnittszeile unlesbar: „{teilstueck.strip()[:60]}“")
i = j
if not k["datei"].startswith(f"{n:02d}-"):
aus["fehler"].append(f"Kapitel {n}: Dateiname muss mit {n:02d}- beginnen ({k['datei']})")
aus["kapitel"].append(k)
elif z.startswith("**Kapitel"):
aus["fehler"].append(f"Kapitelzeile nicht im Format: „{z[:80]}“")
i += 1
for k in aus["kapitel"]:
if not 3 <= len(k["abschnitte"]) <= 6:
aus["fehler"].append(f"Kapitel {k['n']} hat {len(k['abschnitte'])} Abschnitte (3–6 verlangt)")
nummern = [k["n"] for k in aus["kapitel"]]
if nummern != list(range(1, len(nummern) + 1)):
aus["fehler"].append(f"Kapitelnummern nicht fortlaufend: {nummern}")
if not aus["kapitel"]:
aus["fehler"].append("Keine Kapitelzeile gefunden")
return aus

123
backend/main.py Normal file
View File

@@ -0,0 +1,123 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import config, montage, pipeline, state
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
@asynccontextmanager
async def lifespan(app: FastAPI):
config.STORAGE.mkdir(exist_ok=True)
yield
for name in list(pipeline._laeufe):
await pipeline.pausieren(name)
app = FastAPI(title="buchwerk", lifespan=lifespan)
class ThemaNeu(BaseModel):
name: str
hinweise: str = ""
def _pruefen(name: str) -> dict:
if not state.name_ok(name) or not (t := state.thema(name)):
raise HTTPException(404, "Thema unbekannt")
return t
@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"]})
return aus
@app.post("/api/topics")
async def topic_anlegen(body: ThemaNeu):
name = body.name.strip()
if not state.name_ok(name):
raise HTTPException(400, "Ungültiger Name")
try:
return state.anlegen(name, body.hinweise)
except ValueError as e:
raise HTTPException(409, str(e))
@app.delete("/api/topics/{name}")
async def topic_loeschen(name: str):
_pruefen(name)
await pipeline.pausieren(name)
state.loeschen(name)
return {"ok": True}
@app.post("/api/topics/{name}/start")
async def start(name: str):
_pruefen(name)
if state.zustand(name)["status"] == "fertig":
raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen")
pipeline.starten(name)
return {"ok": True}
@app.post("/api/topics/{name}/pause")
async def pause(name: str):
_pruefen(name)
await pipeline.pausieren(name)
return {"ok": True}
@app.post("/api/topics/{name}/reset")
async def reset(name: str):
_pruefen(name)
await pipeline.pausieren(name)
state.zuruecksetzen(name)
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:]}
@app.get("/api/topics/{name}/guide")
async def guide(name: str):
_pruefen(name)
return montage.guide(name)
@app.get("/api/topics/{name}/guide.md", response_class=PlainTextResponse)
async def guide_md(name: str):
_pruefen(name)
return montage.markdown(name)
@app.get("/api/topics/{name}/buch.pdf")
async def buch_pdf(name: str):
_pruefen(name)
p = state.verzeichnis(name) / "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")
if config.FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=config.FRONTEND_DIST / "assets"), name="assets")
@app.get("/{pfad:path}")
async def spa(pfad: str):
return FileResponse(config.FRONTEND_DIST / "index.html")

186
backend/montage.py Normal file
View File

@@ -0,0 +1,186 @@
"""Buch aus den Kapiteldateien zusammensetzen: Guide-JSON, README, Gesamt-Markdown, PDF."""
import html
import logging
import re
import shutil
import subprocess
from . import config, gliederung, state
log = logging.getLogger("buchwerk")
ABSCHNITT_RE = re.compile(r"^## (\d+\.\d+) (.+?)\s*$", re.M)
def _woerter(text: str) -> int:
return len(re.sub(r"```.*?```", "", text, flags=re.S).split())
def _kapitel_lesen(v, k: dict) -> dict:
p = v / "buch" / k["datei"]
aus = {"id": f"{k['n']:02d}", "ord": k["n"], "titel": k["titel"], "teil": k["teil"], "datei": f"buch/{k['datei']}",
"status": "fehlt", "intro": "", "abschnitte": [], "woerter": 0, "minuten": 0,
"berichte": {"autor": state.bericht_lesen(v.name, f"kapitel-{k['n']:02d}"),
"pruefer": state.bericht_lesen(v.name, f"pruefung-{k['n']:02d}")}}
if not p.exists():
return aus
text = p.read_text()
text = re.sub(r"^# .*\n", "", text, count=1)
treffer = list(ABSCHNITT_RE.finditer(text))
aus["intro"] = text[:treffer[0].start()].strip() if treffer else text.strip()
for i, m in enumerate(treffer):
ende = treffer[i + 1].start() if i + 1 < len(treffer) else len(text)
inhalt = text[m.end():ende].strip()
w = _woerter(inhalt)
aus["abschnitte"].append({"id": f"{k['n']:02d}-{m.group(1).split('.')[1]}", "nr": m.group(1),
"titel": m.group(2), "text": inhalt, "woerter": w,
"minuten": round(w / config.LESE_WPM)})
aus["woerter"] = _woerter(text)
aus["minuten"] = round(aus["woerter"] / config.LESE_WPM)
st = state.zustand(v.name)
if state.stufe(v.name, "konsistenz")["status"] == "fertig":
aus["status"] = "konsistent"
elif aus["berichte"]["pruefer"]:
aus["status"] = "geprueft"
else:
aus["status"] = "geschrieben"
return aus
def guide(name: str) -> dict:
v = state.verzeichnis(name)
t = state.thema(name) or {}
d = state.deutung(name) or {}
gp = v / "buch" / "GLIEDERUNG.md"
g = gliederung.parsen(gp.read_text()) if gp.exists() else {"titel": "", "untertitel": "", "kapitel": []}
kapitel = [_kapitel_lesen(v, k) for k in g["kapitel"]]
vorwort = ""
vp = v / "buch" / "00-vorwort.md"
if vp.exists():
vorwort = re.sub(r"^# .*\n", "", vp.read_text(), count=1).strip()
qp = v / "quellen" / "QUELLEN.md"
woerter = sum(k["woerter"] for k in kapitel)
return {"titel": d.get("titel") or g["titel"] or t.get("titel") or name,
"untertitel": d.get("untertitel") or g["untertitel"] or "",
"status": state.zustand(name)["status"], "vorwort": vorwort, "kapitel": kapitel,
"woerter": woerter, "minuten": round(woerter / config.LESE_WPM),
"gliederung": gp.read_text() if gp.exists() else "",
"quellen": qp.read_text() if qp.exists() else "",
"pdf": (v / "buch" / "buch.pdf").exists()}
def markdown(name: str) -> str:
g = guide(name)
v = state.verzeichnis(name)
zeilen = [f"# {g['titel']}", ""]
if g["untertitel"]:
zeilen += [f"*{g['untertitel']}*", ""]
if g["vorwort"]:
zeilen += ["## Vorwort", "", g["vorwort"], ""]
teil = None
for k in g["kapitel"]:
p = v / "buch" / k["datei"].split("/", 1)[1]
if not p.exists():
continue
if k["teil"] and k["teil"] != teil:
teil = k["teil"]
zeilen += [f"# {teil}", ""]
zeilen += [p.read_text().strip(), ""]
return "\n".join(zeilen)
def readme(name: str) -> str:
g = guide(name)
out = [f"# {g['titel']}", ""]
if g["untertitel"]:
out += [f"*{g['untertitel']}*", ""]
out += ["PDF: [buch.pdf](buch.pdf) · Gesamttext: [buch.md](buch.md)", "", "## Inhalt", "", "[Vorwort](00-vorwort.md)", ""]
teil = None
for k in g["kapitel"]:
if k["teil"] and k["teil"] != teil:
teil = k["teil"]
out += [f"### {teil}", ""]
d = k["datei"].split("/", 1)[1]
out += [f"**[Kapitel {k['ord']} – {k['titel']}]({d})** ",
" · ".join(f"{a['nr']} {a['titel']}" for a in k["abschnitte"]), ""]
out += [f"*{len(g['kapitel'])} Kapitel, rund {round(g['woerter'], -3):,} Wörter.*".replace(",", "."), ""]
return "\n".join(out)
# ---- PDF ----------------------------------------------------------------------
CSS = """
@page{size:A5;margin:17mm 14mm 19mm 14mm;@bottom-center{content:counter(page);font:9pt "DejaVu Serif"}}
@page:first{@bottom-center{content:none}}
body{font-family:"DejaVu Serif",serif;font-size:9.3pt;line-height:1.42;text-align:left;hyphens:auto}
h1.chap{font-size:16pt;margin:0 0 9mm;page-break-before:always}
h2{font-size:11.8pt;margin:7mm 0 2mm;page-break-after:avoid}
h3{font-size:10pt;margin:4mm 0 1mm;page-break-after:avoid}
p{margin:0 0 2.2mm;orphans:2;widows:2}
code{font-family:"DejaVu Sans Mono",monospace;font-size:7.9pt;hyphens:none}
.codehilite,pre{background:#f5f5f2!important;border:0.4pt solid #ccc;padding:2mm;margin:2mm 0 3mm;border-radius:1mm}
pre{white-space:pre-wrap;word-wrap:break-word;font-size:7.3pt;line-height:1.3;margin:0;border:none;padding:0}
table{border-collapse:collapse;font-size:8pt;margin:3mm 0;width:100%}
td,th{border:0.4pt solid #888;padding:1.2mm;vertical-align:top;text-align:left}
ul,ol{padding-left:5mm} li{margin-bottom:1.2mm}
.title{page-break-after:always;text-align:center;padding-top:50mm}
.title h1{font-size:28pt;margin:0}.title p{font-size:12pt}
.part{page-break-before:always;page-break-after:always;text-align:center;padding-top:60mm}
.parttitle{font-size:19pt}
.toc{page-break-before:always}.toc ul{list-style:none;padding:0}
.tpart{font-weight:bold;margin-top:4mm}.tch a{color:black;text-decoration:none}
.tch a::after{content:leader(".") target-counter(attr(href),page)}
a{color:black}
"""
def pdf(name: str) -> bool:
exe = shutil.which("weasyprint")
if not exe:
log.warning("weasyprint fehlt – kein PDF")
return False
import markdown as md_mod
g = guide(name)
v = state.verzeichnis(name)
md = md_mod.Markdown(extensions=["tables", "sane_lists", "fenced_code", "codehilite"],
extension_configs={"codehilite": {"noclasses": True, "pygments_style": "friendly", "guess_lang": False}})
body, toc, teil = [], [], None
if g["vorwort"]:
body.append(f'<section class="chapter"><h1 id="k0" class="chap">Vorwort</h1>{md.convert(g["vorwort"])}</section>')
toc.append('<li class="tch"><a href="#k0">Vorwort</a></li>')
md.reset()
for k in g["kapitel"]:
p = v / "buch" / k["datei"].split("/", 1)[1]
if not p.exists():
continue
if k["teil"] and k["teil"] != teil:
teil = k["teil"]
tt = html.escape(teil).replace(" – ", "<br>", 1)
body.append(f'<section class="part"><h1 class="parttitle">{tt}</h1></section>')
toc.append(f'<li class="tpart">{html.escape(teil)}</li>')
aid = f"k{k['ord']}"
t = f"Kapitel {k['ord']} – {k['titel']}"
toc.append(f'<li class="tch"><a href="#{aid}">{html.escape(t)}</a></li>')
h = md.convert(p.read_text()).replace("<h1>", f'<h1 id="{aid}" class="chap">', 1)
h = h.replace("border: 1px solid #FF0000", "border: none")
body.append(f'<section class="chapter">{h}</section>')
md.reset()
doc = (f'<!doctype html><html lang="de"><head><meta charset="utf-8"><title>{html.escape(g["titel"])}</title>'
f'<style>{CSS}</style></head><body><div class="title"><h1>{html.escape(g["titel"])}</h1>'
f'<p>{html.escape(g["untertitel"])}</p></div><div class="toc"><h1 style="font-size:16pt">Inhalt</h1>'
f'<ul>{"".join(toc)}</ul></div>{"".join(body)}</body></html>')
hp, pp = v / "arbeit" / "buch.html", v / "buch" / "buch.pdf"
hp.parent.mkdir(exist_ok=True)
hp.write_text(doc)
r = subprocess.run([exe, str(hp), str(pp)], capture_output=True, text=True, timeout=900)
if r.returncode != 0:
log.error("weasyprint: %s", r.stderr[-500:])
return False
return True
def bauen(name: str) -> bool:
"""README, Gesamt-Markdown, PDF. Läuft in einem Thread – keine Zustandsänderungen hier."""
v = state.verzeichnis(name)
(v / "buch" / "README.md").write_text(readme(name))
(v / "buch" / "buch.md").write_text(markdown(name))
return pdf(name)

214
backend/pipeline.py Normal file
View File

@@ -0,0 +1,214 @@
"""Lauf-Orchestrierung: Stufen seriell, innerhalb einer Stufe parallele Agenten; jede Stufe resümierbar über
Dateimarker (Bericht existiert = Agent fertig)."""
import asyncio
import logging
from . import agenten, config, gliederung, montage, prompts, state
log = logging.getLogger("buchwerk")
_laeufe: dict[str, asyncio.Task] = {}
class Abbruch(Exception):
pass
def laeuft(name: str) -> bool:
t = _laeufe.get(name)
return bool(t and not t.done())
def starten(name: str) -> None:
if laeuft(name):
return
st = state.zustand(name)
st.update(status="laeuft", grund="")
state.speichern(name)
_laeufe[name] = asyncio.create_task(_lauf(name))
async def pausieren(name: str) -> None:
t = _laeufe.get(name)
if t and not t.done():
t.cancel()
try:
await t
except (asyncio.CancelledError, Exception):
pass
async def _lauf(name: str) -> None:
st = state.zustand(name)
try:
for sid, _ in config.STUFEN:
s = state.stufe(name, sid)
if s["status"] == "fertig":
continue
s.update(status="laeuft", gestartet=s["gestartet"] or state.jetzt(), beendet="")
st["stufe"] = sid
state.speichern(name)
await STUFEN_FN[sid](name)
s.update(status="fertig", beendet=state.jetzt())
state.speichern(name)
st.update(status="fertig", grund="", stufe="")
except asyncio.CancelledError:
st.update(status="pausiert", grund="Pausiert")
_offen(name)
state.speichern(name)
raise
except Exception as e:
log.exception("Lauf %s", name)
st.update(status="fehler", grund=f"{type(e).__name__}: {e}"[:500])
for s in st["stufen"]:
if s["status"] == "laeuft":
s["status"] = "fehler"
finally:
state.speichern(name)
def _offen(name: str) -> None:
for s in state.zustand(name)["stufen"]:
if s["status"] == "laeuft":
s["status"] = "offen"
def _thema(name: str) -> tuple[str, str]:
t = state.thema(name) or {}
return t.get("titel") or name, t.get("hinweise", "")
def _kapitel(name: str) -> list[dict]:
p = state.verzeichnis(name) / "buch" / "GLIEDERUNG.md"
g = gliederung.parsen(p.read_text())
if g["fehler"]:
raise Abbruch("Gliederung unlesbar: " + "; ".join(g["fehler"][:3]))
return g["kapitel"]
async def _parallel(name: str, sid: str, aufgaben: list) -> None:
"""aufgaben: [(agent_id, titel, prompt)], schon fertige sind vorher entfernt."""
sem = asyncio.Semaphore(config.PARALLEL)
s = state.stufe(name, sid)
fehler = []
async def eins(agent_id, titel, prompt):
async with sem:
r = await agenten.laufen(name, agent_id, sid, titel, prompt)
if r["status"] == "fertig":
s["fertig"] += 1
else:
fehler.append(f"{titel}: {r['bericht'][:120]}")
state.speichern(name)
await asyncio.gather(*(eins(*a) for a in aufgaben))
if fehler:
raise Abbruch(f"{len(fehler)} Agent(en) gescheitert – „Fortsetzen“ wiederholt nur die offenen: " + " | ".join(fehler[:3]))
# ---- Stufen -------------------------------------------------------------------
async def st_recherche(name: str) -> None:
thema, hinweise = _thema(name)
s = state.stufe(name, "recherche")
s.update(gesamt=1, fertig=0)
v = state.verzeichnis(name)
if not state.bericht_lesen(name, "recherche"):
p = prompts.laden("recherche", thema=thema, hinweise_block=prompts.hinweise_block(hinweise),
stil_basis=(config.PROMPTS / "stil_basis.md").read_text())
r = await agenten.laufen(name, "recherche", "recherche", "Thema deuten und Quellen beschaffen", p)
if r["status"] != "fertig":
raise Abbruch("Recherche gescheitert: " + r["bericht"][:200])
fehlend = [f for f in ("deutung.json", "buch/STIL.md", "quellen/QUELLEN.md") if not (v / f).exists()]
if fehlend:
(v / "berichte" / "recherche.md").unlink(missing_ok=True)
raise Abbruch("Recherche unvollständig, es fehlt: " + ", ".join(fehlend))
d = state.deutung(name)
state.zustand(name)["deutung"] = d
if d and d.get("titel"):
t = state.thema(name)
t["titel"] = d["titel"]
state._schreiben(v / "thema.json", t)
s["fertig"] = 1
async def st_gliederung(name: str) -> None:
thema, hinweise = _thema(name)
s = state.stufe(name, "gliederung")
s.update(gesamt=1, fertig=0)
p = state.verzeichnis(name) / "buch" / "GLIEDERUNG.md"
hinweis = ""
for versuch in (1, 2):
if p.exists() and not gliederung.parsen(p.read_text())["fehler"] and state.bericht_lesen(name, "gliederung"):
break
pr = prompts.laden("gliederung", thema=thema, hinweise_block=prompts.hinweise_block(hinweise)) + hinweis
r = await agenten.laufen(name, "gliederung", "gliederung", "Gliederung entwerfen", pr)
if r["status"] != "fertig":
raise Abbruch("Gliederung gescheitert: " + r["bericht"][:200])
g = gliederung.parsen(p.read_text()) if p.exists() else {"fehler": ["Datei fehlt"]}
if not g["fehler"]:
break
hinweis = ("\n\nFORMATFEHLER im vorigen Versuch – bitte buch/GLIEDERUNG.md exakt im vorgegebenen Format neu schreiben:\n- "
+ "\n- ".join(g["fehler"][:10]))
state.ereignis(name, "gliederung", "fehler", "Format ungültig, zweiter Versuch")
else:
raise Abbruch("Gliederung auch im zweiten Versuch nicht parsebar")
s["fertig"] = 1
async def st_schreiben(name: str) -> None:
thema, _ = _thema(name)
kap = _kapitel(name)
s = state.stufe(name, "schreiben")
offen = [k for k in kap if not state.bericht_lesen(name, f"kapitel-{k['n']:02d}")]
s.update(gesamt=len(kap), fertig=len(kap) - len(offen))
aufgaben = []
for k in offen:
p = prompts.laden("kapitel", thema=thema, n=str(k["n"]), kapitel_titel=k["titel"], datei=k["datei"])
if (state.verzeichnis(name) / "buch" / k["datei"]).exists():
p += "\n\nHinweis: `buch/" + k["datei"] + "` existiert bereits aus einem abgebrochenen Lauf – prüfe sie und vervollständige oder ersetze sie."
aufgaben.append((f"kapitel-{k['n']:02d}", f"Kapitel {k['n']} schreiben", p))
await _parallel(name, "schreiben", aufgaben)
async def st_pruefung(name: str) -> None:
thema, _ = _thema(name)
kap = _kapitel(name)
s = state.stufe(name, "pruefung")
offen = [k for k in kap if not state.bericht_lesen(name, f"pruefung-{k['n']:02d}")]
s.update(gesamt=len(kap), fertig=len(kap) - len(offen))
aufgaben = []
for k in offen:
autor = state.bericht_lesen(name, f"kapitel-{k['n']:02d}") or "(kein Autorenbericht vorhanden)"
p = prompts.laden("pruefung", thema=thema, n=str(k["n"]), kapitel_titel=k["titel"], datei=k["datei"], autor_bericht=autor)
aufgaben.append((f"pruefung-{k['n']:02d}", f"Kapitel {k['n']} prüfen", p))
await _parallel(name, "pruefung", aufgaben)
async def st_konsistenz(name: str) -> None:
thema, _ = _thema(name)
kap = _kapitel(name)
gruppen = [kap[i:i + config.KONSISTENZ_GRUPPE] for i in range(0, len(kap), config.KONSISTENZ_GRUPPE)]
s = state.stufe(name, "konsistenz")
aufgaben = []
for gi, g in enumerate(gruppen, 1):
if state.bericht_lesen(name, f"konsistenz-{gi}"):
continue
dateien = ", ".join(f"`buch/{k['datei']}` (Kapitel {k['n']})" for k in g)
aufgaben.append((f"konsistenz-{gi}", f"Querverweise Kapitel {g[0]['n']}–{g[-1]['n']}",
prompts.laden("konsistenz", thema=thema, dateien=dateien, gruppe=str(gi))))
if not state.bericht_lesen(name, "vorwort"):
aufgaben.append(("vorwort", "Vorwort schreiben", prompts.laden("vorwort", thema=thema)))
s.update(gesamt=len(gruppen) + 1, fertig=len(gruppen) + 1 - len(aufgaben))
await _parallel(name, "konsistenz", aufgaben)
async def st_montage(name: str) -> None:
s = state.stufe(name, "montage")
s.update(gesamt=1, fertig=0)
ok = await asyncio.to_thread(montage.bauen, name)
state.ereignis(name, "montage", "text" if ok else "fehler", "README, buch.md und PDF erzeugt" if ok else "README und buch.md erzeugt, PDF fehlgeschlagen")
s["fertig"] = 1
STUFEN_FN = {"recherche": st_recherche, "gliederung": st_gliederung, "schreiben": st_schreiben,
"pruefung": st_pruefung, "konsistenz": st_konsistenz, "montage": st_montage}

13
backend/prompts.py Normal file
View File

@@ -0,0 +1,13 @@
"""Prompts liegen als Markdown in prompts/, Platzhalter {{name}}."""
from . import config
def laden(name: str, **werte: str) -> str:
text = (config.PROMPTS / f"{name}.md").read_text()
for k, v in werte.items():
text = text.replace("{{" + k + "}}", v)
return text
def hinweise_block(hinweise: str) -> str:
return f"Hinweise des Auftraggebers (verbindlich): {hinweise}\n" if hinweise else ""

5
backend/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
claude-agent-sdk
fastapi
uvicorn[standard]
markdown
pygments

169
backend/state.py Normal file
View File

@@ -0,0 +1,169 @@
"""Zustand je Thema: storage/<name>/{thema.json,state.json,log.jsonl}. 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(name: str) -> Path:
return config.STORAGE / 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,
"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) -> dict:
v = verzeichnis(name)
if v.exists():
raise ValueError("Thema existiert schon")
for u in ("quellen", "buch", "berichte", "arbeit"):
(v / u).mkdir(parents=True)
t = {"name": name, "titel": name, "hinweise": hinweise.strip(), "angelegt": jetzt()}
_schreiben(v / "thema.json", t)
_schreiben(v / "state.json", leerer_zustand())
return t
def loeschen(name: str) -> None:
_cache.pop(name, None)
shutil.rmtree(verzeichnis(name), ignore_errors=True)
def zuruecksetzen(name: str) -> None:
v = verzeichnis(name)
t = _lesen(v / "thema.json", None)
_cache.pop(name, 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"):
(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 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"
_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 "kapitel"