diff --git a/backend/guide.py b/backend/guide.py index 7eecbb2..f54b384 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -41,13 +41,15 @@ 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). +# Nur der Writer (Schreiben) bleibt 1 Agent je Baustein (variable Längen, kein Kürzen, keine +# Längen-Angleichung zwischen Bausteinen). GUIDE_CHUNK = 10 +# Prüf-Schritte als Panel: CHECK_PANEL Judges je Chunk, Section beanstandet bei Mehrheit. +# Ein einzelner Judge ist bias-/sampling-anfällig; ein kleines Panel ist stabiler. +CHECK_PANEL = 3 + # 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 @@ -134,10 +136,10 @@ def _reset_fertig(content_path: Path, step: int) -> None: # 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 + ("gliederung*",), # 0 Gliederung (inkl. Auswahl-Filter) + ("inhalt-chunk-*", "inhalt-nach-*"), # 1 Inhalte (inkl. Nachrunde) + ("inhalt-check-*", "inhalt-fix-*"), # 2 Inhalts-Check + ("chunk-*",), # 3 Schreiben (chunk-* matcht auch chunk-nach-*) ("lese-check-*", "fix-r*"), # 4 Lese-Prüfung ) @@ -171,6 +173,31 @@ def _lese_probleme_schema(data): return out or None +def _panel_probleme(judge_paths: list[Path], geltung: set[int], idx: dict[str, int]) -> dict[int, str]: + """Panel-Aggregation: mehrere Judge-Outputs eines Chunks → beanstandete {num: problem}. + + Eine Stimme je Judge, der eine Section nennt. Beanstandet, wenn > Hälfte der + GELIEFERTEN (valid geparsten) Judges sie nennt (3→≥2, 2→≥2, 1→≥1). Robust gegen + Einzel-Ausfall: fehlende Dateien zählen nicht mit. Problem-Text vom erstnennenden Judge. + """ + outputs = [p for p in (_lese_probleme_schema(_json_datei(j)) for j in judge_paths) if p is not None] + if not outputs: + return {} + votes: dict[int, int] = {} + problem: dict[int, str] = {} + for out in outputs: + gesehen: set[int] = set() + for item in out: + num = _titel_aufloesen(idx, item["section"]) + if num is None or num not in geltung or num in gesehen: + continue + gesehen.add(num) + votes[num] = votes.get(num, 0) + 1 + problem.setdefault(num, item["problem"]) + schwelle = len(outputs) / 2 + return {num: problem[num] for num, v in votes.items() if v > schwelle} + + 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"}]. @@ -385,40 +412,80 @@ async def _generate_sections( if not inhalt_by_num: await _fail(guide_id, "Keine Inhalte identifiziert") return None + + # Nachrunde: fehlende Bausteine (Chunk-Ausfall oder Lazy-Output) gezielt nachziehen — eine Runde. + geplant_nums = [num for ch in plan for num in ch["nums"]] + fehlend = [num for num in geplant_nums if num not in inhalt_by_num] + if fehlend: + _log(topic, f"Inhalte: {len(fehlend)} Baustein(e) fehlen — Nachrunde…") + nach_chunks = [[{"title": "Weitere", "nums": fehlend[k:k + GUIDE_CHUNK]}] for k in range(0, len(fehlend), GUIDE_CHUNK)] + nach_paths = [content_path.parent / f"{content_path.stem}.inhalt-nach-{k}.md" for k in range(1, len(nach_chunks) + 1)] + nach_offen = [k for k, p in enumerate(nach_paths) if not p.exists()] + if nach_offen: + async def melde_n(d, t): await _set_step(guide_id, 1, f"Sammle fehlende Inhalte {d}/{t}…") + await _gather_fortschritt([ + run_agent( + f"{guide_id}-inhalt-nach-{k + 1}", + _prompt( + "Guide-Inhalt", + topic=topic, zuteilung=_zuteilung_subs(nach_chunks[k], entries, subs_by_titel), + facts=facts, out_path=nach_paths[k], extra=_extra(instructions), + ), + _timeout("inhalt", len(nach_chunks[k][0]["nums"])), provider=provider, role="guide", capabilities="full", + ) + for k in nach_offen + ], len(nach_chunks), melde_n, start=len(nach_chunks) - len(nach_offen)) + if is_cancelled(): + return None + for p in nach_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 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: + # Schritt 3: Inhalte prüfen — CHECK_PANEL Judges je Chunk, Mehrheit beanstandet. + # + beanstandete einmal überarbeiten. Resume: nur fehlende Judge-Dateien neu starten. + check_judge_paths = [ + [content_path.parent / f"{content_path.stem}.inhalt-check-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)] + for i in range(1, writer_count + 1) + ] + offen_slots = [ + (i, j) for i in range(writer_count) if inhalt_chunk_nums[i] + for j in range(CHECK_PANEL) if _lese_probleme_schema(_json_datei(check_judge_paths[i][j])) is None + ] + if offen_slots: await _set_step(guide_id, 2, "Prüfe Inhalte…") + sections_je_chunk = { + i: "\n\n".join(f"SECTION: {_titel(entries[num])}\n{inhalt_by_num[num]}" for num in inhalt_chunk_nums[i]) + for i, _ in offen_slots + } slots = [{ - "key": f"{guide_id}-inhalt-check-{i + 1}", + "key": f"{guide_id}-inhalt-check-{i + 1}-j{j + 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), + topic=topic, format_name=format_name, sections=sections_je_chunk[i], + out_path=check_judge_paths[i][j], 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] + "payload": (lambda result, p=check_judge_paths[i][j]: _lese_probleme_schema(_json_datei(p))), + } for i, j in offen_slots] 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) + await _race(topic, "Inhalts-Prüfung", slots, len(slots), _timeout("inhalt_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=KONSENS_GRACE) 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"] + for i in range(writer_count): + if inhalt_chunk_nums[i]: + probleme_by_num.update(_panel_probleme(check_judge_paths[i], set(inhalt_chunk_nums[i]), idx)) if probleme_by_num: _log(topic, f"Inhalts-Prüfung: {len(probleme_by_num)} Baustein(e) beanstandet") @@ -473,9 +540,9 @@ async def _generate_sections( "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), + spec=spec, out_path=paths[i], extra=_extra(instructions), ), - _timeout("writer", 1), provider=provider, role="guide", capabilities="full", + _timeout("writer", 1), provider=provider, role="guide", capabilities="files", ) for i in offen ], len(w_chunks), melde, start=len(w_chunks) - len(offen)) @@ -505,6 +572,38 @@ async def _generate_sections( if not by_num: await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden") return None + + # Nachrunde: fehlende Sections (Writer-Ausfall) gezielt nachschreiben — eine Runde. + nach_fehlend = [num for num in geplant_nums if num not in by_num] + if nach_fehlend: + _log(topic, f"Schreiben: {len(nach_fehlend)} Section(s) fehlen — Nachrunde…") + nw_chunks = [[{"title": "Weitere", "nums": [num]}] for num in nach_fehlend] + nw_paths = [content_path.parent / f"{content_path.stem}.chunk-nach-{k}.md" for k in range(1, len(nw_chunks) + 1)] + nw_offen = [k for k, p in enumerate(nw_paths) if not p.exists()] + if nw_offen: + async def melde_nw(d, t): await _set_step(guide_id, 3, f"Schreibe fehlende Sections {d}/{t}…") + await _gather_fortschritt([ + run_agent( + f"{guide_id}-w-nach-{k + 1}", + _prompt( + "Guide-Writer", + topic=topic, format_name=format_name, zuteilung=_zuteilung_subs(nw_chunks[k], entries, subs_by_titel), + inhalte=inhalte_text(nw_chunks[k]), spec=spec, out_path=nw_paths[k], extra=_extra(instructions), + ), + _timeout("writer", 1), provider=provider, role="guide", capabilities="files", + ) + for k in nw_offen + ], len(nw_chunks), melde_nw, start=len(nw_chunks) - len(nw_offen)) + if is_cancelled(): + return None + for p in nw_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 by_num and sec["md"].strip(): + by_num[num] = sec + if all(p.exists() for p in paths): _set_fertig(content_path, 3) # Schreiben vollständig @@ -524,39 +623,41 @@ async def _generate_sections( 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: + # CHECK_PANEL Judges je Paket; Mehrheit beanstandet. Aggregation robust gegen Einzel-Ausfall. + check_judge_paths = [ + [content_path.parent / f"{content_path.stem}.lese-check-r{runde}-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)] + for i in range(1, writer_count + 1) + ] + offen_slots = [ + (i, j) for i in range(writer_count) if scope[i] + for j in range(CHECK_PANEL) if _lese_probleme_schema(_json_datei(check_judge_paths[i][j])) is None + ] + if offen_slots: await _set_step(guide_id, 4, "Prüfe Lesbarkeit…") + sections_je_chunk = {i: sections_text(scope[i]) for i, _ in offen_slots} slots = [{ - "key": f"{guide_id}-lese-check-r{runde}-{i + 1}", + "key": f"{guide_id}-lese-check-r{runde}-{i + 1}-j{j + 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), + sections=sections_je_chunk[i], + out_path=check_judge_paths[i][j], 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] + "payload": (lambda result, p=check_judge_paths[i][j]: _lese_probleme_schema(_json_datei(p))), + } for i, j in offen_slots] 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) + 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, grace=KONSENS_GRACE) 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 + _log(topic, f"Lese-Prüfung Runde {runde}: kein volles Quorum — vorhandene Judges aggregiert") 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"] + for i in range(writer_count): + if scope[i]: + probleme_by_num.update(_panel_probleme(check_judge_paths[i], set(scope[i]), idx)) # Deterministisches Lesbarkeits-Gate: zu schwere Sections in dieselbe # Überarbeitung einreihen (LLM-Beanstandung hat Vorrang). Gate aus → no-op. diff --git a/templates/Prompt/Guide-Writer.md b/templates/Prompt/Guide-Writer.md index dcca6d5..5272f9f 100644 --- a/templates/Prompt/Guide-Writer.md +++ b/templates/Prompt/Guide-Writer.md @@ -3,8 +3,6 @@ Schreibe Sections für einen Lern-Guide zum Thema "{topic}" (Format: {format_nam Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteilte Section muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Stufe (`[einfach]`/`[mittel]`/`[schwer]`): {zuteilung} -{facts} - Das Format ({format_name}) beeinflusst NICHT, wie ein Baustein geschrieben wird — nur, wie viele Bausteine das Thema abdecken. Derselbe Baustein bekommt in MiniGuide, Guide und FullGuide denselben Text. Jeder Baustein bekommt ZWEI Fassungen mit DENSELBEN Inhalten (dieselben Subbausteine): @@ -24,7 +22,7 @@ SO SCHREIBST DU die ausführliche Fassung — EIN zusammenhängender Text für e GEPRÜFTE INHALTE je Baustein — das ist verbindlich, was gelehrt werden muss: {inhalte} -Nutze diese Inhalte. Erfinde nichts dazu, lass nichts Wesentliches weg. Lehre simpel: kurze Sätze, Listen für Aufzählungen, ein Anfänger versteht es sofort. +Recherchiere NICHT und suche NICHT im Web. Alle nötigen Fakten stehen in diesen geprüften Inhalten — nutze ausschließlich sie. Erfinde nichts dazu, lass nichts Wesentliches weg. Lehre simpel: kurze Sätze, Listen für Aufzählungen, ein Anfänger versteht es sofort. SECTION-SPEZIFIKATION (gilt je Baustein): {spec}