This commit is contained in:
team3
2026-06-12 17:18:42 +02:00
parent cfc666055c
commit 78d5833fe4
38 changed files with 1854 additions and 740 deletions

View File

@@ -1,11 +1,14 @@
"""Guide-Generierung: 6 Schritte mit Prüfung nach jeder Phase (OnePager hat einen eigenen Weg).
"""Guide-Generierung als Konsens-Pipeline (OnePager hat einen eigenen Weg).
Prüf-Agenten notieren nur Probleme; das Anpassen übernimmt der jeweilige Erzeuger-Typ.
Auswahl: 5 Agenten (min. 3, Grace) → Code-Voting (Mehrheit = Konsens) →
Mapping-Agent sortiert Strittiges → Klärungs-Loop (max. KONSENS_MAX_RUNDEN).
Gliederung: 5 Vorschläge (min. 3, Grace) → ein Judge wählt und kombiniert.
Schreiben: Writer pro Chunk. Lese-Prüfung: Check→Fix-Loop (max. Runden-Cap),
Folgerunden prüfen nur ersetzte Sections; danach bleiben Beanstandungen stehen.
Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
"""
import asyncio
import json
import logging
import math
from datetime import datetime, timezone
@@ -13,17 +16,20 @@ from pathlib import Path
from agents import run_agent
from bausteine import _pdfs_konvertieren
from config import DEFAULT_PROVIDER, FORMAT_ANTEIL, TEMPLATES_DIR
from config import (
DEFAULT_PROVIDER, FORMAT_ANTEIL, KONSENS_GRACE, KONSENS_MAX_RUNDEN,
TEMPLATES_DIR,
)
from database import list_guides, update_guide
from fsutil import atomic_write_json, atomic_write_text
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_datei
from onepager import _generate_onepager
from paths import bausteine_path, guide_content_path, project_dir
from pipeline import (
CANCELLED, FAILED, GenContext, _check_then_fix, _claude_error, _extra,
_fail, _gather_error, _log, _prompt, _race, _semaphore, _set_progress,
_set_step, _timeout, clear_guide_cancelled, is_guide_cancelled,
run_single_slot,
CANCELLED, FAILED, GenContext, _claude_error, _extra,
_fail, _gather_error, _log, _prompt, _race, _rest_schema, _runde_schema,
_semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
is_guide_cancelled, run_single_slot,
)
from textkit import (
_eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks,
@@ -32,7 +38,7 @@ from textkit import (
log = logging.getLogger("creator.guide")
GUIDE_STEPS = ("Auswahl", "Auswahl-Prüfung", "Gliederung", "Gliederungs-Prüfung", "Schreiben", "Lese-Prüfung")
GUIDE_STEPS = ("Auswahl", "Gliederung", "Schreiben", "Lese-Prüfung")
# Writer skalieren mit der Section-Zahl: 1 Writer je ~30 Sections (gedeckelt).
# Kleine Pakete vermeiden Lazy-Output bei langen Listen und begrenzen den Schaden
@@ -43,12 +49,18 @@ WRITER_MAX = 20
def _guide_files(content_path: Path) -> dict:
d, stem = content_path.parent, content_path.stem
runden = range(1, KONSENS_MAX_RUNDEN + 1)
return {
"auswahl": d / f"{stem}.auswahl.json",
"auswahl_check": d / f"{stem}.auswahl-check.json",
"gliederung": d / f"{stem}.gliederung.json",
"gliederung_check": d / f"{stem}.gliederung-check.json",
# chunk-/lese-check-/fix-Dateien sind dynamisch: {stem}.chunk-i.md usw.
# Runde 1: 5 volle Auswahl-Vorschläge; Runden 2+: 3 Klärungs-Voten
"auswahl_slots": {
n: [d / f"{stem}.auswahl-r{n}-{i}.json" for i in range(1, (5 if n == 1 else 3) + 1)]
for n in runden
},
"auswahl_mapping": {n: d / f"{stem}.auswahl-mapping-r{n}.json" for n in runden},
"gliederung_slots": [d / f"{stem}.gliederung-{i}.json" for i in (1, 2, 3, 4, 5)],
"gliederung": d / f"{stem}.gliederung.json", # Judge-Ausgabe
# chunk-/lese-check-/fix-Dateien sind dynamisch:
# {stem}.chunk-i.md, {stem}.lese-check-r{n}-{i}.json, {stem}.fix-r{n}-{i}.md
}
@@ -131,6 +143,221 @@ def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max:
return chapters
def _voting(stimmen: list[list[int]]) -> tuple[list[int], dict[int, int]]:
"""Mehrheit (> Hälfte der Stimmen) → Konsens; ≥1 Stimme → Rest mit Votenzahl."""
zaehler: dict[int, int] = {}
for stimme in stimmen:
for num in stimme:
zaehler[num] = zaehler.get(num, 0) + 1
konsens = sorted(num for num, v in zaehler.items() if v > len(stimmen) / 2)
rest = {num: v for num, v in sorted(zaehler.items()) if v <= len(stimmen) / 2}
return konsens, rest
def _resolve_uebernehmen(data, entries: dict[int, str]) -> list[int] | None:
"""{"uebernehmen": [Titel]} → Nummern; leer gültig; >15 % unauflösbar → None."""
titel = _rest_schema(data)
if titel is None:
return None
if not titel:
return []
idx = _titel_index(entries)
nums: list[int] = []
seen: set[int] = set()
unknown = 0
for t in titel:
num = _titel_aufloesen(idx, t)
if num is None:
unknown += 1
elif num not in seen:
seen.add(num)
nums.append(num)
if unknown / len(titel) > 0.15:
return None
return nums
def _resolve_runde(data, entries: dict[int, str], konsens: list[int], k_min: int, k_max: int, final: bool) -> tuple[list[int], list[int]] | None:
"""Auswahl-Mapping-Runde auflösen — erzwingt die Zielgrößen-Grenzen schema-seitig.
Immer: Konsens + Aufnehmen + Rest muss 0.9*k_min erreichen können (sonst
wäre die Mindestgröße in späteren Runden unerreichbar). Aufnehmen über
1.1*k_max hinaus ist ungültig; final erzwingt zusätzlich leeren Rest und
die Mindestgröße. Ein bereits zu großer Konsens allein ist kein Fehler —
der Agent kann dann nichts mehr aufnehmen.
"""
res = _runde_schema(data, final=final)
if res is None:
return None
idx = _titel_index(entries)
bekannt = set(konsens)
listen: list[list[int]] = []
for titel_liste in res:
nums: list[int] = []
unknown = 0
for t in titel_liste:
num = _titel_aufloesen(idx, t)
if num is None:
unknown += 1
elif num not in bekannt:
bekannt.add(num)
nums.append(num)
if titel_liste and unknown / len(titel_liste) > 0.15:
return None
listen.append(nums)
aufnehmen, rest = listen
gesamt = len(konsens) + len(aufnehmen)
if aufnehmen and gesamt > 1.1 * k_max:
return None
if gesamt + len(rest) < 0.9 * k_min:
return None
if (final or not rest) and gesamt < 0.9 * k_min:
return None
return aufnehmen, rest
async def _konsens_auswahl(
ctx: GenContext, files: dict, entries: dict[int, str],
k_min: int, k_max: int, auswahl_auftrag: str, format_name: str,
bausteine_liste: str, instructions: str,
) -> list[int] | None:
"""Schritt 0: 5 Auswahl-Agenten → Code-Voting → Mapping → Klärungs-Loop.
Rückgabe: finale Baustein-Nummern; None = Fehler/Abbruch (bereits gemeldet).
"""
guide_id, topic, provider = ctx.guide_id, ctx.topic, ctx.provider
is_cancelled = ctx.is_cancelled
n = len(entries)
def titel_liste(nums) -> str:
return "\n".join(f"- {_titel(entries[num])}" for num in nums)
konsens: list[int] = []
rest: list[int] = []
runde = 0
while True:
runde += 1
final_runde = runde == KONSENS_MAX_RUNDEN
# Voten der Runde einsammeln — Slot-Dateien zuerst (Resume), Rest per Race
if runde == 1:
await _set_step(guide_id, 0, "Wähle Bausteine (5 Vorschläge)…")
stimmen: list[list[int]] = []
offen = []
for i, path in enumerate(files["auswahl_slots"][1], 1):
res = _resolve_auswahl(_json_datei(path), entries, k_min, k_max)
if res is not None:
stimmen.append(res)
else:
offen.append((i, path))
if len(stimmen) < 3:
slots = [
{
"key": f"{guide_id}-auswahl-r1-{i}",
"prompt": _prompt(
"Guide-Auswahl",
topic=topic, format_name=format_name, bausteine=bausteine_liste,
auswahl_auftrag=auswahl_auftrag, out_path=path, extra=_extra(instructions),
),
"role": "guide", "capabilities": "files",
"payload": (lambda result, p=path: _resolve_auswahl(_json_datei(p), entries, k_min, k_max)),
}
for i, path in offen
]
neue = await _race(
topic, "Guide-Auswahl", slots, 3 - len(stimmen), _timeout("guide_auswahl", n),
provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, "Auswahl fehlgeschlagen (Minimum nicht erreicht)")
return None
stimmen += neue
konsens, voten = _voting(stimmen)
rest = list(voten)
stimmen_n = len(stimmen)
else:
await _set_step(guide_id, 0, f"Kläre strittige Bausteine (Runde {runde}/{KONSENS_MAX_RUNDEN})…")
entscheidungen: list[list[int]] = []
offen = []
for i, path in enumerate(files["auswahl_slots"][runde], 1):
res = _resolve_uebernehmen(_json_datei(path), entries)
if res is not None:
entscheidungen.append(res)
else:
offen.append((i, path))
if len(entscheidungen) < 2:
slots = [
{
"key": f"{guide_id}-auswahl-r{runde}-{i}",
"prompt": _prompt(
"Guide-Klaerung",
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
konsens=titel_liste(konsens) or "- (leer)", rest=titel_liste(rest),
out_path=path, extra=_extra(instructions),
),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=path: _resolve_uebernehmen(_json_datei(p), entries)),
}
for i, path in offen
]
neue = await _race(
topic, f"Guide-Klärung r{runde}", slots, 2 - len(entscheidungen),
_timeout("auswahl", len(rest)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, f"Auswahl fehlgeschlagen (Runde {runde}, Minimum nicht erreicht)")
return None
entscheidungen += neue
voten = {num: sum(1 for e in entscheidungen if num in e) for num in rest}
stimmen_n = len(entscheidungen)
# Mapping-Agent sortiert die strittigen Voten — gültige Datei = Resume
mapping_path = files["auswahl_mapping"][runde]
ergebnis = _resolve_runde(_json_datei(mapping_path), entries, konsens, k_min, k_max, final_runde)
if ergebnis is None:
mapping_path.unlink(missing_ok=True)
voten_block = "\n".join(
f"{i}. {_titel(entries[num])} (von {voten[num]}/{stimmen_n} Agenten gewählt)"
for i, num in enumerate(rest, 1)
) or "- (keine)"
final_zusatz = (
"\n- LETZTE RUNDE: Es gibt keine weitere Runde. `rest` MUSS leer sein"
" — entscheide JEDEN Eintrag selbst: aufnehmen oder verwerfen."
if final_runde else ""
)
status, ergebnis = await run_single_slot(
ctx, f"Auswahl-Mapping r{runde}",
key=f"{guide_id}-auswahl-mapping-r{runde}",
prompt=_prompt(
"Guide-Auswahl-Mapping",
topic=topic, format_name=format_name, n=stimmen_n,
auswahl_auftrag=auswahl_auftrag, konsens_n=len(konsens),
k_min=k_min, k_max=k_max,
konsens=titel_liste(konsens) or "- (leer)", rest=voten_block,
final=final_zusatz, out_path=mapping_path,
),
role="judge", capabilities="files",
payload=lambda result, p=mapping_path, k=tuple(konsens), f=final_runde:
_resolve_runde(_json_datei(p), entries, list(k), k_min, k_max, f),
timeout=_timeout("auswahl_mapping", len(konsens) + len(rest)),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, f"Auswahl-Mapping fehlgeschlagen (Runde {runde})")
return None
aufnehmen, rest = ergebnis
konsens = konsens + aufnehmen
_log(topic, f"Auswahl Runde {runde}: {len(aufnehmen)} aufgenommen, {len(rest)} strittig, Konsens {len(konsens)}")
if not rest or final_runde:
return konsens
async def _generate_sections(
guide_id: str, topic: str, format_name: str, entries: dict[int, str],
facts: str, instructions: str, provider: str,
@@ -152,134 +379,83 @@ async def _generate_sections(
"Wähle, was diesem Zweck dient — lass weg, was dafür nicht nötig ist."
)
# Schritt 1: Auswahl — vorhandene gültige Datei wird übernommen (Resume)
auswahl = _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max)
if auswahl is None:
await _set_step(guide_id, 0, "Wähle Bausteine…")
files["auswahl"].unlink(missing_ok=True)
status, auswahl = await run_single_slot(
ctx, "Guide-Auswahl",
key=f"{guide_id}-auswahl",
prompt=_prompt(
"Guide-Auswahl",
topic=topic, format_name=format_name, bausteine=bausteine_liste,
auswahl_auftrag=auswahl_auftrag, out_path=files["auswahl"], extra=_extra(instructions),
),
role="guide", capabilities="files",
payload=lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max),
timeout=_timeout("guide_auswahl", n),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Auswahl fehlgeschlagen")
return None
def auswahl_titel() -> str:
return "\n".join(f"- {_titel(entries[num])}" for num in auswahl)
def auswahl_json() -> str:
return json.dumps({"bausteine": [_titel(entries[num]) for num in auswahl]}, ensure_ascii=False)
# Schritt 2: Auswahl-Prüfung — notiert Probleme; Anpassung macht ein Auswahl-Agent
status, fixed = await _check_then_fix(
ctx, name="Auswahl", step=1,
check_key=f"{guide_id}-auswahl-check",
check_prompt=_prompt(
"Guide-Auswahl-Check",
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
bausteine=bausteine_liste, auswahl=auswahl_titel(),
out_path=files["auswahl_check"], extra=_extra(instructions),
),
check_path=files["auswahl_check"], check_timeout=_timeout("guide_check", len(auswahl)),
fix_key=f"{guide_id}-auswahl-fix",
build_fix_prompt=lambda probleme: _prompt(
"Guide-Auswahl-Fix",
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
bausteine=bausteine_liste, auswahl=auswahl_titel(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=files["auswahl"], extra=_extra(instructions),
),
fix_payload=lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max),
fix_timeout=_timeout("guide_auswahl", n), fix_role="guide",
on_fix_invalid=lambda: atomic_write_text(files["auswahl"], auswahl_json()),
# Schritt 0: Auswahl-Konsens (5 Agenten → Voting → Mapping → Klärungs-Loop)
auswahl = await _konsens_auswahl(
ctx, files, entries, k_min, k_max, auswahl_auftrag, format_name,
bausteine_liste, instructions,
)
if status == CANCELLED:
if auswahl is None:
return None
if status == FAILED:
await _fail(guide_id, "Auswahl-Prüfung fehlgeschlagen")
return None
if fixed is not None:
auswahl = fixed
sel_entries = {num: entries[num] for num in auswahl}
soll = len(sel_entries)
sel_liste = "\n".join(f"- {t}" for t in sel_entries.values())
# Schritt 3: Gliederung der festen Auswahl
# Schritt 1: Gliederung — 5 Vorschläge (min. 3, Grace), ein Judge wählt.
# Gültiges gliederung.json (auch aus Altläufen) überspringt den Schritt.
plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
if plan is None:
await _set_step(guide_id, 2, "Plane Gliederung…")
await _set_step(guide_id, 1, "Gliederungs-Vorschläge (5 Agenten)")
files["gliederung"].unlink(missing_ok=True)
vorschlaege: list[list[dict]] = []
offen = []
for i, path in enumerate(files["gliederung_slots"], 1):
res = _resolve_gliederung(_json_datei(path), sel_entries, soll, soll)
if res is not None:
vorschlaege.append(res)
else:
offen.append((i, path))
if len(vorschlaege) < 3:
slots = [
{
"key": f"{guide_id}-gliederung-{i}",
"prompt": _prompt(
"Guide-Gliederung",
topic=topic, format_name=format_name, bausteine=sel_liste,
out_path=path, extra=_extra(instructions),
),
"role": "guide", "capabilities": "files",
"payload": (lambda result, p=path: _resolve_gliederung(_json_datei(p), sel_entries, soll, soll)),
}
for i, path in offen
]
neue = await _race(
topic, "Gliederung", slots, 3 - len(vorschlaege), _timeout("plan", soll),
provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, "Gliederung fehlgeschlagen (Minimum nicht erreicht)")
return None
vorschlaege += neue
await _set_step(guide_id, 1, "Wähle beste Gliederung…")
bloecke = "\n\n".join(
f"### Vorschlag {i}\n"
+ "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in v)
for i, v in enumerate(vorschlaege, 1)
)
status, plan = await run_single_slot(
ctx, "Gliederung",
key=f"{guide_id}-gliederung",
ctx, "Gliederungs-Judge",
key=f"{guide_id}-gliederung-judge",
prompt=_prompt(
"Guide-Gliederung",
topic=topic, format_name=format_name, bausteine=sel_liste,
"Guide-Gliederung-Judge",
topic=topic, format_name=format_name, zweck=zweck, n=len(vorschlaege),
bausteine=sel_liste, gliederungen=bloecke,
out_path=files["gliederung"], extra=_extra(instructions),
),
role="guide", capabilities="files",
role="judge", capabilities="files",
payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
timeout=_timeout("plan", soll),
timeout=_timeout("plan_judge", soll),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Gliederung fehlgeschlagen")
await _fail(guide_id, "Gliederung fehlgeschlagen (Judge ohne gültiges Ergebnis)")
return None
def gliederung_text() -> str:
return "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in plan)
def gliederung_json() -> str:
return json.dumps(
{"kapitel": [{"titel": ch["title"], "bausteine": [_titel(entries[num]) for num in ch["nums"]]} for ch in plan]},
ensure_ascii=False,
)
# Schritt 4: Gliederungs-Prüfung
status, fixed = await _check_then_fix(
ctx, name="Gliederung", step=3,
check_key=f"{guide_id}-gliederung-check",
check_prompt=_prompt(
"Guide-Gliederung-Check",
topic=topic, format_name=format_name, zweck=zweck,
auswahl=auswahl_titel(), gliederung=gliederung_text(),
out_path=files["gliederung_check"], extra=_extra(instructions),
),
check_path=files["gliederung_check"], check_timeout=_timeout("guide_check", soll),
fix_key=f"{guide_id}-gliederung-fix",
build_fix_prompt=lambda probleme: _prompt(
"Guide-Gliederung-Fix",
topic=topic, format_name=format_name,
auswahl=auswahl_titel(), gliederung=gliederung_text(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=files["gliederung"], extra=_extra(instructions),
),
fix_payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
fix_timeout=_timeout("plan", soll), fix_role="guide",
on_fix_invalid=lambda: atomic_write_text(files["gliederung"], gliederung_json()),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Gliederungs-Prüfung fehlgeschlagen")
return None
if fixed is not None:
plan = fixed
# Schritt 5: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume)
# Schritt 2: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume)
total_sections = sum(len(c["nums"]) for c in plan)
chunks = _split_chunks(plan, min(WRITER_MAX, max(1, math.ceil(total_sections / WRITER_SECTIONS))))
zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks]
@@ -288,7 +464,7 @@ async def _generate_sections(
paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, writer_count + 1)]
offen = [i for i, p in enumerate(paths) if not p.exists()]
if offen:
await _set_step(guide_id, 4, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
await _set_step(guide_id, 2, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
results = await asyncio.gather(*[
run_agent(
f"{guide_id}-w{i + 1}",
@@ -329,61 +505,69 @@ async def _generate_sections(
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
return None
# Schritt 6: Lese-Prüfung pro Writer-Paket Fix beauftragt Writer nur mit beanstandeten Sections
# Schritt 3: Lese-Prüfungs-Loop — Check pro Writer-Paket, Fix nur für
# beanstandete Sections; Folgerunden prüfen NUR die ersetzten Sections.
# Nach dem Runden-Cap bleiben offene Beanstandungen stehen.
chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks]
check_paths = [content_path.parent / f"{content_path.stem}.lese-check-{i}.json" for i in range(1, writer_count + 1)]
offen_checks = [i for i, p in enumerate(check_paths) if _lese_probleme_schema(_json_datei(p)) is None and chunk_nums[i]]
if offen_checks:
await _set_step(guide_id, 5, f"Prüfe Lesbarkeit ({len(offen_checks)} Prüfer)…" if len(offen_checks) > 1 else "Prüfe Lesbarkeit…")
def sections_text(nums: list[int]) -> str:
return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)
def sections_text(nums: list[int]) -> str:
return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)
slots = [{
"key": f"{guide_id}-lese-check-{i + 1}",
"prompt": _prompt(
"Guide-Lese-Check",
topic=topic, format_name=format_name, spec=spec,
sections=sections_text(chunk_nums[i]),
out_path=check_paths[i], extra=_extra(instructions),
),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))),
} for i in offen_checks]
res = await _race(topic, "Lese-Prüfung", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
await _fail(guide_id, "Lese-Prüfung fehlgeschlagen")
return None
def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str:
return "\n\n".join(
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
for num in nums
)
probleme_by_num: dict[int, str] = {}
for p in check_paths:
for item in (_lese_probleme_schema(_json_datei(p)) or []):
num = _titel_aufloesen(idx, item["section"])
if num in by_num and num not in probleme_by_num:
probleme_by_num[num] = item["problem"]
scope = chunk_nums
for runde in range(1, KONSENS_MAX_RUNDEN + 1):
check_paths = [content_path.parent / f"{content_path.stem}.lese-check-r{runde}-{i}.json" for i in range(1, writer_count + 1)]
offen_checks = [i for i, p in enumerate(check_paths) if scope[i] and _lese_probleme_schema(_json_datei(p)) is None]
if offen_checks:
await _set_step(guide_id, 3, f"Prüfe Lesbarkeit (Runde {runde}/{KONSENS_MAX_RUNDEN})…")
slots = [{
"key": f"{guide_id}-lese-check-r{runde}-{i + 1}",
"prompt": _prompt(
"Guide-Lese-Check",
topic=topic, format_name=format_name, spec=spec,
sections=sections_text(scope[i]),
out_path=check_paths[i], extra=_extra(instructions),
),
"role": "judge", "capabilities": "files",
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))),
} for i in offen_checks]
res = await _race(topic, f"Lese-Prüfung r{runde}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
if runde == 1:
await _fail(guide_id, "Lese-Prüfung fehlgeschlagen")
return None
_log(topic, f"Lese-Prüfung Runde {runde} fehlgeschlagen — Stand bleibt")
break
if probleme_by_num:
_log(topic, f"Lese-Prüfung: {len(probleme_by_num)} Section(s) beanstandet")
await _set_step(guide_id, 5, f"Überarbeite {len(probleme_by_num)} Section(s)…")
probleme_by_num: dict[int, str] = {}
for i, p in enumerate(check_paths):
geltung = set(scope[i])
for item in (_lese_probleme_schema(_json_datei(p)) or []):
num = _titel_aufloesen(idx, item["section"])
if num in geltung and num in by_num and num not in probleme_by_num:
probleme_by_num[num] = item["problem"]
if not probleme_by_num:
break
_log(topic, f"Lese-Prüfung Runde {runde}: {len(probleme_by_num)} Section(s) beanstandet")
await _set_step(guide_id, 3, f"Überarbeite {len(probleme_by_num)} Section(s) (Runde {runde})…")
fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in chunk_nums]
fix_offen = [i for i, nums in enumerate(fix_chunks) if nums]
fix_paths = [content_path.parent / f"{content_path.stem}.fix-{i + 1}.md" for i in range(writer_count)]
def auftraege_text(nums: list[int]) -> str:
return "\n\n".join(
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
for num in nums
)
fix_paths = [content_path.parent / f"{content_path.stem}.fix-r{runde}-{i + 1}.md" for i in range(writer_count)]
fix_offen = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()]
results = await asyncio.gather(*[
run_agent(
f"{guide_id}-fix-w{i + 1}",
f"{guide_id}-fix-r{runde}-w{i + 1}",
_prompt(
"Guide-Sections-Fix",
topic=topic, format_name=format_name, facts=facts, spec=spec,
auftraege=auftraege_text(fix_chunks[i]),
auftraege=auftraege_text(fix_chunks[i], probleme_by_num),
out_path=fix_paths[i], extra=_extra(instructions),
),
_timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
@@ -394,17 +578,23 @@ async def _generate_sections(
return None
for i, r in zip(fix_offen, results):
if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0):
_log(topic, f"Sections-Fix {i + 1} fehlgeschlagen — Original bleibt")
ersetzt = 0
for i in fix_offen:
if not fix_paths[i].exists():
_log(topic, f"Sections-Fix {i + 1} (Runde {runde}) fehlgeschlagen — Original bleibt")
ersetzt: set[int] = set()
for p in fix_paths:
if not p.exists():
continue
for sec in _parse_fragment(fix_paths[i].read_text(encoding="utf-8")):
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
num = _titel_aufloesen(idx, sec["titel"])
if num in probleme_by_num and sec["md"].strip():
by_num[num] = sec
ersetzt += 1
_log(topic, f"Lese-Prüfung: {ersetzt} Section(s) überarbeitet")
ersetzt.add(num)
_log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet")
if not ersetzt:
break
if runde == KONSENS_MAX_RUNDEN:
_log(topic, f"Lese-Prüfung: Cap erreicht — letzte Überarbeitung bleibt ungeprüft")
break
scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums]
await _set_progress(guide_id, "Setze zusammen…")
chapters: list[dict] = []