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:
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}
|
||||
Reference in New Issue
Block a user