Files
buchwerk/backend/lektorat.py
team3 d07218307f 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>
2026-10-05 20:45:40 +02:00

90 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}