Stufe Lektorat: Sprache glätten, Abschnitt für Abschnitt

Nach der Prüfung glättet ein Lektor ohne Werkzeuge jeden Abschnitt
(Satzbau, Zusammenhang, Wortwahl, Begriffe erinnern). Eingebaute
Verlustprüfung: Bezeichner, Zahlen, Überschriften, Code, Diagramme,
Querverweise, Umfang ±20 % – sonst bleibt das Original. Alte Zustände
bekommen die Stufe beim Laden nachgetragen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
team3
2026-10-05 20:45:40 +02:00
parent e27d2177b9
commit d07218307f
7 changed files with 171 additions and 2 deletions

View File

@@ -74,3 +74,20 @@ async def laufen(name: str, agent_id: str, stufe_id: str, titel: str, prompt: st
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}
async def fragen(prompt: str, *, system: str, effort: str = "medium") -> tuple[str, float, int]:
"""Eine einzelne Modellantwort ohne Werkzeuge (für das Lektorat). → (Text, Kosten, Tokens)"""
options = ClaudeAgentOptions(model=config.MODELL, effort=effort, max_turns=1, allowed_tools=[], system_prompt=system)
text, kosten, tokens = [], 0.0, 0
async for m in query(prompt=prompt, options=options):
if isinstance(m, AssistantMessage):
for b in m.content:
if isinstance(b, TextBlock):
text.append(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"))
return "".join(text).strip(), kosten, tokens

View File

@@ -9,6 +9,7 @@ PORT = 8020
MODELL = "claude-opus-5-5"
EFFORT = "high"
PARALLEL = 5 # gleichzeitige Kapitel-Agenten
LEKTORAT_PARALLEL = 8 # gleichzeitige Lektorat-Anfragen (ohne Werkzeuge, je Abschnitt)
MAX_TURNS = 800
KONSISTENZ_GRUPPE = 5 # Kapitel je Querverweis-Agent
LESE_WPM = 120
@@ -18,6 +19,7 @@ STUFEN = [
("gliederung", "Gliederung"),
("schreiben", "Kapitel schreiben"),
("pruefung", "Kapitel prüfen"),
("lektorat", "Sprache glätten"),
("konsistenz", "Querverweise und Vorwort"),
("montage", "Buch zusammensetzen"),
]

89
backend/lektorat.py Normal file
View File

@@ -0,0 +1,89 @@
"""Lektorat: jedes geprüfte Kapitel wird Abschnitt für Abschnitt sprachlich geglättet (ohne Werkzeuge).
Ein Abschnitt wird nur übernommen, wenn nichts verloren ging (Bezeichner, Zahlen, Überschriften, Code, Verweise, Diagramme)
und der Umfang nicht über 20 % wächst; sonst bleibt der Originalabschnitt."""
import asyncio
import re
from . import agenten, config, prompts, state
SYSTEM = "Du bist Lektor für deutschsprachige Sachbuchprosa. Du antwortest nur mit dem überarbeiteten Text."
_sem = asyncio.Semaphore(config.LEKTORAT_PARALLEL)
def teilen(text: str) -> list[str]:
"""Kapiteltext → [Kopf (Titel + Einleitung), Abschnitt 1, Abschnitt 2, …], jeweils mit ihrer ##-Überschrift."""
return [t for t in re.split(r"(?m)^(?=## )", text) if t.strip()]
def _bezeichner(t: str) -> set[str]:
return {x.strip() for x in re.findall(r"`([^`\n]{1,80})`", t)}
def pruefen(alt: str, neu: str) -> list[str]:
"""Was beim Glätten verloren ging oder dazukam – leer, wenn der Abschnitt übernommen werden darf."""
fehler = []
if not neu.strip():
return ["leere Antwort"]
for name, rx in (("Überschriften", r"^#{1,4} .*$"), ("Querverweise", r"→ (?:Abschnitt|Kapitel) [0-9.]+")):
a, n = re.findall(rx, alt, flags=re.M), re.findall(rx, neu, flags=re.M)
if sorted(a) != sorted(n):
fehler.append(f"{name} verändert")
for name, rx in (("Codeblöcke", r"^```(?!diagramm)"), ("Diagramme", r"^```diagramm")):
if len(re.findall(rx, alt, flags=re.M)) != len(re.findall(rx, neu, flags=re.M)):
fehler.append(f"{name} verändert")
fehlt = {b for b in _bezeichner(alt) - _bezeichner(neu) if b not in neu}
if fehlt:
fehler.append("Bezeichner fehlen: " + ", ".join(sorted(fehlt)[:5]))
zahlen = set(re.findall(r"\b\d+(?:[.,]\d+)?\b", alt)) - set(re.findall(r"\b\d+(?:[.,]\d+)?\b", neu))
if zahlen:
fehler.append("Zahlen fehlen: " + ", ".join(sorted(zahlen)[:5]))
wa, wn = len(alt.split()), len(neu.split())
if wn > wa * 1.2:
fehler.append(f"zu lang: {wn} statt {wa} Wörter")
if wn < wa * 0.8:
fehler.append(f"zu kurz: {wn} statt {wa} Wörter")
return fehler
async def _abschnitt(thema: str, text: str) -> tuple[str, float, int, list[str]]:
async with _sem:
neu, kosten, tokens = await agenten.fragen(prompts.laden("lektorat", thema=thema, abschnitt=text), system=SYSTEM)
neu = re.sub(r"^```(?:markdown|md)?\s*\n(.*)\n```\s*$", r"\1", neu.strip(), flags=re.S) # falls das Modell alles in einen Block packt
fehler = pruefen(text, neu)
return (text if fehler else neu.rstrip() + "\n"), kosten, tokens, fehler
async def kapitel(name: str, k: dict, thema: str) -> dict:
"""Glättet ein Kapitel; schreibt die Datei, den Agenteneintrag und den Bericht. → {"status", "kosten"}"""
agent_id = f"lektorat-{k['n']:02d}"
a = state.agent_eintrag(name, agent_id, "lektorat", f"Kapitel {k['n']} glätten")
state.ereignis(name, agent_id, "start", f"Kapitel {k['n']} glätten")
pfad = state.verzeichnis(name) / "buch" / k["datei"]
try:
teile = teilen(pfad.read_text())
ergebnisse = await asyncio.gather(*(_abschnitt(thema, t) for t in teile))
except asyncio.CancelledError:
state.agent_fertig(name, a, "abgebrochen", "", 0.0, 0)
state.ereignis(name, agent_id, "ende", "abgebrochen")
raise
except Exception as e:
state.agent_fertig(name, a, "fehler", f"**Fehler:** {type(e).__name__}: {e}", 0.0, 0)
state.ereignis(name, agent_id, "fehler", f"fehler · {type(e).__name__}")
return {"status": "fehler", "kosten": 0.0}
neu = "\n".join(t.rstrip("\n") + "\n" for t, *_ in ergebnisse)
vorher = sum(len(t.split()) for t in teile)
nachher = len(neu.split())
kosten = sum(r[1] for r in ergebnisse)
tokens = sum(r[2] for r in ergebnisse)
behalten = [(i, f) for i, (_, _, _, f) in enumerate(ergebnisse) if f]
pfad.write_text(neu)
zeilen = [f"Kapitel {k['n']} geglättet: {len(teile)} Teile, {vorher} → {nachher} Wörter ({100 * nachher / max(1, vorher) - 100:+.0f} %)."]
if behalten:
zeilen.append(f"{len(behalten)} Teil(e) blieben im Original, weil die Prüfung anschlug: "
+ "; ".join(f"Teil {i}: {', '.join(f)}" for i, f in behalten))
bericht = "\n".join(zeilen)
a["werkzeuge"] = len(teile)
state.agent_fertig(name, a, "fertig", bericht, kosten, tokens)
state.bericht_schreiben(name, agent_id, bericht)
state.ereignis(name, agent_id, "ende", f"fertig · {kosten:.2f} USD · {len(teile)} Abschnitte" + (f" · {len(behalten)} im Original" if behalten else ""))
return {"status": "fertig", "kosten": kosten}

View File

@@ -3,7 +3,7 @@ Dateimarker (Bericht existiert = Agent fertig)."""
import asyncio
import logging
from . import agenten, config, gliederung, montage, prompts, state
from . import agenten, config, gliederung, lektorat, montage, prompts, state
log = logging.getLogger("buchwerk")
_laeufe: dict[str, asyncio.Task] = {}
@@ -184,6 +184,30 @@ async def st_pruefung(name: str) -> None:
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)
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}")]
s.update(gesamt=len(kap), fertig=len(kap) - len(offen))
sem = asyncio.Semaphore(config.PARALLEL)
fehler = []
async def eins(k):
async with sem:
r = await lektorat.kapitel(name, k, thema)
if r["status"] == "fertig":
s["fertig"] += 1
else:
fehler.append(f"Kapitel {k['n']}")
state.speichern(name)
await asyncio.gather(*(eins(k) for k in offen))
if fehler:
raise Abbruch("Lektorat gescheitert bei " + ", ".join(fehler[:5]) + " – „Fortsetzen“ wiederholt nur die offenen")
async def st_konsistenz(name: str) -> None:
thema, _ = _thema(name)
kap = _kapitel(name)
@@ -211,4 +235,4 @@ async def st_montage(name: str) -> None:
STUFEN_FN = {"recherche": st_recherche, "gliederung": st_gliederung, "schreiben": st_schreiben,
"pruefung": st_pruefung, "konsistenz": st_konsistenz, "montage": st_montage}
"pruefung": st_pruefung, "lektorat": st_lektorat, "konsistenz": st_konsistenz, "montage": st_montage}

View File

@@ -165,6 +165,13 @@ 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
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]
_cache[name] = st
return _cache[name]