diff --git a/backend/generator.py b/backend/generator.py index 1e7c133..fe06d28 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -6,8 +6,10 @@ import shutil import subprocess import re import uuid +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path +from typing import Callable from agents import run_agent, kill_process from config import ( @@ -185,6 +187,82 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: await asyncio.gather(*tasks.keys(), return_exceptions=True) +@dataclass +class GenContext: + """Durchgereichte Pipeline-Parameter — erspart lange Argument-Signaturen.""" + topic: str + provider: str + is_cancelled: Callable[[], bool] + guide_id: str | None = None + + +# Ergebnis-Status von run_single_slot/_check_then_fix +OK, CANCELLED, FAILED = "ok", "cancelled", "failed" + + +async def run_single_slot( + ctx: GenContext, label: str, *, + key: str, prompt: str, role: str, capabilities: str, payload, timeout: int, +) -> tuple[str, object]: + """Ein Agent, ein gültiges Ergebnis (Race mit Quorum 1). + + → (OK, wert) | (CANCELLED, None) | (FAILED, None) + """ + slots = [{"key": key, "prompt": prompt, "role": role, "capabilities": capabilities, "payload": payload}] + res = await _race(ctx.topic, label, slots, 1, timeout, ctx.provider, cancelled=ctx.is_cancelled) + if ctx.is_cancelled(): + return CANCELLED, None + if res is None: + return FAILED, None + return OK, res[0] + + +async def _check_then_fix( + ctx: GenContext, *, name: str, step: int, + check_key: str, check_prompt: str, check_path: Path, check_timeout: int, + fix_key: str, build_fix_prompt, fix_payload, fix_timeout: int, + fix_role: str = "fast", fix_caps: str = "files", + on_fix_invalid=None, +) -> tuple[str, object]: + """Check→Fix-Muster: Prüf-Agent notiert Probleme (JSON), Fix-Agent behebt sie. + + Resume: existierende Check-Datei überspringt den ganzen Schritt. + Check ist fatal (FAILED), Fix nicht — Original bleibt; on_fix_invalid kann + das kanonische Original zurückschreiben, falls der Fix-Agent die + Artefakt-Datei zerschrieben hat. + Lese-Check (Multi-Slot, Section-genau) und Bausteine-Auswahl-Check + (Patch-Semantik) passen bewusst NICHT in dieses Muster. + → (OK, neues_artefakt | None=unverändert) | (CANCELLED, None) | (FAILED, None) + """ + if check_path.exists(): + return OK, None + await _set_step(ctx.guide_id, step, f"Prüfe {name}…") + status, probleme = await run_single_slot( + ctx, f"{name}-Prüfung", key=check_key, prompt=check_prompt, + role="fast", capabilities="files", + payload=lambda result: _probleme_schema(_json_datei(check_path)), + timeout=check_timeout, + ) + if status != OK: + return status, None + if not probleme: + return OK, None + _log(ctx.topic, f"{name}-Prüfung: {len(probleme)} Problem(e) notiert") + await _set_step(ctx.guide_id, step, f"Passe {name} an…") + status, fixed = await run_single_slot( + ctx, f"{name}-Fix", key=fix_key, prompt=build_fix_prompt(probleme), + role=fix_role, capabilities=fix_caps, payload=fix_payload, timeout=fix_timeout, + ) + if status == CANCELLED: + return CANCELLED, None + if status == FAILED: + _log(ctx.topic, f"{name}-Fix ungültig — Original bleibt") + if on_fix_invalid: + on_fix_invalid() + return OK, None + return OK, fixed + + # --- Bausteine-Pipeline: 4x Recherche (3) → 2x Auswahl (1) → Prüfung — reines Inventar, unsortiert --- _bausteine_progress: dict[str, str] = {} @@ -426,6 +504,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = def abgebrochen() -> None: _bausteine_errors[topic] = "Abgebrochen — Fortschritt bleibt erhalten" + ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled) + try: async with _semaphore: files["arbeit"].mkdir(parents=True, exist_ok=True) @@ -508,20 +588,21 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = f"### Recherche {i}\n" + "\n".join(f"- {_titel(t)}" for t in _parse_auswahl(text).values()) for i, text in enumerate(recherchen, 1) ) - slots = [{ - "key": f"bausteine-{topic}-auswahlcheck-1", - "prompt": _prompt("Bausteine-Auswahl-Check", topic=topic, results=titel_listen, auswahl=flat, out_path=check_path), - "role": "fast", "capabilities": "files", - "payload": (lambda result: _auswahl_check_schema(_json_datei(check_path))), - }] - checks = await _race(topic, "Auswahl-Check", slots, 1, _timeout("auswahl_check", len(entries)), provider, cancelled=is_cancelled) - if is_cancelled(): + status, check = await run_single_slot( + ctx, "Auswahl-Check", + key=f"bausteine-{topic}-auswahlcheck-1", + prompt=_prompt("Bausteine-Auswahl-Check", topic=topic, results=titel_listen, auswahl=flat, out_path=check_path), + role="fast", capabilities="files", + payload=lambda result: _auswahl_check_schema(_json_datei(check_path)), + timeout=_timeout("auswahl_check", len(entries)), + ) + if status == CANCELLED: abgebrochen() return - if checks is None: + if status == FAILED: _log(topic, "Auswahl-Check fehlgeschlagen — fahre ohne Korrekturen fort") else: - patch = checks[0] + patch = check if patch is not None and (patch["streichen"] or patch["nachtraege"]): idx = _titel_index(entries) weg = {num for t in patch["streichen"] if (num := _titel_aufloesen(idx, t)) is not None} @@ -541,24 +622,24 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = ergaenzungen = _ergaenzung_schema(_json_datei(erg_path)) if ergaenzungen is None: erg_path.unlink(missing_ok=True) - slots = [{ - "key": f"bausteine-{topic}-ergaenzung-1", - "prompt": _prompt( + status, ergaenzungen = await run_single_slot( + ctx, "Ergänzung", + key=f"bausteine-{topic}-ergaenzung-1", + prompt=_prompt( "Bausteine-Ergaenzung", topic=topic, bausteine="\n".join(f"- {t}" for t in entries.values()), out_path=erg_path, extra=_extra(instructions), ), - "role": "quick", "capabilities": "full", - "payload": (lambda result: _ergaenzung_schema(_json_datei(erg_path))), - }] - res = await _race(topic, "Ergänzung", slots, 1, _timeout("ergaenzung"), provider, cancelled=is_cancelled) - if is_cancelled(): + role="quick", capabilities="full", + payload=lambda result: _ergaenzung_schema(_json_datei(erg_path)), + timeout=_timeout("ergaenzung"), + ) + if status == CANCELLED: abgebrochen() return - if res is None: + if status == FAILED: _bausteine_errors[topic] = "Ergänzung fehlgeschlagen (kein gültiges Ergebnis)" return - ergaenzungen = res[0] idx = _titel_index(entries) neu = [(t, b) for t, b in ergaenzungen if _titel_aufloesen(idx, t) is None] if neu: @@ -763,8 +844,7 @@ async def _generate_onepager( guide_id: str, topic: str, instructions: str, provider: str, project: Path | None, content_path: Path, ) -> list[dict] | None: - def is_cancelled() -> bool: - return guide_id in _cancelled + ctx = GenContext(topic=topic, provider=provider, is_cancelled=lambda: guide_id in _cancelled, guide_id=guide_id) # 3×3-Raster: 7 Karten mit festen Schlüsseln (Reihenfolge = Lesereihenfolge mobil) KARTEN_KEYS = ("info", "eigenschaften", "beispiel", "zusammenhaenge", "voraussetzungen", "modern", "veraltet") @@ -814,118 +894,91 @@ async def _generate_onepager( recherche = recherche_payload() if recherche is None: await _set_step(guide_id, 0, "Recherchiere…") - slots = [{ - "key": f"{guide_id}-recherche", - "prompt": _prompt(recherche_template, topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)), - "role": "quick", "capabilities": "files" if project else "full", - "payload": recherche_payload, - }] - res = await _race(topic, "OnePager-Recherche", slots, 1, _timeout("onepager_recherche"), provider, cancelled=is_cancelled) - if is_cancelled(): + status, recherche = await run_single_slot( + ctx, "OnePager-Recherche", + key=f"{guide_id}-recherche", + prompt=_prompt(recherche_template, topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)), + role="quick", capabilities="files" if project else "full", + payload=recherche_payload, timeout=_timeout("onepager_recherche"), + ) + if status == CANCELLED: return None - if res is None: + if status == FAILED: await _fail(guide_id, "OnePager-Recherche fehlgeschlagen") return None - recherche = res[0] # Schritt 2: Recherche-Prüfung — notiert Probleme; Anpassung macht ein Recherche-Agent - if not recherche_check_path.exists(): - await _set_step(guide_id, 1, "Prüfe Recherche…") - slots = [{ - "key": f"{guide_id}-recherche-check", - "prompt": _prompt(recherche_check_template, topic=topic, recherche=recherche, out_path=recherche_check_path), - "role": "fast", "capabilities": "files", - "payload": (lambda result: _probleme_schema(_json_datei(recherche_check_path))), - }] - res = await _race(topic, "Recherche-Prüfung", slots, 1, _timeout("onepager_verify"), provider, cancelled=is_cancelled) - if is_cancelled(): - return None - if res is None: - await _fail(guide_id, "Recherche-Prüfung fehlgeschlagen") - return None - probleme = res[0] - if probleme: - _log(topic, f"Recherche-Prüfung: {len(probleme)} Problem(e) notiert") - await _set_step(guide_id, 1, "Passe Recherche an…") - slots = [{ - "key": f"{guide_id}-recherche-fix", - "prompt": _prompt( - "OnePager-Recherche-Fix", - topic=topic, source=source, recherche=recherche, - probleme="\n".join(f"- {p}" for p in probleme), - out_path=recherche_path, extra=_extra(instructions), - ), - "role": "quick", "capabilities": "files" if project else "full", - "payload": recherche_payload, - }] - res = await _race(topic, "Recherche-Fix", slots, 1, _timeout("onepager_recherche"), provider, cancelled=is_cancelled) - if is_cancelled(): - return None - if res is None: - _log(topic, "Recherche-Fix ungültig — ursprüngliche Recherche bleibt") - else: - recherche = res[0] + status, fixed = await _check_then_fix( + ctx, name="Recherche", step=1, + check_key=f"{guide_id}-recherche-check", + check_prompt=_prompt(recherche_check_template, topic=topic, recherche=recherche, out_path=recherche_check_path), + check_path=recherche_check_path, check_timeout=_timeout("onepager_verify"), + fix_key=f"{guide_id}-recherche-fix", + build_fix_prompt=lambda probleme: _prompt( + "OnePager-Recherche-Fix", + topic=topic, source=source, recherche=recherche, + probleme="\n".join(f"- {p}" for p in probleme), + out_path=recherche_path, extra=_extra(instructions), + ), + fix_payload=recherche_payload, fix_timeout=_timeout("onepager_recherche"), + fix_role="quick", fix_caps="files" if project else "full", + ) + if status == CANCELLED: + return None + if status == FAILED: + await _fail(guide_id, "Recherche-Prüfung fehlgeschlagen") + return None + if fixed is not None: + recherche = fixed # Schritt 3: Bauen — Karten nur aus der Faktenbasis (Resume: gültige Datei wird übernommen) karten = karten_schema(_json_datei(karten_path)) if karten is None: await _set_step(guide_id, 2, "Baue OnePager…") karten_path.unlink(missing_ok=True) - slots = [{ - "key": f"{guide_id}-bauen", - "prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=karten_path, extra=_extra(instructions)), - "role": "fast", "capabilities": "files", - "payload": (lambda result: karten_schema(_json_datei(karten_path))), - }] - res = await _race(topic, "OnePager-Bauen", slots, 1, _timeout("onepager_bauen"), provider, cancelled=is_cancelled) - if is_cancelled(): + status, karten = await run_single_slot( + ctx, "OnePager-Bauen", + key=f"{guide_id}-bauen", + prompt=_prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=karten_path, extra=_extra(instructions)), + role="fast", capabilities="files", + payload=lambda result: karten_schema(_json_datei(karten_path)), + timeout=_timeout("onepager_bauen"), + ) + if status == CANCELLED: return None - if res is None: + if status == FAILED: await _fail(guide_id, "OnePager-Bau fehlgeschlagen") return None - karten = res[0] def karten_block() -> str: return "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in karten) # Schritt 4: Prüfung — notiert Probleme; Anpassung macht ein Bauen-Agent - if not check_path.exists(): - await _set_step(guide_id, 3, "Prüfe OnePager…") - slots = [{ - "key": f"{guide_id}-verify", - "prompt": _prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=check_path), - "role": "fast", "capabilities": "files", - "payload": (lambda result: _probleme_schema(_json_datei(check_path))), - }] - res = await _race(topic, "OnePager-Prüfung", slots, 1, _timeout("onepager_verify"), provider, cancelled=is_cancelled) - if is_cancelled(): - return None - if res is None: - await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen") - return None - probleme = res[0] - if probleme: - _log(topic, f"OnePager-Prüfung: {len(probleme)} Problem(e) notiert") - await _set_step(guide_id, 3, "Passe OnePager an…") - slots = [{ - "key": f"{guide_id}-karten-fix", - "prompt": _prompt( - "OnePager-Fix", - topic=topic, recherche=recherche, karten=karten_block(), - probleme="\n".join(f"- {p}" for p in probleme), - out_path=karten_path, extra=_extra(instructions), - ), - "role": "fast", "capabilities": "files", - "payload": (lambda result: karten_schema(_json_datei(karten_path))), - }] - res = await _race(topic, "OnePager-Fix", slots, 1, _timeout("onepager_bauen"), provider, cancelled=is_cancelled) - if is_cancelled(): - return None - if res is None: - _log(topic, "OnePager-Fix ungültig — ursprüngliche Karten bleiben") - atomic_write_json(karten_path, {"karten": {k["key"]: {"titel": k["titel"], "md": k["md"]} for k in karten}}) - else: - karten = res[0] + status, fixed = await _check_then_fix( + ctx, name="OnePager", step=3, + check_key=f"{guide_id}-verify", + check_prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=check_path), + check_path=check_path, check_timeout=_timeout("onepager_verify"), + fix_key=f"{guide_id}-karten-fix", + build_fix_prompt=lambda probleme: _prompt( + "OnePager-Fix", + topic=topic, recherche=recherche, karten=karten_block(), + probleme="\n".join(f"- {p}" for p in probleme), + out_path=karten_path, extra=_extra(instructions), + ), + fix_payload=lambda result: karten_schema(_json_datei(karten_path)), + fix_timeout=_timeout("onepager_bauen"), + on_fix_invalid=lambda: atomic_write_json( + karten_path, {"karten": {k["key"]: {"titel": k["titel"], "md": k["md"]} for k in karten}}, + ), + ) + if status == CANCELLED: + return None + if status == FAILED: + await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen") + return None + if fixed is not None: + karten = fixed sections = [ {"num": i, "title": k["titel"], "md": k["md"], "key": k["key"]} @@ -942,6 +995,7 @@ async def _generate_sections( def is_cancelled() -> bool: return guide_id in _cancelled + ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id) spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8") files = _guide_files(content_path) bausteine_liste = "\n".join(f"- {t}" for t in entries.values()) @@ -959,23 +1013,23 @@ async def _generate_sections( if auswahl is None: await _set_step(guide_id, 0, "Wähle Bausteine…") files["auswahl"].unlink(missing_ok=True) - slots = [{ - "key": f"{guide_id}-auswahl", - "prompt": _prompt( + 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)), - }] - res = await _race(topic, "Guide-Auswahl", slots, 1, _timeout("guide_auswahl", n), provider, cancelled=is_cancelled) - if is_cancelled(): + 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 res is None: + if status == FAILED: await _fail(guide_id, "Auswahl fehlgeschlagen") return None - auswahl = res[0] def auswahl_titel() -> str: return "\n".join(f"- {_titel(entries[num])}" for num in auswahl) @@ -984,49 +1038,35 @@ async def _generate_sections( 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 - if not files["auswahl_check"].exists(): - await _set_step(guide_id, 1, "Prüfe Auswahl…") - slots = [{ - "key": f"{guide_id}-auswahl-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), - ), - "role": "fast", "capabilities": "files", - "payload": (lambda result: _probleme_schema(_json_datei(files["auswahl_check"]))), - }] - res = await _race(topic, "Auswahl-Prüfung", slots, 1, _timeout("guide_check", len(auswahl)), provider, cancelled=is_cancelled) - if is_cancelled(): - return None - if res is None: - await _fail(guide_id, "Auswahl-Prüfung fehlgeschlagen") - return None - probleme = res[0] - if probleme: - _log(topic, f"Auswahl-Prüfung: {len(probleme)} Problem(e) notiert") - await _set_step(guide_id, 1, "Passe Auswahl an…") - slots = [{ - "key": f"{guide_id}-auswahl-fix", - "prompt": _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), - ), - "role": "guide", "capabilities": "files", - "payload": (lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max)), - }] - res = await _race(topic, "Auswahl-Fix", slots, 1, _timeout("guide_auswahl", n), provider, cancelled=is_cancelled) - if is_cancelled(): - return None - if res is None: - _log(topic, "Auswahl-Fix ungültig — ursprüngliche Auswahl bleibt") - atomic_write_text(files["auswahl"], auswahl_json()) - else: - auswahl = res[0] + 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()), + ) + if status == CANCELLED: + 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) @@ -1037,23 +1077,23 @@ async def _generate_sections( if plan is None: await _set_step(guide_id, 2, "Plane Gliederung…") files["gliederung"].unlink(missing_ok=True) - slots = [{ - "key": f"{guide_id}-gliederung", - "prompt": _prompt( + status, plan = await run_single_slot( + ctx, "Gliederung", + key=f"{guide_id}-gliederung", + prompt=_prompt( "Guide-Gliederung", topic=topic, format_name=format_name, bausteine=sel_liste, out_path=files["gliederung"], extra=_extra(instructions), ), - "role": "guide", "capabilities": "files", - "payload": (lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)), - }] - res = await _race(topic, "Gliederung", slots, 1, _timeout("plan", soll), provider, cancelled=is_cancelled) - if is_cancelled(): + role="guide", capabilities="files", + payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll), + timeout=_timeout("plan", soll), + ) + if status == CANCELLED: return None - if res is None: + if status == FAILED: await _fail(guide_id, "Gliederung fehlgeschlagen") return None - plan = res[0] def gliederung_text() -> str: return "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in plan) @@ -1065,49 +1105,35 @@ async def _generate_sections( ) # Schritt 4: Gliederungs-Prüfung - if not files["gliederung_check"].exists(): - await _set_step(guide_id, 3, "Prüfe Gliederung…") - slots = [{ - "key": f"{guide_id}-gliederung-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), - ), - "role": "fast", "capabilities": "files", - "payload": (lambda result: _probleme_schema(_json_datei(files["gliederung_check"]))), - }] - res = await _race(topic, "Gliederungs-Prüfung", slots, 1, _timeout("guide_check", soll), provider, cancelled=is_cancelled) - if is_cancelled(): - return None - if res is None: - await _fail(guide_id, "Gliederungs-Prüfung fehlgeschlagen") - return None - probleme = res[0] - if probleme: - _log(topic, f"Gliederungs-Prüfung: {len(probleme)} Problem(e) notiert") - await _set_step(guide_id, 3, "Passe Gliederung an…") - slots = [{ - "key": f"{guide_id}-gliederung-fix", - "prompt": _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), - ), - "role": "guide", "capabilities": "files", - "payload": (lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)), - }] - res = await _race(topic, "Gliederungs-Fix", slots, 1, _timeout("plan", soll), provider, cancelled=is_cancelled) - if is_cancelled(): - return None - if res is None: - _log(topic, "Gliederungs-Fix ungültig — ursprüngliche Gliederung bleibt") - atomic_write_text(files["gliederung"], gliederung_json()) - else: - plan = res[0] + 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) total_sections = sum(len(c["nums"]) for c in plan)