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:
@@ -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
|
||||
|
||||
@@ -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
89
backend/lektorat.py
Normal 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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user