diff --git a/backend/config.py b/backend/config.py index 0590981..662de5b 100644 --- a/backend/config.py +++ b/backend/config.py @@ -24,7 +24,7 @@ LESBARKEIT_HART_ANTEIL = 0.30 # … ODER wenn dieser Anteil der Sätze hart ist # Deckel für gleichzeitige CLI-Agenten-Prozesse (über alle Generierungen hinweg). # Eigene Spur für interaktive Aufrufe (Chat, Elemente), damit sie nicht hinter # laufenden Writern in der Warteschlange hängen. -MAX_CONCURRENT_AGENTS = 20 +MAX_CONCURRENT_AGENTS = 30 MAX_CONCURRENT_INTERACTIVE = 4 # Grace-Fenster der Konsens-Races (Bausteine, Guide, OnePager): Nach dem ersten diff --git a/backend/guide.py b/backend/guide.py index baee7a9..1a2d699 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -2,8 +2,8 @@ Auswahl: 5 Agenten (min. 3, Grace) → Code-Voting (Mehrheit = Konsens) → Mapping-Agent sortiert Strittiges → Klärungs-Loop (max. KONSENS_MAX_RUNDEN). -Gliederung: 5 Vorschläge (min. 3, Grace) → ein Judge wählt und kombiniert. -Schreiben: Writer pro Chunk. Lese-Prüfung: Check→Fix-Loop (max. Runden-Cap), +Gliederung: 3 Vorschläge (Grace) → ein Judge wählt und kombiniert. +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. """ @@ -46,6 +46,10 @@ GUIDE_STEPS = ("Auswahl", "Gliederung", "Inhalte", "Inhalts-Check", "Schreiben", WRITER_SECTIONS = 30 WRITER_MAX = 20 +# 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 + def _load_subbausteine(topic: str) -> dict[str, list[dict]]: """Sidecar laden: {Baustein-Titel: [{titel, stufe}, …]}. Fehlt sie → {} (Fallback).""" @@ -85,7 +89,7 @@ def _guide_files(content_path: Path) -> dict: for n in runden }, "auswahl_mapping": {n: d / f"{stem}.auswahl-mapping-r{n}.json" for n in runden}, - "gliederung_slots": [d / f"{stem}.gliederung-{i}.json" for i in (1, 2, 3, 4, 5)], + "gliederung_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 @@ -97,6 +101,28 @@ def guide_slot_dateien(content_path: Path) -> list[Path]: return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path] +# 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 +) + + +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) + + 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): @@ -455,12 +481,12 @@ async def _generate_sections( soll = len(sel_entries) sel_liste = "\n".join(f"- {t}" for t in sel_entries.values()) - # Schritt 1: Gliederung — bis zu 5 Vorschläge (Grace), ein Judge wählt. Bricht NIE ab: + # Schritt 1: Gliederung — bis zu 3 Vorschläge (Grace), ein Judge wählt. 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. plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll) if plan is None: - await _set_step(guide_id, 1, "Gliederungs-Vorschläge (5 Agenten)…") + await _set_step(guide_id, 1, "Gliederungs-Vorschläge (3 Agenten)…") files["gliederung"].unlink(missing_ok=True) vorschlaege: list[list[dict]] = [] offen = [] @@ -706,11 +732,11 @@ async def _generate_sections( ) scope = chunk_nums - for runde in range(1, KONSENS_MAX_RUNDEN + 1): + 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, 5, f"Prüfe Lesbarkeit (Runde {runde}/{KONSENS_MAX_RUNDEN})…") + await _set_step(guide_id, 5, "Prüfe Lesbarkeit…") slots = [{ "key": f"{guide_id}-lese-check-r{runde}-{i + 1}", "prompt": _prompt( @@ -787,8 +813,8 @@ async def _generate_sections( _log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet") if not ersetzt: break - if runde == KONSENS_MAX_RUNDEN: - _log(topic, f"Lese-Prüfung: Cap erreicht — letzte Überarbeitung bleibt ungeprüft") + 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] @@ -835,7 +861,7 @@ async def reconcile_guides() -> None: 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) -> None: +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) @@ -851,9 +877,12 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio if project: await asyncio.to_thread(_pdfs_konvertieren, project) - # „Neu erstellen": fertiger Guide → kompletter Frischstart. + # 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 content_path.exists(): + 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) diff --git a/backend/models.py b/backend/models.py index adddd7a..0ce3e45 100644 --- a/backend/models.py +++ b/backend/models.py @@ -17,6 +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 class TopicCreateRequest(BaseModel): diff --git a/backend/routes.py b/backend/routes.py index b14709f..3d7c594 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -458,7 +458,7 @@ async def create(req: GuideCreateRequest): "updated_at": now, } await create_guide(guide) - asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider)) + asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider, ab_step=req.ab_step)) return guide diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 135fb2f..4354d6f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -283,7 +283,7 @@ function handleOpenBausteineView() { previewGuide.value = null } -async function handleFormatClick({ format, instructions }) { +async function handleFormatClick({ format, instructions, abStep = null }) { if (!selectedTopic.value) return // Kein Duplikat-Start: läuft für Thema+Format schon eine Generierung, ignorieren const running = guides.value.some( @@ -293,7 +293,7 @@ async function handleFormatClick({ format, instructions }) { if (running) return uiError.value = null try { - await apiCreate(selectedTopic.value, format, instructions, provider.value) + await apiCreate(selectedTopic.value, format, instructions, provider.value, abStep) } catch (e) { uiError.value = e.message return diff --git a/frontend/src/api.js b/frontend/src/api.js index d132d1e..a45e6d9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -23,11 +23,11 @@ export async function fetchGuideLocks(topic) { return res.json() } -export async function createGuide(topic, format, instructions = '', provider = 'claude') { +export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) { const res = await fetch(`${BASE}/guides`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, format, instructions, provider }), + body: JSON.stringify({ topic, format, instructions, provider, ab_step: abStep }), }) return jsonOrThrow(res) } diff --git a/frontend/src/components/TopicSidebar.vue b/frontend/src/components/TopicSidebar.vue index b103d12..a089fc7 100644 --- a/frontend/src/components/TopicSidebar.vue +++ b/frontend/src/components/TopicSidebar.vue @@ -1,5 +1,5 @@