This commit is contained in:
team3
2026-06-23 21:44:28 +02:00
parent 6a5a1cccd9
commit 9e888c216a
2 changed files with 148 additions and 49 deletions

View File

@@ -41,13 +41,15 @@ log = logging.getLogger("creator.guide")
GUIDE_STEPS = ("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
# eines fehlgeschlagenen Writers.
# Inhalte/Inhalts-Check/Lese-Prüfung laufen in Paketen von ~GUIDE_CHUNK Bausteinen je Agent. # Inhalte/Inhalts-Check/Lese-Prüfung laufen in Paketen von ~GUIDE_CHUNK Bausteinen je Agent.
# Nur der Writer (Schreiben) bleibt 1 Agent je Baustein (variable Längen, kein Kürzen). # Nur der Writer (Schreiben) bleibt 1 Agent je Baustein (variable Längen, kein Kürzen, keine
# Längen-Angleichung zwischen Bausteinen).
GUIDE_CHUNK = 10 GUIDE_CHUNK = 10
# Prüf-Schritte als Panel: CHECK_PANEL Judges je Chunk, Section beanstandet bei Mehrheit.
# Ein einzelner Judge ist bias-/sampling-anfällig; ein kleines Panel ist stabiler.
CHECK_PANEL = 3
# Lese-Prüfung: nur EINE Runde (Check + Fix). Folgerunden brachten kaum Mehrwert # Lese-Prüfung: nur EINE Runde (Check + Fix). Folgerunden brachten kaum Mehrwert
# (1 Agent je Baustein prüft ohnehin fein), kosten aber extra Agenten. # (1 Agent je Baustein prüft ohnehin fein), kosten aber extra Agenten.
LESE_RUNDEN = 1 LESE_RUNDEN = 1
@@ -134,10 +136,10 @@ def _reset_fertig(content_path: Path, step: int) -> None:
# Slot-Datei-Globs je Schritt (Index = GUIDE_STEPS). Stem-verankert, kollisionsfrei. # Slot-Datei-Globs je Schritt (Index = GUIDE_STEPS). Stem-verankert, kollisionsfrei.
_STEP_GLOBS = ( _STEP_GLOBS = (
("gliederung*",), # 0 Gliederung (inkl. Auswahl-Filter) ("gliederung*",), # 0 Gliederung (inkl. Auswahl-Filter)
("inhalt-chunk-*",), # 1 Inhalte ("inhalt-chunk-*", "inhalt-nach-*"), # 1 Inhalte (inkl. Nachrunde)
("inhalt-check-*", "inhalt-fix-*"), # 2 Inhalts-Check ("inhalt-check-*", "inhalt-fix-*"), # 2 Inhalts-Check
("chunk-*",), # 3 Schreiben ("chunk-*",), # 3 Schreiben (chunk-* matcht auch chunk-nach-*)
("lese-check-*", "fix-r*"), # 4 Lese-Prüfung ("lese-check-*", "fix-r*"), # 4 Lese-Prüfung
) )
@@ -171,6 +173,31 @@ def _lese_probleme_schema(data):
return out or None return out or None
def _panel_probleme(judge_paths: list[Path], geltung: set[int], idx: dict[str, int]) -> dict[int, str]:
"""Panel-Aggregation: mehrere Judge-Outputs eines Chunks → beanstandete {num: problem}.
Eine Stimme je Judge, der eine Section nennt. Beanstandet, wenn > Hälfte der
GELIEFERTEN (valid geparsten) Judges sie nennt (3→≥2, 2→≥2, 1→≥1). Robust gegen
Einzel-Ausfall: fehlende Dateien zählen nicht mit. Problem-Text vom erstnennenden Judge.
"""
outputs = [p for p in (_lese_probleme_schema(_json_datei(j)) for j in judge_paths) if p is not None]
if not outputs:
return {}
votes: dict[int, int] = {}
problem: dict[int, str] = {}
for out in outputs:
gesehen: set[int] = set()
for item in out:
num = _titel_aufloesen(idx, item["section"])
if num is None or num not in geltung or num in gesehen:
continue
gesehen.add(num)
votes[num] = votes.get(num, 0) + 1
problem.setdefault(num, item["problem"])
schwelle = len(outputs) / 2
return {num: problem[num] for num, v in votes.items() if v > schwelle}
def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None: def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None:
"""{"kapitel": [{"titel", "nummern": [1, 3, 7]}]} → [{"title", "nums"}]. """{"kapitel": [{"titel", "nummern": [1, 3, 7]}]} → [{"title", "nums"}].
@@ -385,40 +412,80 @@ async def _generate_sections(
if not inhalt_by_num: if not inhalt_by_num:
await _fail(guide_id, "Keine Inhalte identifiziert") await _fail(guide_id, "Keine Inhalte identifiziert")
return None return None
# Nachrunde: fehlende Bausteine (Chunk-Ausfall oder Lazy-Output) gezielt nachziehen — eine Runde.
geplant_nums = [num for ch in plan for num in ch["nums"]]
fehlend = [num for num in geplant_nums if num not in inhalt_by_num]
if fehlend:
_log(topic, f"Inhalte: {len(fehlend)} Baustein(e) fehlen — Nachrunde…")
nach_chunks = [[{"title": "Weitere", "nums": fehlend[k:k + GUIDE_CHUNK]}] for k in range(0, len(fehlend), GUIDE_CHUNK)]
nach_paths = [content_path.parent / f"{content_path.stem}.inhalt-nach-{k}.md" for k in range(1, len(nach_chunks) + 1)]
nach_offen = [k for k, p in enumerate(nach_paths) if not p.exists()]
if nach_offen:
async def melde_n(d, t): await _set_step(guide_id, 1, f"Sammle fehlende Inhalte {d}/{t}")
await _gather_fortschritt([
run_agent(
f"{guide_id}-inhalt-nach-{k + 1}",
_prompt(
"Guide-Inhalt",
topic=topic, zuteilung=_zuteilung_subs(nach_chunks[k], entries, subs_by_titel),
facts=facts, out_path=nach_paths[k], extra=_extra(instructions),
),
_timeout("inhalt", len(nach_chunks[k][0]["nums"])), provider=provider, role="guide", capabilities="full",
)
for k in nach_offen
], len(nach_chunks), melde_n, start=len(nach_chunks) - len(nach_offen))
if is_cancelled():
return None
for p in nach_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 all(p.exists() for p in inhalt_paths): if all(p.exists() for p in inhalt_paths):
_set_fertig(content_path, 1) # 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] 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. # Schritt 3: Inhalte prüfen — CHECK_PANEL Judges je Chunk, Mehrheit beanstandet.
check_paths = [content_path.parent / f"{content_path.stem}.inhalt-check-{i}.json" for i in range(1, writer_count + 1)] # + beanstandete einmal überarbeiten. Resume: nur fehlende Judge-Dateien neu starten.
offen_checks = [i for i, p in enumerate(check_paths) if inhalt_chunk_nums[i] and _lese_probleme_schema(_json_datei(p)) is None] check_judge_paths = [
if offen_checks: [content_path.parent / f"{content_path.stem}.inhalt-check-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)]
for i in range(1, writer_count + 1)
]
offen_slots = [
(i, j) for i in range(writer_count) if inhalt_chunk_nums[i]
for j in range(CHECK_PANEL) if _lese_probleme_schema(_json_datei(check_judge_paths[i][j])) is None
]
if offen_slots:
await _set_step(guide_id, 2, "Prüfe Inhalte…") await _set_step(guide_id, 2, "Prüfe Inhalte…")
sections_je_chunk = {
i: "\n\n".join(f"SECTION: {_titel(entries[num])}\n{inhalt_by_num[num]}" for num in inhalt_chunk_nums[i])
for i, _ in offen_slots
}
slots = [{ slots = [{
"key": f"{guide_id}-inhalt-check-{i + 1}", "key": f"{guide_id}-inhalt-check-{i + 1}-j{j + 1}",
"prompt": _prompt( "prompt": _prompt(
"Guide-Inhalt-Check", "Guide-Inhalt-Check",
topic=topic, format_name=format_name, topic=topic, format_name=format_name, sections=sections_je_chunk[i],
sections="\n\n".join(f"SECTION: {_titel(entries[num])}\n{inhalt_by_num[num]}" for num in inhalt_chunk_nums[i]), out_path=check_judge_paths[i][j], extra=_extra(instructions),
out_path=check_paths[i], extra=_extra(instructions),
), ),
"role": "judge", "capabilities": "files", "role": "judge", "capabilities": "files",
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))), "payload": (lambda result, p=check_judge_paths[i][j]: _lese_probleme_schema(_json_datei(p))),
} for i in offen_checks] } for i, j in offen_slots]
n_checks = len(slots) n_checks = len(slots)
upd = lambda n: asyncio.create_task(_set_step(guide_id, 2, 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) await _race(topic, "Inhalts-Prüfung", slots, len(slots), _timeout("inhalt_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=KONSENS_GRACE)
if is_cancelled(): if is_cancelled():
return None return None
probleme_by_num: dict[int, str] = {} probleme_by_num: dict[int, str] = {}
for i, p in enumerate(check_paths): for i in range(writer_count):
geltung = set(inhalt_chunk_nums[i]) if inhalt_chunk_nums[i]:
for item in (_lese_probleme_schema(_json_datei(p)) or []): probleme_by_num.update(_panel_probleme(check_judge_paths[i], set(inhalt_chunk_nums[i]), idx))
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: if probleme_by_num:
_log(topic, f"Inhalts-Prüfung: {len(probleme_by_num)} Baustein(e) beanstandet") _log(topic, f"Inhalts-Prüfung: {len(probleme_by_num)} Baustein(e) beanstandet")
@@ -473,9 +540,9 @@ async def _generate_sections(
"Guide-Writer", "Guide-Writer",
topic=topic, format_name=format_name, zuteilung=w_zuteil[i], topic=topic, format_name=format_name, zuteilung=w_zuteil[i],
inhalte=inhalte_text(w_chunks[i]), inhalte=inhalte_text(w_chunks[i]),
facts=facts, spec=spec, out_path=paths[i], extra=_extra(instructions), spec=spec, out_path=paths[i], extra=_extra(instructions),
), ),
_timeout("writer", 1), provider=provider, role="guide", capabilities="full", _timeout("writer", 1), provider=provider, role="guide", capabilities="files",
) )
for i in offen for i in offen
], len(w_chunks), melde, start=len(w_chunks) - len(offen)) ], len(w_chunks), melde, start=len(w_chunks) - len(offen))
@@ -505,6 +572,38 @@ async def _generate_sections(
if not by_num: if not by_num:
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden") await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
return None return None
# Nachrunde: fehlende Sections (Writer-Ausfall) gezielt nachschreiben — eine Runde.
nach_fehlend = [num for num in geplant_nums if num not in by_num]
if nach_fehlend:
_log(topic, f"Schreiben: {len(nach_fehlend)} Section(s) fehlen — Nachrunde…")
nw_chunks = [[{"title": "Weitere", "nums": [num]}] for num in nach_fehlend]
nw_paths = [content_path.parent / f"{content_path.stem}.chunk-nach-{k}.md" for k in range(1, len(nw_chunks) + 1)]
nw_offen = [k for k, p in enumerate(nw_paths) if not p.exists()]
if nw_offen:
async def melde_nw(d, t): await _set_step(guide_id, 3, f"Schreibe fehlende Sections {d}/{t}")
await _gather_fortschritt([
run_agent(
f"{guide_id}-w-nach-{k + 1}",
_prompt(
"Guide-Writer",
topic=topic, format_name=format_name, zuteilung=_zuteilung_subs(nw_chunks[k], entries, subs_by_titel),
inhalte=inhalte_text(nw_chunks[k]), spec=spec, out_path=nw_paths[k], extra=_extra(instructions),
),
_timeout("writer", 1), provider=provider, role="guide", capabilities="files",
)
for k in nw_offen
], len(nw_chunks), melde_nw, start=len(nw_chunks) - len(nw_offen))
if is_cancelled():
return None
for p in nw_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 by_num and sec["md"].strip():
by_num[num] = sec
if all(p.exists() for p in paths): if all(p.exists() for p in paths):
_set_fertig(content_path, 3) # Schreiben vollständig _set_fertig(content_path, 3) # Schreiben vollständig
@@ -524,39 +623,41 @@ async def _generate_sections(
scope = chunk_nums scope = chunk_nums
for runde in range(1, LESE_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)] # CHECK_PANEL Judges je Paket; Mehrheit beanstandet. Aggregation robust gegen Einzel-Ausfall.
offen_checks = [i for i, p in enumerate(check_paths) if scope[i] and _lese_probleme_schema(_json_datei(p)) is None] check_judge_paths = [
if offen_checks: [content_path.parent / f"{content_path.stem}.lese-check-r{runde}-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)]
for i in range(1, writer_count + 1)
]
offen_slots = [
(i, j) for i in range(writer_count) if scope[i]
for j in range(CHECK_PANEL) if _lese_probleme_schema(_json_datei(check_judge_paths[i][j])) is None
]
if offen_slots:
await _set_step(guide_id, 4, "Prüfe Lesbarkeit…") await _set_step(guide_id, 4, "Prüfe Lesbarkeit…")
sections_je_chunk = {i: sections_text(scope[i]) for i, _ in offen_slots}
slots = [{ slots = [{
"key": f"{guide_id}-lese-check-r{runde}-{i + 1}", "key": f"{guide_id}-lese-check-r{runde}-{i + 1}-j{j + 1}",
"prompt": _prompt( "prompt": _prompt(
"Guide-Lese-Check", "Guide-Lese-Check",
topic=topic, format_name=format_name, spec=spec, topic=topic, format_name=format_name, spec=spec,
sections=sections_text(scope[i]), sections=sections_je_chunk[i],
out_path=check_paths[i], extra=_extra(instructions), out_path=check_judge_paths[i][j], extra=_extra(instructions),
), ),
"role": "judge", "capabilities": "files", "role": "judge", "capabilities": "files",
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))), "payload": (lambda result, p=check_judge_paths[i][j]: _lese_probleme_schema(_json_datei(p))),
} for i in offen_checks] } for i, j in offen_slots]
n_checks = len(slots) n_checks = len(slots)
upd = lambda n: asyncio.create_task(_set_step(guide_id, 4, 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) 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, grace=KONSENS_GRACE)
if is_cancelled(): if is_cancelled():
return None return None
if res is None: if res is None:
# Nicht hart failen: bei 1 Agent je Baustein darf ein einzelner Check-Ausfall _log(topic, f"Lese-Prüfung Runde {runde}: kein volles Quorum — vorhandene Judges aggregiert")
# 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] = {} probleme_by_num: dict[int, str] = {}
for i, p in enumerate(check_paths): for i in range(writer_count):
geltung = set(scope[i]) if scope[i]:
for item in (_lese_probleme_schema(_json_datei(p)) or []): probleme_by_num.update(_panel_probleme(check_judge_paths[i], set(scope[i]), idx))
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 # Deterministisches Lesbarkeits-Gate: zu schwere Sections in dieselbe
# Überarbeitung einreihen (LLM-Beanstandung hat Vorrang). Gate aus → no-op. # Überarbeitung einreihen (LLM-Beanstandung hat Vorrang). Gate aus → no-op.

View File

@@ -3,8 +3,6 @@ Schreibe Sections für einen Lern-Guide zum Thema "{topic}" (Format: {format_nam
Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteilte Section muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Stufe (`[einfach]`/`[mittel]`/`[schwer]`): Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteilte Section muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Stufe (`[einfach]`/`[mittel]`/`[schwer]`):
{zuteilung} {zuteilung}
{facts}
Das Format ({format_name}) beeinflusst NICHT, wie ein Baustein geschrieben wird — nur, wie viele Bausteine das Thema abdecken. Derselbe Baustein bekommt in MiniGuide, Guide und FullGuide denselben Text. Das Format ({format_name}) beeinflusst NICHT, wie ein Baustein geschrieben wird — nur, wie viele Bausteine das Thema abdecken. Derselbe Baustein bekommt in MiniGuide, Guide und FullGuide denselben Text.
Jeder Baustein bekommt ZWEI Fassungen mit DENSELBEN Inhalten (dieselben Subbausteine): Jeder Baustein bekommt ZWEI Fassungen mit DENSELBEN Inhalten (dieselben Subbausteine):
@@ -24,7 +22,7 @@ SO SCHREIBST DU die ausführliche Fassung — EIN zusammenhängender Text für e
GEPRÜFTE INHALTE je Baustein — das ist verbindlich, was gelehrt werden muss: GEPRÜFTE INHALTE je Baustein — das ist verbindlich, was gelehrt werden muss:
{inhalte} {inhalte}
Nutze diese Inhalte. Erfinde nichts dazu, lass nichts Wesentliches weg. Lehre simpel: kurze Sätze, Listen für Aufzählungen, ein Anfänger versteht es sofort. Recherchiere NICHT und suche NICHT im Web. Alle nötigen Fakten stehen in diesen geprüften Inhalten — nutze ausschließlich sie. Erfinde nichts dazu, lass nichts Wesentliches weg. Lehre simpel: kurze Sätze, Listen für Aufzählungen, ein Anfänger versteht es sofort.
SECTION-SPEZIFIKATION (gilt je Baustein): SECTION-SPEZIFIKATION (gilt je Baustein):
{spec} {spec}