Compare commits

..
26 Commits
Author SHA1 Message Date
team3andClaude Fable 5.1 54d1d6a8da Archiv: je Version quellen.tar und arbeit.tar statt einzelner Dateien – SMB schafft 25 Dateien/s, am Stück 90 MB/s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 16:14:49 +02:00
team3andClaude Fable 5.1 7ba08b68e0 Buchansicht: eigene Monospace-Schrift mit Kastenzeichen für Textdiagramme (DejaVu Sans Mono, Teilmenge)
Handy-Schriften haben keine Kastenzeichen; der Ersatz aus einer anderen Schrift ist breiter und verschiebt die Linien.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 15:52:05 +02:00
team3andClaude Fable 5.1 ff813f85d1 Buchansicht: Textdiagramme auf schmalen Bildschirmen auf die Blockbreite verkleinern statt abschneiden
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 15:46:33 +02:00
team3andClaude Fable 5.1 be19abc065 Archiv: Quellen und Arbeitsdateien fertiger Versionen auf ARCHIV_DIR auslagern (z. B. Storage Box)
Nach jedem fertigen Lauf und beim Start nachholend; QUELLEN.md bleibt. Löschen und Zurücksetzen räumen das Archiv mit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 15:31:35 +02:00
team3andClaude Fable 5.1 478ce6b412 Agenten: Nachrichtenpuffer des SDK auf 20 MB, ein gelesenes Tafelbild sprengte 1 MB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 15:05:55 +02:00
team3andClaude Fable 5.1 c32fbce88e Lauf zeigt während des Laufs an, dass er nach der Gliederung anhält
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 19:40:31 +02:00
team3andClaude Fable 5.1 1e8a057615 Stufe „Stoff auswählen“ vor der Gliederung: Auswahl und Tiefe je Thema
Neuer Agent entscheidet aus Quellen und Leitfrage, welche Themen ins Buch
kommen und wie tief (herleiten, erklären, nennen, weggelassen), mit
Begründung in buch/STOFF.md. Die Gliederung baut nur daraus, das Gewicht
folgt der Tiefe. Autoren und Prüfer halten sich an STOFF.md.
Nachgetragene Stufen gelten als fertig, wenn eine spätere schon fertig ist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 17:53:12 +02:00
team3andClaude Fable 5.1 1edc13556a Fehlermeldung beim Anlegen nennt die belegte Bezeichnung
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 16:04:47 +02:00
team3andClaude Fable 5.1 e06be9d50a Stopp nach der Gliederung als Häkchen neben „Buch erzeugen“
Das Häkchen steht im Lauf neben dem Startknopf und bei „Neue Version“
neben „Buch erzeugen“, nicht mehr im Anlegen-Formular. Es wird mit dem
Start gespeichert und ist sichtbar, solange die Gliederung aussteht.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 16:02:57 +02:00
team3andClaude Fable 5.1 4cd4b7ed2e Optionaler Stopp nach der Gliederung je Version
Häkchen beim Anlegen und bei „Neue Version“: der Lauf hält nach der
Gliederung als „pausiert“ an, damit man sie prüfen kann, bevor die
Kapitel geschrieben werden. „Fortsetzen“ schreibt weiter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:52:14 +02:00
team3andClaude Fable 5.1 78a74d140b Platzhalter Spezialisierung: „z. B. Kartenspiel“
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:44:23 +02:00
team3andClaude Fable 5.1 4c989bc93c Spezialisierung gehört zum Thema, Hinweise zur Version
Thema „Game Design“ plus Spezialisierung „digitale Sammelkartenspiele“
wird zur Bezeichnung „Game Design – digitale Sammelkartenspiele“
(Ordner, Liste). Die Prompts bekommen das Hauptthema und die
Spezialisierung getrennt. „Neue Version“ ändert nur noch die Hinweise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:42:44 +02:00
team3andClaude Fable 5.1 d2da9920a3 Eingaben im Lauf als Raster: eine Zeile je Feld
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:32:36 +02:00
team3andClaude Fable 5.1 97e465181a Hinweise und Spezialisierung je Version: im Lauf sichtbar, bei „Neue Version“ änderbar
Eingaben liegen jetzt in vN/state.json; alte Versionen ohne eigene
Eingaben nehmen die des Themas. „Neue Version“ öffnet ein Formular mit
den bisherigen Werten, Thema bleibt fest. Zurücksetzen behält die Eingaben.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:29:34 +02:00
team3andClaude Fable 5.1 7538812cfd Spezialisierung je Thema: begrenzt den Umfang auf einen Ausschnitt
Drittes Feld neben Thema und Hinweisen. Hinweise fügen hinzu, die
Spezialisierung nimmt weg: Recherche, Gliederung, Kapitel und Prüfung
bekommen sie als verbindliche Grenze; nur Stoff, der dafür gebraucht
wird, Grundlagen nur soweit nötig.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:20:10 +02:00
team3andClaude Fable 5.1 efd3bc8598 Merken-Zeilen als ganze Sätze, allein verständlich, 80 bis 120 Wörter je Kapitel
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 22:07:01 +02:00
team3andClaude Fable 5.1 1cd091ceff Merken-Abschnitt als Spickzettel: Bullet-Liste „Begriff: Kern“, fünf bis zehn Zeilen
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 21:57:25 +02:00
team3andClaude Fable 5.1 62b112e1f1 Merken-Abschnitt: knapp, zwei bis vier kurze Sätze je Absatz, keine Herleitungen
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 21:35:44 +02:00
team3andClaude Fable 5.1 8c2ede965e Merken-Abschnitt statt Kern-Block: nummerierter letzter Abschnitt mit Aussagen
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 18:53:54 +02:00
team3andClaude Fable 5.1 2067f0615f Als App installierbar (Web-Manifest, Vollbild) und Vollbild-Rückholung im Lesemodus
- Manifest mit Icons, display fullscreen; Backend liefert statische Dateien
  des Frontends direkt aus.
- Nach dem Sperren des Handys holt das nächste Umblättern das Vollbild zurück.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 18:29:38 +02:00
team3andClaude Fable 5.1 48cf53db9c Thema „nacht“: reines Schwarz und dunkles Rot zum Lesen im Dunkeln
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 18:18:27 +02:00
team3andClaude Fable 5.1 d1193115c1 Oberfläche: Kern-Knopf entfernt, Reset als Symbol
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 18:13:49 +02:00
team3andClaude Fable 5.1 ade75bca71 Reset-Knopf: Leseposition beider Ansichten auf den Anfang
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 18:04:53 +02:00
team3andClaude Fable 5.1 9945b20469 Leseposition: Buchansicht und Lesemodus teilen einen Stand, nie rückwärts
- Lesemodus-Schlüssel (03-4:0) wurden in der Buchansicht nicht erkannt.
- Es gilt immer die weiter hinten liegende Stelle beider Ansichten.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 18:03:42 +02:00
team3andClaude Fable 5.1 7b60b20856 Buchansicht: Sprung beim Laden überschreibt die Lesemodus-Position nicht mehr
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:04:01 +02:00
team3 20f33c07bb Revert "Buchansicht startet beim Laden immer oben; nur der Lesemodus setzt fort"
This reverts commit af935b8cbd.
2026-10-08 17:03:49 +02:00
30 changed files with 457 additions and 97 deletions

No files matched your search

+3
View File
@@ -3,3 +3,6 @@
CLAUDE_CODE_OAUTH_TOKEN=
# oder ein API-Key
ANTHROPIC_API_KEY=
# Archiv für Quellen und Arbeitsdateien fertiger Versionen, z. B. die Storage Box; leer: alles bleibt in storage/
ARCHIV_DIR=
+15 -9
View File
@@ -12,21 +12,25 @@ Ohne `v2` gilt die neueste Version.
## Themen und Versionen
- `GET /api/themen` → `[{slug, name, titel, hinweise, angelegt, status, laeuft, stufe, versionen: [{v, status, laeuft, stufe, angelegt, kosten_usd}]}]`
- `GET /api/themen` → `[{slug, name, thema, spezialisierung, titel, hinweise, angelegt, status, laeuft, stufe, versionen: [{v, status, laeuft, stufe, angelegt, kosten_usd}]}]`
– `status/laeuft/stufe` gelten für die neueste Version; `status`: `neu | laeuft | pausiert | fertig | fehler`
- `POST /api/themen` `{name, hinweise}` → Thema wie oben (legt `v1` an)
- `POST /api/themen` `{name, hinweise, spezialisierung}` → Thema wie oben (legt `v1` an)
– `name`: Hauptthema; `spezialisierung`: grenzt es ein, nur was dafür gebraucht wird; die Bezeichnung (`name` in der Antwort, Ordner) wird „Thema – Spezialisierung“
– `hinweise`: wie das Thema zu verstehen ist; gelten je Version und sind bei einer neuen Version änderbar
- `GET /api/themen/{slug}` → Thema wie oben
- `DELETE /api/themen/{slug}` – ganzes Thema
- `POST /api/themen/{slug}/versionen` → `{"v": N}` – nächste Version, gleiche Eingaben, ohne Start
- `POST /api/themen/{slug}/versionen` `{hinweise?, stopp_gliederung?}` → `{"v": N}` – nächste Version ohne Start; ohne Angabe übernimmt sie die Hinweise der neuesten Version
- `DELETE /api/themen/{slug}/v{N}` – nur diese Version
- `POST /api/themen/{slug}/v{N}/start` – startet oder setzt fort (resümiert an der offenen Stufe)
- `POST /api/themen/{slug}/v{N}/start` `{stopp_gliederung?}` – startet oder setzt fort (resümiert an der offenen Stufe); `stopp_gliederung: true` hält nach der Gliederung als `pausiert` an, „Fortsetzen“ schreibt die Kapitel
- `POST /api/themen/{slug}/v{N}/pause` – bricht laufende Agenten ab; Neustart mit `start`
- `POST /api/themen/{slug}/v{N}/reset` – löscht alles in der Version außer dem Zustand „neu“
- `GET|PUT /api/themen/{slug}/v{N}/position` – Leseposition `{abschnitt, lesemodus}`
## Stufen
`recherche → gliederung → schreiben → pruefung → lektorat → konsistenz → montage`. Das Lektorat glättet jedes geprüfte Kapitel
`recherche → stoff → gliederung → schreiben → pruefung → lektorat → konsistenz → montage`. Die Stoffauswahl (`prompts/stoff.md`)
entscheidet vor der Gliederung, welche Themen ins Buch kommen und wie tief (`herleiten`, `erklären`, `nennen`, weggelassen), mit
Begründung in `buch/STOFF.md`; die Gliederung baut nur daraus, Autoren und Prüfer halten sich daran. Das Lektorat glättet jedes geprüfte Kapitel
Abschnitt für Abschnitt sprachlich (ohne Werkzeuge, `prompts/lektorat.md`); ein Abschnitt wird nur übernommen, wenn Bezeichner,
Zahlen, Überschriften, Codeblöcke und Diagramme erhalten sind und der Umfang nicht über 20 % wächst. Marker: `berichte/lektorat-NN.md`.
Alte Zustände ohne diese Stufe bekommen sie beim Laden nachgetragen.
@@ -37,7 +41,7 @@ Alte Zustände ohne diese Stufe bekommen sie beim Laden nachgetragen.
```json
{
"slug": "godot-4", "name": "Godot 4", "version": 1, "titel": "Godot 4", "hinweise": "…",
"slug": "godot-4", "name": "Godot 4", "version": 1, "thema": "Godot 4", "spezialisierung": "", "titel": "Godot 4", "hinweise": "…", // hinweise: die dieser Version
"status": "laeuft", "grund": "", "stufe": "schreiben", "angelegt": "2026-09-28T10:00:00Z",
"deutung": {"titel": "Godot 4", "untertitel": "…", "deutung": "…", "buchtyp": "werkzeug|wissen", "zielgruppe": "…"},
"stufen": [
@@ -91,16 +95,18 @@ Solange ein Lauf läuft, enthält `buch` die bereits geschriebenen Kapitel (Stat
```
storage/<Thema>/
thema.json {name, slug, titel, hinweise, angelegt}
thema.json {name, thema, spezialisierung, slug, titel, hinweise, angelegt} – name = „Thema – Spezialisierung“
v1/, v2/, … je Version:
state.json Zustand wie oben (ohne Buch)
state.json Zustand wie oben (ohne Buch), mit hinweise und stopp_gliederung der Version
deutung.json vom Recherche-Agenten
position.json Leseposition
quellen/ QUELLEN.md + geklonte/geladene Quellen
buch/ STIL.md, GLIEDERUNG.md, 00-vorwort.md, NN-*.md, README.md, buch.md, buch.pdf
buch/ STIL.md, STOFF.md, GLIEDERUNG.md, 00-vorwort.md, NN-*.md, README.md, buch.md, buch.pdf
berichte/ <agent-id>.md (Abschlussberichte)
arbeit/ Arbeitsdateien der Agenten
log.jsonl Ereignisse
```
Alte Ablagen (alles direkt unter `storage/<Thema>/`) werden beim Start nach `v1/` verschoben, fehlende Slugs nachgetragen.
Bei fertigen Versionen liegen `quellen/` (außer QUELLEN.md) und `arbeit/` als `quellen.tar` und `arbeit.tar` im Archiv
(`BUCHWERK_ARCHIV/<Thema>/vN/`), wenn eines konfiguriert ist; Ereignisse dazu tragen den Agenten `archiv`.
+7 -2
View File
@@ -1,7 +1,7 @@
# 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
Aus einem Thema (plus optionaler Spezialisierung, die es eingrenzt, und optionalen Hinweisen) entsteht ein deutschsprachiges Sachbuch. Ein Python-Backend
steuert Agenten des Claude Agent SDK (Opus 5.5): Recherche und Deutung, Stoffauswahl (was kommt rein, wie tief), 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.
@@ -24,6 +24,11 @@ make logs / make stop
`storage/` und `.claude-data/` (Sitzungen der CLI) werden als Volumes eingebunden; die Claude-Code-CLI
liegt als Binary im SDK, Node wird im Container nicht gebraucht.
Archiv: ist `ARCHIV_DIR` in `.env` gesetzt (auf dem Server ein Ordner der Storage Box, deren Mount `gid=1000` hat, da der
Container als `app` schreibt), packt `backend/archiv.py` nach jedem fertigen Lauf `quellen/` (außer QUELLEN.md) und `arbeit/`
der Version als `quellen.tar` und `arbeit.tar` dorthin und löscht sie – das sind die Gigabytes an Klonen. Beim Start holt er
nach, was fehlt. Ohne Variable bleibt alles in `storage/`.
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
+2
View File
@@ -35,6 +35,8 @@ async def laufen(name: str, agent_id: str, stufe_id: str, titel: str, prompt: st
system_prompt={"type": "preset", "preset": "claude_code", "append": SYSTEM_ZUSATZ},
# Workflow läuft im Hintergrund und ließ einen Agenten ohne Bericht enden – gesperrt.
disallowed_tools=["Workflow"],
# Ein gelesenes Bild (Tafelbild als PNG) sprengte die Vorgabe von 1 MB je Nachricht.
max_buffer_size=20 * 1024 * 1024,
)
letzter_text, kosten, tokens, status, fehler = "", 0.0, 0, "fehler", ""
try:
+78
View File
@@ -0,0 +1,78 @@
"""Quellen und Arbeitsdateien fertiger Versionen als tar ins Archiv (config.ARCHIV, auf dem Server die Storage Box).
Je Version quellen.tar (ohne QUELLEN.md, die bleibt im Speicher und steht in der Buchansicht) und arbeit.tar. Ein tar statt
einzelner Dateien, weil SMB je Datei mehrere Round-Trips braucht (25 Dateien/s), am Stück aber 90 MB/s schafft.
Nichts liest danach noch daraus: neue Versionen recherchieren neu, fertige Läufe setzen nie fort.
Erst packen, dann löschen – ein Abbruch lässt den Speicher vollständig, der nächste Start holt nach."""
import asyncio
import logging
import shutil
import subprocess
from pathlib import Path
from . import config, state
log = logging.getLogger("buchwerk")
ORDNER = ("quellen", "arbeit")
BLEIBT = {"QUELLEN.md"}
_aufgaben: set[asyncio.Task] = set()
def faellig(key: str) -> bool:
"""Fertig und unter quellen/ oder arbeit/ liegt noch etwas."""
v = state.verzeichnis(key)
return state.zustand(key)["status"] == "fertig" and any(_eintraege(v / u) for u in ORDNER)
def _eintraege(d: Path) -> list[Path]:
return [p for p in d.iterdir() if p.name not in BLEIBT] if d.is_dir() else []
def auslagern(key: str) -> None:
"""Packt quellen/ (außer QUELLEN.md) und arbeit/ nach ARCHIV/<key>/<ordner>.tar und löscht sie. Blockiert – im Thread aufrufen."""
v = state.verzeichnis(key)
ziel = config.ARCHIV / key
ziel.mkdir(parents=True, exist_ok=True)
for u in ORDNER:
eintraege = _eintraege(v / u)
if not eintraege:
continue
tmp = ziel / f"{u}.tar.tmp"
r = subprocess.run(["tar", "-cf", str(tmp), "-C", str(v / u), *[p.name for p in eintraege]], capture_output=True, text=True)
if r.returncode:
raise OSError(f"tar {u}/ von {key}: {r.stderr.strip()[-300:]}")
tmp.replace(ziel / f"{u}.tar")
for p in eintraege:
if p.is_dir() and not p.is_symlink():
shutil.rmtree(p)
else:
p.unlink()
async def _auslagern(key: str) -> None:
state.ereignis(key, "archiv", "start", f"Quellen und Arbeitsdateien als tar nach {config.ARCHIV / key}")
try:
await asyncio.to_thread(auslagern, key)
state.ereignis(key, "archiv", "ende", "ausgelagert")
except OSError as e:
log.exception("Archiv %s", key)
state.ereignis(key, "archiv", "fehler", f"{type(e).__name__}: {e}")
state.speichern(key)
def anstossen(key: str) -> None:
"""Im Hintergrund, der Lauf gilt schon als fertig."""
if config.ARCHIV and faellig(key):
t = asyncio.create_task(_auslagern(key))
_aufgaben.add(t)
t.add_done_callback(_aufgaben.discard)
async def nachholen() -> None:
"""Beim Start: alle fertigen Versionen mit Restbestand, nacheinander (Bestand und abgebrochene Läufe)."""
if not config.ARCHIV:
return
for t in state.themen():
for v in state.versionen(t["name"]):
key = f"{t['name']}/v{v}"
if faellig(key):
await _auslagern(key)
+4
View File
@@ -1,9 +1,12 @@
import os
from pathlib import Path
WURZEL = Path(__file__).resolve().parent.parent
STORAGE = WURZEL / "storage"
PROMPTS = WURZEL / "prompts"
FRONTEND_DIST = WURZEL / "frontend" / "dist"
# Quellen und Arbeitsdateien fertiger Versionen wandern hierhin (backend/archiv.py); leer: alles bleibt in storage/.
ARCHIV = Path(os.environ["BUCHWERK_ARCHIV"]) if os.environ.get("BUCHWERK_ARCHIV") else None
PORT = 8020
MODELL = "claude-opus-5-5"
@@ -16,6 +19,7 @@ LESE_WPM = 120
STUFEN = [
("recherche", "Thema deuten und Quellen beschaffen"),
("stoff", "Stoff auswählen"),
("gliederung", "Gliederung"),
("schreiben", "Kapitel schreiben"),
("pruefung", "Kapitel prüfen"),
+33 -10
View File
@@ -1,3 +1,4 @@
import asyncio
import json
import logging
from contextlib import asynccontextmanager
@@ -8,7 +9,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.middleware.gzip import GZipMiddleware
from pydantic import BaseModel
from . import config, montage, pipeline, state
from . import archiv, config, montage, pipeline, state
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
@@ -17,7 +18,9 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
async def lifespan(app: FastAPI):
config.STORAGE.mkdir(exist_ok=True)
state.migrieren()
nachholen = asyncio.create_task(archiv.nachholen())
yield
nachholen.cancel()
for name in list(pipeline._laeufe):
await pipeline.pausieren(name)
@@ -29,6 +32,16 @@ app.add_middleware(GZipMiddleware, minimum_size=1000) # Buchdaten sind 1–2 MB
class ThemaNeu(BaseModel):
name: str
hinweise: str = ""
spezialisierung: str = ""
class VersionNeu(BaseModel):
hinweise: str | None = None # None: wie die bisherige Version
stopp_gliederung: bool = False
class Start(BaseModel):
stopp_gliederung: bool | None = None # None: wie gespeichert
class Position(BaseModel):
@@ -67,7 +80,8 @@ def _thema_info(t: dict) -> dict | None:
if not vs:
return None
neueste = vs[-1]
return {"slug": t["slug"], "name": t["name"], "titel": t["titel"], "hinweise": t.get("hinweise", ""),
return {"slug": t["slug"], "name": t["name"], "thema": t.get("thema") or t["name"], "spezialisierung": t.get("spezialisierung", ""),
"titel": t["titel"], "hinweise": t.get("hinweise", ""),
"angelegt": t.get("angelegt", ""), "status": neueste["status"], "laeuft": neueste["laeuft"],
"stufe": neueste["stufe"], "versionen": vs}
@@ -80,10 +94,10 @@ async def themen():
@app.post("/api/themen")
async def thema_anlegen(body: ThemaNeu):
name = body.name.strip()
if not state.name_ok(name):
if not state.name_ok(name) or not state.name_ok(state.bezeichnung(name, body.spezialisierung.strip())):
raise HTTPException(400, "Ungültiger Name")
try:
return _thema_info(state.anlegen(name, body.hinweise))
return _thema_info(state.anlegen(name, body.hinweise, body.spezialisierung))
except ValueError as e:
raise HTTPException(409, str(e))
@@ -103,9 +117,10 @@ async def thema_loeschen(slug: str):
@app.post("/api/themen/{slug}/versionen")
async def version_anlegen(slug: str):
"""Neue Version: gleiche Eingaben, frischer Lauf, ohne Wissen über die Vorgänger."""
key = state.version_anlegen(_thema(slug)["name"])
async def version_anlegen(slug: str, body: VersionNeu | None = None):
"""Neue Version: frischer Lauf ohne Wissen über die Vorgänger; Hinweise wahlweise geändert."""
b = body or VersionNeu()
key = state.version_anlegen(_thema(slug)["name"], b.hinweise, b.stopp_gliederung)
return {"v": int(key.rsplit("/v", 1)[1])}
@@ -114,7 +129,8 @@ async def zustand(slug: str, n: int):
key = _key(slug, n)
t = _thema(slug)
st = state.zustand(key)
return {"slug": slug, "name": t["name"], "version": n, "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st,
return {"slug": slug, "name": t["name"], "thema": t.get("thema") or t["name"], "version": n, "titel": t["titel"], **st,
"hinweise": state.hinweise_von(key), "spezialisierung": state.spezialisierung_von(key),
"deutung": st.get("deutung") or state.deutung(key), "ereignisse": st["ereignisse"][-100:]}
@@ -127,10 +143,13 @@ async def version_loeschen(slug: str, n: int):
@app.post("/api/themen/{slug}/v{n}/start")
async def start(slug: str, n: int):
async def start(slug: str, n: int, body: Start | None = None):
key = _key(slug, n)
if state.zustand(key)["status"] == "fertig":
st = state.zustand(key)
if st["status"] == "fertig":
raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen")
if body and body.stopp_gliederung is not None:
st["stopp_gliederung"] = body.stopp_gliederung
pipeline.starten(key)
return {"ok": True}
@@ -190,4 +209,8 @@ if config.FRONTEND_DIST.exists():
async def spa(pfad: str):
if pfad.startswith("api/"):
raise HTTPException(404, "Unbekannter API-Pfad")
# Statische Dateien des Frontends (Manifest, Icons) direkt ausliefern, alles andere ist die App
datei = (config.FRONTEND_DIST / pfad).resolve()
if pfad and datei.is_file() and config.FRONTEND_DIST.resolve() in datei.parents:
return FileResponse(datei)
return FileResponse(config.FRONTEND_DIST / "index.html")
+2 -2
View File
@@ -9,7 +9,7 @@ from . import config, gliederung, state
log = logging.getLogger("buchwerk")
ABSCHNITT_RE = re.compile(r"^## (\d+\.\d+) (.+?)\s*$", re.M)
KERN_RE = re.compile(r"^## Kern[ \t]*\n(.*?)(?=^## |\Z)", re.S | re.M)
KERN_RE = re.compile(r"^## (?:Kern|\d+\.\d+ Merken)[ \t]*\n(.*?)(?=^## |\Z)", re.S | re.M)
def _woerter(text: str) -> int:
@@ -90,7 +90,7 @@ def markdown(name: str) -> str:
def kern(name: str) -> str:
"""Alle `## Kern`-Blöcke der Kapitel in einer Datei – zum Auffrischen eines gelesenen Buchs."""
"""Alle Merken-Abschnitte (bzw. ältere `## Kern`-Blöcke) der Kapitel in einer Datei – zum Auffrischen eines gelesenen Buchs."""
g = guide(name); v = state.verzeichnis(name)
out = [f"# Kernerkenntnisse – {g['titel']}", ""]
for k in g["kapitel"]:
+42 -13
View File
@@ -3,7 +3,7 @@ Dateimarker (Bericht existiert = Agent fertig)."""
import asyncio
import logging
from . import agenten, config, gliederung, lektorat, montage, prompts, state
from . import agenten, archiv, config, gliederung, lektorat, montage, prompts, state
log = logging.getLogger("buchwerk")
_laeufe: dict[str, asyncio.Task] = {}
@@ -50,6 +50,9 @@ async def _lauf(name: str) -> None:
await STUFEN_FN[sid](name)
s.update(status="fertig", beendet=state.jetzt())
state.speichern(name)
if sid == "gliederung" and st.get("stopp_gliederung"):
st.update(status="pausiert", grund="Nach der Gliederung angehalten – „Fortsetzen“ schreibt die Kapitel", stufe="")
return
st.update(status="fertig", grund="", stufe="")
except asyncio.CancelledError:
st.update(status="pausiert", grund="Pausiert")
@@ -64,6 +67,8 @@ async def _lauf(name: str) -> None:
s["status"] = "fehler"
finally:
state.speichern(name)
if st["status"] == "fertig":
archiv.anstossen(name)
def _offen(name: str) -> None:
@@ -72,9 +77,10 @@ def _offen(name: str) -> None:
s["status"] = "offen"
def _thema(name: str) -> tuple[str, str]:
def _thema(name: str) -> tuple[str, str, str]:
"""Hauptthema (ohne Spezialisierung), Hinweise der Version, Spezialisierung des Themas."""
t = state.thema(name) or {}
return t.get("titel") or name, t.get("hinweise", "")
return t.get("thema") or state.thema_von(name), state.hinweise_von(name), state.spezialisierung_von(name)
def _kapitel(name: str) -> list[dict]:
@@ -108,12 +114,13 @@ async def _parallel(name: str, sid: str, aufgaben: list) -> None:
# ---- Stufen -------------------------------------------------------------------
async def st_recherche(name: str) -> None:
thema, hinweise = _thema(name)
thema, hinweise, spez = _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),
spezialisierung_block=prompts.spezialisierung_block(spez),
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":
@@ -131,8 +138,27 @@ async def st_recherche(name: str) -> None:
s["fertig"] = 1
async def st_stoff(name: str) -> None:
"""Auswahl vor der Struktur: welche Themen ins Buch kommen und wie tief (buch/STOFF.md); die Gliederung baut nur daraus."""
thema, hinweise, spez = _thema(name)
s = state.stufe(name, "stoff")
s.update(gesamt=1, fertig=0)
v = state.verzeichnis(name)
if not state.bericht_lesen(name, "stoff") or not (v / "buch" / "STOFF.md").exists():
(v / "berichte" / "stoff.md").unlink(missing_ok=True)
p = prompts.laden("stoff", thema=thema, hinweise_block=prompts.hinweise_block(hinweise),
spezialisierung_block=prompts.spezialisierung_block(spez))
r = await agenten.laufen(name, "stoff", "stoff", "Stoff auswählen", p)
if r["status"] != "fertig":
raise Abbruch("Stoffauswahl gescheitert: " + r["bericht"][:200])
if not (v / "buch" / "STOFF.md").exists():
(v / "berichte" / "stoff.md").unlink(missing_ok=True)
raise Abbruch("Stoffauswahl unvollständig: buch/STOFF.md fehlt")
s["fertig"] = 1
async def st_gliederung(name: str) -> None:
thema, hinweise = _thema(name)
thema, hinweise, spez = _thema(name)
s = state.stufe(name, "gliederung")
s.update(gesamt=1, fertig=0)
p = state.verzeichnis(name) / "buch" / "GLIEDERUNG.md"
@@ -140,7 +166,8 @@ async def st_gliederung(name: str) -> None:
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
pr = prompts.laden("gliederung", thema=thema, hinweise_block=prompts.hinweise_block(hinweise),
spezialisierung_block=prompts.spezialisierung_block(spez)) + hinweis
r = await agenten.laufen(name, "gliederung", "gliederung", "Gliederung entwerfen", pr)
if r["status"] != "fertig":
raise Abbruch("Gliederung gescheitert: " + r["bericht"][:200])
@@ -156,14 +183,15 @@ async def st_gliederung(name: str) -> None:
async def st_schreiben(name: str) -> None:
thema, _ = _thema(name)
thema, _, spez = _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"])
p = prompts.laden("kapitel", thema=thema, n=str(k["n"]), kapitel_titel=k["titel"], datei=k["datei"],
spezialisierung_block=prompts.spezialisierung_block(spez))
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))
@@ -171,7 +199,7 @@ async def st_schreiben(name: str) -> None:
async def st_pruefung(name: str) -> None:
thema, _ = _thema(name)
thema, _, spez = _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}")]
@@ -179,14 +207,15 @@ async def st_pruefung(name: str) -> None:
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)
p = prompts.laden("pruefung", thema=thema, n=str(k["n"]), kapitel_titel=k["titel"], datei=k["datei"], autor_bericht=autor,
spezialisierung_block=prompts.spezialisierung_block(spez))
aufgaben.append((f"pruefung-{k['n']:02d}", f"Kapitel {k['n']} prüfen", p))
await _parallel(name, "pruefung", aufgaben)
async def st_lektorat(name: str) -> None:
"""Geprüfte Kapitel sprachlich glätten; Marker: berichte/lektorat-NN.md."""
thema, _ = _thema(name)
thema, _, _ = _thema(name)
kap = [k for k in _kapitel(name) if (state.verzeichnis(name) / "buch" / k["datei"]).exists()]
s = state.stufe(name, "lektorat")
offen = [k for k in kap if state.bericht_lesen(name, f"pruefung-{k['n']:02d}") and not state.bericht_lesen(name, f"lektorat-{k['n']:02d}")]
@@ -209,7 +238,7 @@ async def st_lektorat(name: str) -> None:
async def st_konsistenz(name: str) -> None:
thema, _ = _thema(name)
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")
@@ -234,5 +263,5 @@ async def st_montage(name: str) -> None:
s["fertig"] = 1
STUFEN_FN = {"recherche": st_recherche, "gliederung": st_gliederung, "schreiben": st_schreiben,
STUFEN_FN = {"recherche": st_recherche, "stoff": st_stoff, "gliederung": st_gliederung, "schreiben": st_schreiben,
"pruefung": st_pruefung, "lektorat": st_lektorat, "konsistenz": st_konsistenz, "montage": st_montage}
+8
View File
@@ -11,3 +11,11 @@ def laden(name: str, **werte: str) -> str:
def hinweise_block(hinweise: str) -> str:
return f"Hinweise des Auftraggebers (verbindlich): {hinweise}\n" if hinweise else ""
def spezialisierung_block(spezialisierung: str) -> str:
"""Hinweise fügen hinzu, die Spezialisierung nimmt weg: sie begrenzt den Umfang des Buchs auf einen Ausschnitt des Themas."""
if not spezialisierung:
return ""
return (f"Spezialisierung (verbindliche Grenze des Umfangs): {spezialisierung}. Das Buch behandelt vom Thema nur, was dafür "
"gebraucht wird; alles andere entfällt, auch wenn es zum Thema gehört. Grundlagen nur, soweit sie dafür nötig sind.\n")
+50 -14
View File
@@ -55,19 +55,46 @@ def name_von_slug(sl: str) -> str | None:
return next((t["name"] for t in themen() if t.get("slug") == sl), None)
def version_anlegen(name: str) -> str:
n = (versionen(name) or [0])[-1] + 1
key = f"{name}/v{n}"
def version_anlegen(name: str, hinweise: str | None = None, stopp_gliederung: bool = False) -> str:
"""Neue Version; ohne Angabe übernimmt sie die Hinweise der neuesten Version (bzw. des Themas).
stopp_gliederung: der Lauf hält nach der Gliederung an (günstiger Testlauf), „Fortsetzen“ schreibt weiter."""
vs = versionen(name)
alt = hinweise_von(f"{name}/v{vs[-1]}") if vs else (thema(name) or {}).get("hinweise", "")
key = f"{name}/v{(vs or [0])[-1] + 1}"
v = verzeichnis(key)
for u in ("quellen", "buch", "berichte", "arbeit"):
(v / u).mkdir(parents=True)
_schreiben(v / "state.json", leerer_zustand())
_schreiben(v / "state.json", {**leerer_zustand(), "hinweise": (alt if hinweise is None else hinweise).strip(),
"stopp_gliederung": stopp_gliederung})
return key
def hinweise_von(key: str) -> str:
"""Hinweise gelten je Version; alte Versionen ohne eigene nehmen die des Themas."""
st = zustand(key)
return st["hinweise"] if "hinweise" in st else (thema(key) or {}).get("hinweise", "")
def spezialisierung_von(key: str) -> str:
"""Die Spezialisierung gehört zum Thema: eine andere Spezialisierung ist ein anderes Buch."""
return (thema(key) or {}).get("spezialisierung", "")
def bezeichnung(name: str, spezialisierung: str) -> str:
"""Ordner- und Anzeigename: „Game Design – digitale Sammelkartenspiele“."""
return f"{name} – {spezialisierung}" if spezialisierung else name
def _archiv_loeschen(key: str) -> None:
"""Ausgelagerte Quellen einer Version oder eines Themas (backend/archiv.py) mit entfernen."""
if config.ARCHIV:
shutil.rmtree(config.ARCHIV / key, ignore_errors=True)
def version_loeschen(key: str) -> None:
_cache.pop(key, None)
shutil.rmtree(verzeichnis(key), ignore_errors=True)
_archiv_loeschen(key)
name = thema_von(key)
if not versionen(name):
shutil.rmtree(config.STORAGE / name, ignore_errors=True)
@@ -119,14 +146,18 @@ def themen() -> list[dict]:
return aus
def anlegen(name: str, hinweise: str) -> dict:
d = config.STORAGE / name
def anlegen(name: str, hinweise: str, spezialisierung: str = "") -> dict:
"""name ist das Hauptthema („Game Design“), die Spezialisierung grenzt es ein; beides zusammen bildet die Bezeichnung."""
spezialisierung = spezialisierung.strip()
bez = bezeichnung(name, spezialisierung)
d = config.STORAGE / bez
if d.exists():
raise ValueError("Thema existiert schon")
raise ValueError(f"„{bez}“ gibt es schon – anderes Thema oder andere Spezialisierung wählen")
d.mkdir(parents=True)
t = {"name": name, "slug": slug_frei(name), "titel": name, "hinweise": hinweise.strip(), "angelegt": jetzt()}
t = {"name": bez, "thema": name, "spezialisierung": spezialisierung, "slug": slug_frei(bez), "titel": bez,
"hinweise": hinweise.strip(), "angelegt": jetzt()}
_schreiben(d / "thema.json", t)
version_anlegen(name)
version_anlegen(bez)
return t
@@ -134,17 +165,20 @@ def loeschen(name: str) -> None:
for k in [k for k in _cache if thema_von(k) == name]:
_cache.pop(k, None)
shutil.rmtree(config.STORAGE / name, ignore_errors=True)
_archiv_loeschen(name)
def zuruecksetzen(key: str) -> None:
_archiv_loeschen(key)
v = verzeichnis(key)
h, stopp = hinweise_von(key), bool(zustand(key).get("stopp_gliederung"))
_cache.pop(key, None)
for u in ("quellen", "buch", "berichte", "arbeit"):
shutil.rmtree(v / u, ignore_errors=True)
(v / u).mkdir()
for f in ("deutung.json", "log.jsonl", "state.json", "position.json"):
(v / f).unlink(missing_ok=True)
_schreiben(v / "state.json", leerer_zustand())
_schreiben(v / "state.json", {**leerer_zustand(), "hinweise": h, "stopp_gliederung": stopp})
def thema(key: str) -> dict | None:
@@ -165,13 +199,15 @@ def zustand(name: str) -> dict:
for a in st["agenten"]:
if a["status"] == "laeuft":
a["status"] = "abgebrochen"
# Später hinzugekommene Stufen (z. B. Lektorat) in alte Zustände einfügen, in der Reihenfolge der Konfiguration
# Später hinzugekommene Stufen (z. B. Lektorat, Stoff) in alte Zustände einfügen, in der Reihenfolge der Konfiguration;
# eine nachgetragene Stufe gilt als fertig, wenn eine spätere Stufe schon fertig ist (der Lauf ist darüber hinaus).
vorhanden = {x["id"] for x in st["stufen"]}
if vorhanden != {sid for sid, _ in config.STUFEN}:
alt = {x["id"]: x for x in st["stufen"]}
fertig = st.get("status") == "fertig"
st["stufen"] = [alt.get(sid) or {"id": sid, "titel": t, "status": "fertig" if fertig else "offen", "fertig": 0, "gesamt": 0,
"kosten_usd": 0.0, "tokens": 0, "gestartet": "", "beendet": ""} for sid, t in config.STUFEN]
reihe = [sid for sid, _ in config.STUFEN]
fertig_bis = max((reihe.index(x["id"]) for x in st["stufen"] if x["status"] == "fertig" and x["id"] in reihe), default=-1)
st["stufen"] = [alt.get(sid) or {"id": sid, "titel": t, "status": "fertig" if i <= fertig_bis else "offen", "fertig": 0, "gesamt": 0,
"kosten_usd": 0.0, "tokens": 0, "gestartet": "", "beendet": ""} for i, (sid, t) in enumerate(config.STUFEN)]
_cache[name] = st
return _cache[name]
+2
View File
@@ -7,11 +7,13 @@ services:
environment:
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- BUCHWERK_ARCHIV=${ARCHIV_DIR:+/archiv}
networks:
- web
volumes:
- ./storage:/app/storage
- ./.claude-data:/home/app/.claude
- ${ARCHIV_DIR:-./archiv}:/archiv
labels:
- "traefik.enable=true"
- "traefik.http.routers.buchwerk.rule=Host(`buchwerk.marha.de`)"
+4
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>buchwerk</title>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#000000" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%237a5b2c'/><path d='M8 8h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H8zM24 8h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z' fill='%23f8f1de'/></svg>" />
</head>
<body>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"name": "buchwerk",
"short_name": "buchwerk",
"description": "Sachbücher lesen",
"lang": "de",
"start_url": "/",
"scope": "/",
"display": "fullscreen",
"orientation": "portrait",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
+10 -6
View File
@@ -35,13 +35,14 @@ const state = ref(null)
const fehler = ref('')
const neuName = ref('')
const neuHinweise = ref('')
const neuSpezialisierung = 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'
document.documentElement.dataset.theme = theme.value === 'nacht' ? 'nacht' : theme.value === 'papier' ? 'sepia' : hell ? 'light' : 'dark'
localStorage.setItem('theme', theme.value)
}
watch(theme, themeAnwenden)
@@ -104,17 +105,18 @@ async function anlegen() {
const name = neuName.value.trim()
if (!name) return
try {
const t = await api.anlegen(name, neuHinweise.value.trim())
const t = await api.anlegen(name, neuHinweise.value.trim(), neuSpezialisierung.value.trim())
neuName.value = ''
neuHinweise.value = ''
neuSpezialisierung.value = ''
await topicsLaden()
themaWaehlen(t.slug)
} catch (e) { fehler.value = String(e.message || e) }
}
// Neue Version: gleiche Eingaben, frischer Lauf; die alte Version bleibt samt Leseposition erhalten.
async function neueVersion() {
// Neue Version: frischer Lauf mit ggf. geänderten Hinweisen/Spezialisierung; die alte Version bleibt samt Leseposition erhalten.
async function neueVersion(eingaben) {
try {
const r = await api.versionAnlegen(slug.value)
const r = await api.versionAnlegen(slug.value, eingaben)
await topicsLaden()
geheZu({ v: r.v, tab: 'lauf' })
await aktion(() => api.start(slug.value, r.v))
@@ -170,6 +172,7 @@ function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? M
<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" />
<input v-model="neuSpezialisierung" placeholder="Spezialisierung (optional), z. B. Kartenspiel" title="Grenzt das Thema ein; Bezeichnung wird „Thema – Spezialisierung“" @keyup.enter="anlegen" />
<button @click="anlegen">Anlegen</button>
</div>
</aside>
@@ -188,6 +191,7 @@ function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? M
<select v-model="theme" title="Farbschema">
<option value="papier">papier</option>
<option value="dunkel">dunkel</option>
<option value="nacht">nacht</option>
<option value="hell">hell</option>
<option value="auto">auto</option>
</select>
@@ -195,7 +199,7 @@ function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? M
<div v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }} — klicken zum Schließen</div>
<main class="inhalt" id="inhalt" tabindex="-1">
<Lauf v-if="tab === 'lauf' && state" :state="state"
@start="aktion(() => api.start(slug, versionAktiv))"
@start="(stopp) => aktion(() => api.start(slug, versionAktiv, { stopp_gliederung: stopp }))"
@pause="aktion(() => api.pause(slug, versionAktiv))"
@reset="aktion(() => api.reset(slug, versionAktiv))"
@neue-version="neueVersion" />
+3 -3
View File
@@ -18,12 +18,12 @@ const e = encodeURIComponent
const v = (slug, n) => `/themen/${e(slug)}/v${n}`
export const api = {
themen: () => req('/themen'),
anlegen: (name, hinweise = '') => req('/themen', { method: 'POST', body: { name, hinweise } }),
anlegen: (name, hinweise = '', spezialisierung = '') => req('/themen', { method: 'POST', body: { name, hinweise, spezialisierung } }),
loeschen: (slug) => req(`/themen/${e(slug)}`, { method: 'DELETE' }),
versionAnlegen: (slug) => req(`/themen/${e(slug)}/versionen`, { method: 'POST' }),
versionAnlegen: (slug, eingaben) => req(`/themen/${e(slug)}/versionen`, { method: 'POST', body: eingaben }),
versionLoeschen: (slug, n) => req(v(slug, n), { method: 'DELETE' }),
state: (slug, n) => req(v(slug, n)),
start: (slug, n) => req(v(slug, n) + '/start', { method: 'POST' }),
start: (slug, n, body) => req(v(slug, n) + '/start', { method: 'POST', body }),
pause: (slug, n) => req(v(slug, n) + '/pause', { method: 'POST' }),
reset: (slug, n) => req(v(slug, n) + '/reset', { method: 'POST' }),
buch: (slug, n) => req(v(slug, n) + '/buch'),
+39 -7
View File
@@ -19,6 +19,24 @@ function idVonNr(nr) {
}
return ''
}
// Lesemodus-Schlüssel (vorwort, k3, a03-4:2, ende) → Element-ID der Buchansicht, wenn zuletzt dort gelesen wurde
function idVonLesemodus(pos) {
if (!pos.lesemodus || !daten.value) return ''
const m = pos.lesemodus
if (m === 'vorwort') return 'vorwort'
const kap = /^k(\d+)$/.exec(m)
if (kap) return 'k' + (daten.value.kapitel[Number(kap[1]) - 1]?.id || '')
const abs = /^a?([\d-]+):/.exec(m)
if (abs) return 'a' + abs[1]
return ''
}
// Von zwei Element-IDs die, die im Buch weiter hinten liegt (Dokumentreihenfolge)
function spaetere(a, b) {
const ea = a && document.getElementById(a), eb = b && document.getElementById(b)
if (!ea) return eb ? b : ''
if (!eb) return a
return ea.compareDocumentPosition(eb) & Node.DOCUMENT_POSITION_FOLLOWING ? b : a
}
function nrVonId(id) {
if (!id || !daten.value) return ''
if (id === 'vorwort') return 'vorwort'
@@ -34,6 +52,14 @@ function lesemodusStart() {
emit('tab', 'lesen')
document.documentElement.requestFullscreen?.().catch(() => {})
}
// Leseposition beider Ansichten auf den Anfang zurücksetzen (geräteübergreifend)
async function zuruecksetzen() {
if (!window.confirm('Leseposition auf den Anfang zurücksetzen?')) return
await api.positionSetzen(props.slug, props.version, { abschnitt: 'vorwort', lesemodus: 'vorwort', zuletzt: 'lesen' }).catch(() => {})
aktiv.value = 'vorwort'
lese.value?.scrollTo({ top: 0 })
navAuf.value = false
}
function lesemodusEnde() {
emit('tab', 'buch')
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
@@ -57,7 +83,8 @@ const aktiv = ref('')
const lese = ref(null)
let beobachter = null
let ladeSeq = 0
// Leseposition auf dem Server merken (geräteübergreifend), gebündelt alle 1,5 s
// Leseposition auf dem Server merken (geräteübergreifend), gebündelt alle 1,5 s; nicht während des Sprungs beim Laden
let ruhe = false, ruheTimer = null
let merkTimer = null
function merken(id) {
clearTimeout(merkTimer)
@@ -75,9 +102,14 @@ async function laden() {
daten.value = d
await nextTick()
spy()
// Die Buchansicht beginnt beim Laden immer oben; nur der Lesemodus setzt an der gemerkten Stelle fort.
// Zum Abschnitt springen nur bei Klicks im Inhaltsverzeichnis und über die Zurück-Taste (siehe watch auf props.nr).
void pos
// Gemerkte Position hat Vorrang (die Adresse spiegelt nur den letzten Scrollstand); zuletzt im Lesemodus gelesen → dessen Stelle
const ziel = spaetere(idVonLesemodus(pos), pos.abschnitt) || idVonNr(props.nr)
// Der Sprung beim Laden darf die gemerkte Position nicht überschreiben; erst eigenes Scrollen zählt wieder
ruhe = true
clearTimeout(ruheTimer)
ruheTimer = setTimeout(() => { ruhe = false }, 2500)
// Am Buchanfang (Vorwort) nicht scrollen, damit Titel und Knopfleiste sichtbar bleiben
if (ziel && ziel !== 'vorwort' && !aktiv.value) scrollZu(ziel)
}
watch(() => [props.slug, props.version], () => { aktiv.value = ''; cache.clear(); laden() }, { immediate: true })
// Zurück-Taste oder getippte Adresse: zum Abschnitt springen, wenn er nicht schon aktiv ist
@@ -119,7 +151,7 @@ function spy() {
beobachter?.disconnect()
beobachter = new IntersectionObserver((es) => {
if (lesemodus.value) return
for (const e of es) if (e.isIntersecting) { aktiv.value = e.target.id; merken(e.target.id); emit('nr', { nr: nrVonId(e.target.id), push: false }) }
for (const e of es) if (e.isIntersecting) { aktiv.value = e.target.id; if (!ruhe) merken(e.target.id); emit('nr', { nr: nrVonId(e.target.id), push: false }) }
}, { root: lese.value, rootMargin: '0px 0px -75% 0px' })
document.querySelectorAll('.buch [data-spy]').forEach((el) => beobachter.observe(el))
}
@@ -150,7 +182,7 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
<button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button>
<div class="werkzeuge">
<button class="klein" title="Lesemodus: Abschnitt für Abschnitt" @click="lesemodusStart" :disabled="!abschnitte.length">Lesen ▶</button>
<a class="knopf klein sekundaer" title="Kernerkenntnisse aller Kapitel zum Auffrischen" :href="api.kernUrl(slug, version)" target="_blank">Kern</a>
<button class="klein sekundaer" title="Leseposition auf den Anfang zurücksetzen" aria-label="Leseposition zurücksetzen" @click="zuruecksetzen">↺</button>
<a class="knopf klein sekundaer" title="Als Markdown-Datei herunterladen" :href="api.buchMdUrl(slug, version)" download>MD</a>
<a v-if="daten?.pdf" class="knopf klein sekundaer" title="Als PDF herunterladen" :href="api.pdfUrl(slug, version)" download>PDF</a>
</div>
@@ -177,7 +209,7 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
<div class="mobil-leiste">
<button class="sekundaer klein" @click="navAuf = !navAuf">☰ Inhalt</button>
<button class="klein" @click="lesemodusStart" :disabled="!abschnitte.length">Lesen ▶</button>
<a class="knopf klein sekundaer" :href="api.kernUrl(slug, version)" target="_blank">Kern</a>
<button class="klein sekundaer" title="Leseposition auf den Anfang zurücksetzen" aria-label="Leseposition zurücksetzen" @click="zuruecksetzen">↺</button>
<a class="knopf klein sekundaer" :href="api.buchMdUrl(slug, version)" download>MD</a>
<a v-if="daten?.pdf" class="knopf klein sekundaer" :href="api.pdfUrl(slug, version)" download>PDF</a>
</div>
+27 -4
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { render } from '../markdown.js'
const props = defineProps({ state: Object })
@@ -34,18 +34,28 @@ const gruppen = computed(() => {
return out.filter((g) => g.agenten.length)
})
const ereignisse = computed(() => (props.state?.ereignisse || []).slice(-100).reverse())
// Stopp nach der Gliederung: wählbar, solange die Gliederung noch aussteht; wird mit dem Start gespeichert
const stopp = ref(false)
watch(() => props.state?.stopp_gliederung, (s) => { stopp.value = !!s }, { immediate: true })
const vorGliederung = computed(() => (props.state?.stufen || []).find((s) => s.id === 'gliederung')?.status !== 'fertig')
function reset() { if (confirm('Lauf zurücksetzen? Alles außer dem Thema wird gelöscht.')) emit('reset') }
function neueVersion() { if (confirm('Neue Version erzeugen? Gleiche Eingaben, kompletter neuer Lauf. Die bisherige Version bleibt erhalten.')) emit('neue-version') }
// Neue Version: Formular mit den Hinweisen dieser Version vorbelegt; Thema und Spezialisierung bleiben.
const neuOffen = ref(false)
const neuHinweise = ref('')
const neuStopp = ref(false)
function neuOeffnen() { neuHinweise.value = props.state.hinweise || ''; neuStopp.value = !!props.state.stopp_gliederung; neuOffen.value = true }
function neuStarten() { neuOffen.value = false; emit('neue-version', { hinweise: neuHinweise.value.trim(), stopp_gliederung: neuStopp.value }) }
</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 && !fertig" @click="emit('start', stopp)">{{ begonnen ? 'Fortsetzen' : 'Buch erzeugen' }}</button>
<label v-if="!laeuft && !fertig && vorGliederung" class="haken" title="Günstiger Testlauf: Recherche und Gliederung, dann Pause"><input type="checkbox" v-model="stopp" /> Nach der Gliederung anhalten</label>
<button v-if="laeuft" class="sekundaer" @click="emit('pause')">Pause</button>
<button v-if="!laeuft && begonnen && !fertig" class="sekundaer" @click="reset">Zurücksetzen</button>
<button v-if="!laeuft && begonnen" class="sekundaer" title="Gleiches Thema, gleiche Hinweise, neuer Lauf" @click="neueVersion">Neue Version</button>
<button v-if="!laeuft && begonnen" class="sekundaer" title="Gleiches Thema, kompletter neuer Lauf; Hinweise änderbar" @click="neuOeffnen">Neue Version</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>
@@ -53,6 +63,19 @@ function neueVersion() { if (confirm('Neue Version erzeugen? Gleiche Eingaben, k
</div>
<div v-if="state.grund" class="grund">{{ state.grund }}</div>
<div v-if="neuOffen" class="neu-version">
<div class="titel">Neue Version von „{{ state.name }}“</div>
<input v-model="neuHinweise" placeholder="Hinweise (optional), z. B. für Entwickler, ab Version 4.6" @keyup.enter="neuStarten" />
<div class="knoepfe"><button @click="neuStarten">Buch erzeugen</button><label class="haken" title="Günstiger Testlauf: Recherche und Gliederung, dann Pause"><input type="checkbox" v-model="neuStopp" /> Nach der Gliederung anhalten</label><span class="spacer"></span><button class="sekundaer" @click="neuOffen = false">Abbrechen</button></div>
</div>
<div class="eingaben">
<b>Thema</b><span>{{ state.thema }}</span>
<b>Spezialisierung</b><span>{{ state.spezialisierung || '–' }}</span>
<b>Hinweise</b><span>{{ state.hinweise || '–' }}</span>
<template v-if="laeuft && state.stopp_gliederung && vorGliederung"><b>Lauf</b><span>hält nach der Gliederung an</span></template>
</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>
+21 -10
View File
@@ -46,8 +46,19 @@ function html(s) {
return cache.get(key)
}
function vor() { if (pos.value < screens.value.length - 1) pos.value++ }
function zurueck() { if (pos.value > 0) pos.value-- }
// Nach dem Sperren des Handys ist das Vollbild weg; ein Tippen darf es wieder anfordern. Als installierte App unnötig.
function vollbildHolen() {
if (document.fullscreenElement || !document.documentElement.requestFullscreen) return
if (matchMedia('(display-mode: fullscreen), (display-mode: standalone)').matches) return
document.documentElement.requestFullscreen().catch(() => {})
}
function vor() { vollbildHolen(); if (pos.value < screens.value.length - 1) pos.value++ }
// Von vorn: auch die Buchansicht zurücksetzen, sonst zöge sie den Lesemodus wieder nach hinten
async function vonVorn() {
await api.positionSetzen(props.slug, props.version, { abschnitt: 'vorwort', lesemodus: 'vorwort', zuletzt: 'lesen' }).catch(() => {})
pos.value = 0
}
function zurueck() { vollbildHolen(); if (pos.value > 0) pos.value-- }
let merkTimer = null
watch(pos, (p) => {
buehne.value?.scrollTo({ top: 0 })
@@ -84,14 +95,14 @@ onMounted(async () => {
window.addEventListener('keydown', taste)
const p = await api.position(props.slug, props.version).catch(() => ({}))
const merk = p.lesemodus
// Zuletzt in der Buchansicht gelesen → beim passenden Abschnitt bzw. Kapitel beginnen
let i = -1
if (p.zuletzt === 'buch' && p.abschnitt) {
const a = p.abschnitt
i = screens.value.findIndex((s) => s.key === a || s.key.startsWith(a + ':') || (s.typ === 'kapitel' && 'k' + s.kap.id === a))
}
// Alte Positionen waren ein Index, neue sind ein key.
if (i < 0) i = typeof merk === 'string' ? screens.value.findIndex((s) => s.key === merk) : Number(merk || 0)
let i = typeof merk === 'string' ? screens.value.findIndex((s) => s.key === merk) : Number(merk || 0)
// Beide Ansichten teilen einen Stand: Liegt die Stelle der Buchansicht weiter hinten, gilt sie; nie zurück.
if (p.abschnitt) {
const a = p.abschnitt.replace(/^a/, '')
const j = screens.value.findIndex((s) => s.key === p.abschnitt || s.key.startsWith(a + ':') || (s.typ === 'kapitel' && 'k' + s.kap.id === p.abschnitt))
if (j > i) i = j
}
pos.value = Math.min(Math.max(i, 0), screens.value.length - 1)
})
onUnmounted(() => window.removeEventListener('keydown', taste))
@@ -128,7 +139,7 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
<section v-else class="lm-screen ende">
<h1>Fertig gelesen</h1>
<p class="intro">Alle {{ gesamt }} Seiten von „{{ daten.titel }}“.</p>
<button class="sekundaer" style="margin-top: 16px" @click="pos = 0">Von vorn</button>
<button class="sekundaer" style="margin-top: 16px" @click="vonVorn">Von vorn</button>
</section>
</main>
<footer class="lm-fuss">
+4 -1
View File
@@ -51,7 +51,10 @@ const md = new MarkdownIt({
typographer: false,
highlight(code, lang) {
// Textdiagramme (Kästen und Pfeile) bleiben unformatiert und werden nie umgebrochen.
if (lang === 'diagramm') return `<pre class="diagramm"><code>${md.utils.escapeHtml(code)}</code></pre>`
if (lang === 'diagramm') {
const n = Math.max(...code.split('\n').map((z) => z.length))
return `<pre class="diagramm" style="--n:${n}"><code>${md.utils.escapeHtml(code)}</code></pre>`
}
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>`
+25 -1
View File
@@ -20,6 +20,14 @@
--code-bg: #ece2c7;
--code-fg: #3b3224; --c-kw: #7a3e9d; --c-str: #2f6b2f; --c-com: #8a7c62; --c-num: #9a5b12; --c-fn: #2b5aa6; --c-type: #1c6f74;
}
/* Nacht: reines Schwarz (OLED-Pixel aus) und dunkles Rot, das Stäbchen kaum reizt – zum Lesen im Dunkeln */
:root[data-theme="nacht"] {
--bg: #000000; --panel: #000000; --karte: #0d0303; --rand: #2a0a0a;
--text: #b03535; --dim: #6b2222; --akzent: #7a1c1c; --gruen: #8a2e2e; --gelb: #9a4a22; --rot: #c0392b;
--lese-bg: #000000; --lese-text: #b03535; --lese-dim: #6e2626; --lese-rand: #2a0a0a;
--code-bg: #070101;
--code-fg: #a83333; --c-kw: #c24a4a; --c-str: #9a3f2a; --c-com: #5e2020; --c-num: #b8552a; --c-fn: #b84040; --c-type: #c25a2a;
}
* { 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; }
@@ -72,6 +80,13 @@ a.knopf { display: inline-block; text-decoration: none; line-height: normal; }
.lauf { padding: 18px max(22px, calc((100% - 760px) / 2)); overflow-y: auto; } /* Spalte zentriert wie in der Buchansicht */
.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; }
.lauf .eingaben { max-width: 760px; margin: 0 0 12px; display: grid; grid-template-columns: max-content 1fr; gap: 4px 10px; font-size: 12.5px; color: var(--dim); }
.lauf .eingaben b { font-weight: 600; color: var(--text); }
.neu-version { max-width: 760px; margin: 0 0 16px; padding: 12px 14px; background: var(--panel); border: 1px solid var(--rand); border-radius: 8px; display: grid; gap: 8px; }
.neu-version .titel { font-weight: 600; }
.neu-version .knoepfe { display: flex; gap: 8px; align-items: center; }
.haken { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--dim); cursor: pointer; }
.haken input { accent-color: var(--akzent); }
.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; }
@@ -163,7 +178,12 @@ a.knopf { display: inline-block; text-decoration: none; line-height: normal; }
.buch p, .buch li { overflow-wrap: anywhere; }
.buch pre { background: var(--code-bg); color: var(--code-fg); padding: 12px 14px; border-radius: 8px; font-size: 0.86em; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; hyphens: manual; }
.buch pre code { background: none; padding: 0; }
.buch pre.diagramm, .lm-screen pre.diagramm { white-space: pre; overflow-x: auto; font-size: 0.8em; line-height: 1.2; }
/* Eigene Monospace-Schrift mit Kastenzeichen und Pfeilen (DejaVu Sans Mono, Teilmenge): Handy-Schriften haben sie nicht,
und ein Ersatz aus einer anderen Schrift ist breiter – die Linien laufen dann über. */
@font-face { font-family: "Diagramm"; src: url(/fonts/diagramm.woff2) format("woff2"); font-display: swap; }
.buch pre.diagramm, .lm-screen pre.diagramm { white-space: pre; overflow-x: auto; font-size: 0.8em; line-height: 1.17; container-type: inline-size; font-family: "Diagramm", "DejaVu Sans Mono", monospace; }
/* Schmale Bildschirme: die breiteste Zeile (--n Zeichen) soll in den Block passen; unter 8 px wird gescrollt. */
.buch pre.diagramm code, .lm-screen pre.diagramm code { font-size: clamp(8px, calc(100cqw / var(--n) / 0.61), 0.86em); }
.buch table { border-collapse: collapse; margin: 0 0 1em; font-size: 0.92em; display: block; max-width: 100%; overflow-x: auto; hyphens: manual; }
.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; }
@@ -245,3 +265,7 @@ pre.hljs, .hljs { background: var(--code-bg); color: var(--code-fg); }
.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); }
/* Nacht: kein Weiß, auch nicht auf Knöpfen */
:root[data-theme="nacht"] button, :root[data-theme="nacht"] a.knopf, :root[data-theme="nacht"] .lm-fuss button.weiter, :root[data-theme="nacht"] .stufe.fertig .kreis, :root[data-theme="nacht"] .skip { color: var(--text); }
:root[data-theme="nacht"] img, :root[data-theme="nacht"] video { filter: brightness(0.35) sepia(1) hue-rotate(-30deg) saturate(2); }
+8 -6
View File
@@ -1,11 +1,13 @@
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.
{{hinweise_block}}{{spezialisierung_block}}
Lies zuerst vollständig: `buch/STOFF.md`, `buch/STIL.md`, `quellen/QUELLEN.md` und `deutung.json`.
Die Gliederung entsteht aus `buch/STOFF.md`: nur die dort genommenen Themen, in der dort festgelegten Tiefe. Die Quellen unter `quellen/` nutzt du, um Themen richtig zuzuschneiden und zu ordnen, nicht um neue hinzuzufügen. Fehlt in STOFF.md etwas, ohne das ein Kapitel nicht verständlich wäre, nimm es als `(kurz)` auf und nenne es im Bericht.
Anforderungen – der Stoff bestimmt den Umfang, nicht eine Zielzahl:
- Ein Kapitel ist ein eigenständiges Thema mit eigener Leitfrage. Verwandte Themen zusammenlegen, wenn sie gemeinsam unter 4 Abschnitte passen. Keine Kapitel der Vollständigkeit halber – aber Grundlagen des Fachgebiets bleiben drin, notfalls als `(kurz)`-Abschnitt; weggelassen wird nur, was außerhalb der Deutung liegt. Ab 8 Kapiteln in Teile gruppieren.
- Je Kapitel so viele Abschnitte, wie es getrennte Fragen gibt: typisch 4, mindestens 2, höchstens 7. Reihenfolge nach dem, was man zuerst wissen muss.
- Jeder Abschnitt bekommt ein Gewicht, das seine Länge festlegt: `(kurz)` ≈ 800 Wörter für Abgrenzung, Überblick, Einordnung; ohne Angabe = mittel ≈ 1.300 Wörter, der Normalfall; `(lang)` ≈ 2.000 Wörter für Kernverfahren mit Herleitung und Beispiel.
- Ein Kapitel ist ein eigenständiges Thema mit eigener Leitfrage. Verwandte Themen zusammenlegen, wenn sie gemeinsam unter 4 Abschnitte passen. Keine Kapitel der Vollständigkeit halber; was STOFF.md weglässt, bekommt weder Kapitel noch Abschnitt. Ab 8 Kapiteln in Teile gruppieren.
- Ein Abschnitt je genommenem Thema der Tiefe `erklären` oder `herleiten`; eng zusammengehörige Themen dürfen einen Abschnitt teilen. Themen der Tiefe `nennen` bekommen keinen Abschnitt, sie gehören als Absatz in den Abschnitt, der sie braucht. Mindestens 2, höchstens 7 Abschnitte je Kapitel, sonst teilen oder zusammenlegen. Reihenfolge nach dem, was man zuerst wissen muss.
- Das Gewicht eines Abschnitts folgt der Tiefe aus STOFF.md: `erklären` → `(kurz)` ≈ 800 Wörter; `herleiten` → mittel ≈ 1.300 Wörter (ohne Angabe) oder `(lang)` ≈ 2.000 Wörter, wenn Herleitung und Beispiel beide nötig sind.
- Ein Kapitel soll in einer Lesesitzung zu schaffen sein: Summe der Richtwerte höchstens 8.000 Wörter. Braucht ein Thema mehr, wird es geteilt, nicht gestapelt.
- Jedes Thema hat genau ein zuständiges Kapitel; Überschneidungen vermeiden (jedes Thema wird nur in seinem Kapitel erklärt).
- Titel deutsch, konkret, keine Marketingsprache.
@@ -29,4 +31,4 @@ Schreibe `buch/GLIEDERUNG.md` GENAU in diesem Format (Parser liest es):
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 „ · “; das Gewicht steht als `(kurz)` oder `(lang)` am Ende des Abschnittstitels, mittel bleibt ohne Angabe; 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, Summe der Richtwerte in Wörtern, und was bewusst weggelassen oder zusammengelegt wurde.
Antworte am Ende kurz (max. 6 Zeilen): Anzahl Teile/Kapitel/Abschnitte, Summe der Richtwerte in Wörtern, welche Themen aus STOFF.md zusammengelegt wurden und was du über STOFF.md hinaus ergänzt hast.
+5 -3
View File
@@ -1,14 +1,16 @@
Du schreibst ein Kapitel für ein deutschsprachiges Sachbuch zum Thema „{{thema}}“.
{{spezialisierung_block}}
Lies zuerst vollständig: `buch/STIL.md`, `buch/GLIEDERUNG.md`, `buch/STOFF.md`, `quellen/QUELLEN.md`.
Lies zuerst vollständig: `buch/STIL.md`, `buch/GLIEDERUNG.md`, `quellen/QUELLEN.md`.
`buch/STOFF.md` legt fest, welche Themen das Buch behandelt und wie tief. Was dort weggelassen ist, kommt nicht vor, auch nicht als Beispiel oder Exkurs. Themen der Tiefe `nennen` bekommen höchstens einen Absatz, dort, wo dein Kapitel sie braucht.
Aufgabe: **Kapitel {{n}} – {{kapitel_titel}}**, Datei `buch/{{datei}}`, mit genau den Abschnitten aus der Gliederung. Das Gewicht hinter dem Abschnittstitel – `(kurz)` ≈ 800 Wörter, ohne Angabe ≈ 1.300, `(lang)` ≈ 2.000 – ist der Richtwert für die Länge; es gehört nicht in die Überschrift.
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). Am Ende des Kapitels der Block `## Kern` nach STIL.md: alle Kerneinsichten als Frage-Antwort-Paare.
2. SCHREIBEN mit Write (bei Länge in mehreren Schritten, dann anhängen). Am Ende des Kapitels der Abschnitt `## N.M Merken` nach STIL.md: Bullet-Liste „Begriff: Kern“, fünf bis zehn Zeilen.
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 – im Gerüst unter `arbeit/`, ins Buch kommt nur der Kern. 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, keine Verweisklammern, keine Wiederholung von Themen anderer Kapitel, Umfang, Umlaute. Überarbeite, bis alles passt.
4. PRÜFEN gegen STIL.md: Überschriften exakt wie in der Gliederung, keine Übungen/Merkkästen/Zusammenfassungen/Literaturlisten, Prosa statt Listenwüste, keine Verweisklammern, keine Wiederholung von Themen anderer Kapitel, nichts, was STOFF.md weglässt oder was außerhalb der Spezialisierung liegt, auch nicht als Beispiel oder Exkurs; 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.
+3 -3
View File
@@ -1,6 +1,6 @@
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`.
{{spezialisierung_block}}
Lies zuerst: `buch/STIL.md`, `buch/GLIEDERUNG.md`, `buch/STOFF.md`, `quellen/QUELLEN.md`.
Prüfe vollständig: `buch/{{datei}}` (Kapitel {{n}} – {{kapitel_titel}}).
@@ -11,7 +11,7 @@ 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“), Textdiagramme (jedes Element und jeder Pfeil gegen Quellcode/Doku) und Ausgabeblöcke (gegen den eigenen Testlauf).
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, dazu am Ende `## Kern` mit Frage-Antwort-Paaren, die jeden Abschnitt abdecken und nichts enthalten, was das Kapitel nicht erklärt; keine Übungen/Merkkästen/Zusammenfassungen/Literaturlisten; keine Verweisklammern „(→ …)“ und keine Rückverweise auf frühere Abschnitte; Vorausverweise nur als Halbsatz und nur, wenn das Thema dort laut Gliederung steht; keine größeren Doppelungen mit Themen, die in andere Kapitel gehören (dann kürzen und verweisen); Rechtschreibung, Umlaute, Markdown. Satzbau: Sätze über 30 Wörter in mehrere auflösen, Inhalt unverändert; Zielschnitt unter 20 Wörter je Satz. Sätze mit drei und mehr Code-Bezeichnern entflechten: in Worten erklären, Namenslisten streichen. Verständlichkeit: Begriffe, die der Abschnitt benutzt, ohne sie bis dahin eingeführt zu haben, kurz erklären oder auf die Einführung verweisen; Interna, die kein sichtbares Verhalten erklären, streichen; Zwischenabschnitte unter 150 Wörtern mit dem Nachbarn zusammenlegen; Gedankensprünge schließen, wo zwischen zwei Sätzen oder Absätzen die Verbindung fehlt. Umfang: Abschnitte über 120 % ihres Richtwerts aus der Gliederung (`(kurz)` ≈ 800, ohne Angabe ≈ 1.300, `(lang)` ≈ 2.000 Wörter) straffen – zuerst Wiederholungen und Quellenformeln ohne Informationswert, keine Fakten oder Beispiele streichen.
4. Prüfe Form: Überschriften exakt wie GLIEDERUNG.md, dazu als letzter Abschnitt `## N.M Merken` (fortlaufend nummeriert), als Bullet-Liste „Begriff: Kern“ mit fünf bis zehn Zeilen aus ganzen, allein verständlichen Sätzen, ohne Beispiele, Herleitungen oder Absätze, der nichts enthält, was das Kapitel nicht erklärt (Stichwortreihen zu Sätzen machen, über 120 Wörter kürzen); keine Übungen/Merkkästen/Zusammenfassungen/Literaturlisten; keine Verweisklammern „(→ …)“ und keine Rückverweise auf frühere Abschnitte; Vorausverweise nur als Halbsatz und nur, wenn das Thema dort laut Gliederung steht; keine größeren Doppelungen mit Themen, die in andere Kapitel gehören (dann kürzen und verweisen); Stoff, den `buch/STOFF.md` weglässt oder der außerhalb der Spezialisierung liegt, streichen, auch Beispiele und Exkurse; Themen der Tiefe `nennen` auf einen Absatz kürzen; Rechtschreibung, Umlaute, Markdown. Satzbau: Sätze über 30 Wörter in mehrere auflösen, Inhalt unverändert; Zielschnitt unter 20 Wörter je Satz. Sätze mit drei und mehr Code-Bezeichnern entflechten: in Worten erklären, Namenslisten streichen. Verständlichkeit: Begriffe, die der Abschnitt benutzt, ohne sie bis dahin eingeführt zu haben, kurz erklären oder auf die Einführung verweisen; Interna, die kein sichtbares Verhalten erklären, streichen; Zwischenabschnitte unter 150 Wörtern mit dem Nachbarn zusammenlegen; Gedankensprünge schließen, wo zwischen zwei Sätzen oder Absätzen die Verbindung fehlt. Umfang: Abschnitte über 120 % ihres Richtwerts aus der Gliederung (`(kurz)` ≈ 800, ohne Angabe ≈ 1.300, `(lang)` ≈ 2.000 Wörter) straffen – zuerst Wiederholungen und Quellenformeln ohne Informationswert, keine Fakten oder Beispiele streichen.
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.
+2 -2
View File
@@ -1,10 +1,10 @@
Du bereitest ein deutschsprachiges Sachbuch zum Thema „{{thema}}“ vor.
{{hinweise_block}}
{{hinweise_block}}{{spezialisierung_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 verstehen, wie sie gebaut ist und warum sie sich so verhält; Anwendung ist nicht das Ziel) oder `wissen` (Wissenschaft, Fachgebiet, Phänomen – der Leser soll den Forschungsstand verstehen). Zielgruppe und Anspruch: Standard sind Leser ohne Vorkenntnisse im Thema – bei Software Programmiergrundlagen in irgendeiner Sprache, aber keine Erfahrung mit dem Werkzeug; bei Wissensthemen allgemeine Bildung. Anspruch: die Sache lesend verstehen und einordnen, nicht selbst umsetzen. Von diesem Standard nur abweichen, wenn die Hinweise oben es verlangen.
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 verstehen, wie sie gebaut ist und warum sie sich so verhält; Anwendung ist nicht das Ziel) oder `wissen` (Wissenschaft, Fachgebiet, Phänomen – der Leser soll den Forschungsstand verstehen). Zielgruppe und Anspruch: Standard sind Leser ohne Vorkenntnisse im Thema – bei Software Programmiergrundlagen in irgendeiner Sprache, aber keine Erfahrung mit dem Werkzeug; bei Wissensthemen allgemeine Bildung. Anspruch: die Sache lesend verstehen und einordnen, nicht selbst umsetzen. Von diesem Standard nur abweichen, wenn die Hinweise oben es verlangen. Eine Spezialisierung oben ist die Grenze des Buchs: Deutung und Quellen beschränken sich auf diesen Ausschnitt des Themas.
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.
+1 -1
View File
@@ -7,7 +7,7 @@ Ein deutschsprachiges Sachbuch, das **Verständnis** vermittelt: Wie ist die Sac
- Wortwahl: Verben statt Substantivierungen („prüfen“ statt „eine Prüfung vornehmen“, „wahrnehmen“ statt „die Wahrnehmung erfolgt“). Lange zusammengesetzte Wörter nur, wenn sie der Fachbegriff sind; sonst umschreiben oder bei der ersten Nennung erklären. Keine Füllwörter („grundsätzlich“, „im Wesentlichen“, „durchaus“).
- Code-Bezeichner im Fließtext nur, wenn der Satz von genau diesem Bezeichner handelt. Erst in Worten erklären, was etwas tut, dann den Namen nennen. Keine Aufzählungen von Methoden-, Eigenschafts- oder Klassennamen, weder im Satz noch als Tabelle: Solche Listen weglassen. Faustregel: höchstens ein Bezeichner je Satz, selten zwei.
- **Keine** Übungen, Selbsttests, Lernziele, Merkkästen, Checklisten, „Praxistipps“, Schritt-für-Schritt-Anleitungen als Selbstzweck, Literaturlisten.
- Jedes Kapitel endet mit einem Block `## Kern`: alle Kerneinsichten des Kapitels als Frage-Antwort-Paare. Die Frage ist fett gesetzt und lautet so, wie ein Leser sie sich stellen würde („Warum nehmen Spieler Regeln hin, die alles umständlicher machen?“). Die Antwort sind zwei bis fünf Sätze Prosa mit dem Warum und dem konkreten Beispiel aus dem Kapitel. Jeder Abschnitt des Kapitels ist mit mindestens einer Frage vertreten, so lang wie nötig. Nichts Neues, keine Bullets, keine Begriffe, die das Kapitel nicht eingeführt hat. Der Kern zählt nicht zum Richtwert des Umfangs.
- Jedes Kapitel endet mit einem eigenen Abschnitt `## N.M Merken`, nummeriert als letzter Abschnitt des Kapitels (hat das Kapitel 1.1 bis 1.4, heißt er `## 1.5 Merken`). Er ist ein Spickzettel zum Auffrischen nach Monaten: eine Bullet-Liste, je Zeile ein Begriff, Fakt oder System in der Form „Begriff: Kern“. Der Kern sind ein bis zwei ganze Sätze, die ohne das Kapitel verständlich sind (`- Antwortempfindlichkeit: Wie fein die Steuerung auf kleine Eingaben reagiert. Liegt am Programm, nicht am Gerät.`), kein Telegrammstil, keine Stichwortreihen. Fünf bis zehn Zeilen je Kapitel, etwa 80 bis 120 Wörter. Nur, was man sich merken soll; keine Beispiele, keine Herleitungen, keine Begründungen, keine Absätze, keine Abschnittsnummern, keine Fragen, nichts Neues, keine Begriffe, die das Kapitel nicht eingeführt hat. Der Abschnitt steht nicht in der Gliederung und zählt nicht zum Richtwert des Umfangs.
- Überwiegend Fließtext. Listen und Tabellen nur, wo sie echte Klarheit bringen – sparsam. Eine Tabelle vergleicht Varianten mit ihren Kosten; sie zählt nie Methoden, Eigenschaften, Konstanten oder Einstellungen auf.
- Kein Nachschlagewissen: keine Einstellungspfade (eine Projekteinstellung wird in Worten genannt, nicht als `pfad/zur/einstellung`), keine Standardwerte, Grenzwerte, Signaturen oder Parameterpositionen, die nur beim Bauen helfen. Keine Versionsnotizen („seit 4.3“, „in Godot 3 hieß“, „bis 4.2“): Das Buch beschreibt den Stand der Referenzversion, als hätte es nie einen anderen gegeben.
- Keine Autoren als Erzählgerüst: nicht „X beschreibt“, „X hält“, „X rät“, sondern die Sache selbst erklären und begründen. Der Erfinder eines Verfahrens wird höchstens einmal genannt, wenn das Verfahren nach ihm heißt. Keine Jahreszahlen, Daten, Lebensläufe oder Anekdoten ohne Erklärwert. Keine Testberichte im Text („Im Testlauf …“, „Das Skript läuft ohne Fehler“, „Ein Versuch zeigt“): Die Ausgabe spricht für sich.
+43
View File
@@ -0,0 +1,43 @@
Du wählst den Stoff für ein deutschsprachiges Sachbuch zum Thema „{{thema}}“ aus.
{{hinweise_block}}{{spezialisierung_block}}
Lies zuerst vollständig: `deutung.json`, `buch/STIL.md`, `quellen/QUELLEN.md`. Verschaffe dir dann anhand der Quellen unter `quellen/` einen Überblick, welche Themen es gibt (Inhaltsverzeichnisse der Doku, Klassenlisten, Titel der Studien, Artikel).
Deine Aufgabe ist die Auswahl, nicht die Struktur: Du entscheidest, welche Themen ins Buch kommen und wie tief. Die Gliederung entsteht später nur aus deiner Auswahl. Der Stoff kommt aus den Quellen, die Auswahl aus der Leitfrage.
Maßstab ist der Leser aus STIL.md: ohne Vorkenntnisse, liest das Buch von vorn nach hinten, will die Sache verstehen, nicht anwenden und nicht nachschlagen. Für solche Leser gilt: Weniger, zusammenhängend erklärt, wird besser verstanden als mehr. Interessante, aber unnötige Details senken das Verständnis.
Vorgehen:
1. **Leitfrage** formulieren: Was soll der Leser am Ende verstanden haben? Ein bis zwei Sätze.
2. **Kandidaten** sammeln: alle Themen, die die Quellen zum Thema hergeben. Diese Liste ist nicht das Buch.
3. **Je Kandidat entscheiden** anhand einer Frage: Kann der Leser die Leitfrage ohne dieses Thema beantworten?
- `herleiten` – Kern: ohne das Thema ist die Leitfrage nicht zu beantworten. Bekommt Raum für Herleitung und Beispiel.
- `erklären` – Voraussetzung oder Zusammenhang, den der Kern braucht. Was es ist und warum es so ist, ohne Detail.
- `nennen` – der Leser soll den Begriff einmal gehört und eingeordnet haben. Ein Absatz an passender Stelle, kein eigener Abschnitt.
- `weglassen` – alles andere. Auch wenn es in den Quellen steht, auch wenn es interessant ist.
4. **Typische Streichkandidaten** gesondert prüfen: Geschichte und Entstehung; Vergleiche mit anderen Produkten, Werkzeugen oder Spielen, sofern sie keine Entscheidung im Hauptthema erklären; Vollständigkeitslisten (alle Varianten, alle Optionen, alle Klassen); Betrieb, Geschäft und Organisation rund um die Sache; Ratschläge zur Anwendung; Forschungsdebatten ohne Folge für das Verständnis.
5. Hinweise und Spezialisierung oben entscheiden im Zweifel. Was außerhalb der Spezialisierung liegt, wird weggelassen, auch wenn es zum Thema gehört.
Schreibe `buch/STOFF.md` GENAU in diesem Format (die Gliederung liest es):
```
# Stoffauswahl – <Thema>
## Leitfrage
<ein bis zwei Sätze>
## Genommen
| Thema | Tiefe | Warum der Leser es braucht |
|---|---|---|
| <Thema> | herleiten | <ein Satz> |
| <Thema> | erklären | <ein Satz> |
| <Thema> | nennen | <ein Satz> |
## Weggelassen
| Thema | Warum nicht |
|---|---|
| <Thema> | <ein Satz> |
```
Ein Thema ist ein benennbarer Gegenstand („Mana als wachsende Ressource“, „Signale als lose Kopplung“), kein Kapiteltitel und kein Stichwort. Reihenfolge in „Genommen“: so, wie der Leser die Themen braucht, Voraussetzungen zuerst. Keine weiteren Abschnitte in der Datei.
Antworte am Ende kurz (max. 8 Zeilen): Leitfrage; Anzahl je Tiefe; die wichtigsten Streichungen und warum.