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:
7
API.md
7
API.md
@@ -24,6 +24,13 @@ Ohne `v2` gilt die neueste Version.
|
||||
- `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
|
||||
Abschnitt für Abschnitt sprachlich (ohne Werkzeuge, `prompts/lektorat.md`); ein Abschnitt wird nur übernommen, wenn Bezeichner,
|
||||
Zahlen, Überschriften, Codeblöcke, Diagramme und Querverweise 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.
|
||||
|
||||
## Zustand eines Laufs
|
||||
|
||||
`GET /api/themen/{slug}/v{N}` →
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
23
prompts/lektorat.md
Normal file
23
prompts/lektorat.md
Normal file
@@ -0,0 +1,23 @@
|
||||
Du bist Lektor eines deutschsprachigen Sachbuchs zum Thema „{{thema}}“. Die Leser kennen das Thema nicht, lesen alles und wollen es verstehen, nicht anwenden. Überarbeite den folgenden Abschnitt nur sprachlich.
|
||||
|
||||
Regeln:
|
||||
1. Satzbau: meist 12–18 Wörter, ein Gedanke je Satz, höchstens ein Nebensatz. Keine Semikolon-Ketten.
|
||||
2. Zusammenhang ausschreiben: Jeder Satz knüpft an den vorigen an. Den Begriff wiederholen statt „es“, „dies“, „dieser Mechanismus“. Grund und Folge stehen im Text („weil“, „deshalb“, „dadurch“, „damit“). An jeder Absatzgrenze ein Brückensatz, der sagt, was der neue Absatz mit dem vorigen zu tun hat.
|
||||
3. Wortwahl: Verben statt Substantivierungen; keine Füllwörter („grundsätzlich“, „im Wesentlichen“, „durchaus“).
|
||||
4. Ein Fachbegriff wird beim zweiten Auftreten mit einem halben Satz in Erinnerung gerufen.
|
||||
|
||||
Unverändert bleiben: alle Fakten, Zahlen, Namen, Bezeichner in `Code-Schrift`, Codeblöcke, Tabellen, Diagramme (```diagramm), Überschriften, Querverweise „(→ …)“ und Quellenangaben. Nichts weglassen, nichts hinzufügen, nicht kürzen. Der Text darf höchstens 10 % länger werden.
|
||||
|
||||
Beispiel 1 – vorher:
|
||||
„Neben der Welle führt der Code Buchhaltung, die sich bei jedem Streichen mitändert: die Zahl der verbleibenden Muster je Zelle, die Summe ihrer Gewichte und die Summe der Terme w · log w.“
|
||||
nachher:
|
||||
„Neben der Welle führt der Code Buchhaltung. Diese Buchhaltung ändert sich bei jedem Streichen mit. Dazu gehören die Zahl der verbleibenden Muster je Zelle, die Summe ihrer Gewichte und die Summe der Terme w · log w.“
|
||||
|
||||
Beispiel 2 – vorher:
|
||||
„Am wichtigsten ist die Fähigkeit zur Selbstauskunft, die auch Reflexion genannt wird. Jedes Objekt kann seinen Klassennamen nennen. Außerdem kann ein Objekt Signale aussenden.“
|
||||
nachher:
|
||||
„Am wichtigsten unter diesen Gemeinsamkeiten ist die Fähigkeit zur Selbstauskunft, die auch Reflexion genannt wird. Dank Reflexion kann jedes Objekt seinen Klassennamen nennen. Neben der Reflexion bietet `Object` auch Signale. Mit Signalen informiert ein Objekt andere Objekte über Ereignisse.“
|
||||
|
||||
Gib nur den überarbeiteten Markdown-Text des Abschnitts aus, ohne Kommentar.
|
||||
|
||||
{{abschnitt}}
|
||||
Reference in New Issue
Block a user