Das Buch wird von vorn nach hinten gelesen. Verweisklammern „(→ …)“ und Rückverweise entfallen, Vorausverweise nur als Halbsatz. Die Konsistenz- stufe entfernt Reste und kürzt Doppelungen ohne Verweis. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
90 lines
4.6 KiB
Python
90 lines
4.6 KiB
Python
"""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, 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} .*$"),):
|
||
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}
|