"""OnePager-Pipeline als Konsens-Kette (7 Karten im 3×3-Raster). Recherche: 3 Agenten (min. 2, Grace) → Mapping konsolidiert zu EINER Faktenbasis. Bauen: 3 Agenten bauen je einen Karten-Satz → ein Judge wählt und kombiniert. Prüfung: Verify→Fix-Loop (max. KONSENS_MAX_RUNDEN); Runde 1 ist fatal, danach bleibt bei Fehlern die letzte gültige Version. Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort. """ from pathlib import Path from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN from jsonio import read_json_file as _json_datei from pipeline import ( CANCELLED, FAILED, GenContext, _extra, _fail, _log, _probleme_schema, _prompt, _race, _set_step, _timeout, is_guide_cancelled, run_single_slot, ) ONEPAGER_STEPS = ("Recherche", "Bauen", "Prüfung") async def _generate_onepager( guide_id: str, topic: str, instructions: str, provider: str, project: Path | None, content_path: Path, ) -> list[dict] | None: is_cancelled = lambda: is_guide_cancelled(guide_id) ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_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") def karten_schema(data): """{"karten": {key: {titel, md}}} → Liste · sonst None.""" if not isinstance(data, dict): return None karten = data.get("karten") if not isinstance(karten, dict): return None out = [] for key in KARTEN_KEYS: k = karten.get(key) if not isinstance(k, dict) or not isinstance(k.get("titel"), str) or not isinstance(k.get("md"), str): return None titel, md = k["titel"].strip(), k["md"].strip() if not titel or len(md) < 5: # abgebrochene/leere Karten sind ungültig return None out.append({"key": key, "titel": titel, "md": md}) return out d, stem = content_path.parent, content_path.stem recherche_slots = [d / f"{stem}.recherche-{i}.md" for i in (1, 2, 3)] recherche_path = d / f"{stem}.recherche.md" # konsolidierte Faktenbasis karten_slots = [d / f"{stem}.karten-{i}.json" for i in (1, 2, 3)] karten_path = d / f"{stem}.karten.json" # Judge-Ausgabe verify_paths = {n: d / f"{stem}.verify-r{n}.json" for n in range(1, KONSENS_MAX_RUNDEN + 1)} fix_paths = {n: d / f"{stem}.karten-fix-r{n}.json" for n in range(1, KONSENS_MAX_RUNDEN + 1)} # Projekte bekommen eigene Recherche-Dimensionen — Produkt-Fragen # (Version, Lizenz, Alternativen) laufen dort ins Leere. if project: source = _prompt("OnePager-Quelle-Projekt", project=project) recherche_template = "OnePager-Recherche-Projekt" else: source = _prompt("OnePager-Quelle-Thema", topic=topic) recherche_template = "OnePager-Recherche" def text_payload(path: Path): if not path.exists(): return None text = path.read_text(encoding="utf-8").strip() return text or None # Schritt 0: Recherche — 3 Agenten (min. 2, Grace), Mapping konsolidiert. # Eine gültige recherche.md (auch aus Altläufen) überspringt den Schritt. recherche = text_payload(recherche_path) if recherche is None: await _set_step(guide_id, 0, "Recherchiere (3 Agenten)…") recherchen = [] offen = [] for i, path in enumerate(recherche_slots, 1): text = text_payload(path) if text is not None: recherchen.append(text) else: offen.append((i, path)) if len(recherchen) < 2: slots = [ { "key": f"{guide_id}-recherche-{i}", "prompt": _prompt(recherche_template, topic=topic, source=source, out_path=path, extra=_extra(instructions)), "role": "quick", "capabilities": "files" if project else "full", "payload": (lambda result, p=path: text_payload(p)), } for i, path in offen ] neue = await _race( topic, "OnePager-Recherche", slots, 2 - len(recherchen), _timeout("onepager_recherche"), provider, cancelled=is_cancelled, grace=KONSENS_GRACE, ) if is_cancelled(): return None if neue is None: await _fail(guide_id, "OnePager-Recherche fehlgeschlagen (Minimum nicht erreicht)") return None recherchen += neue await _set_step(guide_id, 0, "Konsolidiere Recherche…") recherchen_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1)) status, recherche = await run_single_slot( ctx, "Recherche-Mapping", key=f"{guide_id}-recherche-mapping", prompt=_prompt( "OnePager-Recherche-Mapping", topic=topic, n=len(recherchen), recherchen=recherchen_block, out_path=recherche_path, ), role="judge", capabilities="files", payload=lambda result: text_payload(recherche_path), timeout=_timeout("onepager_mapping"), ) if status == CANCELLED: return None if status == FAILED: await _fail(guide_id, "Recherche-Konsolidierung fehlgeschlagen") return None # Schritt 1: Bauen — 3 Entwürfe (min. 2, Grace), ein Judge kombiniert. # Gültiges karten.json (auch aus Altläufen) überspringt den Schritt. karten = karten_schema(_json_datei(karten_path)) if karten is None: await _set_step(guide_id, 1, "Baue OnePager (3 Entwürfe)…") entwuerfe = [] offen = [] for i, path in enumerate(karten_slots, 1): res = karten_schema(_json_datei(path)) if res is not None: entwuerfe.append(res) else: offen.append((i, path)) if len(entwuerfe) < 2: slots = [ { "key": f"{guide_id}-bauen-{i}", "prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=path, extra=_extra(instructions)), "role": "fast", "capabilities": "files", "payload": (lambda result, p=path: karten_schema(_json_datei(p))), } for i, path in offen ] neue = await _race( topic, "OnePager-Bauen", slots, 2 - len(entwuerfe), _timeout("onepager_bauen"), provider, cancelled=is_cancelled, grace=KONSENS_GRACE, ) if is_cancelled(): return None if neue is None: await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Minimum nicht erreicht)") return None entwuerfe += neue await _set_step(guide_id, 1, "Wähle besten Entwurf…") saetze_block = "\n\n".join( f"## Entwurf {i}\n\n" + "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in satz) for i, satz in enumerate(entwuerfe, 1) ) status, karten = await run_single_slot( ctx, "Bauen-Judge", key=f"{guide_id}-bauen-judge", prompt=_prompt( "OnePager-Bauen-Judge", topic=topic, n=len(entwuerfe), recherche=recherche, kartensaetze=saetze_block, out_path=karten_path, extra=_extra(instructions), ), role="judge", capabilities="files", payload=lambda result: karten_schema(_json_datei(karten_path)), timeout=_timeout("onepager_judge"), ) if status == CANCELLED: return None if status == FAILED: await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Judge ohne gültiges Ergebnis)") return None def karten_block() -> str: return "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in karten) # Schritt 2: Prüf-Loop — Verify notiert Probleme, Fix behebt; max. Runden-Cap. # Runde 1 ist fatal (wie früher der Einzel-Check), danach bleibt bei Fehlern # die letzte gültige Version stehen. for runde in range(1, KONSENS_MAX_RUNDEN + 1): probleme = _probleme_schema(_json_datei(verify_paths[runde])) if probleme is None: await _set_step(guide_id, 2, f"Prüfe OnePager (Runde {runde}/{KONSENS_MAX_RUNDEN})…") verify_paths[runde].unlink(missing_ok=True) status, probleme = await run_single_slot( ctx, f"OnePager-Prüfung r{runde}", key=f"{guide_id}-verify-r{runde}", prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=verify_paths[runde]), role="judge", capabilities="files", payload=lambda result, p=verify_paths[runde]: _probleme_schema(_json_datei(p)), timeout=_timeout("onepager_verify"), ) if status == CANCELLED: return None if status == FAILED: if runde == 1: await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen") return None _log(topic, f"OnePager-Prüfung Runde {runde} fehlgeschlagen — letzte gültige Version bleibt") break if not probleme: break if runde == KONSENS_MAX_RUNDEN: _log(topic, f"OnePager-Prüfung: {len(probleme)} Problem(e) bleiben nach Runde {runde} stehen") break _log(topic, f"OnePager-Prüfung Runde {runde}: {len(probleme)} Problem(e) notiert") fixed = karten_schema(_json_datei(fix_paths[runde])) # Resume if fixed is None: await _set_step(guide_id, 2, f"Überarbeite OnePager (Runde {runde})…") status, fixed = await run_single_slot( ctx, f"OnePager-Fix r{runde}", key=f"{guide_id}-karten-fix-r{runde}", prompt=_prompt( "OnePager-Fix", topic=topic, recherche=recherche, karten=karten_block(), probleme="\n".join(f"- {p}" for p in probleme), out_path=fix_paths[runde], extra=_extra(instructions), ), role="fast", capabilities="files", payload=lambda result, p=fix_paths[runde]: karten_schema(_json_datei(p)), timeout=_timeout("onepager_bauen"), ) if status == CANCELLED: return None if status == FAILED: _log(topic, f"OnePager-Fix Runde {runde} ungültig — letzte gültige Version bleibt") break karten = fixed sections = [ {"num": i, "title": k["titel"], "md": k["md"], "key": k["key"]} for i, k in enumerate(karten, 1) ] return [{"title": topic, "sections": sections}]