Files
creator/backend/onepager.py
2026-06-12 17:18:42 +02:00

245 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}]