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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.venv/
storage/
frontend/node_modules/
frontend/dist/
__pycache__/
.env

81
API.md Normal file
View File

@@ -0,0 +1,81 @@
# buchwerk – Schnittstelle Backend ↔ Frontend
Backend: FastAPI auf Port 8020, liefert `frontend/dist/` aus. Alle Pfade unter `/api`.
## Themen
- `GET /api/topics` → `[{name, titel, status, laeuft, stufe}]`
- `status`: `neu | laeuft | pausiert | fertig | fehler`
- `POST /api/topics` `{name, hinweise}` → Topic (name = Thema, z. B. „Godot 4“; hinweise optional, z. B. „für Entwickler, ab Version 4.6“)
- `DELETE /api/topics/{name}`
- `POST /api/topics/{name}/start` – startet oder setzt fort (resümiert an der Stufe, die noch offen ist)
- `POST /api/topics/{name}/pause` – bricht laufende Agenten ab; Neustart mit `start`
- `POST /api/topics/{name}/reset` – löscht alles außer `thema.json`
## Zustand eines Laufs
`GET /api/topics/{name}/state` →
```json
{
"name": "Godot 4", "titel": "Godot 4", "hinweise": "…",
"status": "laeuft", "grund": "", "stufe": "schreiben",
"deutung": {"titel": "Godot 4", "untertitel": "…", "deutung": "…", "buchtyp": "werkzeug|wissen", "zielgruppe": "…"},
"stufen": [
{"id": "recherche", "titel": "Thema deuten und Quellen beschaffen", "status": "fertig", "fertig": 1, "gesamt": 1,
"kosten_usd": 1.2, "tokens": 250000, "gestartet": "2026-09-28T10:00:00Z", "beendet": "…"},
{"id": "gliederung", "titel": "Gliederung", …},
{"id": "schreiben", "titel": "Kapitel schreiben", "status": "laeuft", "fertig": 3, "gesamt": 18, …},
{"id": "pruefung", "titel": "Kapitel prüfen", …},
{"id": "konsistenz", "titel": "Querverweise und Vorwort", …},
{"id": "montage", "titel": "Buch zusammensetzen", …}
],
"kosten_usd": 12.3, "tokens": 4100000,
"agenten": [
{"id": "schreiben-03", "stufe": "schreiben", "name": "Kapitel 3 schreiben", "status": "laeuft|fertig|fehler|abgebrochen",
"kosten_usd": 0.9, "tokens": 180000, "werkzeuge": 41, "gestartet": "…", "beendet": "…", "bericht": "<Abschlussbericht des Agenten, Markdown>"}
],
"ereignisse": [{"zeit": "…", "agent": "schreiben-03", "art": "tool|text|start|ende|fehler", "text": "Write buch/03-dal.md"}]
}
```
`stufen[].status`: `offen | laeuft | fertig | fehler`. `ereignisse` sind die letzten 100.
## Buch
`GET /api/topics/{name}/guide` →
```json
{
"titel": "Godot 4", "untertitel": "Architektur, Konzepte und Praxis der Engine", "status": "fertig",
"vorwort": "<Markdown ohne H1>",
"kapitel": [
{"id": "03", "ord": 3, "titel": "GDScript", "teil": "Teil I – Grundlagen", "datei": "buch/03-gdscript.md",
"status": "fehlt|geschrieben|geprueft|konsistent", "intro": "<Markdown vor dem ersten Abschnitt>",
"abschnitte": [{"id": "03-1", "nr": "3.1", "titel": "Sprache und Syntax", "text": "<Markdown ohne die ##-Überschrift>", "woerter": 1400, "minuten": 8}],
"woerter": 7000, "minuten": 39,
"berichte": {"autor": "<Markdown>", "pruefer": "<Markdown>"}}
],
"woerter": 130000, "minuten": 720,
"gliederung": "<GLIEDERUNG.md>", "quellen": "<QUELLEN.md, Markdown>",
"pdf": true
}
```
- `GET /api/topics/{name}/guide.md` – ganzes Buch als Markdown
- `GET /api/topics/{name}/buch.pdf` – PDF (404, solange nicht gebaut)
Solange ein Lauf läuft, enthält `guide` die bereits geschriebenen Kapitel (Status `geschrieben` bis der Prüfer fertig ist).
## Dateien im Speicher
```
storage/<name>/
thema.json {name, titel, hinweise, angelegt}
state.json Zustand wie oben (ohne guide)
deutung.json vom Recherche-Agenten
quellen/ QUELLEN.md + geklonte/geladene Quellen
buch/ STIL.md, GLIEDERUNG.md, 00-vorwort.md, NN-*.md, README.md, buch.md, buch.pdf
berichte/ <agent-id>.md (Abschlussberichte)
log.jsonl Ereignisse
```

15
Makefile Normal file
View File

@@ -0,0 +1,15 @@
PY := .venv/bin/python
install:
python3 -m venv .venv
.venv/bin/pip install -r backend/requirements.txt
cd frontend && npm install
build:
cd frontend && npm run build
run:
$(PY) -m uvicorn backend.main:app --port 8020
dev:
$(PY) -m uvicorn backend.main:app --reload --port 8020 & cd frontend && npm run dev

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
# buchwerk
Aus einem Thema (plus optionalen Hinweisen) entsteht ein deutschsprachiges Sachbuch. Ein Python-Backend
steuert Agenten des Claude Agent SDK (Opus 5.5): Recherche und Deutung, Gliederung, Kapitel parallel
schreiben, jedes Kapitel durch einen unabhängigen Agenten prüfen, Querverweise abgleichen, Vorwort,
Montage (Markdown, README, PDF). Das Buch wird im Browser als Leseansicht gezeigt.
```bash
make install # venv + npm
make build # Frontend bauen
make run # http://localhost:8020
```
Auth: der Agent SDK nutzt den Claude-Code-Login des Rechners (oder ANTHROPIC_API_KEY, falls gesetzt).
Ablauf (backend/pipeline.py): Stufen seriell, innerhalb einer Stufe bis zu `PARALLEL` Agenten gleichzeitig.
Jeder Agent arbeitet im Themenverzeichnis `storage/<Thema>/`; sein Abschlussbericht landet unter `berichte/`
und dient als Marker: existiert der Bericht, gilt die Aufgabe als erledigt. Pause bricht laufende Agenten
ab, „Fortsetzen“ wiederholt nur, was noch fehlt. Prompts liegen in `prompts/`, Schnittstelle in `API.md`.

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"

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>buchwerk</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

133
frontend/mock/server.js Normal file
View File

@@ -0,0 +1,133 @@
// Kleiner statischer Mock des Backends laut API.md für den Sichttest: node mock/server.js (Port 8020, PORT überschreibt)
import http from 'node:http'
const PORT = Number(process.env.PORT || 8020)
const jetzt = Date.now()
const iso = (minVor) => new Date(jetzt - minVor * 60000).toISOString()
const topics = [
{ name: 'Godot 4', titel: 'Godot 4', status: 'laeuft', laeuft: true, stufe: 'schreiben' },
{ name: 'Symfony 7', titel: 'Symfony 7', status: 'neu', laeuft: false, stufe: '' },
]
const bericht = `## Ergebnis
Kapitel 3 geschrieben, 4 Abschnitte, 6 900 Wörter.
- Quellen: \`quellen/godot-docs/tutorials/scripting/gdscript/\`
- Offen: Abschnitt 3.4 braucht ein Beispiel zu \`await\`
| Abschnitt | Wörter |
|---|---|
| 3.1 | 1400 |
| 3.2 | 1900 |
`
const state = {
name: 'Godot 4', titel: 'Godot 4', hinweise: 'für Entwickler, ab Version 4.6',
status: 'laeuft', grund: '', stufe: 'schreiben',
deutung: { titel: 'Godot 4', untertitel: 'Architektur, Konzepte und Praxis der Engine', buchtyp: 'werkzeug',
zielgruppe: 'Entwickler mit Programmiererfahrung, neu in Godot', referenz: 'Godot 4.6, offizielle Dokumentation',
deutung: 'Ein Werkzeugbuch: erklärt die Engine entlang ihrer Konzepte (Szenenbaum, Signale, GDScript) und zeigt jeweils die Praxis.' },
stufen: [
{ id: 'recherche', titel: 'Thema deuten und Quellen beschaffen', status: 'fertig', fertig: 1, gesamt: 1, kosten_usd: 1.2, tokens: 250000, gestartet: iso(60), beendet: iso(52) },
{ id: 'gliederung', titel: 'Gliederung', status: 'fertig', fertig: 1, gesamt: 1, kosten_usd: 0.8, tokens: 120000, gestartet: iso(52), beendet: iso(45) },
{ id: 'schreiben', titel: 'Kapitel schreiben', status: 'laeuft', fertig: 3, gesamt: 18, kosten_usd: 4.1, tokens: 1100000, gestartet: iso(45), beendet: '' },
{ id: 'pruefung', titel: 'Kapitel prüfen', status: 'laeuft', fertig: 1, gesamt: 18, kosten_usd: 0.9, tokens: 300000, gestartet: iso(30), beendet: '' },
{ id: 'konsistenz', titel: 'Querverweise und Vorwort', status: 'offen', fertig: 0, gesamt: 1, kosten_usd: 0, tokens: 0, gestartet: '', beendet: '' },
{ id: 'montage', titel: 'Buch zusammensetzen', status: 'offen', fertig: 0, gesamt: 1, kosten_usd: 0, tokens: 0, gestartet: '', beendet: '' },
],
kosten_usd: 7.0, tokens: 1770000,
agenten: [
{ id: 'recherche', stufe: 'recherche', name: 'Recherche', status: 'fertig', kosten_usd: 1.2, tokens: 250000, werkzeuge: 58, gestartet: iso(60), beendet: iso(52), bericht: '## Quellen\n\n12 Quellen geladen, davon 9 brauchbar.' },
{ id: 'gliederung', stufe: 'gliederung', name: 'Gliederung', status: 'fertig', kosten_usd: 0.8, tokens: 120000, werkzeuge: 14, gestartet: iso(52), beendet: iso(45), bericht: 'Gliederung mit 18 Kapiteln in 4 Teilen.' },
{ id: 'schreiben-01', stufe: 'schreiben', name: 'Kapitel 1 schreiben', status: 'fertig', kosten_usd: 0.9, tokens: 180000, werkzeuge: 41, gestartet: iso(45), beendet: iso(38), bericht },
{ id: 'schreiben-02', stufe: 'schreiben', name: 'Kapitel 2 schreiben', status: 'fehler', kosten_usd: 0.3, tokens: 60000, werkzeuge: 9, gestartet: iso(45), beendet: iso(42), bericht: '' },
{ id: 'schreiben-03', stufe: 'schreiben', name: 'Kapitel 3 schreiben', status: 'fertig', kosten_usd: 1.1, tokens: 220000, werkzeuge: 52, gestartet: iso(38), beendet: iso(30), bericht },
{ id: 'schreiben-04', stufe: 'schreiben', name: 'Kapitel 4 schreiben', status: 'laeuft', kosten_usd: 0.4, tokens: 90000, werkzeuge: 17, gestartet: iso(8), beendet: '', bericht: '' },
{ id: 'pruefung-01', stufe: 'pruefung', name: 'Kapitel 1 prüfen', status: 'fertig', kosten_usd: 0.5, tokens: 150000, werkzeuge: 22, gestartet: iso(30), beendet: iso(20), bericht: '## Prüfung\n\nAlle Aussagen belegt. Zwei Formulierungen korrigiert.' },
{ id: 'pruefung-03', stufe: 'pruefung', name: 'Kapitel 3 prüfen', status: 'laeuft', kosten_usd: 0.4, tokens: 150000, werkzeuge: 30, gestartet: iso(12), beendet: '', bericht: '' },
],
ereignisse: Array.from({ length: 100 }, (_, i) => ({
zeit: iso(100 - i), agent: i % 3 ? 'schreiben-04' : 'pruefung-03',
art: ['tool', 'text', 'start', 'ende', 'fehler'][i % 5],
text: i % 5 === 0 ? 'Write buch/04-szenen.md' : i % 5 === 1 ? 'Ich lese jetzt die Dokumentation zum Szenenbaum und gleiche die Signale ab.' : `Ereignis ${i}`,
})),
}
const text = (n) => Array.from({ length: n }, (_, i) => `Absatz ${i + 1}: Der Szenenbaum ist die zentrale Datenstruktur von Godot. Jeder Knoten hat genau einen Elternknoten, und Signale verbinden Knoten lose miteinander.`).join('\n\n')
const code = '\n\n```gdscript\nextends Node2D\n\nsignal getroffen(schaden: int)\n\nfunc _ready() -> void:\n\tgetroffen.connect(_on_getroffen)\n\nfunc _on_getroffen(schaden: int) -> void:\n\tprint("Schaden: %d" % schaden)\n```\n\n| Knoten | Zweck |\n|---|---|\n| Node2D | 2D-Transform |\n| Control | UI |\n\n'
const kap = (ord, titel, teil, status, n) => ({
id: String(ord).padStart(2, '0'), ord, titel, teil, datei: `buch/${String(ord).padStart(2, '0')}.md`, status,
intro: status === 'fehlt' ? '' : `Worum es in Kapitel ${ord} geht: ${titel}. Dieses Kapitel führt in die Grundlagen ein.`,
abschnitte: status === 'fehlt' ? [] : Array.from({ length: n }, (_, i) => ({
id: `${String(ord).padStart(2, '0')}-${i + 1}`, nr: `${ord}.${i + 1}`, titel: `Abschnitt ${i + 1} von ${titel}`,
text: text(4) + (i === 0 ? code : ''), woerter: 1400, minuten: 8 })),
woerter: status === 'fehlt' ? 0 : n * 1400, minuten: status === 'fehlt' ? 0 : n * 8,
berichte: status === 'fehlt' ? { autor: '', pruefer: '' } : { autor: bericht, pruefer: status === 'geschrieben' ? '' : '## Prüfung\n\n- 3.1: belegt\n- 3.2: eine Zahl korrigiert' },
})
const guide = {
titel: 'Godot 4', untertitel: 'Architektur, Konzepte und Praxis der Engine', status: 'laeuft',
vorwort: 'Dieses Buch erklärt Godot 4 entlang seiner Konzepte.\n\nEs richtet sich an Entwickler, die die Engine **verstehen** wollen, nicht nur bedienen.',
kapitel: [
kap(1, 'Die Engine im Überblick', 'Teil I – Grundlagen', 'konsistent', 3),
kap(2, 'Szenen und Knoten', 'Teil I – Grundlagen', 'fehlt', 0),
kap(3, 'GDScript', 'Teil I – Grundlagen', 'geprueft', 4),
kap(4, 'Signale', 'Teil II – Praxis', 'geschrieben', 2),
],
woerter: 12600, minuten: 72,
gliederung: '# Gliederung\n\n## Teil I – Grundlagen\n\n1. Die Engine im Überblick\n2. Szenen und Knoten\n3. GDScript\n\n## Teil II – Praxis\n\n4. Signale',
quellen: '# Quellen\n\n- Godot-Dokumentation 4.6 (docs.godotengine.org)\n- Engine-Quelltext, Tag 4.6-stable\n\n## Prüfmethode\n\nJedes Kapitel wird von einem unabhängigen Agenten gegen die Quellen geprüft.',
pdf: false,
}
function json(res, code, body) {
res.writeHead(code, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
http.createServer((req, res) => {
const url = new URL(req.url, 'http://x')
const p = url.pathname
let m
if (req.method === 'GET' && p === '/api/topics') return json(res, 200, topics)
if (req.method === 'POST' && p === '/api/topics') {
let body = ''
req.on('data', (c) => body += c)
return req.on('end', () => {
const { name } = JSON.parse(body || '{}')
const t = { name, titel: name, status: 'neu', laeuft: false, stufe: '' }
topics.push(t); json(res, 200, t)
})
}
if ((m = p.match(/^\/api\/topics\/([^/]+)$/)) && req.method === 'DELETE') {
const i = topics.findIndex((t) => t.name === decodeURIComponent(m[1]))
if (i >= 0) topics.splice(i, 1)
return json(res, 200, { ok: true })
}
if ((m = p.match(/^\/api\/topics\/([^/]+)\/(start|pause|reset)$/)) && req.method === 'POST') {
const t = topics.find((t) => t.name === decodeURIComponent(m[1]))
if (!t) return json(res, 404, { detail: 'unbekanntes Thema' })
t.status = m[2] === 'start' ? 'laeuft' : m[2] === 'pause' ? 'pausiert' : 'neu'; t.laeuft = t.status === 'laeuft'
if (t.name === state.name) state.status = t.status
return json(res, 200, { ok: true })
}
if ((m = p.match(/^\/api\/topics\/([^/]+)\/state$/))) {
const name = decodeURIComponent(m[1])
const t = topics.find((t) => t.name === name)
if (!t) return json(res, 404, { detail: 'unbekanntes Thema' })
if (name === state.name) return json(res, 200, state)
return json(res, 200, { name, titel: t.titel, hinweise: '', status: t.status, grund: '', stufe: '', deutung: null,
stufen: state.stufen.map((s) => ({ ...s, status: 'offen', fertig: 0, kosten_usd: 0, tokens: 0, gestartet: '', beendet: '' })),
kosten_usd: 0, tokens: 0, agenten: [], ereignisse: [] })
}
if ((m = p.match(/^\/api\/topics\/([^/]+)\/guide$/))) {
const name = decodeURIComponent(m[1])
if (name === state.name) return json(res, 200, guide)
return json(res, 200, { titel: name, untertitel: '', status: 'neu', vorwort: '', kapitel: [], woerter: 0, minuten: 0, gliederung: '', quellen: '', pdf: false })
}
if ((m = p.match(/^\/api\/topics\/([^/]+)\/guide\.md$/))) {
res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' }); return res.end('# ' + guide.titel + '\n')
}
if ((m = p.match(/^\/api\/topics\/([^/]+)\/buch\.pdf$/))) return json(res, 404, { detail: 'PDF noch nicht gebaut' })
json(res, 404, { detail: 'nicht gefunden: ' + p })
}).listen(PORT, () => console.log(`Mock-Backend auf http://localhost:${PORT}`))

1461
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "buchwerk-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5190",
"build": "vite build"
},
"dependencies": {
"dompurify": "^3.2.0",
"highlight.js": "^11.11.0",
"markdown-it": "^14.1.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.0"
}
}

153
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,153 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { api } from './api.js'
import Lauf from './components/Lauf.vue'
import Guide from './components/Guide.vue'
const params = new URLSearchParams(location.search)
const topics = ref([])
const topic = ref(params.get('topic') || localStorage.getItem('topic') || '')
const tab = ref((params.get('tab') || localStorage.getItem('tab')) === 'buch' ? 'buch' : 'lauf')
const theme = ref(localStorage.getItem('theme') || 'papier')
const state = ref(null)
const fehler = ref('')
const neuName = ref('')
const neuHinweise = ref('')
const navAuf = ref(false)
let timer = null
const media = window.matchMedia('(prefers-color-scheme: light)')
function themeAnwenden() {
const hell = theme.value === 'hell' || (theme.value === 'auto' && media.matches)
document.documentElement.dataset.theme = theme.value === 'papier' ? 'sepia' : hell ? 'light' : 'dark'
localStorage.setItem('theme', theme.value)
}
watch(theme, themeAnwenden)
media.addEventListener('change', themeAnwenden)
themeAnwenden()
// URL spiegelt Thema und Tab; beim Themenwechsel fällt der Deep-Link (#a…/#k…) weg
function urlSchreiben(hashBehalten) {
const p = new URLSearchParams()
if (topic.value) p.set('topic', topic.value)
p.set('tab', tab.value)
history.replaceState(null, '', location.pathname + '?' + p + (hashBehalten ? location.hash : ''))
}
watch(topic, (t) => { localStorage.setItem('topic', t); urlSchreiben(false) })
watch(tab, (t) => { localStorage.setItem('tab', t); urlSchreiben(true) })
const aktuell = computed(() => topics.value.find((t) => t.name === topic.value))
const laeuft = computed(() => state.value?.status === 'laeuft')
const fortschritt = computed(() => (state.value?.stufen || []).reduce((n, s) => n + (s.fertig || 0), 0))
async function topicsLaden() {
try {
topics.value = await api.topics()
fehler.value = ''
if (topic.value && !topics.value.some((t) => t.name === topic.value)) topic.value = ''
if (!topic.value && topics.value.length) topic.value = topics.value[0].name
} catch (e) {
// Server gerade nicht erreichbar (z. B. Neustart): alle 3 s erneut versuchen, bis er antwortet
fehler.value = 'Server nicht erreichbar — versuche es weiter …'
setTimeout(async () => { await topicsLaden(); if (!fehler.value) { await stateLaden(); if (laeuft.value) pollen() } }, 3000)
}
}
async function stateLaden() {
if (!topic.value) { state.value = null; return }
try { state.value = await api.state(topic.value) } catch (e) { fehler.value = String(e.message || e) }
}
function pollen() {
clearInterval(timer)
timer = setInterval(async () => {
if (!topic.value) return
await stateLaden()
if (!laeuft.value) { clearInterval(timer); timer = null; await topicsLaden() }
}, 2000)
}
watch(topic, async () => { await stateLaden(); if (laeuft.value) pollen() })
watch(laeuft, (l) => { if (l && !timer) pollen() })
async function anlegen() {
const name = neuName.value.trim()
if (!name) return
try {
await api.anlegen(name, neuHinweise.value.trim())
neuName.value = ''
neuHinweise.value = ''
await topicsLaden()
topic.value = name
tab.value = 'lauf'
} catch (e) { fehler.value = String(e.message || e) }
}
async function aktion(fn) {
try { await fn(); await stateLaden(); await topicsLaden(); if (laeuft.value) pollen() }
catch (e) { fehler.value = String(e.message || e) }
}
async function loeschen(name) {
if (!confirm(`Thema „${name}“ samt Buch löschen?`)) return
await aktion(() => api.loeschen(name))
}
function esc(e) { if (e.key === 'Escape') navAuf.value = false }
onMounted(async () => { window.addEventListener('keydown', esc); urlSchreiben(true); await topicsLaden(); await stateLaden(); if (laeuft.value) pollen() })
onUnmounted(() => { clearInterval(timer); window.removeEventListener('keydown', esc) })
const statusBadge = computed(() => {
const s = state.value?.status
return { fertig: ['gruen', 'fertig'], laeuft: ['blau', 'läuft'], pausiert: ['gelb', 'pausiert'],
fehler: ['rot', 'Fehler'], neu: ['', 'neu'] }[s] || ['', s || '']
})
function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n || 0) }
</script>
<template>
<div class="app" :class="{ 'tab-buch': tab === 'buch' }">
<div v-if="navAuf" class="backdrop" @click="navAuf = false"></div>
<aside class="sidebar" :class="{ offen: navAuf }">
<button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button>
<div class="topics">
<div v-for="t in topics" :key="t.name" class="topic-eintrag" :class="{ aktiv: t.name === topic }"
@click="topic = t.name; navAuf = false">
<span class="punkt" :class="t.laeuft ? 'laeuft' : t.status"></span>
<span style="flex: 1">{{ t.titel }}</span>
<button class="klein sekundaer" title="Löschen" @click.stop="loeschen(t.name)">✕</button>
</div>
<div v-if="!topics.length" style="color: var(--dim); padding: 8px">Noch kein Thema.</div>
</div>
<div class="neu">
<input v-model="neuName" placeholder="Thema, z. B. Godot 4" @keyup.enter="anlegen" />
<input v-model="neuHinweise" placeholder="Hinweise (optional), z. B. für Entwickler, ab Version 4.6" @keyup.enter="anlegen" />
<button @click="anlegen">Anlegen</button>
</div>
</aside>
<div class="haupt">
<div class="topbar">
<button class="burger sekundaer klein" title="Themen" @click="navAuf = !navAuf">☰</button>
<span class="titel">{{ aktuell?.titel || '' }}</span>
<div class="tabs">
<button :class="{ aktiv: tab === 'lauf' }" @click="tab = 'lauf'">Lauf</button>
<button :class="{ aktiv: tab === 'buch' }" @click="tab = 'buch'">Buch</button>
</div>
<span v-if="state" class="badge" :class="statusBadge[0]">{{ statusBadge[1] }}</span>
<span v-if="tab === 'lauf' && state?.tokens" class="badge">{{ (state.kosten_usd || 0).toFixed(2) }} $ · {{ tokens(state.tokens) }} Tokens</span>
<span class="spacer"></span>
<select v-model="theme" title="Farbschema">
<option value="papier">papier</option>
<option value="dunkel">dunkel</option>
<option value="hell">hell</option>
<option value="auto">auto</option>
</select>
</div>
<div v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }} — klicken zum Schließen</div>
<div class="inhalt">
<Lauf v-if="tab === 'lauf' && state" :state="state"
@start="aktion(() => api.start(topic))"
@pause="aktion(() => api.pause(topic))"
@reset="aktion(() => api.reset(topic))" />
<Guide v-else-if="tab === 'buch' && topic" :topic="topic" :laeuft="laeuft" :fortschritt="fortschritt" />
<div v-else style="padding: 24px; color: var(--dim)">Thema links anlegen oder wählen.</div>
</div>
</div>
</div>
</template>

28
frontend/src/api.js Normal file
View File

@@ -0,0 +1,28 @@
async function req(pfad, opts = {}) {
const r = await fetch('/api' + pfad, {
headers: { 'content-type': 'application/json' },
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
})
if (!r.ok) {
let detail = r.statusText
try { detail = (await r.json()).detail || detail } catch { /* leer */ }
throw new Error(detail)
}
const ct = r.headers.get('content-type') || ''
return ct.includes('json') ? r.json() : r.text()
}
const e = encodeURIComponent
export const api = {
topics: () => req('/topics'),
anlegen: (name, hinweise = '') => req('/topics', { method: 'POST', body: { name, hinweise } }),
loeschen: (t) => req(`/topics/${e(t)}`, { method: 'DELETE' }),
start: (t) => req(`/topics/${e(t)}/start`, { method: 'POST' }),
pause: (t) => req(`/topics/${e(t)}/pause`, { method: 'POST' }),
reset: (t) => req(`/topics/${e(t)}/reset`, { method: 'POST' }),
state: (t) => req(`/topics/${e(t)}/state`),
guide: (t) => req(`/topics/${e(t)}/guide`),
guideMdUrl: (t) => `/api/topics/${e(t)}/guide.md`,
pdfUrl: (t) => `/api/topics/${e(t)}/buch.pdf`,
}

View File

@@ -0,0 +1,174 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { api } from '../api.js'
import { render, zeit } from '../markdown.js'
import Lesemodus from './Lesemodus.vue'
const props = defineProps({ topic: String, laeuft: Boolean, fortschritt: Number })
const daten = ref(null)
const lesemodus = ref(false)
const navAuf = ref(false)
// Wischgeste (nur Buchansicht, nicht im Lesemodus): nach rechts öffnet das Inhaltsverzeichnis, nach links schließt es
let wisch = null
function wischStart(e) {
const t = e.touches[0]
wisch = { x: t.clientX, y: t.clientY, t: Date.now() }
}
function wischEnde(e) {
if (!wisch || lesemodus.value || window.innerWidth > 800) return
const t = e.changedTouches[0]
const dx = t.clientX - wisch.x, dy = t.clientY - wisch.y, dauer = Date.now() - wisch.t
wisch = null
if (dauer > 800 || Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.6) return
navAuf.value = dx > 0
}
const aktiv = ref('')
const lese = ref(null)
let beobachter = null
let ladeSeq = 0
const cache = new Map() // Schlüssel: Abschnitt + Textlänge → unveränderte Abschnitte werden nicht neu gerendert
onUnmounted(() => { beobachter?.disconnect() })
async function laden() {
if (!props.topic) { daten.value = null; return }
const seq = ++ladeSeq
const d = await api.guide(props.topic)
if (seq !== ladeSeq) return
daten.value = d
await nextTick()
spy()
// Deep-Link (#a03-1 / #k03) hat Vorrang vor der gemerkten Position; beides nur beim ersten Laden
const ziel = location.hash.slice(1) || localStorage.getItem('pos:' + props.topic)
if (ziel && !aktiv.value) document.getElementById(ziel)?.scrollIntoView({ block: 'start' })
}
watch(() => props.topic, () => { aktiv.value = ''; cache.clear(); laden() }, { immediate: true })
watch(() => props.laeuft, (l, alt) => { if (alt && !l) laden() })
// Während eines Laufs nur nachladen, wenn sich der Fortschritt geändert hat
watch(() => props.fortschritt, (n, alt) => { if (props.laeuft && n !== alt) laden() })
function html(a) {
const key = a.id + ':' + (a.text || '').length
if (!cache.has(key)) cache.set(key, render(a.text))
return cache.get(key)
}
const abschnitte = computed(() => (daten.value?.kapitel || []).flatMap((k) => k.abschnitte))
const gelesen = computed(() => {
const ids = abschnitte.value.map((a) => 'a' + a.id)
const i = ids.indexOf(aktiv.value)
return ids.length ? Math.round(100 * Math.max(0, i) / ids.length) : 0
})
// Kapitel, in dem die aktive Stelle liegt — nur dieses zeigt in der Nav seine Abschnitte; sonst das erste
const aktivKap = computed(() => {
const id = aktiv.value, ks = daten.value?.kapitel || []
const k = ks.find((k) => 'k' + k.id === id || k.abschnitte.some((a) => 'a' + a.id === id))
return (k || ks[0])?.id || ''
})
function teilNeu(i) {
const ks = daten.value.kapitel
return ks[i].teil && (i === 0 || ks[i - 1].teil !== ks[i].teil)
}
const kapBadge = { geschrieben: ['gelb', 'geschrieben'], geprueft: ['blau', 'geprüft'], fehlt: ['', 'fehlt'] }
function spy() {
beobachter?.disconnect()
beobachter = new IntersectionObserver((es) => {
for (const e of es) if (e.isIntersecting) { aktiv.value = e.target.id; localStorage.setItem('pos:' + props.topic, e.target.id) }
}, { root: lese.value, rootMargin: '0px 0px -75% 0px' })
document.querySelectorAll('.buch [data-spy]').forEach((el) => beobachter.observe(el))
}
function springe(id) {
navAuf.value = false
aktiv.value = id
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
history.replaceState(null, '', location.pathname + location.search + '#' + id)
}
function taste(e) { if (e.key === 'Escape') navAuf.value = false }
onMounted(() => window.addEventListener('keydown', taste))
onUnmounted(() => window.removeEventListener('keydown', taste))
</script>
<template>
<div class="guide">
<div v-if="navAuf" class="backdrop" @click="navAuf = false"></div>
<nav class="guide-nav" :class="{ offen: navAuf }">
<button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button>
<div class="werkzeuge">
<button class="klein" title="Lesemodus: Abschnitt für Abschnitt" @click="lesemodus = true" :disabled="!abschnitte.length">Lesen ▶</button>
<a class="knopf klein sekundaer" title="Als Markdown-Datei herunterladen" :href="api.guideMdUrl(topic)" download>MD</a>
<a v-if="daten?.pdf" class="knopf klein sekundaer" title="Als PDF herunterladen" :href="api.pdfUrl(topic)" download>PDF</a>
</div>
<template v-if="daten">
<div class="meta">
{{ daten.kapitel.length }} Kapitel · {{ daten.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(daten.minuten) }}
<div class="fortschritt"><i :style="{ width: gelesen + '%' }"></i></div>
</div>
<template v-for="(k, i) in daten.kapitel" :key="k.id">
<div v-if="teilNeu(i)" class="teil">{{ k.teil }}</div>
<a class="kap" :class="{ aktiv: aktiv === 'k' + k.id }" @click="springe('k' + k.id)">
<span class="pfeil">{{ aktivKap === k.id ? '▾' : '▸' }}</span>
<span class="t">{{ k.ord }}. {{ k.titel }}</span><span class="zeit">{{ zeit(k.minuten) }}</span></a>
<template v-if="aktivKap === k.id">
<a v-for="a in k.abschnitte" :key="a.id" class="abs" :class="{ aktiv: aktiv === 'a' + a.id }" @click="springe('a' + a.id)">
<span>{{ a.nr }} {{ a.titel }}</span><span class="zeit">{{ zeit(a.minuten) }}</span></a>
</template>
</template>
<div v-if="!daten.kapitel.length" class="leer">Noch kein Text — das Buch erscheint hier, sobald die ersten Kapitel geschrieben sind.</div>
</template>
</nav>
<div ref="lese" class="lese" @touchstart.passive="wischStart" @touchend.passive="wischEnde">
<div class="mobil-leiste">
<button class="sekundaer klein" @click="navAuf = !navAuf">☰ Inhalt</button>
<button class="klein" @click="lesemodus = true" :disabled="!abschnitte.length">Lesen ▶</button>
<a class="knopf klein sekundaer" :href="api.guideMdUrl(topic)" download>MD</a>
<a v-if="daten?.pdf" class="knopf klein sekundaer" :href="api.pdfUrl(topic)" download>PDF</a>
</div>
<article v-if="daten" class="buch">
<h1>{{ daten.titel }}</h1>
<div v-if="daten.untertitel" class="untertitel">{{ daten.untertitel }}</div>
<div class="meta">{{ daten.kapitel.length }} Kapitel · {{ daten.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(daten.minuten) }} Lesezeit</div>
<p v-if="daten.status && daten.status !== 'fertig'" class="hinweis">
Stand: {{ daten.status }} — Kapitel erscheinen, sobald sie geschrieben sind, und werden danach von einem unabhängigen Agenten geprüft.</p>
<section v-if="daten.vorwort" id="vorwort" class="vorwort" data-spy>
<h2>Vorwort</h2>
<div v-html="render(daten.vorwort)"></div>
</section>
<section v-for="(k, i) in daten.kapitel" :key="k.id" :id="'k' + k.id" class="kapitel" data-spy>
<div v-if="teilNeu(i)" class="teil-titel">{{ k.teil }}</div>
<h2>Kapitel {{ k.ord }} – {{ k.titel }}<span v-if="k.status !== 'konsistent' && kapBadge[k.status]" class="badge" :class="kapBadge[k.status][0]">{{ kapBadge[k.status][1] }}</span></h2>
<p v-if="k.status === 'fehlt'" class="hinweis">Dieses Kapitel wird noch geschrieben.</p>
<div v-else class="kap-meta">{{ k.abschnitte.length }} Abschnitte · {{ zeit(k.minuten) }}</div>
<div v-if="k.intro" class="kap-intro" v-html="render(k.intro)"></div>
<section v-for="a in k.abschnitte" :key="a.id" :id="'a' + a.id" data-spy>
<h3>{{ a.nr }} {{ a.titel }}</h3>
<div v-html="html(a)"></div>
</section>
<details v-if="k.berichte?.autor || k.berichte?.pruefer" class="pruefung">
<summary>Prüfung</summary>
<template v-if="k.berichte.autor">
<h4>Autorenbericht</h4>
<div class="md" v-html="render(k.berichte.autor)"></div>
</template>
<template v-if="k.berichte.pruefer">
<h4>Prüfbericht</h4>
<div class="md" v-html="render(k.berichte.pruefer)"></div>
</template>
</details>
</section>
<div v-if="daten.quellen || daten.gliederung" class="ende">
<details v-if="daten.quellen">
<summary>Quellen und Prüfmethode</summary>
<div class="md" v-html="render(daten.quellen)"></div>
</details>
<details v-if="daten.gliederung">
<summary>Gliederung</summary>
<div class="md" v-html="render(daten.gliederung)"></div>
</details>
</div>
</article>
</div>
<Lesemodus v-if="lesemodus && daten" :daten="daten" :topic="topic" @schliessen="lesemodus = false" />
</div>
</template>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue'
import { render } from '../markdown.js'
const props = defineProps({ state: Object })
const emit = defineEmits(['start', 'pause', 'reset'])
const laeuft = computed(() => props.state?.status === 'laeuft')
const fertig = computed(() => props.state?.status === 'fertig')
const begonnen = computed(() => ['pausiert', 'fehler', 'fertig'].includes(props.state?.status)
|| (props.state?.stufen || []).some((s) => s.status !== 'offen'))
function usd(n) { return (n || 0).toFixed(2) + ' $' }
function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(2) + ' M' : n >= 1e3 ? Math.round(n / 1e3) + ' k' : String(n || 0) }
function dauer(s) {
if (!s.gestartet) return ''
const a = new Date(s.gestartet)
const b = s.beendet ? new Date(s.beendet) : new Date()
const sek = Math.max(0, Math.round((b - a) / 1000))
return sek < 90 ? `${sek} s` : `${Math.round(sek / 60)} min`
}
function uhr(t) { return t ? new Date(t).toLocaleTimeString('de-DE') : '' }
const statusText = { offen: 'wartet', laeuft: 'läuft', fertig: 'fertig', pausiert: 'pausiert', fehler: 'Fehler', abgebrochen: 'abgebrochen' }
const statusFarbe = { laeuft: 'blau', fertig: 'gruen', fehler: 'rot', abgebrochen: 'gelb', pausiert: 'gelb' }
// Laufende Agenten zuerst, danach die übrigen nach Stufe (in Stufenreihenfolge)
const gruppen = computed(() => {
const ag = props.state?.agenten || []
const out = [{ titel: 'Läuft gerade', agenten: ag.filter((a) => a.status === 'laeuft') }]
const rest = ag.filter((a) => a.status !== 'laeuft')
const stufen = (props.state?.stufen || []).map((s) => s.id)
for (const id of stufen) out.push({ titel: props.state.stufen.find((s) => s.id === id).titel, agenten: rest.filter((a) => a.stufe === id) })
out.push({ titel: 'Sonstige', agenten: rest.filter((a) => !stufen.includes(a.stufe)) })
return out.filter((g) => g.agenten.length)
})
const ereignisse = computed(() => (props.state?.ereignisse || []).slice(-100).reverse())
function reset() { if (confirm('Lauf zurücksetzen? Alles außer dem Thema wird gelöscht.')) emit('reset') }
</script>
<template>
<div class="lauf">
<div class="aktionen">
<button v-if="!laeuft && !fertig" @click="emit('start')">{{ begonnen ? 'Fortsetzen' : 'Buch erzeugen' }}</button>
<button v-if="laeuft" class="sekundaer" @click="emit('pause')">Pause</button>
<button v-if="!laeuft && begonnen" class="sekundaer" @click="reset">Zurücksetzen</button>
<span class="spacer"></span>
<span class="badge" :class="statusFarbe[state.status]">{{ statusText[state.status] || state.status }}<template v-if="state.stufe && laeuft"> · {{ state.stufe }}</template></span>
<span class="badge">{{ usd(state.kosten_usd) }}</span>
<span class="badge">{{ tokens(state.tokens) }} Tokens</span>
</div>
<div v-if="state.grund" class="grund">{{ state.grund }}</div>
<div v-if="state.deutung" class="deutung">
<div class="titel">{{ state.deutung.titel }}</div>
<div v-if="state.deutung.untertitel" class="sub">{{ state.deutung.untertitel }}</div>
<p v-if="state.deutung.deutung">{{ state.deutung.deutung }}</p>
<div class="fakten">
<span v-if="state.deutung.buchtyp"><b>Buchtyp</b> {{ state.deutung.buchtyp === 'werkzeug' ? 'Werkzeug' : state.deutung.buchtyp === 'wissen' ? 'Wissen' : state.deutung.buchtyp }}</span>
<span v-if="state.deutung.zielgruppe"><b>Zielgruppe</b> {{ state.deutung.zielgruppe }}</span>
<span v-if="state.deutung.referenz"><b>Referenz</b> {{ state.deutung.referenz }}</span>
</div>
</div>
<div class="stufen">
<div v-for="s in state.stufen" :key="s.id" class="stufe" :class="s.status">
<div class="kreis">{{ s.status === 'fertig' ? '✓' : '' }}</div>
<div>
<div class="name">{{ s.titel }}</div>
<div class="sub">{{ statusText[s.status] || s.status }}<template v-if="s.gesamt"> · {{ s.fertig }} / {{ s.gesamt }}</template></div>
<div v-if="s.gesamt" class="balken"><i :style="{ width: Math.min(100, 100 * s.fertig / s.gesamt) + '%' }"></i></div>
</div>
<div class="sub rechts">{{ s.tokens ? usd(s.kosten_usd) + ' · ' + tokens(s.tokens) + ' Tokens' : '' }}</div>
<div class="sub rechts">{{ dauer(s) }}</div>
</div>
</div>
<div v-if="gruppen.length" class="agenten">
<template v-for="g in gruppen" :key="g.titel">
<h3>{{ g.titel }}</h3>
<details v-for="a in g.agenten" :key="a.id" class="agent" :class="a.status">
<summary>
<span class="name">{{ a.name }}</span>
<span class="badge" :class="statusFarbe[a.status]">{{ statusText[a.status] || a.status }}</span>
<span class="sub">{{ usd(a.kosten_usd) }}</span>
<span class="sub">{{ tokens(a.tokens) }} Tokens</span>
<span class="sub">{{ a.werkzeuge || 0 }} Werkzeuge</span>
<span class="sub">{{ dauer(a) }}</span>
</summary>
<div v-if="a.bericht" class="bericht md" v-html="render(a.bericht)"></div>
<div v-else class="bericht sub">Noch kein Abschlussbericht.</div>
</details>
</template>
</div>
<details v-if="ereignisse.length" class="ereignisse">
<summary>Ereignisse ({{ ereignisse.length }})</summary>
<div v-for="(e, i) in ereignisse" :key="i" class="ereignis" :class="e.art">
<span class="zeit">{{ uhr(e.zeit) }}</span>
<span class="wer">{{ e.agent }}</span>
<span class="text">{{ e.text }}</span>
</div>
</details>
</div>
</template>

View File

@@ -0,0 +1,107 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { render, zeit } from '../markdown.js'
const props = defineProps({ daten: Object, topic: String })
const emit = defineEmits(['schliessen'])
const buehne = ref(null)
const pos = ref(0)
// Bildschirme: Vorwort, je Kapitel ein Intro, je Abschnitt ein Bildschirm, Ende.
const screens = computed(() => {
const out = []
const kaps = props.daten?.kapitel || []
if (props.daten?.vorwort) out.push({ typ: 'vorwort' })
kaps.forEach((kap, ki) => {
out.push({ typ: 'kapitel', kap, nr: ki + 1, von: kaps.length })
for (const a of kap.abschnitte) out.push({ typ: 'abschnitt', a, kap })
})
out.push({ typ: 'ende' })
return out
})
const screen = computed(() => screens.value[pos.value] || { typ: 'ende' })
const gesamt = computed(() => screens.value.filter((s) => s.typ === 'abschnitt').length)
const gelesen = computed(() => screens.value.slice(0, pos.value + 1).filter((s) => s.typ === 'abschnitt').length)
const cache = new Map()
function html(a) {
const key = a.id + ':' + (a.text || '').length
if (!cache.has(key)) cache.set(key, render(a.text))
return cache.get(key)
}
function vor() { if (pos.value < screens.value.length - 1) pos.value++ }
function zurueck() { if (pos.value > 0) pos.value-- }
watch(pos, (p) => { localStorage.setItem('lm:' + props.topic, String(p)); buehne.value?.scrollTo({ top: 0 }) })
function taste(e) {
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); vor() }
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); zurueck() }
else if (e.key === 'Escape') emit('schliessen')
else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
buehne.value?.scrollBy({ top: e.key === 'ArrowDown' ? 160 : -160, behavior: 'smooth' })
}
}
let wisch = null
function wischStart(e) {
for (let el = e.target; el && el !== e.currentTarget; el = el.parentElement) {
if (el.scrollWidth > el.clientWidth + 1) { wisch = null; return }
}
const t = e.touches[0]; wisch = { x: t.clientX, y: t.clientY }
}
function wischEnde(e) {
if (!wisch) return
const t = e.changedTouches[0]
const dx = t.clientX - wisch.x, dy = t.clientY - wisch.y
wisch = null
if (Math.abs(dx) < 60 || Math.abs(dx) < 2 * Math.abs(dy)) return
dx < 0 ? vor() : zurueck()
}
onMounted(() => {
window.addEventListener('keydown', taste)
const merk = Number(localStorage.getItem('lm:' + props.topic) || 0)
pos.value = Math.min(merk, screens.value.length - 1)
})
onUnmounted(() => window.removeEventListener('keydown', taste))
</script>
<template>
<div class="lesemodus" @touchstart.passive="wischStart" @touchend.passive="wischEnde">
<header class="lm-kopf">
<span>{{ gelesen }} / {{ gesamt }} Abschnitte</span>
<div class="balken"><i :style="{ width: (gesamt ? 100 * gelesen / gesamt : 0) + '%' }"></i></div>
<span v-if="screen.typ === 'abschnitt'">{{ screen.kap.titel }}</span>
<button title="Schließen (Esc)" @click="emit('schliessen')">✕</button>
</header>
<main ref="buehne" class="lm-buehne">
<section v-if="screen.typ === 'vorwort'" class="lm-screen">
<div class="lm-label">Vorwort</div>
<h1>{{ daten.titel }}</h1>
<div v-if="daten.untertitel" class="stats" style="margin: 0 0 18px">{{ daten.untertitel }}</div>
<div class="intro" v-html="render(daten.vorwort)"></div>
<div class="stats">{{ daten.kapitel.length }} Kapitel · {{ daten.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(daten.minuten) }}</div>
</section>
<section v-else-if="screen.typ === 'kapitel'" class="lm-screen">
<div class="lm-label">Kapitel {{ screen.nr }} / {{ screen.von }}</div>
<h1>{{ screen.kap.titel }}</h1>
<div v-if="screen.kap.intro" class="intro" v-html="render(screen.kap.intro)"></div>
<div v-if="screen.kap.status === 'fehlt'" class="stats">Dieses Kapitel wird noch geschrieben.</div>
<div v-else class="stats">{{ screen.kap.abschnitte.length }} Abschnitte · {{ zeit(screen.kap.minuten) }}</div>
</section>
<section v-else-if="screen.typ === 'abschnitt'" class="lm-screen">
<div class="lm-label">{{ screen.kap.titel }}</div>
<h2>{{ screen.a.nr }} {{ screen.a.titel }}</h2>
<div v-html="html(screen.a)"></div>
</section>
<section v-else class="lm-screen ende">
<h1>Fertig gelesen</h1>
<p class="intro">Alle {{ gesamt }} Abschnitte von „{{ daten.titel }}“.</p>
<button class="sekundaer" style="margin-top: 16px" @click="pos = 0">Von vorn</button>
</section>
</main>
<footer class="lm-fuss">
<button :disabled="pos === 0" @click="zurueck">← Zurück</button>
<button class="weiter" :disabled="pos >= screens.length - 1" @click="vor">Weiter →<kbd>␣</kbd></button>
</footer>
</div>
</template>

5
frontend/src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

48
frontend/src/markdown.js Normal file
View File

@@ -0,0 +1,48 @@
import MarkdownIt from 'markdown-it'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/core'
import python from 'highlight.js/lib/languages/python'
import php from 'highlight.js/lib/languages/php'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import json from 'highlight.js/lib/languages/json'
import yaml from 'highlight.js/lib/languages/yaml'
import bash from 'highlight.js/lib/languages/bash'
import xml from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import csharp from 'highlight.js/lib/languages/csharp'
import cpp from 'highlight.js/lib/languages/cpp'
import c from 'highlight.js/lib/languages/c'
import glsl from 'highlight.js/lib/languages/glsl'
import ini from 'highlight.js/lib/languages/ini'
import sql from 'highlight.js/lib/languages/sql'
import rust from 'highlight.js/lib/languages/rust'
import go from 'highlight.js/lib/languages/go'
import java from 'highlight.js/lib/languages/java'
import twig from 'highlight.js/lib/languages/twig'
import diff from 'highlight.js/lib/languages/diff'
for (const [n, l] of Object.entries({ python, php, javascript, typescript, json, yaml, bash, xml, css, csharp, cpp, c, glsl, ini, sql, rust, go, java, twig, diff })) hljs.registerLanguage(n, l)
hljs.registerAliases(['js'], { languageName: 'javascript' }); hljs.registerAliases(['ts'], { languageName: 'typescript' })
hljs.registerAliases(['sh', 'shell', 'console', 'zsh'], { languageName: 'bash' }); hljs.registerAliases(['html', 'svg', 'tscn', 'tres'], { languageName: 'xml' })
hljs.registerAliases(['yml'], { languageName: 'yaml' }); hljs.registerAliases(['toml', 'cfg', 'godot'], { languageName: 'ini' }); hljs.registerAliases(['cs'], { languageName: 'csharp' })
hljs.registerAliases(['gdscript', 'gd'], { languageName: 'python' }) // nah genug für lesbare Farben
const md = new MarkdownIt({
html: true,
linkify: false,
typographer: false,
highlight(code, lang) {
const l = lang && hljs.getLanguage(lang) ? lang : null
const out = l ? hljs.highlight(code, { language: l }).value : md.utils.escapeHtml(code)
return `<pre class="hljs"><code>${out}</code></pre>`
},
})
export function render(text) {
return DOMPurify.sanitize(md.render(text || ''))
}
export function zeit(min) {
const m = Math.max(1, Math.round(min))
return m < 60 ? `~${m} min` : `~${Math.floor(m / 60)} h ${m % 60} min`
}

227
frontend/src/style.css Normal file
View File

@@ -0,0 +1,227 @@
/* Themes über Custom Properties: dunkel (Standard), hell, papier. */
:root {
--bg: #12161c; --panel: #181e27; --karte: #202834; --rand: #2d3746;
--text: #dbe2ec; --dim: #8d99aa; --akzent: #4f9cf7; --gruen: #43c17f; --gelb: #e6c04a; --rot: #e36b6b;
--lese-bg: #14181f; --lese-text: #c9d1dc; --lese-dim: #98a3b3; --lese-rand: #2a3340;
--code-bg: #0e1218;
--code-fg: #d8dee9; --c-kw: #c792ea; --c-str: #a5d6a7; --c-com: #7b8794; --c-num: #f6c177; --c-fn: #82aaff; --c-type: #ffcb6b;
}
:root[data-theme="light"] {
--bg: #f4f6f9; --panel: #ffffff; --karte: #eef1f5; --rand: #d5dbe3;
--text: #1d2531; --dim: #5f6b7c; --akzent: #1b6fd6; --gruen: #1f8a57; --gelb: #9a7a12; --rot: #c43d3d;
--lese-bg: #ffffff; --lese-text: #2b3440; --lese-dim: #5c6674; --lese-rand: #dfe4ea;
--code-bg: #f1f3f6;
--code-fg: #1f2933; --c-kw: #7c3aed; --c-str: #1a7f37; --c-com: #6b7280; --c-num: #b45309; --c-fn: #1d4ed8; --c-type: #0e7490;
}
:root[data-theme="sepia"] {
--bg: #efe6d2; --panel: #f8f1de; --karte: #eee3c8; --rand: #d8caa6;
--text: #43392a; --dim: #82745a; --akzent: #7a5b2c; --gruen: #4f7a42; --gelb: #8f6b14; --rot: #a6442e;
--lese-bg: #f6efdd; --lese-text: #3f3527; --lese-dim: #6f6249; --lese-rand: #d9cba8;
--code-bg: #ece2c7;
--code-fg: #3b3224; --c-kw: #7a3e9d; --c-str: #2f6b2f; --c-com: #8a7c62; --c-num: #9a5b12; --c-fn: #2b5aa6; --c-type: #1c6f74;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
button, a.knopf { background: var(--akzent); color: #fff; border: 0; border-radius: 6px; padding: 6px 14px; cursor: pointer; font: inherit; }
button.sekundaer, a.knopf.sekundaer { background: var(--karte); color: var(--text); border: 1px solid var(--rand); }
button.rot { background: var(--rot); }
button.klein, a.knopf.klein { padding: 3px 9px; font-size: 12px; }
button:disabled { opacity: 0.45; cursor: default; }
input, select { background: var(--karte); color: var(--text); border: 1px solid var(--rand); border-radius: 6px; padding: 6px 10px; font: inherit; }
a { color: var(--akzent); }
a.knopf { display: inline-block; text-decoration: none; line-height: normal; }
.app { height: 100%; display: flex; }
.sidebar { width: 230px; min-width: 230px; background: var(--panel); border-right: 1px solid var(--rand); display: flex; flex-direction: column; }
.sidebar .topics { flex: 1; overflow-y: auto; padding: 8px; }
.topic-eintrag { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 6px; cursor: pointer; color: var(--dim); }
.topic-eintrag.aktiv { background: var(--karte); color: var(--text); }
.topic-eintrag .punkt { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
.topic-eintrag .punkt.fertig { background: var(--gruen); }
.topic-eintrag .punkt.laeuft { background: var(--akzent); animation: puls 1.2s infinite; }
.topic-eintrag .punkt.pausiert { background: var(--gelb); }
.topic-eintrag .punkt.fehler { background: var(--rot); }
@keyframes puls { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.sidebar .neu { padding: 10px; border-top: 1px solid var(--rand); display: grid; gap: 6px; }
/* Eingeklappte Themenliste (Tab „Buch“ und schmale Bildschirme): ☰ öffnet sie als Overlay */
.schliessen { display: none; }
.sidebar.offen { display: flex; position: fixed; inset: 0 auto 0 0; z-index: 25; box-shadow: 0 0 30px rgba(0,0,0,0.4); }
.sidebar.offen .schliessen { display: inline-block; margin: 10px 10px 4px; align-self: flex-start; }
.burger { display: none; }
.app.tab-buch .burger { display: inline-block; }
.app.tab-buch .sidebar:not(.offen) { display: none; }
.backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.35); z-index: 24; }
.haupt { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar { display: flex; gap: 10px; align-items: center; padding: 8px 16px; background: var(--panel); border-bottom: 1px solid var(--rand); flex-wrap: wrap; }
.topbar .titel { font-weight: 600; margin-right: 6px; }
.tabs { display: flex; gap: 4px; }
.tabs button { background: transparent; color: var(--dim); }
.tabs button.aktiv { background: var(--karte); color: var(--text); }
.spacer { flex: 1; }
.badge { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 11.5px; background: var(--karte); color: var(--dim); }
.badge.gruen { color: var(--gruen); } .badge.gelb { color: var(--gelb); } .badge.rot { color: var(--rot); } .badge.blau { color: var(--akzent); }
.inhalt { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.fehler { margin: 10px 16px; padding: 8px 12px; border: 1px solid var(--rot); border-radius: 6px; color: var(--rot); cursor: pointer; }
/* Lauf-Ansicht */
.lauf { padding: 18px 22px; overflow-y: auto; }
.lauf .aktionen { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.lauf .grund { margin: 8px 0 14px; padding: 8px 12px; background: var(--karte); border-left: 3px solid var(--gelb); border-radius: 4px; }
.deutung { max-width: 760px; margin: 0 0 16px; padding: 12px 14px; background: var(--panel); border: 1px solid var(--rand); border-radius: 8px; }
.deutung .titel { font-weight: 600; font-size: 15px; }
.deutung .sub { color: var(--dim); font-size: 12.5px; }
.deutung p { margin: 8px 0; }
.deutung .fakten { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12.5px; color: var(--dim); }
.deutung .fakten b { font-weight: 600; color: var(--text); margin-right: 4px; }
.stufen { display: grid; gap: 6px; max-width: 760px; }
.stufe { display: grid; grid-template-columns: 26px 1fr auto auto; gap: 10px; align-items: center; padding: 9px 12px; background: var(--panel); border: 1px solid var(--rand); border-radius: 8px; }
.stufe .kreis { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--rand); display: grid; place-items: center; font-size: 11px; }
.stufe.fertig .kreis { background: var(--gruen); border-color: var(--gruen); color: #fff; }
.stufe.laeuft .kreis { border-color: var(--akzent); border-top-color: transparent; animation: dreh 0.9s linear infinite; }
.stufe.pausiert .kreis { border-color: var(--gelb); } .stufe.fehler .kreis { background: var(--rot); border-color: var(--rot); }
@keyframes dreh { to { transform: rotate(360deg); } }
.stufe .name { font-weight: 600; }
.stufe .sub { color: var(--dim); font-size: 12px; }
.stufe .balken { height: 4px; background: var(--karte); border-radius: 2px; margin-top: 5px; overflow: hidden; }
.stufe .balken i { display: block; height: 100%; background: var(--akzent); }
.stufe.fertig .balken i { background: var(--gruen); }
/* Agenten: Zeile aufklappen zeigt den Abschlussbericht */
.agenten { max-width: 760px; margin-top: 18px; }
.agenten h3, .ereignisse summary { margin: 18px 0 8px; font-size: 13px; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: 0.05em; }
.agent { background: var(--panel); border: 1px solid var(--rand); border-radius: 8px; margin-bottom: 6px; }
.agent.laeuft { border-color: var(--akzent); }
.agent.fehler { border-color: var(--rot); }
.agent summary { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 8px 12px; cursor: pointer; list-style: none; }
.agent summary::-webkit-details-marker { display: none; }
.agent summary .name { font-weight: 600; flex: 1; min-width: 160px; }
.agent summary .sub { color: var(--dim); font-size: 12px; white-space: nowrap; }
.agent .bericht { padding: 6px 12px 12px; border-top: 1px solid var(--rand); }
.agent .bericht.sub { color: var(--dim); font-size: 12.5px; }
.ereignisse { max-width: 760px; margin-top: 18px; }
.ereignisse summary { cursor: pointer; }
.ereignis { display: grid; grid-template-columns: 64px 130px 1fr; gap: 10px; padding: 2px 0; font: 12px/1.5 "SF Mono", Consolas, Menlo, monospace; border-bottom: 1px solid var(--rand); }
.ereignis .zeit, .ereignis .wer { color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ereignis .text { overflow-wrap: anywhere; }
.ereignis.fehler .text { color: var(--rot); }
.ereignis.start .text, .ereignis.ende .text { color: var(--akzent); }
/* Gerendertes Markdown außerhalb des Buchtexts (Berichte, Prüfung, Quellen, Gliederung) */
.md { font: 13px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
.md h1, .md h2, .md h3, .md h4 { font-size: 13.5px; margin: 12px 0 4px; line-height: 1.4; }
.md p, .md ul, .md ol { margin: 0 0 0.7em; }
.md ul, .md ol { padding-left: 1.4em; }
.md li { margin: 0.15em 0; }
.md code { font-family: "SF Mono", Consolas, Menlo, monospace; font-size: 0.9em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
.md pre { background: var(--code-bg); color: var(--code-fg); padding: 10px 12px; border-radius: 6px; overflow-x: auto; font-size: 0.9em; line-height: 1.5; }
.md pre code { background: none; padding: 0; }
.md table { border-collapse: collapse; margin: 0 0 0.8em; display: block; max-width: 100%; overflow-x: auto; }
.md th, .md td { border: 1px solid var(--rand); padding: 3px 8px; text-align: left; }
/* Buch-Ansicht */
.guide { flex: 1; display: flex; min-height: 0; }
.guide-nav { width: 270px; min-width: 240px; overflow-y: auto; padding: 14px 10px; border-right: 1px solid var(--rand); background: var(--panel); font-size: 13px; }
.guide-nav .werkzeuge { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.guide-nav .meta { color: var(--dim); font-size: 12px; margin-bottom: 10px; }
.guide-nav a.kap { display: flex; align-items: baseline; gap: 6px; padding: 6px 8px; border-radius: 6px; cursor: pointer; color: var(--text); text-decoration: none; font-weight: 600; margin-top: 6px; }
.guide-nav a.kap .pfeil { color: var(--dim); font-size: 10px; width: 10px; flex: none; }
.guide-nav a.kap .t { flex: 1; }
.guide-nav a.abs { display: flex; justify-content: space-between; gap: 6px; padding: 3px 8px 3px 24px; border-radius: 6px; cursor: pointer; color: var(--dim); text-decoration: none; }
.guide-nav a.aktiv { background: var(--karte); color: var(--text); }
.guide-nav .zeit { color: var(--dim); font-size: 11px; white-space: nowrap; }
.guide-nav .fortschritt { height: 4px; background: var(--karte); border-radius: 2px; margin: 8px 0 4px; overflow: hidden; }
.guide-nav .fortschritt i { display: block; height: 100%; background: var(--gruen); }
.guide-nav .leer { color: var(--dim); padding: 8px; }
.guide-nav .teil { margin: 14px 0 2px; font: 600 11px system-ui, sans-serif; letter-spacing: 0.08em; text-transform: uppercase; color: var(--dim); }
.lese { flex: 1; overflow-y: auto; background: var(--lese-bg); color: var(--lese-text); }
.buch { max-width: 68ch; margin: 0 auto; padding: 36px 28px 120px; font-family: "Charter", "Bitstream Charter", "Iowan Old Style", "Palatino Linotype", Georgia, serif; font-size: 1.125rem; line-height: 1.65; hyphens: auto; hyphenate-limit-chars: 8 4 4; }
.buch h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 10px; }
.buch .untertitel { color: var(--lese-text); font-style: italic; font-size: 1.1rem; margin-bottom: 6px; }
.buch .meta { color: var(--lese-dim); margin-bottom: 26px; font-size: 0.95rem; }
.buch .vorwort { margin-top: 8px; }
.buch .vorwort h2 { font-size: 1.3rem; margin-bottom: 14px; }
.buch .kapitel { margin-top: 56px; padding-top: 18px; border-top: 1px solid var(--lese-rand); }
.buch .kapitel:first-of-type { border-top: 0; margin-top: 8px; }
.buch h2 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 6px; }
.buch h2 .badge { font: 11.5px system-ui, sans-serif; vertical-align: middle; margin-left: 10px; }
.buch .kap-meta { color: var(--lese-dim); font-size: 0.85rem; margin-bottom: 14px; }
.buch .kap-intro { color: var(--lese-text); margin-bottom: 24px; }
.buch h3 { font-size: 1.22rem; margin: 34px 0 4px; line-height: 1.3; scroll-margin-top: 16px; }
.buch p { margin: 0 0 1em; }
.buch ul, .buch ol { padding-left: 1.4em; margin: 0 0 1em; }
.buch li { margin: 0.25em 0; }
.buch code { font-family: "SF Mono", Consolas, Menlo, monospace; font-size: 0.86em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
.buch p, .buch li { overflow-wrap: anywhere; }
.buch pre { background: var(--code-bg); color: var(--code-fg); padding: 12px 14px; border-radius: 8px; overflow-x: auto; font-size: 0.86em; line-height: 1.5; }
.buch pre code { background: none; padding: 0; }
.buch table { border-collapse: collapse; margin: 0 0 1em; font-size: 0.92em; display: block; max-width: 100%; overflow-x: auto; }
.buch th, .buch td { border: 1px solid var(--lese-rand); padding: 4px 9px; text-align: left; }
.buch .hinweis { background: var(--karte); border-radius: 6px; padding: 8px 12px; font-size: 0.85rem; color: var(--dim); font-family: system-ui, sans-serif; margin: 0 0 14px; }
.buch .teil-titel { font: 600 0.78rem system-ui, sans-serif; letter-spacing: 0.1em; text-transform: uppercase; color: var(--lese-dim); margin: 40px 0 6px; }
/* Prüfung je Kapitel (Autoren- und Prüfbericht), eingeklappt */
.buch .pruefung { margin-top: 28px; border: 1px solid var(--lese-rand); border-radius: 8px; font-family: system-ui, sans-serif; }
.buch .pruefung summary { padding: 8px 12px; cursor: pointer; color: var(--lese-dim); font-size: 0.85rem; font-weight: 600; }
.buch .pruefung h4 { margin: 10px 12px 4px; font-size: 0.78rem; color: var(--lese-dim); text-transform: uppercase; letter-spacing: 0.06em; }
.buch .pruefung .md { padding: 0 12px 10px; }
.buch .ende { margin-top: 60px; border-top: 1px solid var(--lese-rand); padding-top: 16px; font-family: system-ui, sans-serif; }
.buch .ende details { margin-bottom: 8px; }
.buch .ende summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; padding: 6px 0; }
.buch .ende .md { padding: 4px 0 8px; }
.mobil-leiste { display: none; }
/* Lesemodus (Overlay, Bildschirm für Bildschirm) */
.lesemodus { position: fixed; inset: 0; z-index: 40; background: var(--lese-bg); color: var(--lese-text); display: flex; flex-direction: column; }
.lm-kopf { display: flex; align-items: center; gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--lese-rand); font: 12.5px system-ui, sans-serif; color: var(--lese-dim); }
.lm-kopf .balken { flex: 1; height: 4px; background: var(--karte); border-radius: 2px; overflow: hidden; }
.lm-kopf .balken i { display: block; height: 100%; background: var(--gruen); }
.lm-kopf button { background: transparent; color: var(--lese-dim); font-size: 18px; padding: 2px 8px; }
.lm-buehne { flex: 1; overflow-y: auto; }
.lm-screen { max-width: 66ch; margin: 0 auto; padding: 40px 28px 80px; font-family: "Charter", "Bitstream Charter", "Iowan Old Style", "Palatino Linotype", Georgia, serif; font-size: 1.15rem; line-height: 1.65; hyphens: auto; hyphenate-limit-chars: 8 4 4; }
.lm-label { font: 600 11.5px system-ui, sans-serif; letter-spacing: 0.08em; text-transform: uppercase; color: var(--lese-dim); margin-bottom: 8px; }
.lm-screen h1 { font-size: 2.1rem; line-height: 1.2; margin: 0 0 12px; }
.lm-screen h2 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 6px; }
.lm-screen .intro { color: var(--lese-text); }
.lm-screen .stats { color: var(--lese-dim); font: 13px system-ui, sans-serif; margin-top: 14px; }
.lm-screen p { margin: 0 0 1em; }
.lm-screen code { font-family: "SF Mono", Consolas, monospace; font-size: 0.86em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
.lm-screen pre { background: var(--code-bg); color: var(--code-fg); padding: 12px 14px; border-radius: 8px; overflow-x: auto; font-size: 0.86em; }
.lm-screen pre code { background: none; padding: 0; }
.lm-screen ul, .lm-screen ol { padding-left: 1.4em; }
.lm-screen table { border-collapse: collapse; margin: 0 0 1em; font-size: 0.92em; display: block; max-width: 100%; overflow-x: auto; }
.lm-screen th, .lm-screen td { border: 1px solid var(--lese-rand); padding: 4px 9px; text-align: left; }
.lm-screen.ende { text-align: center; padding-top: 120px; }
.lm-fuss { display: flex; justify-content: space-between; padding: 10px 18px; border-top: 1px solid var(--lese-rand); font-family: system-ui, sans-serif; }
.lm-fuss button { background: var(--karte); color: var(--text); border: 1px solid var(--rand); }
.lm-fuss button.weiter { background: var(--akzent); color: #fff; border: 0; }
kbd { font: 11px system-ui, sans-serif; border: 1px solid var(--rand); border-radius: 4px; padding: 0 5px; margin-left: 6px; color: var(--dim); }
@media (max-width: 800px) {
.topbar { gap: 8px; padding: 8px 10px; flex-wrap: nowrap; }
.topbar .badge { display: none; }
.topbar .titel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.mobil-leiste { display: flex; gap: 8px; padding: 10px 16px 0; align-items: center; }
.guide-nav.offen .schliessen { display: inline-block; margin: 10px 10px 4px; }
.lese { overflow-x: hidden; }
.sidebar:not(.offen) { display: none; }
.burger { display: inline-block; }
.guide-nav { display: none; }
.guide-nav.offen { display: block; position: fixed; inset: 0 auto 0 0; z-index: 25; width: 82vw; }
.buch { padding: 22px 16px 100px; font-size: 1.02rem; }
.lm-screen { padding: 24px 16px 60px; font-size: 1.05rem; }
.lm-kopf, .lm-fuss { padding: 8px 12px; }
.stufe { grid-template-columns: 26px 1fr; }
.stufe .rechts { grid-column: 2; }
.ereignis { grid-template-columns: 64px 1fr; }
.ereignis .text { grid-column: 1 / -1; }
}
/* Code-Farben je Theme (statt einer festen highlight.js-Vorlage) */
pre.hljs, .hljs { background: var(--code-bg); color: var(--code-fg); }
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-meta { color: var(--c-kw); }
.hljs-string, .hljs-regexp, .hljs-addition { color: var(--c-str); }
.hljs-comment, .hljs-quote { color: var(--c-com); font-style: italic; }
.hljs-number, .hljs-symbol, .hljs-bullet { color: var(--c-num); }
.hljs-title, .hljs-function, .hljs-section, .hljs-name { color: var(--c-fn); }
.hljs-built_in, .hljs-type, .hljs-class, .hljs-attr, .hljs-variable { color: var(--c-type); }

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: { proxy: { '/api': 'http://localhost:8020' } },
})

30
prompts/gliederung.md Normal file
View File

@@ -0,0 +1,30 @@
Du entwirfst die Gliederung eines deutschsprachigen Sachbuchs zum Thema „{{thema}}“.
{{hinweise_block}}
Lies zuerst vollständig: `buch/STIL.md`, `quellen/QUELLEN.md` und `deutung.json`. Verschaffe dir dann anhand der Quellen unter `quellen/` (Inhaltsverzeichnisse der Doku, Klassenlisten, Titel der Studien) einen Überblick, was das Thema umfasst – die Gliederung muss aus dem Stoff kommen, nicht aus dem Gedächtnis.
Anforderungen:
- Breite Übersicht über alles Wichtige; keine feste Kapitelzahl, typisch 10–25 Kapitel, ab 8 Kapiteln in Teile gruppiert.
- Je Kapitel 3–6 Abschnitte. Reihenfolge nach dem, was man zuerst wissen muss.
- Jedes Thema hat genau ein zuständiges Kapitel; Überschneidungen vermeiden (später wird per Querverweis verwiesen).
- Titel deutsch, konkret, keine Marketingsprache.
Schreibe `buch/GLIEDERUNG.md` GENAU in diesem Format (Parser liest es):
```
# Gliederung – <Buchtitel>. <Untertitel>
## Teil I – <Name des Teils>
**Kapitel 1 – <Titel>** (`01-<slug>.md`)
1.1 <Titel> · 1.2 <Titel> · 1.3 <Titel>
**Kapitel 2 – <Titel>** (`02-<slug>.md`)
2.1 <Titel> · 2.2 <Titel> · 2.3 <Titel> · 2.4 <Titel>
## Teil II – <Name>
…
```
Regeln für das Format: Dateiname zweistellig nummeriert, slug nur Kleinbuchstaben, Ziffern und Bindestriche (ae/oe/ue/ss statt Umlauten); Abschnitte in einer Zeile, getrennt durch „ · “; Nummerierung fortlaufend; Teile mit `## Teil <römisch> – Name`; bei weniger als 8 Kapiteln keine Teile-Zeilen. Keine weiteren Zeilen außer Leerzeilen.
Antworte am Ende kurz (max. 6 Zeilen): Anzahl Teile/Kapitel/Abschnitte und was bewusst weggelassen wurde.

15
prompts/kapitel.md Normal file
View File

@@ -0,0 +1,15 @@
Du schreibst ein Kapitel für ein deutschsprachiges Sachbuch zum Thema „{{thema}}“.
Lies zuerst vollständig: `buch/STIL.md`, `buch/GLIEDERUNG.md`, `quellen/QUELLEN.md`.
Aufgabe: **Kapitel {{n}} – {{kapitel_titel}}**, Datei `buch/{{datei}}`, mit genau den Abschnitten aus der Gliederung.
Arbeite in diesem Loop:
1. RECHERCHE in den lokalen Quellen (siehe QUELLEN.md): Verstehe die Sache anhand von Quellcode, Referenz, Doku bzw. Abstracts, bevor du schreibst. Online nur, wenn lokal nicht klärbar und QUELLEN.md es erlaubt.
2. SCHREIBEN mit Write (bei Länge in mehreren Schritten, dann anhängen).
3. VERIFIZIEREN: Lies das fertige Kapitel vollständig. Prüfe JEDE Tatsachenangabe (Bezeichner, Signaturen, Versionsangaben, Namen, Jahre, Zahlen, Studienergebnisse) gegen die Quellen; führe Codebeispiele aus, wo STIL.md das verlangt. Korrigiere oder entschärfe alles Unbelegte.
4. PRÜFEN gegen STIL.md: Überschriften exakt wie in der Gliederung, keine Übungen/Merkkästen/Zusammenfassungen/Literaturlisten, Prosa statt Listenwüste, Querverweise statt Redundanz zu anderen Kapiteln, Umfang, Umlaute. Überarbeite, bis alles passt.
Arbeitsdateien (Testskripte, Notizen) nur unter `arbeit/kapitel-{{n}}/` anlegen, nicht in `buch/` oder `quellen/`. Keine persönlichen Daten an externe Dienste senden.
Antworte am Ende mit einem kurzen Bericht (max. 10 Zeilen, Markdown): Wortzahl; was du per Quelle abgesichert oder korrigiert hast; eine Liste der Aussagen, die du NICHT belegen konntest (diese Liste bekommt der unabhängige Prüfer).

16
prompts/konsistenz.md Normal file
View File

@@ -0,0 +1,16 @@
Du prüfst Querverweise und Doppelungen in einem deutschsprachigen Sachbuch zum Thema „{{thema}}“. Alle Kapitel sind geschrieben und einzeln geprüft; sie liegen unter `buch/` (Gliederung: `buch/GLIEDERUNG.md`).
Deine Kapitel: {{dateien}}
Für jeden Querverweis der Form „(→ Kapitel N)“, „(→ Abschnitt N.M)“ oder Formulierungen wie „wird in Kapitel N behandelt“ in deinen Kapiteln:
1. Stelle fest, auf welches Thema verwiesen wird.
2. Prüfe per grep/Read in der Zieldatei, ob das Thema dort tatsächlich behandelt wird – nicht nur laut Gliederung, im Text. Bei „Abschnitt N.M“: steht es in genau diesem Abschnitt?
3. Wenn nicht: Korrigiere den Verweis auf die Stelle, wo das Thema wirklich steht (grep über alle Kapitel), oder entferne den Verweis bzw. formuliere den Satz um, falls das Thema nirgends behandelt wird. Behauptungen wie „wird in Kapitel X ausführlich behandelt“ nur stehen lassen, wenn das zutrifft.
Doppelungen: Prüfe, ob dieselbe Studie, dasselbe Konzept oder dieselbe Mechanik in mehreren Kapiteln AUSFÜHRLICH beschrieben wird (grep über alle Kapitel). Die Hauptstelle ist das Kapitel, zu dessen Thema es laut Gliederung gehört. Kürze in DEINEN Kapiteln Nebenstellen auf ein bis zwei Sätze mit Verweis auf die Hauptstelle. Ändere Kapitel anderer Gruppen nicht; melde stattdessen, was dort zu kürzen wäre.
Widersprüche: Widersprechen sich Angaben zum selben Sachverhalt zwischen deinen Kapiteln und dem Zielkapitel (andere Zahl, anderes Jahr, andere Versionsangabe, anderes Verhalten)? Kläre per Quelle (siehe `quellen/QUELLEN.md`) und gleiche in deinen Kapiteln an; melde, wenn das andere Kapitel falsch ist.
Ändere nur deine Kapitel, minimal-invasiv mit Edit. Arbeitsdateien nur unter `arbeit/konsistenz-{{gruppe}}/`.
Antworte am Ende kurz (max. 8 Zeilen, Markdown): Anzahl geprüfter Verweise, Korrekturen, gefundene Widersprüche und Doppelungen in fremden Kapiteln (Datei, Stelle, was zu tun ist).

19
prompts/pruefung.md Normal file
View File

@@ -0,0 +1,19 @@
Du bist unabhängiger Fachlektor und Faktenprüfer für ein deutschsprachiges Sachbuch zum Thema „{{thema}}“. Du hast das Kapitel nicht geschrieben und traust ihm nicht.
Lies zuerst: `buch/STIL.md`, `buch/GLIEDERUNG.md`, `quellen/QUELLEN.md`.
Prüfe vollständig: `buch/{{datei}}` (Kapitel {{n}} – {{kapitel_titel}}).
Der Autor hat diese Punkte als nicht belegt oder unsicher gemeldet – prüfe sie vorrangig:
{{autor_bericht}}
Loop, bis nichts mehr zu korrigieren ist:
1. Lies das Kapitel komplett. Extrahiere alle überprüfbaren Behauptungen: Bezeichner und Signaturen, Versionsangaben, Studien (Autoren, Jahr, Design, Stichprobe, Ergebnis, Effektgröße), Zahlen, Zitate, Verhaltensbeschreibungen („was passiert wann“), Zuschreibungen („X hat Y eingeführt“).
2. Verifiziere möglichst ALLE gegen die lokalen Quellen (QUELLEN.md). Priorisiere nach Fehlerwahrscheinlichkeit. Führe Codebeispiele aus bzw. teste beschriebenes Verhalten mit kleinen Skripten, wo STIL.md das vorsieht. Versionsangaben gegen Tags/Changelogs prüfen. Achte besonders auf: falsche Zuschreibungen, verdrehte Effektrichtungen, überzogene Kausalaussagen aus Korrelationsstudien, erfundene Stichprobengrößen, veraltete oder erfundene Bezeichner, Doku-Aussagen, die dem Quellcode widersprechen (dann gilt der Quellcode).
3. Korrigiere Fehler direkt in der Datei mit Edit (minimal-invasiv, Stil beibehalten). Unbelegbares vorsichtiger formulieren oder streichen.
4. Prüfe Form: Überschriften exakt wie GLIEDERUNG.md; keine Übungen/Merkkästen/Zusammenfassungen/Literaturlisten; Querverweise zeigen auf Kapitel, die das Thema laut Gliederung (und, falls die Datei schon existiert, laut Text) wirklich behandeln; keine größeren Doppelungen mit Themen, die in andere Kapitel gehören (dann kürzen und verweisen); Rechtschreibung, Umlaute, Markdown.
5. Wiederhole, bis du keine Fehler mehr findest.
Ändere nur `buch/{{datei}}`. Arbeitsdateien nur unter `arbeit/pruefung-{{n}}/`. Keine persönlichen Daten an externe Dienste senden.
Antworte am Ende mit einem kurzen Bericht (max. 10 Zeilen, Markdown): Anzahl geprüfter Behauptungen und ausgeführter Tests; konkrete Korrekturen; was weiterhin unbelegt ist; Widersprüche zu anderen Kapiteln, die dir aufgefallen sind (Datei und Stelle).

26
prompts/recherche.md Normal file
View File

@@ -0,0 +1,26 @@
Du bereitest ein deutschsprachiges Sachbuch zum Thema „{{thema}}“ vor.
{{hinweise_block}}
Du arbeitest im Verzeichnis dieses Buchprojekts (aktuelles Arbeitsverzeichnis). Unterordner: `quellen/` (Rohmaterial), `buch/` (Buchdateien), `berichte/`.
Deine Aufgaben, in dieser Reihenfolge:
1. **Deutung.** Kläre, was mit dem Thema gemeint ist. Bei mehrdeutigen Namen wähle die wahrscheinlichste Deutung selbst (keine Rückfragen möglich). Lege den Buchtyp fest: `werkzeug` (Software, Framework, Engine, Sprache – der Leser soll die Sache verstehen und richtig einsetzen) oder `wissen` (Wissenschaft, Fachgebiet, Phänomen – der Leser soll den Forschungsstand verstehen). Bestimme Zielgruppe und Anspruch; beachte die Hinweise oben.
2. **Quellen beschaffen – lokal, prüfbar.** Lege alles unter `quellen/` ab, damit die späteren Autoren und Prüfer ohne Websuche arbeiten können (das Websuch-Kontingent je Agent ist begrenzt):
- Software: Quellcode und offizielle Dokumentation der aktuellen stabilen Version per `git clone --depth 1` (Tag der aktuellen Version ermitteln), ggf. auch der Vorversion für Vergleiche; Klassenreferenzen; wenn eine ausführbare Version frei erhältlich ist (z. B. ein Engine-Binary, ein CLI), herunterladen und einen kurzen Testlauf machen, damit Beispiele später ausgeführt werden können; Upgrade-/Changelog-Dateien.
- Wissenschaft: einen Korpus von Studien mit Abstracts, z. B. per Europe PMC REST (`https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=…&resultType=core&pageSize=1000&format=json&cursorMark=*`, alle Seiten), als JSON unter `quellen/`; dazu die zentralen Metaanalysen und Übersichtsarbeiten benennen; Crossref (`https://api.crossref.org/works?query.bibliographic=…`) für Metadaten.
- Sonstiges: Wikipedia-Artikel (de/en) als Text, offizielle Seiten, Standards, Primärquellen – als Dateien speichern.
- Nutze WebSearch sparsam (Kontingent), WebFetch und `curl` gezielt. Keine persönlichen Daten an Dienste senden.
3. **`quellen/QUELLEN.md` schreiben:** Was liegt wo (Pfade), Version/Stand, wie man darin prüft (konkrete grep-/curl-/Skript-Beispiele), was verlässlicher ist (z. B. Quellcode vor Doku), welche Online-Quellen bei Bedarf erlaubt sind. Diese Datei ist die Arbeitsanweisung für alle späteren Agenten.
4. **`buch/STIL.md` schreiben:** Beginne mit dem Leitfaden unten unverändert, ergänze dann den Abschnitt „## Quellen und Prüfung“ mit den themenspezifischen Regeln (Referenzversion, welche Prüfschritte Pflicht sind – z. B. Bezeichner gegen Klassenreferenz, Codebeispiele headless ausführen, Studienangaben gegen Abstracts – und Verweise auf QUELLEN.md) und den Abschnitt „## Buchtyp und Leser“ (Zielgruppe, vorausgesetztes Wissen, Hauptsprache der Beispiele).
5. **`deutung.json` schreiben** (im Arbeitsverzeichnis): `{"titel": "…", "untertitel": "…", "deutung": "<1–3 Sätze, was das Buch behandelt>", "buchtyp": "werkzeug|wissen", "zielgruppe": "…", "referenz": "<Version/Stand, z. B. 'Godot 4.7.2' oder 'Literatur bis 2026'>"}`.
Antworte am Ende kurz (max. 8 Zeilen): Deutung, Buchtyp, was unter `quellen/` liegt, was fehlt oder unsicher ist.
---
LEITFADEN (unverändert als Anfang von buch/STIL.md übernehmen, mit der Überschrift „# Stil- und Qualitätsleitfaden“):
{{stil_basis}}

30
prompts/stil_basis.md Normal file
View File

@@ -0,0 +1,30 @@
## Ziel
Ein deutschsprachiges Sachbuch, das **Verständnis** vermittelt: Wie ist die Sache aufgebaut, warum ist sie so, welche Befunde, Argumente oder Mechanismen stehen dahinter, was ist gesichert, was umstritten? Ziel ist eine breite Übersicht über alles Wichtige, nicht Vollständigkeit bis ins letzte Detail. Kein Tutorial, keine Referenz, kein Ratgeber.
## Form
- Sprache: Deutsch, korrekte Umlaute und ß, gut lesbare Sachbuchprosa. Keine direkte Anrede als Stilmittel; „wir“ sparsam.
- **Keine** Übungen, Selbsttests, Lernziele, Merkkästen, Checklisten, „Praxistipps“, Schritt-für-Schritt-Anleitungen als Selbstzweck, Zusammenfassungen am Kapitelende, Literaturlisten.
- Überwiegend Fließtext. Listen und Tabellen nur, wo sie echte Klarheit bringen – sparsam.
- Fachbegriffe bei der ersten Nennung kurz erklären; englische Fachbegriffe nennen, wo sie üblich sind.
- Konkrete Belege im Text: Studien mit Autor und Jahr („(Curran & Hill, 2019)“), Klassen und Befehle in `Code`-Formatierung, Versionsangaben („seit 4.3“) nur, wenn geprüft.
- Querverweise „(→ Kapitel 8)“ bzw. „(→ Abschnitt 8.2)“ statt Wiederholung. Die Gliederung zeigt, was wo steht; Themen anderer Kapitel nur kurz streifen und verweisen.
- Codebeispiele (falls das Thema Software ist): kurz (typisch 5–30 Zeilen), nur wo sie ein Konzept erklären, mit Sprachangabe im Codeblock, im Text erklärt.
## Aufbau jeder Kapiteldatei (Markdown)
```
# Kapitel N – Titel
Einleitung: 2–4 Absätze, die ins Thema führen und die Leitfrage stellen.
## N.1 Titel des Abschnitts
Fließtext, ggf. mit ### Unterabschnitten.
## N.2 …
```
Überschriften exakt wie in GLIEDERUNG.md (Nummerierung beibehalten). Umfang je Abschnitt ca. 1.000–2.000 Wörter, je Kapitel ca. 5.000–10.000 Wörter. Lieber substanziell als aufgebläht.
## Korrektheit
- Jede Tatsachenangabe (Name, Jahr, Zahl, Bezeichner, Signatur, Versionsangabe, Studienergebnis) muss gegen die im Abschnitt „Quellen und Prüfung“ genannten Quellen geprüft sein. Nichts aus dem Gedächtnis übernehmen, ohne es zu prüfen; was sich nicht belegen lässt, vorsichtiger formulieren oder weglassen.
- Keine erfundenen Studien, Zitate, Zahlen, Klassen oder Optionen.
- Kontroversen und Replikationsprobleme fair darstellen.
- Keine persönlichen Daten an externe Dienste senden (keine E-Mail-Adressen als Parameter).

12
prompts/vorwort.md Normal file
View File

@@ -0,0 +1,12 @@
Du schreibst das Vorwort für ein deutschsprachiges Sachbuch zum Thema „{{thema}}“.
Lies: `deutung.json`, `buch/STIL.md`, `buch/GLIEDERUNG.md`, die Einleitungen (Text vor dem ersten `## `) aller Kapitel unter `buch/` sowie die Prüfberichte unter `berichte/` (Dateien `pruefung-*.md`), um zu wissen, was geprüft wurde und wo Grenzen liegen.
Schreibe `buch/00-vorwort.md` (Markdown, Überschrift `# Vorwort`, 350–600 Wörter):
- Worum es geht und für wen das Buch ist; was es nicht ist (kein Tutorial, kein Ratgeber, keine Referenz).
- Wie das Buch aufgebaut ist (die Teile in einem Absatz, keine Liste).
- Woran sich die Darstellung orientiert (Referenzversion bzw. Literaturstand) und wie die Angaben geprüft wurden – ehrlich, ohne Übertreibung; welche Grenzen bleiben.
- Hinweis auf Querverweise „(→ Abschnitt N.M)“.
- Bei Gesundheitsthemen: ein Satz, dass das Buch informiert und nicht berät, mit Hinweis auf Hilfsangebote (Deutschland: 116 117, Telefonseelsorge 0800 111 0 111 / 0800 111 0 222).
Sachbuchprosa, keine Anrede des Lesers, keine Werbung. Antworte am Ende mit einer Zeile.