diff --git a/backend/config.py b/backend/config.py index 0f1bc69..f2a3914 100644 --- a/backend/config.py +++ b/backend/config.py @@ -57,10 +57,8 @@ CRAWL_MIN_CHARS = 400 # zu wenig Text TIMEOUTS = { "recherche": (1800, 0), # fix 30 min "recherche_mapping": (600, 3), # n = vorgemergte Einträge - "auswahl": (300, 2), # Rest-Prüfung im Klärungs-Loop, n = Rest-Einträge - "auswahl_mapping": (600, 2), # n = Rest-Einträge + "auswahl_mapping": (600, 2), # n = Rest-Einträge (Bausteine-Inventar) "ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche) - "guide_auswahl": (300, 5), # pro Baustein im Inventar "plan": (300, 5), "plan_judge": (600, 5), # Judge liest bis zu 5 Gliederungen, n = Sections "inhalt": (600, 90), # Inhalte je Baustein im Chunk identifizieren (Websuche) @@ -77,11 +75,11 @@ TIMEOUTS = { "lese_check": (300, 10), # pro Section im Paket } -# Auswahl-Auftrag je Format: (Mindest-Anteil, Maximal-Anteil, Mindestanzahl, Zweck). -FORMAT_ANTEIL = { - "Guide": (0.0, 1.0, 0, "einen fokussierten Guide — alles Relevante ohne Randthemen"), - "FullGuide": (0.0, 1.0, 0, "einen Komplett-Guide — das ganze Thema inkl. Randthemen"), - "Rest": (0.0, 1.0, 0, "einen Ergänzungs-Guide — nur die Randthemen"), +# Zweck je Format — fließt in den Gliederungs-Judge (was der Guide leisten soll). +FORMAT_ZWECK = { + "Guide": "einen fokussierten Guide — alles Relevante ohne Randthemen", + "FullGuide": "einen Komplett-Guide — das ganze Thema inkl. Randthemen", + "Rest": "einen Ergänzungs-Guide — nur die Randthemen", } # Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden. diff --git a/backend/guide.py b/backend/guide.py index e4799e2..7eecbb2 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -1,8 +1,8 @@ """Guide-Generierung als Konsens-Pipeline. -Auswahl: 5 Agenten (min. 3, Grace) → Code-Voting (Mehrheit = Konsens) → -Mapping-Agent sortiert Strittiges → Klärungs-Loop (max. KONSENS_MAX_RUNDEN). -Gliederung: 3 Vorschläge (Grace) → ein Judge wählt und kombiniert. +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. @@ -18,7 +18,7 @@ from pathlib import Path from agents import run_agent from bausteine import _pdfs_konvertieren, quelle_ordner from config import ( - DEFAULT_PROVIDER, FORMAT_ANTEIL, KONSENS_GRACE, KONSENS_MAX_RUNDEN, + DEFAULT_PROVIDER, FORMAT_ZWECK, KONSENS_GRACE, LESBARKEIT_AKTIV, TEMPLATES_DIR, ) import lesbarkeit @@ -28,18 +28,18 @@ 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, _rest_schema, _runde_schema, + _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, _zuteilung_text, + _titel, _titel_aufloesen, _titel_index, ) log = logging.getLogger("creator.guide") -GUIDE_STEPS = ("Auswahl", "Gliederung", "Inhalte", "Inhalts-Check", "Schreiben", "Lese-Prüfung") +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 @@ -90,14 +90,7 @@ def _zuteilung_subs(chunk: list[dict], entries: dict[int, str], subs_by_titel: d def _guide_files(content_path: Path) -> dict: d, stem = content_path.parent, content_path.stem - runden = range(1, KONSENS_MAX_RUNDEN + 1) return { - # 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)], "gliederung": d / f"{stem}.gliederung.json", # Judge-Ausgabe # chunk-/lese-check-/fix-Dateien sind dynamisch: @@ -141,12 +134,11 @@ def _reset_fertig(content_path: Path, step: int) -> None: # Slot-Datei-Globs je Schritt (Index = GUIDE_STEPS). Stem-verankert, kollisionsfrei. _STEP_GLOBS = ( - ("auswahl-*", "auswahl-mapping-*"), # 0 Auswahl (deterministisch → meist leer) - ("gliederung*",), # 1 Gliederung - ("inhalt-chunk-*",), # 2 Inhalte - ("inhalt-check-*", "inhalt-fix-*"), # 3 Inhalts-Check - ("chunk-*",), # 4 Schreiben - ("lese-check-*", "fix-r*"), # 5 Lese-Prüfung + ("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 ) @@ -162,29 +154,6 @@ def _reset_guide_ab_step(content_path: Path, step: int) -> None: _reset_fertig(content_path, step - 1) # Schritte < step gelten als fertig -def _resolve_auswahl(data, entries: dict[int, str], k_min: int, k_max: int) -> list[int] | None: - """{"bausteine": [Titel]} → Nummern; None bei Schema-Verstoß/Drift/falschem Umfang.""" - if not isinstance(data, dict) or not isinstance(data.get("bausteine"), list): - return None - idx = _titel_index(entries) - nums: list[int] = [] - seen: set[int] = set() - total = unknown = 0 - for t in data["bausteine"]: - total += 1 - num = _titel_aufloesen(idx, t) if isinstance(t, str) else None - if num is None: - unknown += 1 - elif num not in seen: - seen.add(num) - nums.append(num) - if total == 0 or (total - unknown) / total < 0.85: - return None - if len(nums) < 0.9 * k_min or len(nums) > 1.1 * k_max: - return None - return nums - - def _lese_probleme_schema(data): """{"ok": true} → [] · {"probleme": [{"section", "problem"}]} → Liste · sonst None.""" if not isinstance(data, dict): @@ -203,24 +172,25 @@ def _lese_probleme_schema(data): def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None: - """{"kapitel": [{"titel", "bausteine": [Titel]}]} → [{"title", "nums"}]. + """{"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 - idx = _titel_index(entries) + 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("bausteine"), list): + if not isinstance(ch, dict) or not isinstance(ch.get("nummern"), list): return None nums = [] - for t in ch["bausteine"]: + for t in ch["nummern"]: total += 1 - num = _titel_aufloesen(idx, t) if isinstance(t, str) else None - if num is None: + 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) @@ -250,221 +220,6 @@ def _mit_resten(plan: list[dict], entries: dict[int, str]) -> list[dict]: return [*plan, {"title": "Weitere", "nums": fehlen}] if fehlen else plan -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, @@ -476,15 +231,7 @@ async def _generate_sections( 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()) - n = len(entries) - anteil_min, anteil_max, minimum, zweck = FORMAT_ANTEIL[format_name] - k_min = min(n, max(minimum, math.ceil(anteil_min * n))) - k_max = min(n, max(k_min, math.floor(anteil_max * n))) - auswahl_auftrag = ( - f"Wähle MINDESTENS {k_min} und HÖCHSTENS {k_max} der Bausteine und baue daraus {zweck}. " - "Wähle, was diesem Zweck dient — lass weg, was dafür nicht nötig ist." - ) + 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). @@ -493,39 +240,32 @@ async def _generate_sections( def _hat_relevanz(num, art): return any(isinstance(s, dict) and s.get("relevanz") == art for s in subs_raw.get(_titel(entries[num]), [])) - # Schritt 0: Auswahl. Deterministisch je Format: - # Guide=relevante Bausteine · FullGuide=alle · Rest=Rand-Bausteine. + # 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 _hat_relevanz(num, "rand")] - elif format_name == "Guide": + 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) - else: - # 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 auswahl is None: - return None 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) - sel_liste = "\n".join(f"- {t}" for t in sel_entries.values()) + # 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 1: Gliederung — bis zu 3 Vorschläge (Grace), ein Judge wählt. Bricht NIE ab: + # 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 (auch aus Altläufen) überspringt den Schritt. + # 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, 1, "Gliederungs-Vorschläge (3 Agenten)…") + await _set_step(guide_id, 0, "Gliederungs-Vorschläge (3 Agenten)…") files["gliederung"].unlink(missing_ok=True) vorschlaege: list[list[dict]] = [] offen = [] @@ -564,10 +304,10 @@ async def _generate_sections( elif len(vorschlaege) == 1: plan = vorschlaege[0] # ein Vorschlag → kein Judge nötig else: - await _set_step(guide_id, 1, "Wähle beste Gliederung…") + await _set_step(guide_id, 0, "Gliederungen zusammenführen…") 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) + + "\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( @@ -591,7 +331,7 @@ async def _generate_sections( # Garantie: jeder gewählte Baustein steht im Plan (gegen weglassende Agenten/Judges). plan = _mit_resten(plan, sel_entries) - _set_fertig(content_path, 1) # Gliederung steht + _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. @@ -615,7 +355,7 @@ async def _generate_sections( 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, 2, f"Sammle Inhalte {d}/{t}…") + 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}", @@ -646,7 +386,7 @@ async def _generate_sections( await _fail(guide_id, "Keine Inhalte identifiziert") return None if all(p.exists() for p in inhalt_paths): - _set_fertig(content_path, 2) # Inhalte vollständig + _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] @@ -654,7 +394,7 @@ async def _generate_sections( 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, 3, "Prüfe Inhalte…") + await _set_step(guide_id, 2, "Prüfe Inhalte…") slots = [{ "key": f"{guide_id}-inhalt-check-{i + 1}", "prompt": _prompt( @@ -667,7 +407,7 @@ async def _generate_sections( "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, 3, f"Prüfe Inhalte {n}/{n_checks}…")) + 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 @@ -682,7 +422,7 @@ async def _generate_sections( if probleme_by_num: _log(topic, f"Inhalts-Prüfung: {len(probleme_by_num)} Baustein(e) beanstandet") - await _set_step(guide_id, 3, f"Überarbeite {len(probleme_by_num)} Inhalt(e)…") + 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()] @@ -712,7 +452,7 @@ async def _generate_sections( if num in probleme_by_num and sec["md"].strip(): inhalt_by_num[num] = sec["md"] - _set_fertig(content_path, 3) # Inhalts-Check durch + _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. @@ -725,7 +465,7 @@ async def _generate_sections( 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, 4, f"Schreibe Sections {d}/{t}…") + 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}", @@ -766,7 +506,7 @@ async def _generate_sections( 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, 4) # Schreiben vollständig + _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. @@ -787,7 +527,7 @@ async def _generate_sections( 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, 5, "Prüfe Lesbarkeit…") + await _set_step(guide_id, 4, "Prüfe Lesbarkeit…") slots = [{ "key": f"{guide_id}-lese-check-r{runde}-{i + 1}", "prompt": _prompt( @@ -800,7 +540,7 @@ async def _generate_sections( "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, 5, f"Prüfe Lesbarkeit {n}/{n_checks}…")) + 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 @@ -832,7 +572,7 @@ async def _generate_sections( break _log(topic, f"Lese-Prüfung Runde {runde}: {len(probleme_by_num)} Section(s) beanstandet") - await _set_step(guide_id, 5, f"Überarbeite {len(probleme_by_num)} Section(s) (Runde {runde})…") + 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()] @@ -870,7 +610,7 @@ async def _generate_sections( _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, 5) # Lese-Prüfung durch + _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). diff --git a/backend/models.py b/backend/models.py index 84991c0..2a781ba 100644 --- a/backend/models.py +++ b/backend/models.py @@ -17,7 +17,7 @@ class GuideCreateRequest(BaseModel): format: FormatType instructions: str = Field(default="", max_length=2000) provider: ProviderType = "claude" - ab_step: int | None = Field(default=None, ge=0, le=5) # Re-Run ab Guide-Schritt (0 Auswahl … 5 Lese-Prüfung); None = voll/Resume + ab_step: int | None = Field(default=None, ge=0, le=4) # Re-Run ab Guide-Schritt (0 Gliederung … 4 Lese-Prüfung); None = voll/Resume class TopicCreateRequest(BaseModel): diff --git a/frontend/src/components/TopicSidebar.vue b/frontend/src/components/TopicSidebar.vue index d74efff..fdaf82c 100644 --- a/frontend/src/components/TopicSidebar.vue +++ b/frontend/src/components/TopicSidebar.vue @@ -120,7 +120,7 @@ function guideStatus(format) { } // Schritt-Kugeln der Guide-Pipeline -const GUIDE_STEPS = ['Auswahl', 'Gliederung', 'Inhalte', 'Inhalts-Check', 'Schreiben', 'Lese-Prüfung'] +const GUIDE_STEPS = ['Gliederung', 'Inhalte', 'Inhalts-Check', 'Schreiben', 'Lese-Prüfung'] // Kugeln aus dem artefakt-basierten „fertig"-Marker (wie Bausteine, nicht aus dem DB-Zähler): // ≤ fertig = done. Läuft gerade → der nächste Schritt (fertig+1) ist aktiv. diff --git a/templates/Prompt/Guide-Auswahl-Mapping.md b/templates/Prompt/Guide-Auswahl-Mapping.md deleted file mode 100644 index 6332793..0000000 --- a/templates/Prompt/Guide-Auswahl-Mapping.md +++ /dev/null @@ -1,22 +0,0 @@ -{n} Agenten haben unabhängig Bausteine für {format_name} zum Thema "{topic}" gewählt. Der Konsens (Mehrheit) steht fest und ist nur Kontext. Sortiere die strittigen Einträge. - -AUFTRAG DER AUSWAHL: {auswahl_auftrag} - -BESCHLOSSENER KONSENS ({konsens_n} Bausteine — nicht ändern): -{konsens} - -STRITTIGE EINTRÄGE MIT VOTUM: -{rest} - -Regeln: -- Von der Mehrheit der {n} Agenten gewählt und dem Auftrag dienlich → `aufnehmen`. -- Von kaum jemandem gewählt oder für den Auftrag entbehrlich → verwerfen (in keine Liste). -- Uneindeutig → `rest`. Diese Einträge gehen in eine weitere Runde.{final} -- Zielgröße: Der Konsens plus `aufnehmen` muss am Ende in der Spanne {k_min}–{k_max} Bausteine landen. Plane mit `rest` entsprechend. -- Übernimm Titel EXAKT wie gelistet, nicht umformulieren. -- Sind alle Einträge entschieden, ist `rest` LEER — das ist ein gültiges Ergebnis. - -Schreibe NUR die JSON-Datei nach: {out_path} - -Format (kein weiterer Text in der Datei): -{{"aufnehmen": ["Titel"], "rest": ["Titel"]}} diff --git a/templates/Prompt/Guide-Auswahl.md b/templates/Prompt/Guide-Auswahl.md deleted file mode 100644 index be73ae1..0000000 --- a/templates/Prompt/Guide-Auswahl.md +++ /dev/null @@ -1,18 +0,0 @@ -Wähle die Bausteine für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}). - -BAUSTEINE (unsortiertes Inventar): -{bausteine} - -{auswahl_auftrag} - -Denke vom Ziel her: Was soll der Leser am Ende KÖNNEN? -- Wähle, was der Leser dafür praktisch braucht und wirklich benutzt. -- Lass weg: Interna (was das Werkzeug oder die Theorie intern tut, ohne dass man es selbst anfasst), Spezialfälle und Alternativen zum selben Problem — ein Weg reicht. -- "Klingt fundamental" ist kein Kriterium. Frage stattdessen: Fasst der Leser das selbst an? -- Verwende die Titel EXAKT so, wie sie in der Liste stehen. Keine neuen erfinden. - -Schreibe NUR die JSON-Datei nach: {out_path} - -Format: -{{"bausteine": ["Exakter Titel", "Exakter Titel"]}} -{extra} diff --git a/templates/Prompt/Guide-Gliederung-Judge.md b/templates/Prompt/Guide-Gliederung-Judge.md index e4b0a43..319a1c0 100644 --- a/templates/Prompt/Guide-Gliederung-Judge.md +++ b/templates/Prompt/Guide-Gliederung-Judge.md @@ -1,19 +1,24 @@ -{n} Agenten haben die feste Baustein-Auswahl für {format_name} zum Thema "{topic}" unabhängig gegliedert ({zweck}). Wähle die beste Gliederung und finalisiere sie. +{n} Agenten haben die feste Baustein-Auswahl für {format_name} zum Thema "{topic}" unabhängig gegliedert ({zweck}). Führe die Vorschläge zu EINER kohärenten Gliederung zusammen. -GEWÄHLTE BAUSTEINE (die Auswahl steht fest): +GEWÄHLTE BAUSTEINE (jeder mit seiner NUMMER — die Auswahl steht fest): {bausteine} -DIE VORSCHLÄGE: +DIE VORSCHLÄGE (je Kapitel die Baustein-Nummern): {gliederungen} -Vorgehen: -- Wähle den überzeugendsten Vorschlag als Basis: bester roter Faden, vom Bekannten zum Unbekannten, erster Erfolg vor Theorie. -- Übernimm überzeugende Ideen der anderen Vorschläge: besserer Kapitelschnitt, bessere Reihenfolge, klarere Kapiteltitel. -- Es gelten die Gliederungs-Regeln: JEDER Baustein in GENAU einem Kapitel, Titel EXAKT wie gelistet, 3–7 Bausteine pro Kapitel, Kapiteltitel kurz und konkret. -- Erfinde nichts Neues — du kombinierst und entscheidest, du gliederst nicht von vorn. +Vorgehen — MERGEN, nicht nur einen wählen: +- Wo die Vorschläge übereinstimmen (Bausteine im selben Kapitel, ähnliche Reihenfolge) → übernehmen. +- Wo sie sich unterscheiden → selbst entscheiden: bester roter Faden, vom Bekannten zum Unbekannten, erster Erfolg vor Theorie. +- Nimm den besten Kapitelschnitt und die klarsten Kapiteltitel aus allen Vorschlägen. +- Ergebnis ist EINE Gliederung, die besser ist als jeder einzelne Vorschlag. + +Regeln: +- JEDER Baustein in GENAU einem Kapitel. Keinen weglassen, keinen doppelt. +- Ordne per NUMMER. Verwende nur die Nummern aus der Liste oben, jede genau einmal. +- 3–7 Bausteine pro Kapitel; Kapiteltitel kurz und konkret. Schreibe NUR die JSON-Datei nach: {out_path} -Format: -{{"kapitel": [{{"titel": "Grundlagen", "bausteine": ["Exakter Titel", "Exakter Titel"]}}]}} +Format (nummern = die Baustein-Nummern in der gewünschten Reihenfolge): +{{"kapitel": [{{"titel": "Grundlagen", "nummern": [3, 1, 7]}}]}} {extra} diff --git a/templates/Prompt/Guide-Gliederung.md b/templates/Prompt/Guide-Gliederung.md index 2e788a6..1d45137 100644 --- a/templates/Prompt/Guide-Gliederung.md +++ b/templates/Prompt/Guide-Gliederung.md @@ -1,6 +1,6 @@ Plane die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}). -GEWÄHLTE BAUSTEINE (unsortiert — die Auswahl steht fest, du ordnest nur): +GEWÄHLTE BAUSTEINE (jeder mit seiner NUMMER — die Auswahl steht fest, du ordnest nur): {bausteine} REIHENFOLGE — vom Bekannten zum Unbekannten: @@ -10,12 +10,12 @@ REIHENFOLGE — vom Bekannten zum Unbekannten: Regeln: - JEDER gewählte Baustein landet in GENAU einem Kapitel. Keinen weglassen, keine neuen erfinden. -- Verwende die Titel EXAKT so, wie sie in der Liste stehen. +- Ordne per NUMMER. Verwende nur die Nummern aus der Liste oben, jede genau einmal. - 3–7 Bausteine pro Kapitel; die Kapitelzahl folgt aus dem Thema. - Kapiteltitel kurz und konkret. Schreibe NUR die JSON-Datei nach: {out_path} -Format: -{{"kapitel": [{{"titel": "Grundlagen", "bausteine": ["Exakter Titel", "Exakter Titel"]}}]}} +Format (nummern = die Baustein-Nummern in der gewünschten Reihenfolge): +{{"kapitel": [{{"titel": "Grundlagen", "nummern": [3, 1, 7]}}]}} {extra} diff --git a/templates/Prompt/Guide-Klaerung.md b/templates/Prompt/Guide-Klaerung.md deleted file mode 100644 index 644336e..0000000 --- a/templates/Prompt/Guide-Klaerung.md +++ /dev/null @@ -1,23 +0,0 @@ -Bei der Baustein-Auswahl für {format_name} zum Thema "{topic}" sind Einträge strittig geblieben. Entscheide für jeden strittigen Eintrag: übernehmen oder weglassen. - -AUFTRAG DER AUSWAHL: {auswahl_auftrag} - -BESCHLOSSENER KONSENS (nur Kontext — nicht ändern): -{konsens} - -STRITTIGE EINTRÄGE: -{rest} - -Regeln: -- ÜBERNEHMEN, wenn der Baustein dem Auftrag dient und der Konsens das Thema dort nicht schon abdeckt. -- WEGLASSEN, wenn er für den Zweck entbehrlich ist, zu speziell oder vom Konsens abgedeckt. -- Einziges Kriterium ist der Auftrag — nicht persönliche Vollständigkeit. -- Übernimm Titel EXAKT wie gelistet, nicht umformulieren. -- Nichts Übernehmenswertes dabei → leere Liste. Das ist ein gültiges Ergebnis. - -Schreibe NUR die JSON-Datei nach: {out_path} - -Format (kein weiterer Text in der Datei): -{{"uebernehmen": ["Titel"]}} -Nichts übernehmen: {{"uebernehmen": []}} -{extra}