888 lines
40 KiB
Python
888 lines
40 KiB
Python
"""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: 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),
|
|
Folgerunden prüfen nur ersetzte Sections; danach bleiben Beanstandungen stehen.
|
|
Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import math
|
|
from datetime import datetime, timezone
|
|
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,
|
|
LESBARKEIT_AKTIV, TEMPLATES_DIR,
|
|
)
|
|
import lesbarkeit
|
|
from database import list_guides, update_guide
|
|
from fsutil import atomic_write_json
|
|
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, _log, _prompt, _race, _rest_schema, _runde_schema,
|
|
_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,
|
|
)
|
|
|
|
log = logging.getLogger("creator.guide")
|
|
|
|
GUIDE_STEPS = ("Auswahl", "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
|
|
# eines fehlgeschlagenen Writers.
|
|
WRITER_SECTIONS = 30
|
|
WRITER_MAX = 20
|
|
|
|
|
|
def _load_subbausteine(topic: str) -> dict[str, list[dict]]:
|
|
"""Sidecar laden: {Baustein-Titel: [{titel, stufe}, …]}. Fehlt sie → {} (Fallback)."""
|
|
data = _json_datei(subbausteine_path(topic))
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
out: dict[str, list[dict]] = {}
|
|
for titel, subs in data.items():
|
|
if not isinstance(subs, list):
|
|
continue
|
|
gut = [s for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip()
|
|
and s.get("stufe") in ("einfach", "mittel", "schwer")]
|
|
if gut:
|
|
out[titel] = gut
|
|
return out
|
|
|
|
|
|
def _zuteilung_subs(chunk: list[dict], entries: dict[int, str], subs_by_titel: dict[str, list[dict]]) -> str:
|
|
"""Wie _zuteilung_text, aber listet pro Baustein seine Subbausteine mit Stufe."""
|
|
lines: list[str] = []
|
|
for ch in chunk:
|
|
lines.append(f"KAPITEL: {ch['title']}")
|
|
for num in ch["nums"]:
|
|
lines.append(f"- {entries[num]}")
|
|
for s in subs_by_titel.get(_titel(entries[num]), []):
|
|
lines.append(f" [{s['stufe']}] {s['titel']}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
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, 4, 5)],
|
|
"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
|
|
}
|
|
|
|
|
|
def guide_slot_dateien(content_path: Path) -> list[Path]:
|
|
"""Alle Schritt-Dateien eines Guides (für den Frischstart)."""
|
|
return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path]
|
|
|
|
|
|
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):
|
|
return None
|
|
if data.get("ok") is True:
|
|
return []
|
|
p = data.get("probleme")
|
|
if not isinstance(p, list) or not p:
|
|
return None
|
|
out = []
|
|
for x in p:
|
|
if not isinstance(x, dict) or not isinstance(x.get("section"), str) or not isinstance(x.get("problem"), str):
|
|
return None
|
|
out.append({"section": x["section"].strip(), "problem": x["problem"].strip()})
|
|
return out or None
|
|
|
|
|
|
def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None:
|
|
"""{"kapitel": [{"titel", "bausteine": [Titel]}]} → [{"title", "nums"}].
|
|
|
|
`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)
|
|
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):
|
|
return None
|
|
nums = []
|
|
for t in ch["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:
|
|
nums.append(num)
|
|
seen.add(num)
|
|
if nums:
|
|
chapters.append({"title": str(ch.get("titel", "")).strip() or "Kapitel", "nums": nums})
|
|
if not chapters or total == 0:
|
|
return None
|
|
if (total - unknown) / total < 0.85:
|
|
return None
|
|
if len(seen) < 0.9 * soll_min or len(seen) > 1.1 * soll_max:
|
|
return None
|
|
return chapters
|
|
|
|
|
|
def _fallback_gliederung(entries: dict[int, str]) -> list[dict]:
|
|
"""Deterministische Gliederung, wenn die Agenten keine liefern: ein Kapitel mit
|
|
allen gewählten Bausteinen in Reihenfolge. Garantiert vollständige Abdeckung."""
|
|
return [{"title": "Inhalte", "nums": list(entries)}]
|
|
|
|
|
|
def _mit_resten(plan: list[dict], entries: dict[int, str]) -> list[dict]:
|
|
"""Stellt sicher, dass JEDER gewählte Baustein im Plan steht — fehlende landen in
|
|
einem Kapitel „Weitere" (gegen Agenten/Judge, die Bausteine weglassen)."""
|
|
drin = {num for ch in plan for num in ch.get("nums", [])}
|
|
fehlen = [num for num in entries if num not in drin]
|
|
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,
|
|
content_path: Path,
|
|
) -> list[dict] | None:
|
|
def is_cancelled() -> bool:
|
|
return is_guide_cancelled(guide_id)
|
|
|
|
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."
|
|
)
|
|
|
|
# Subbausteine je Baustein (Sidecar) — früh geladen: steuert Auswahl + Sub-Filter je Format.
|
|
# Fehlt sie → {} (Fallback: Guide nimmt alles).
|
|
subs_raw = _load_subbausteine(topic)
|
|
|
|
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.
|
|
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 _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())
|
|
|
|
# Schritt 1: Gliederung — bis zu 5 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)…")
|
|
files["gliederung"].unlink(missing_ok=True)
|
|
vorschlaege: list[list[dict]] = []
|
|
offen = []
|
|
for i, path in enumerate(files["gliederung_slots"], 1):
|
|
res = _resolve_gliederung(_json_datei(path), sel_entries, soll, soll)
|
|
if res is not None:
|
|
vorschlaege.append(res)
|
|
else:
|
|
offen.append((i, path))
|
|
if len(vorschlaege) < 3 and offen:
|
|
slots = [
|
|
{
|
|
"key": f"{guide_id}-gliederung-{i}",
|
|
"prompt": _prompt(
|
|
"Guide-Gliederung",
|
|
topic=topic, format_name=format_name, bausteine=sel_liste,
|
|
out_path=path, extra=_extra(instructions),
|
|
),
|
|
"role": "guide", "capabilities": "files",
|
|
"payload": (lambda result, p=path: _resolve_gliederung(_json_datei(p), sel_entries, soll, soll)),
|
|
}
|
|
for i, path in offen
|
|
]
|
|
# Quorum 1: nimm, was kommt — kein Mindestzwang, kein Abbruch.
|
|
neue = await _race(
|
|
topic, "Gliederung", slots, 1, _timeout("plan", soll),
|
|
provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
|
|
)
|
|
if is_cancelled():
|
|
return None
|
|
vorschlaege += neue or []
|
|
|
|
if not vorschlaege:
|
|
_log(topic, "Gliederung: kein gültiger Vorschlag — deterministischer Fallback")
|
|
plan = _fallback_gliederung(sel_entries)
|
|
elif len(vorschlaege) == 1:
|
|
plan = vorschlaege[0] # ein Vorschlag → kein Judge nötig
|
|
else:
|
|
await _set_step(guide_id, 1, "Wähle beste Gliederung…")
|
|
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)
|
|
for i, v in enumerate(vorschlaege, 1)
|
|
)
|
|
status, plan = await run_single_slot(
|
|
ctx, "Gliederungs-Judge",
|
|
key=f"{guide_id}-gliederung-judge",
|
|
prompt=_prompt(
|
|
"Guide-Gliederung-Judge",
|
|
topic=topic, format_name=format_name, zweck=zweck, n=len(vorschlaege),
|
|
bausteine=sel_liste, gliederungen=bloecke,
|
|
out_path=files["gliederung"], extra=_extra(instructions),
|
|
),
|
|
role="judge", capabilities="files",
|
|
payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
|
|
timeout=_timeout("plan_judge", soll),
|
|
)
|
|
if status == CANCELLED:
|
|
return None
|
|
if status == FAILED or plan is None:
|
|
_log(topic, "Gliederung-Judge ohne Ergebnis — bester Vorschlag bleibt")
|
|
plan = vorschlaege[0]
|
|
|
|
# Garantie: jeder gewählte Baustein steht im Plan (gegen weglassende Agenten/Judges).
|
|
plan = _mit_resten(plan, sel_entries)
|
|
|
|
# Chunks festlegen: 1 Chunk = 1 Baustein. So läuft JEDER Section-Schritt (Inhalte,
|
|
# Inhalts-Prüfung, Writer, Lese-Check) mit genau einem Agent je Baustein. Der Writer
|
|
# rationiert sein Output-Budget dann nicht über viele Sections → längere, variable Texte.
|
|
chunks = [[{"title": ch["title"], "nums": [num]}] for ch in plan for num in ch["nums"]]
|
|
# Subbausteine je Baustein, je Format gefiltert: Guide=relevant · FullGuide=alle · Rest=Rand.
|
|
# Subs ohne Relevanz-Feld (Alt-Themen) zählen als „nicht Rand" (→ Guide/FullGuide).
|
|
if format_name == "FullGuide":
|
|
subs_by_titel = {t: list(subs) for t, subs in subs_raw.items()}
|
|
elif format_name == "Rest":
|
|
subs_by_titel = {t: [s for s in subs if s.get("relevanz") == "rand"] for t, subs in subs_raw.items()}
|
|
else: # Guide
|
|
subs_by_titel = {t: [s for s in subs if s.get("relevanz") != "rand"] for t, subs in subs_raw.items()}
|
|
subs_by_titel = {t: subs for t, subs in subs_by_titel.items() if subs}
|
|
zuteilungen = [_zuteilung_subs(chunk, entries, subs_by_titel) for chunk in chunks]
|
|
chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks]
|
|
writer_count = len(chunks)
|
|
idx = _titel_index(entries)
|
|
|
|
# Schritt 2: Inhalte je Baustein identifizieren — pro Chunk ein Agent (Marker-Output, Resume).
|
|
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:
|
|
await _set_step(guide_id, 2, f"Sammle Inhalte ({writer_count} Agenten)…" if writer_count > 1 else "Sammle Inhalte…")
|
|
results = await asyncio.gather(*[
|
|
run_agent(
|
|
f"{guide_id}-inhalt-{i + 1}",
|
|
_prompt(
|
|
"Guide-Inhalt",
|
|
topic=topic, zuteilung=zuteilungen[i], facts=facts,
|
|
out_path=inhalt_paths[i], extra=_extra(instructions),
|
|
),
|
|
_timeout("inhalt", chunk_sizes[i]), provider=provider, role="guide", capabilities="full",
|
|
)
|
|
for i in offen
|
|
], return_exceptions=True)
|
|
if is_cancelled():
|
|
return None
|
|
if not any(p.exists() for p in inhalt_paths):
|
|
await _fail(guide_id, _gather_error("Inhalts-Fehler", list(results)))
|
|
return None
|
|
|
|
inhalt_by_num: dict[int, str] = {}
|
|
for p in inhalt_paths:
|
|
if not p.exists():
|
|
continue
|
|
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
|
num = _titel_aufloesen(idx, sec["titel"])
|
|
if num is not None and num not in inhalt_by_num and sec["md"].strip():
|
|
inhalt_by_num[num] = sec["md"]
|
|
if not inhalt_by_num:
|
|
await _fail(guide_id, "Keine Inhalte identifiziert")
|
|
return None
|
|
|
|
inhalt_chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in inhalt_by_num] for chunk in chunks]
|
|
|
|
# Schritt 3: Inhalte prüfen (pro Chunk ein Check) + beanstandete einmal überarbeiten.
|
|
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…")
|
|
slots = [{
|
|
"key": f"{guide_id}-inhalt-check-{i + 1}",
|
|
"prompt": _prompt(
|
|
"Guide-Inhalt-Check",
|
|
topic=topic, format_name=format_name,
|
|
sections="\n\n".join(f"SECTION: {_titel(entries[num])}\n{inhalt_by_num[num]}" for num in inhalt_chunk_nums[i]),
|
|
out_path=check_paths[i], extra=_extra(instructions),
|
|
),
|
|
"role": "judge", "capabilities": "files",
|
|
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))),
|
|
} for i in offen_checks]
|
|
await _race(topic, "Inhalts-Prüfung", slots, len(slots), _timeout("inhalt_check", max(chunk_sizes)), provider, cancelled=is_cancelled)
|
|
if is_cancelled():
|
|
return None
|
|
|
|
probleme_by_num: dict[int, str] = {}
|
|
for i, p in enumerate(check_paths):
|
|
geltung = set(inhalt_chunk_nums[i])
|
|
for item in (_lese_probleme_schema(_json_datei(p)) or []):
|
|
num = _titel_aufloesen(idx, item["section"])
|
|
if num in geltung and num in inhalt_by_num and num not in probleme_by_num:
|
|
probleme_by_num[num] = item["problem"]
|
|
|
|
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)…")
|
|
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()]
|
|
results = await asyncio.gather(*[
|
|
run_agent(
|
|
f"{guide_id}-inhalt-fix-{i + 1}",
|
|
_prompt(
|
|
"Guide-Inhalt-Fix",
|
|
topic=topic, facts=facts,
|
|
auftraege="\n\n".join(
|
|
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELL:\n{inhalt_by_num[num]}"
|
|
for num in fix_chunks[i]
|
|
),
|
|
out_path=fix_paths[i], extra=_extra(instructions),
|
|
),
|
|
_timeout("inhalt", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
|
|
)
|
|
for i in fix_offen
|
|
], return_exceptions=True)
|
|
if is_cancelled():
|
|
return None
|
|
for p in fix_paths:
|
|
if not p.exists():
|
|
continue
|
|
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
|
num = _titel_aufloesen(idx, sec["titel"])
|
|
if num in probleme_by_num and sec["md"].strip():
|
|
inhalt_by_num[num] = sec["md"]
|
|
|
|
# Schritt 4: Schreiben — Writer formuliert die geprüften Inhalte aus (Resume).
|
|
def inhalte_text(chunk) -> str:
|
|
nums = [num for ch in chunk for num in ch["nums"] if num in inhalt_by_num]
|
|
return "\n\n".join(f"<!-- section: {_titel(entries[num])} -->\n{inhalt_by_num[num]}" for num in nums)
|
|
|
|
paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, writer_count + 1)]
|
|
offen = [i for i, p in enumerate(paths) if not p.exists()]
|
|
if offen:
|
|
await _set_step(guide_id, 4, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
|
|
results = await asyncio.gather(*[
|
|
run_agent(
|
|
f"{guide_id}-w{i + 1}",
|
|
_prompt(
|
|
"Guide-Writer",
|
|
topic=topic, format_name=format_name, zuteilung=zuteilungen[i],
|
|
inhalte=inhalte_text(chunks[i]),
|
|
facts=facts, spec=spec, out_path=paths[i], extra=_extra(instructions),
|
|
),
|
|
_timeout("writer", chunk_sizes[i]), provider=provider, role="guide", capabilities="full",
|
|
)
|
|
for i in offen
|
|
], return_exceptions=True)
|
|
if is_cancelled():
|
|
return None
|
|
for i, r in zip(offen, results):
|
|
if isinstance(r, BaseException):
|
|
_log(topic, f"Writer {i + 1}: {type(r).__name__}: {r}")
|
|
elif r[0] != 0:
|
|
_log(topic, f"Writer {i + 1}: {_claude_error('Fehler', *r)}")
|
|
elif not paths[i].exists():
|
|
_log(topic, f"Writer {i + 1}: keine Ausgabedatei erstellt")
|
|
if not any(p.exists() for p in paths):
|
|
await _fail(guide_id, _gather_error("Writer-Fehler", list(results)))
|
|
return None
|
|
|
|
by_num: dict[int, dict] = {}
|
|
for p in paths:
|
|
if not p.exists():
|
|
continue
|
|
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
|
num = _titel_aufloesen(idx, sec["titel"])
|
|
if num is None:
|
|
_log(topic, f"Writer lieferte unbekannte Section '{sec['titel'][:40]}' (ignoriert)")
|
|
elif num not in by_num:
|
|
by_num[num] = sec
|
|
if not by_num:
|
|
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
|
|
return None
|
|
|
|
# Schritt 3: Lese-Prüfungs-Loop — Check pro Writer-Paket, Fix nur für
|
|
# beanstandete Sections; Folgerunden prüfen NUR die ersetzten Sections.
|
|
# Nach dem Runden-Cap bleiben offene Beanstandungen stehen.
|
|
chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks]
|
|
|
|
def sections_text(nums: list[int]) -> str:
|
|
return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)
|
|
|
|
def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str:
|
|
return "\n\n".join(
|
|
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
|
|
for num in nums
|
|
)
|
|
|
|
scope = chunk_nums
|
|
for runde in range(1, KONSENS_MAX_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})…")
|
|
slots = [{
|
|
"key": f"{guide_id}-lese-check-r{runde}-{i + 1}",
|
|
"prompt": _prompt(
|
|
"Guide-Lese-Check",
|
|
topic=topic, format_name=format_name, spec=spec,
|
|
sections=sections_text(scope[i]),
|
|
out_path=check_paths[i], extra=_extra(instructions),
|
|
),
|
|
"role": "judge", "capabilities": "files",
|
|
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))),
|
|
} for i in offen_checks]
|
|
res = await _race(topic, f"Lese-Prüfung r{runde}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled)
|
|
if is_cancelled():
|
|
return None
|
|
if res is None:
|
|
# Nicht hart failen: bei 1 Agent je Baustein darf ein einzelner Check-Ausfall
|
|
# nicht den ganzen Guide kippen. Sections sind geschrieben; Lese-Prüfung ist Politur.
|
|
_log(topic, f"Lese-Prüfung Runde {runde} ohne vollständiges Ergebnis — Stand bleibt")
|
|
break
|
|
|
|
probleme_by_num: dict[int, str] = {}
|
|
for i, p in enumerate(check_paths):
|
|
geltung = set(scope[i])
|
|
for item in (_lese_probleme_schema(_json_datei(p)) or []):
|
|
num = _titel_aufloesen(idx, item["section"])
|
|
if num in geltung and num in by_num and num not in probleme_by_num:
|
|
probleme_by_num[num] = item["problem"]
|
|
|
|
# Deterministisches Lesbarkeits-Gate: zu schwere Sections in dieselbe
|
|
# Überarbeitung einreihen (LLM-Beanstandung hat Vorrang). Gate aus → no-op.
|
|
if LESBARKEIT_AKTIV:
|
|
md_by_num = {num: by_num[num]["md"] for nums in scope for num in nums if num in by_num}
|
|
hinweise = await asyncio.to_thread(lesbarkeit.bewerte_sections, md_by_num)
|
|
if hinweise:
|
|
_log(topic, f"Lesbarkeit: {len(hinweise)} Section(s) zu schwer")
|
|
for num, hinweis in hinweise.items():
|
|
probleme_by_num.setdefault(num, hinweis)
|
|
|
|
if not probleme_by_num:
|
|
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})…")
|
|
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()]
|
|
results = await asyncio.gather(*[
|
|
run_agent(
|
|
f"{guide_id}-fix-r{runde}-w{i + 1}",
|
|
_prompt(
|
|
"Guide-Sections-Fix",
|
|
topic=topic, format_name=format_name, facts=facts, spec=spec,
|
|
auftraege=auftraege_text(fix_chunks[i], probleme_by_num),
|
|
out_path=fix_paths[i], extra=_extra(instructions),
|
|
),
|
|
_timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
|
|
)
|
|
for i in fix_offen
|
|
], return_exceptions=True)
|
|
if is_cancelled():
|
|
return None
|
|
for i, r in zip(fix_offen, results):
|
|
if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0):
|
|
_log(topic, f"Sections-Fix {i + 1} (Runde {runde}) fehlgeschlagen — Original bleibt")
|
|
ersetzt: set[int] = set()
|
|
for p in fix_paths:
|
|
if not p.exists():
|
|
continue
|
|
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
|
num = _titel_aufloesen(idx, sec["titel"])
|
|
if num in probleme_by_num and sec["md"].strip():
|
|
by_num[num] = sec
|
|
ersetzt.add(num)
|
|
_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")
|
|
break
|
|
scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums]
|
|
|
|
# Prüfbar = Format hat Prüfung UND Baustein hat ≥1 relevanten Subbaustein.
|
|
# Guide ist immer prüfbar (auch ohne Relevanz-Daten, Fallback = alles).
|
|
def _pruefbar(num):
|
|
if format_name == "Guide":
|
|
return True
|
|
if format_name == "FullGuide":
|
|
return any(isinstance(s, dict) and s.get("relevanz") == "relevant"
|
|
for s in subs_raw.get(_titel(entries[num]), []))
|
|
return False # Rest u.a. → reine Lese-Sections
|
|
|
|
await _set_progress(guide_id, "Setze zusammen…")
|
|
chapters: list[dict] = []
|
|
for ch in plan:
|
|
sections = [
|
|
{"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"],
|
|
"kompakt": by_num[num].get("kompakt", ""), "pruefbar": _pruefbar(num)}
|
|
for num in ch["nums"] if num in by_num
|
|
]
|
|
if sections:
|
|
chapters.append({"title": ch["title"], "sections": sections})
|
|
geplant = {num for ch in plan for num in ch["nums"]}
|
|
missing = sorted(geplant - set(by_num))
|
|
if missing:
|
|
_log(topic, f"Sections fehlen in der Writer-Ausgabe: {[_titel(entries[n]) for n in missing]}")
|
|
if not chapters:
|
|
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
|
|
return None
|
|
return chapters
|
|
|
|
|
|
async def reconcile_guides() -> None:
|
|
"""DB↔Dateisystem abgleichen: status=done ohne Content-Datei → error.
|
|
|
|
Läuft beim Server-Start (nach init_db) — fängt Crashes zwischen
|
|
Datei-Write und Status-Update ab.
|
|
"""
|
|
for g in await list_guides():
|
|
if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists():
|
|
log.warning("[%s] Guide %s: done ohne Content-Datei — auf error gesetzt", g["topic"], g["id"])
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
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 with _semaphore:
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
await update_guide(guide_id, status="generating", progress="Starte…", updated_at=now)
|
|
|
|
content_path = guide_content_path(topic, format_name)
|
|
content_path.parent.mkdir(parents=True, exist_ok=True)
|
|
project = quelle_ordner(topic) # Ordner-Quelle (projekt/uni/link) → Pfad, sonst None
|
|
|
|
try:
|
|
if is_guide_cancelled(guide_id):
|
|
return
|
|
|
|
if project:
|
|
await asyncio.to_thread(_pdfs_konvertieren, project)
|
|
|
|
# „Neu erstellen": fertiger Guide → kompletter Frischstart.
|
|
# Sonst sind Schritt-Dateien Reste eines Abbruchs/Fehlers → Resume.
|
|
if content_path.exists():
|
|
for p_alt in guide_slot_dateien(content_path):
|
|
p_alt.unlink(missing_ok=True)
|
|
|
|
alle = _lade_bausteine(bausteine_path(topic).read_text(encoding="utf-8"))
|
|
if not alle:
|
|
await _fail(guide_id, "Keine Bausteine gefunden")
|
|
return
|
|
entries = _eindeutige_titel(alle)
|
|
facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
|
|
chapters = await _generate_sections(
|
|
guide_id, topic, format_name, entries,
|
|
facts, instructions, provider, content_path,
|
|
)
|
|
if chapters is None or is_guide_cancelled(guide_id):
|
|
return
|
|
content = {"topic": topic, "format": format_name, "chapters": chapters}
|
|
|
|
atomic_write_json(content_path, content, indent=1)
|
|
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
await update_guide(guide_id, status="done", progress=None, step=None, updated_at=now)
|
|
|
|
except asyncio.TimeoutError:
|
|
await _fail(guide_id, "Timeout bei der Generierung")
|
|
except FileNotFoundError:
|
|
await _fail(guide_id, "Bausteine fehlen")
|
|
except Exception as e:
|
|
log.exception("[%s] Guide-Generierung fehlgeschlagen (%s)", topic, guide_id)
|
|
await _fail(guide_id, str(e)[:2000])
|
|
finally:
|
|
clear_guide_cancelled(guide_id)
|