"""Guide-Generierung als Konsens-Pipeline. Gliederung: Auswahl der Bausteine (deterministisch je Format) → 3 Vorschläge (Grace), die Bausteine NUMMERN-basiert in Kapitel ordnen → ein Judge merged die Vorschläge zu einer kohärenten Reihenfolge. Schreiben: Writer je Baustein. Lese-Prüfung: Check→Fix (eine Runde), 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 from pathlib import Path from agents import run_agent from bausteine import _pdfs_konvertieren, quelle_ordner from config import ( DEFAULT_PROVIDER, FORMAT_ZWECK, KONSENS_GRACE, LESBARKEIT_AKTIV, TEMPLATES_DIR, ) import lesbarkeit from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content from fsutil import atomic_write_json, atomic_write_text from jsonio import read_json_file as _json_datei from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path from pipeline import ( CANCELLED, FAILED, GenContext, _claude_error, _extra, _fail, _gather_error, _gather_fortschritt, _log, _prompt, _race, _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, _titel, _titel_aufloesen, _titel_index, ) log = logging.getLogger("creator.guide") GUIDE_STEPS = ("Gliederung", "Inhalte", "Inhalts-Check", "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 # eines fehlgeschlagenen Writers. # Inhalte/Inhalts-Check/Lese-Prüfung laufen in Paketen von ~GUIDE_CHUNK Bausteinen je Agent. # Nur der Writer (Schreiben) bleibt 1 Agent je Baustein (variable Längen, kein Kürzen). GUIDE_CHUNK = 10 # Lese-Prüfung: nur EINE Runde (Check + Fix). Folgerunden brachten kaum Mehrwert # (1 Agent je Baustein prüft ohnehin fein), kosten aber extra Agenten. LESE_RUNDEN = 1 async def _load_subbausteine(topic: str) -> dict[str, list[dict]]: """Subbausteine je Baustein — DB-first ({titel, stufe, relevanz}), Fallback Sidecar-Datei. Fehlt beides → {} (Guide nimmt alles).""" out: dict[str, list[dict]] = {} for r in await list_subbausteine(topic): if r["status"] == "konsens" and r["sub_titel"] and r["stufe"] in ("einfach", "mittel", "schwer"): out.setdefault(r["baustein"], []).append( {"titel": r["sub_titel"], "stufe": r["stufe"], "relevanz": r["relevanz"]}) if out: return out data = _json_datei(subbausteine_path(topic)) if not isinstance(data, dict): return {} for titel, subs in data.items(): if not isinstance(subs, list): continue gut = [s for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip() and s.get("stufe") in ("einfach", "mittel", "schwer")] if gut: out[titel] = gut return out def _zuteilung_subs(chunk: list[dict], entries: dict[int, str], subs_by_titel: dict[str, list[dict]]) -> str: """Wie _zuteilung_text, aber listet pro Baustein seine Subbausteine mit Stufe.""" lines: list[str] = [] for ch in chunk: lines.append(f"KAPITEL: {ch['title']}") for num in ch["nums"]: lines.append(f"- {entries[num]}") for s in subs_by_titel.get(_titel(entries[num]), []): lines.append(f" [{s['stufe']}] {s['titel']}") return "\n".join(lines) def _guide_files(content_path: Path) -> dict: d, stem = content_path.parent, content_path.stem return { "gliederung_slots": [d / f"{stem}.gliederung-{i}.json" for i in (1, 2, 3)], "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 } def guide_slot_dateien(content_path: Path) -> list[Path]: """Alle Schritt-Dateien eines Guides (für den Frischstart).""" return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path] def _fertig_path(content_path: Path) -> Path: return content_path.parent / f"{content_path.stem}.fertig" def guide_fertig_step(content_path: Path) -> int: """Höchster VOLL abgeschlossener Schritt-Index (Marker je Thema+Format). -1 = keiner. Existiert die Content-Datei, sind alle Schritte fertig.""" if content_path.exists(): return len(GUIDE_STEPS) - 1 try: return int(_fertig_path(content_path).read_text(encoding="utf-8").strip()) except (OSError, ValueError): return -1 def _set_fertig(content_path: Path, step: int) -> None: """Marker auf `step` setzen — monoton (nur erhöhen), außer beim Re-Run-Reset (force).""" if step > guide_fertig_step(content_path): atomic_write_text(_fertig_path(content_path), str(step)) def _reset_fertig(content_path: Path, step: int) -> None: """Marker hart auf `step` setzen (für Re-Run ab Schritt; step kann sinken).""" if step < 0: _fertig_path(content_path).unlink(missing_ok=True) else: atomic_write_text(_fertig_path(content_path), str(step)) # Slot-Datei-Globs je Schritt (Index = GUIDE_STEPS). Stem-verankert, kollisionsfrei. _STEP_GLOBS = ( ("gliederung*",), # 0 Gliederung (inkl. Auswahl-Filter) ("inhalt-chunk-*",), # 1 Inhalte ("inhalt-check-*", "inhalt-fix-*"), # 2 Inhalts-Check ("chunk-*",), # 3 Schreiben ("lese-check-*", "fix-r*"), # 4 Lese-Prüfung ) def _reset_guide_ab_step(content_path: Path, step: int) -> None: """Re-Run ab Schritt: Content + alle Slot-Dateien der Schritte ≥ step löschen. Frühere Schritte bleiben → der Resume baut ab `step` neu (alles darunter wiederverwendet).""" content_path.unlink(missing_ok=True) # nicht mehr „done" → kein Frischstart-Wipe d, stem = content_path.parent, content_path.stem for globs in _STEP_GLOBS[step:]: for pat in globs: for p in d.glob(f"{stem}.{pat}"): p.unlink(missing_ok=True) _reset_fertig(content_path, step - 1) # Schritte < step gelten als fertig def _lese_probleme_schema(data): """{"ok": true} → [] · {"probleme": [{"section", "problem"}]} → Liste · sonst None.""" if not isinstance(data, dict): return None if data.get("ok") is True: return [] p = data.get("probleme") if not isinstance(p, list) or not p: return None out = [] for x in p: if not isinstance(x, dict) or not isinstance(x.get("section"), str) or not isinstance(x.get("problem"), str): return None out.append({"section": x["section"].strip(), "problem": x["problem"].strip()}) return out or None def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None: """{"kapitel": [{"titel", "nummern": [1, 3, 7]}]} → [{"title", "nums"}]. Nummern sind die IDs aus `entries` (1-basiert, wie dem Agenten präsentiert). `soll_min`/`soll_max` = erlaubte Spanne gewählter Bausteine (mit kleiner Toleranz). """ if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list): return None gueltig = set(entries) chapters: list[dict] = [] seen: set[int] = set() total = unknown = 0 for ch in data["kapitel"]: if not isinstance(ch, dict) or not isinstance(ch.get("nummern"), list): return None nums = [] for t in ch["nummern"]: total += 1 num = t if isinstance(t, int) and not isinstance(t, bool) else None if num is None or num not in gueltig: unknown += 1 elif num not in seen: nums.append(num) seen.add(num) if nums: chapters.append({"title": str(ch.get("titel", "")).strip() or "Kapitel", "nums": nums}) if not chapters or total == 0: return None if (total - unknown) / total < 0.85: return None if len(seen) < 0.9 * soll_min or len(seen) > 1.1 * soll_max: return None return chapters def _fallback_gliederung(entries: dict[int, str]) -> list[dict]: """Deterministische Gliederung, wenn die Agenten keine liefern: ein Kapitel mit allen gewählten Bausteinen in Reihenfolge. Garantiert vollständige Abdeckung.""" return [{"title": "Inhalte", "nums": list(entries)}] def _mit_resten(plan: list[dict], entries: dict[int, str]) -> list[dict]: """Stellt sicher, dass JEDER gewählte Baustein im Plan steht — fehlende landen in einem Kapitel „Weitere" (gegen Agenten/Judge, die Bausteine weglassen).""" drin = {num for ch in plan for num in ch.get("nums", [])} fehlen = [num for num in entries if num not in drin] return [*plan, {"title": "Weitere", "nums": fehlen}] if fehlen else plan async def _generate_sections( guide_id: str, topic: str, format_name: str, entries: dict[int, str], facts: str, instructions: str, provider: str, content_path: Path, ) -> list[dict] | None: def is_cancelled() -> bool: return is_guide_cancelled(guide_id) 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) zweck = FORMAT_ZWECK[format_name] # Subbausteine je Baustein (DB-first) — früh geladen: steuert Auswahl + Sub-Filter je Format. # Fehlt sie → {} (Fallback: Guide nimmt alles). subs_raw = await _load_subbausteine(topic) def _hat_relevanz(num, art): return any(isinstance(s, dict) and s.get("relevanz") == art for s in subs_raw.get(_titel(entries[num]), [])) # Auswahl (Teil von Gliederung). Deterministisch je Format: # Guide=relevante Bausteine · FullGuide=alle · Rest=reine Rand-Bausteine (keine im Guide). if format_name == "FullGuide": auswahl = list(entries) elif format_name == "Rest": auswahl = [num for num in entries if not _hat_relevanz(num, "relevant")] else: # Guide auswahl = [num for num in entries if _hat_relevanz(num, "relevant")] if not auswahl: _log(topic, "Guide: keine Relevanz-Daten — alle Bausteine genommen") auswahl = list(entries) if not auswahl: await _fail(guide_id, "Keine passenden Bausteine für dieses Format") return None sel_entries = {num: entries[num] for num in auswahl} soll = len(sel_entries) # Nummerierte Liste (ID = Baustein-Nummer aus entries) — Agenten/Judge ordnen per Nummer. sel_liste = "\n".join(f"{num}. {t}" for num, t in sel_entries.items()) # Schritt 0: Gliederung — bis zu 3 Vorschläge (Grace), ein Judge merged sie. Bricht NIE ab: # 0 gültige → Code-Fallback, 1 → direkt, ≥2 → Judge (mit Vorschlag als Rückfall). # Gültiges gliederung.json überspringt den Schritt. plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll) if plan is None: await _set_step(guide_id, 0, "Gliederungs-Vorschläge (3 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 and offen: 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 ] # Quorum 1: nimm, was kommt — kein Mindestzwang, kein Abbruch. neue = await _race( topic, "Gliederung", slots, 1, _timeout("plan", soll), provider, cancelled=is_cancelled, grace=KONSENS_GRACE, ) if is_cancelled(): return None vorschlaege += neue or [] if not vorschlaege: _log(topic, "Gliederung: kein gültiger Vorschlag — deterministischer Fallback") plan = _fallback_gliederung(sel_entries) elif len(vorschlaege) == 1: plan = vorschlaege[0] # ein Vorschlag → kein Judge nötig else: await _set_step(guide_id, 0, "Gliederungen zusammenführen…") bloecke = "\n\n".join( f"### Vorschlag {i}\n" + "\n".join(f"KAPITEL: {ch['title']}\n Nummern: {', '.join(str(num) for num in ch['nums'])}" for ch in v) for i, v in enumerate(vorschlaege, 1) ) status, plan = await run_single_slot( ctx, "Gliederungs-Judge", key=f"{guide_id}-gliederung-judge", prompt=_prompt( "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="judge", capabilities="files", payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll), timeout=_timeout("plan_judge", soll), ) if status == CANCELLED: return None if status == FAILED or plan is None: _log(topic, "Gliederung-Judge ohne Ergebnis — bester Vorschlag bleibt") plan = vorschlaege[0] # Garantie: jeder gewählte Baustein steht im Plan (gegen weglassende Agenten/Judges). plan = _mit_resten(plan, sel_entries) _set_fertig(content_path, 0) # Gliederung steht # Grobe Chunks (~GUIDE_CHUNK Bausteine je Agent) für Inhalte, Inhalts-Check und Lese-Prüfung. # Der Writer baut darunter pro Baustein (eigene feine Chunks, s.u.) → variable Längen. total_sections = sum(len(c["nums"]) for c in plan) chunks = _split_chunks(plan, max(1, math.ceil(total_sections / GUIDE_CHUNK))) # Subbausteine je Baustein, je Format gefiltert: Guide=relevant · FullGuide=alle · Rest=Rand. # Subs ohne Relevanz-Feld (Alt-Themen) zählen als „nicht Rand" (→ Guide/FullGuide). if format_name == "FullGuide": subs_by_titel = {t: list(subs) for t, subs in subs_raw.items()} elif format_name == "Rest": subs_by_titel = {t: [s for s in subs if s.get("relevanz") == "rand"] for t, subs in subs_raw.items()} else: # Guide subs_by_titel = {t: [s for s in subs if s.get("relevanz") != "rand"] for t, subs in subs_raw.items()} subs_by_titel = {t: subs for t, subs in subs_by_titel.items() if subs} zuteilungen = [_zuteilung_subs(chunk, entries, subs_by_titel) for chunk in chunks] chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks] writer_count = len(chunks) idx = _titel_index(entries) # Schritt 2: Inhalte je Baustein identifizieren — pro Chunk ein Agent (Marker-Output, Resume). inhalt_paths = [content_path.parent / f"{content_path.stem}.inhalt-chunk-{i}.md" for i in range(1, writer_count + 1)] offen = [i for i, p in enumerate(inhalt_paths) if not p.exists()] if offen: async def melde(d, t): await _set_step(guide_id, 1, f"Sammle Inhalte {d}/{t}…") results = await _gather_fortschritt([ run_agent( f"{guide_id}-inhalt-{i + 1}", _prompt( "Guide-Inhalt", topic=topic, zuteilung=zuteilungen[i], facts=facts, out_path=inhalt_paths[i], extra=_extra(instructions), ), _timeout("inhalt", chunk_sizes[i]), provider=provider, role="guide", capabilities="full", ) for i in offen ], writer_count, melde, start=writer_count - len(offen)) if is_cancelled(): return None if not any(p.exists() for p in inhalt_paths): await _fail(guide_id, _gather_error("Inhalts-Fehler", list(results))) return None inhalt_by_num: dict[int, str] = {} for p in inhalt_paths: if not p.exists(): continue for sec in _parse_fragment(p.read_text(encoding="utf-8")): num = _titel_aufloesen(idx, sec["titel"]) if num is not None and num not in inhalt_by_num and sec["md"].strip(): inhalt_by_num[num] = sec["md"] if not inhalt_by_num: await _fail(guide_id, "Keine Inhalte identifiziert") return None if all(p.exists() for p in inhalt_paths): _set_fertig(content_path, 1) # Inhalte vollständig inhalt_chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in inhalt_by_num] for chunk in chunks] # Schritt 3: Inhalte prüfen (pro Chunk ein Check) + beanstandete einmal überarbeiten. check_paths = [content_path.parent / f"{content_path.stem}.inhalt-check-{i}.json" for i in range(1, writer_count + 1)] offen_checks = [i for i, p in enumerate(check_paths) if inhalt_chunk_nums[i] and _lese_probleme_schema(_json_datei(p)) is None] if offen_checks: await _set_step(guide_id, 2, "Prüfe Inhalte…") slots = [{ "key": f"{guide_id}-inhalt-check-{i + 1}", "prompt": _prompt( "Guide-Inhalt-Check", topic=topic, format_name=format_name, sections="\n\n".join(f"SECTION: {_titel(entries[num])}\n{inhalt_by_num[num]}" for num in inhalt_chunk_nums[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] n_checks = len(slots) upd = lambda n: asyncio.create_task(_set_step(guide_id, 2, f"Prüfe Inhalte {n}/{n_checks}…")) await _race(topic, "Inhalts-Prüfung", slots, len(slots), _timeout("inhalt_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled) if is_cancelled(): return None probleme_by_num: dict[int, str] = {} for i, p in enumerate(check_paths): geltung = set(inhalt_chunk_nums[i]) for item in (_lese_probleme_schema(_json_datei(p)) or []): num = _titel_aufloesen(idx, item["section"]) if num in geltung and num in inhalt_by_num and num not in probleme_by_num: probleme_by_num[num] = item["problem"] if probleme_by_num: _log(topic, f"Inhalts-Prüfung: {len(probleme_by_num)} Baustein(e) beanstandet") await _set_step(guide_id, 2, f"Überarbeite {len(probleme_by_num)} Inhalt(e)…") fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in inhalt_chunk_nums] fix_paths = [content_path.parent / f"{content_path.stem}.inhalt-fix-{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}-inhalt-fix-{i + 1}", _prompt( "Guide-Inhalt-Fix", topic=topic, facts=facts, auftraege="\n\n".join( f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELL:\n{inhalt_by_num[num]}" for num in fix_chunks[i] ), out_path=fix_paths[i], extra=_extra(instructions), ), _timeout("inhalt", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full", ) for i in fix_offen ], return_exceptions=True) if is_cancelled(): return None for p in fix_paths: if not p.exists(): continue 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(): inhalt_by_num[num] = sec["md"] _set_fertig(content_path, 2) # Inhalts-Check durch # Schritt 4: Schreiben — Writer formuliert die geprüften Inhalte aus (Resume). # FEINE Chunks: genau 1 Baustein je Writer → variable Längen, kein Budget-Rationieren. def inhalte_text(chunk) -> str: nums = [num for ch in chunk for num in ch["nums"] if num in inhalt_by_num] return "\n\n".join(f"\n{inhalt_by_num[num]}" for num in nums) w_chunks = [[{"title": ch["title"], "nums": [num]}] for ch in plan for num in ch["nums"]] w_zuteil = [_zuteilung_subs(c, entries, subs_by_titel) for c in w_chunks] paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, len(w_chunks) + 1)] offen = [i for i, p in enumerate(paths) if not p.exists()] if offen: async def melde(d, t): await _set_step(guide_id, 3, f"Schreibe Sections {d}/{t}…") results = await _gather_fortschritt([ run_agent( f"{guide_id}-w{i + 1}", _prompt( "Guide-Writer", topic=topic, format_name=format_name, zuteilung=w_zuteil[i], inhalte=inhalte_text(w_chunks[i]), facts=facts, spec=spec, out_path=paths[i], extra=_extra(instructions), ), _timeout("writer", 1), provider=provider, role="guide", capabilities="full", ) for i in offen ], len(w_chunks), melde, start=len(w_chunks) - len(offen)) if is_cancelled(): return None for i, r in zip(offen, results): if isinstance(r, BaseException): _log(topic, f"Writer {i + 1}: {type(r).__name__}: {r}") elif r[0] != 0: _log(topic, f"Writer {i + 1}: {_claude_error('Fehler', *r)}") elif not paths[i].exists(): _log(topic, f"Writer {i + 1}: keine Ausgabedatei erstellt") if not any(p.exists() for p in paths): await _fail(guide_id, _gather_error("Writer-Fehler", list(results))) return None by_num: dict[int, dict] = {} for p in paths: if not p.exists(): continue for sec in _parse_fragment(p.read_text(encoding="utf-8")): num = _titel_aufloesen(idx, sec["titel"]) if num is None: _log(topic, f"Writer lieferte unbekannte Section '{sec['titel'][:40]}' (ignoriert)") elif num not in by_num: by_num[num] = sec if not by_num: await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden") return None if all(p.exists() for p in paths): _set_fertig(content_path, 3) # Schreiben vollständig # 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] 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 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 ) scope = chunk_nums for runde in range(1, LESE_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, 4, "Prüfe Lesbarkeit…") 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] n_checks = len(slots) upd = lambda n: asyncio.create_task(_set_step(guide_id, 4, f"Prüfe Lesbarkeit {n}/{n_checks}…")) res = await _race(topic, f"Lese-Prüfung r{runde}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled) if is_cancelled(): return None if res is None: # Nicht hart failen: bei 1 Agent je Baustein darf ein einzelner Check-Ausfall # nicht den ganzen Guide kippen. Sections sind geschrieben; Lese-Prüfung ist Politur. _log(topic, f"Lese-Prüfung Runde {runde} ohne vollständiges Ergebnis — Stand bleibt") break 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"] # Deterministisches Lesbarkeits-Gate: zu schwere Sections in dieselbe # Überarbeitung einreihen (LLM-Beanstandung hat Vorrang). Gate aus → no-op. if LESBARKEIT_AKTIV: md_by_num = {num: by_num[num]["md"] for nums in scope for num in nums if num in by_num} hinweise = await asyncio.to_thread(lesbarkeit.bewerte_sections, md_by_num) if hinweise: _log(topic, f"Lesbarkeit: {len(hinweise)} Section(s) zu schwer") for num, hinweis in hinweise.items(): probleme_by_num.setdefault(num, hinweis) 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, 4, 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_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-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], probleme_by_num), out_path=fix_paths[i], extra=_extra(instructions), ), _timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full", ) for i in fix_offen ], return_exceptions=True) if is_cancelled(): 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} (Runde {runde}) fehlgeschlagen — Original bleibt") ersetzt: set[int] = set() for p in fix_paths: if not p.exists(): continue 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.add(num) _log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet") if not ersetzt: break if runde == LESE_RUNDEN: _log(topic, f"Lese-Prüfung: 1 Runde — Überarbeitung bleibt ungeprüft") break scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums] _set_fertig(content_path, 4) # Lese-Prüfung durch # Prüfbar = Format hat Prüfung UND Baustein hat ≥1 relevanten Subbaustein. # Guide ist immer prüfbar (auch ohne Relevanz-Daten, Fallback = alles). def _pruefbar(num): if format_name == "Guide": return True if format_name == "FullGuide": return any(isinstance(s, dict) and s.get("relevanz") == "relevant" for s in subs_raw.get(_titel(entries[num]), [])) return False # Rest u.a. → reine Lese-Sections await _set_progress(guide_id, "Setze zusammen…") chapters: list[dict] = [] for ch in plan: sections = [ {"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"], "kompakt": by_num[num].get("kompakt", ""), "pruefbar": _pruefbar(num)} for num in ch["nums"] if num in by_num ] if sections: chapters.append({"title": ch["title"], "sections": sections}) geplant = {num for ch in plan for num in ch["nums"]} missing = sorted(geplant - set(by_num)) if missing: _log(topic, f"Sections fehlen in der Writer-Ausgabe: {[_titel(entries[n]) for n in missing]}") if not chapters: await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden") return None return chapters async def reconcile_guides() -> None: """DB↔Dateisystem abgleichen: status=done ohne Content-Datei → error. Läuft beim Server-Start (nach init_db) — fängt Crashes zwischen Datei-Write und Status-Update ab. """ for g in await list_guides(): if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists(): log.warning("[%s] Guide %s: done ohne Content-Datei — auf error gesetzt", g["topic"], g["id"]) now = datetime.now(timezone.utc).isoformat() await update_guide(g["id"], status="error", error_msg="Inhalt fehlt — neu generieren", updated_at=now) async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_step: int | None = None) -> None: async with _semaphore: now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="generating", progress="Starte…", updated_at=now) content_path = guide_content_path(topic, format_name) content_path.parent.mkdir(parents=True, exist_ok=True) project = quelle_ordner(topic) # Ordner-Quelle (projekt/uni/link) → Pfad, sonst None try: if is_guide_cancelled(guide_id): return if project: await asyncio.to_thread(_pdfs_konvertieren, project) # Re-Run ab Schritt: Content + Slots ab `ab_step` löschen, Rest bleibt → Resume baut ab dort. # Sonst „Neu erstellen": fertiger Guide → kompletter Frischstart. # Sonst sind Schritt-Dateien Reste eines Abbruchs/Fehlers → Resume. if ab_step is not None: _reset_guide_ab_step(content_path, ab_step) elif content_path.exists(): for p_alt in guide_slot_dateien(content_path): p_alt.unlink(missing_ok=True) bs = await list_bausteine(topic, status="konsens") if bs: alle = {i: (f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"]) for i, b in enumerate(bs, 1)} else: # Fallback: bausteine.md (Alt-Themen) bp = bausteine_path(topic) alle = _lade_bausteine(bp.read_text(encoding="utf-8")) if bp.exists() else {} if not alle: await _fail(guide_id, "Keine Bausteine gefunden") return entries = _eindeutige_titel(alle) facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema") chapters = await _generate_sections( guide_id, topic, format_name, entries, facts, instructions, provider, content_path, ) if chapters is None or is_guide_cancelled(guide_id): return content = {"topic": topic, "format": format_name, "chapters": chapters} atomic_write_json(content_path, content, indent=1) # Brücke (Resume/Fallback) await set_guide_content(topic, format_name, json.dumps(content, ensure_ascii=False)) now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="done", progress=None, step=None, updated_at=now) except asyncio.TimeoutError: await _fail(guide_id, "Timeout bei der Generierung") except FileNotFoundError: await _fail(guide_id, "Bausteine fehlen") except Exception as e: log.exception("[%s] Guide-Generierung fehlgeschlagen (%s)", topic, guide_id) await _fail(guide_id, str(e)[:2000]) finally: clear_guide_cancelled(guide_id)