This commit is contained in:
team3
2026-06-23 19:26:38 +02:00
parent cec8d1ee5f
commit 6a5a1cccd9
9 changed files with 71 additions and 391 deletions

View File

@@ -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.

View File

@@ -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).

View File

@@ -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 Auswahl5 Lese-Prüfung); None = voll/Resume
ab_step: int | None = Field(default=None, ge=0, le=4) # Re-Run ab Guide-Schritt (0 Gliederung4 Lese-Prüfung); None = voll/Resume
class TopicCreateRequest(BaseModel):