init
This commit is contained in:
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
74
backend/agenten.py
Normal file
74
backend/agenten.py
Normal 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
23
backend/config.py
Normal 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
52
backend/gliederung.py
Normal 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
123
backend/main.py
Normal 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
186
backend/montage.py
Normal 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
214
backend/pipeline.py
Normal 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
13
backend/prompts.py
Normal 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
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
claude-agent-sdk
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
markdown
|
||||
pygments
|
||||
169
backend/state.py
Normal file
169
backend/state.py
Normal 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"
|
||||
Reference in New Issue
Block a user