This commit is contained in:
team3
2026-06-27 00:58:46 +02:00
parent 2a2026c9ae
commit f018ca5048
44 changed files with 3070 additions and 379 deletions

View File

@@ -11,6 +11,7 @@ die Gesamtliste.
"""
import asyncio
import json
import logging
import math
import shutil
@@ -19,11 +20,10 @@ import time
from pathlib import Path
import database as db
from agents import kill_process, cancel_scope, clear_scope
from agents import kill_process, cancel_scope, clear_scope, run_agent
from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS
from fsutil import atomic_write_text, atomic_write_json
from jsonio import read_json_file as _json_datei
from lernen import FRAGETYPEN
from paths import arbeit_dir, bausteine_path, frage_muster_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner
from crawl import crawl
from pipeline import (
@@ -51,6 +51,8 @@ SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min)
KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass
# Frage-Muster-Chunks per LPT nach Sub-Last balancieren (Makespan), statt nach Baustein-Anzahl.
FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk
FAKTEN_CHUNK_SUBS = 25 # Fakten-Extraktion: kleinere Chunks (Fakten sind umfangreicher als Muster)
FAKTEN_CHECK_PANEL = 3 # Judges je Chunk im Fakten-Check (Mehrheit beanstandet)
log = logging.getLogger("creator.bausteine")
@@ -62,10 +64,14 @@ _bausteine_step: dict[str, int] = {}
BAUSTEINE_STEPS = (
"Recherche", "Konsolidierung", "Klärung",
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
"Fakten finden", "Fakten prüfen", "Fakten fix",
"Stufen finden", "Stufen wählen", "Stufen klären",
"Relevanz finden", "Relevanz wählen", "Relevanz klären",
"Gliederung",
"Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen",
"Karteikarten", "Beispiele", "Diagramme",
)
ARTEFAKT_TYPEN = ("karteikarte", "beispiel", "diagramm")
def lade_quelle(topic: str) -> dict:
@@ -93,7 +99,8 @@ def _crawl_fertig(topic: str) -> bool:
return (quelle_crawl_dir(topic) / ".done").exists() # Marker erst bei sauberem Abschluss
_STUFEN = ("einfach", "mittel", "schwer")
# Lernpfad-Stufen (anfaenger/fortgeschritten/experte); alte Schwierigkeits-Werte abwärtskompatibel.
_STUFEN = ("anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer")
async def subbausteine_titel(topic: str, baustein: str) -> list[str]:
@@ -115,19 +122,36 @@ async def lade_frage_muster(topic: str, baustein: str) -> list[dict]:
"""Vordefinierte Frage-Muster eines Bausteins — DB-first, Fallback Sidecar (leer = Live)."""
rows = await db.list_frage_muster(topic, _norm_titel(baustein))
if rows:
return [{"subbaustein": r["sub_titel"], "typ": r["typ"], "frage": r["frage"]} for r in rows if r["frage"]]
return [{"subbaustein": r["sub_titel"], "frage": r["frage"]} for r in rows if r["frage"]]
fm = _json_datei(frage_muster_path(topic))
if not isinstance(fm, dict):
return []
return [
{"subbaustein": str(e.get("subbaustein", "")).strip(),
"typ": str(e.get("typ", "")).strip(),
"frage": frage}
{"subbaustein": str(e.get("subbaustein", "")).strip(), "frage": frage}
for e in (fm.get(baustein) or [])
if isinstance(e, dict) and (frage := str(e.get("frage", "")).strip())
]
async def subbausteine_frei(topic: str, baustein: str, max_ebene: int) -> list[str]:
"""Subbaustein-Titel bis zur freigeschalteten Ebene (≤ max_ebene). Fallback ohne
Ebenen-Wissen (Altbestand/Sidecar): alle Subbaustein-Titel."""
rows = await db.subs_mit_ebene(topic, baustein)
if not rows:
return await subbausteine_titel(topic, baustein)
return [s["titel"] for s in rows if s["ebene"] <= max_ebene and s["titel"]]
async def lade_frage_muster_frei(topic: str, baustein: str, max_ebene: int) -> list[dict]:
"""Frage-Muster, gefiltert auf Subbausteine bis zur freigeschalteten Ebene. Ohne
Ebenen-Wissen (Altbestand/Sidecar) ungefiltert."""
rows = await db.subs_mit_ebene(topic, baustein)
if not rows:
return await lade_frage_muster(topic, baustein)
frei = {s["norm"] for s in rows if s["ebene"] <= max_ebene}
return [m for m in await lade_frage_muster(topic, baustein) if _norm_titel(m["subbaustein"]) in frei]
async def lade_uebersicht(topic: str) -> list[dict]:
"""Strukturierte Baustein-Liste für die Übersicht — DB-first (Konsens + Subs/Stufen/Relevanz),
Fallback bausteine.md + Sidecar (Alt-Themen)."""
@@ -140,7 +164,7 @@ async def lade_uebersicht(topic: str) -> list[dict]:
"num": num, "titel": b["titel"], "beschreibung": b["beschreibung"],
"subbausteine": [
{"titel": s["sub_titel"],
"stufe": s["stufe"] if s["stufe"] in _STUFEN else "mittel",
"stufe": s["stufe"] if s["stufe"] in _STUFEN else "fortgeschritten",
"relevanz": s["relevanz"] if s["relevanz"] in ("relevant", "rand") else None}
for s in subs if s["sub_titel"]
],
@@ -157,7 +181,7 @@ async def lade_uebersicht(topic: str) -> list[dict]:
subbausteine = [
{
"titel": t,
"stufe": s.get("stufe") if s.get("stufe") in _STUFEN else "mittel",
"stufe": s.get("stufe") if s.get("stufe") in _STUFEN else "fortgeschritten",
"relevanz": s.get("relevanz") if s.get("relevanz") in ("relevant", "rand") else None,
}
for s in (sidecar.get(titel) or [])
@@ -177,9 +201,12 @@ def _bausteine_steps(topic: str) -> tuple:
base = ("Recherche", "Konsolidierung", "Klärung")
rest = (
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
"Fakten finden", "Fakten prüfen", "Fakten fix",
"Stufen finden", "Stufen wählen", "Stufen klären",
"Relevanz finden", "Relevanz wählen", "Relevanz klären",
"Gliederung",
"Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen",
"Karteikarten", "Beispiele", "Diagramme",
)
mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest
return (("Quelle aufbereiten",) if q["type"] == "link" else ()) + mitte
@@ -203,9 +230,12 @@ PHASEN = (
("Quelle", ("Quelle aufbereiten",)),
("Inventar", ("Recherche", "Konsolidierung", "Klärung", "Ergänzung")),
("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")),
("Fakten", ("Fakten finden", "Fakten prüfen", "Fakten fix")),
("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")),
("Relevanz", ("Relevanz finden", "Relevanz wählen", "Relevanz klären")),
("Gliederung", ("Gliederung",)),
("Fragen", ("Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen")),
("Artefakte", ("Karteikarten", "Beispiele", "Diagramme")),
)
@@ -244,22 +274,28 @@ def _bausteine_files(topic: str) -> dict:
"mapping": {n: arbeit / f"auswahl-mapping-r{n}.json" for n in runden},
"ergaenzung": arbeit / "ergaenzung.json",
"sub_roh": arbeit / "subbausteine-roh.json",
"fakten": arbeit / "subbausteine-fakten.json",
"sidecar": subbausteine_path(topic),
"frage_muster": frage_muster_path(topic),
"gliederung": arbeit / "gliederung.json",
"gliederung_slots": [arbeit / f"gliederung-{i}.json" for i in (1, 2, 3)],
"artefakte": arbeit / "artefakte.json",
}
def _alle_slot_dateien(files: dict) -> list[Path]:
arbeit = files["arbeit"]
# Subbaustein-/Stufen-Slots sind pro Chunk dynamisch — per Glob einsammeln.
dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*"))
+ list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*"))
dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("fakten-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*"))
+ list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("gliederung-*")) + list(arbeit.glob("artefakt-*"))
+ list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*"))
+ list(arbeit.glob("klaerung*"))) if arbeit.is_dir() else []
return [
*files["recherche"], files["recherche_mapping"],
*(p for slots in files["auswahl"].values() for p in slots),
*files["mapping"].values(), files["ergaenzung"],
files["sub_roh"], files["sidecar"], files["frage_muster"], *dyn,
files["sub_roh"], files["sidecar"], files["frage_muster"],
files["fakten"], files["gliederung"], files["artefakte"], *dyn,
]
@@ -288,12 +324,21 @@ def _resume_step(topic: str) -> int:
# Stufen fertig; nur noch Relevanz offen?
if not _relevanz_komplett(sidecar):
return _step_idx(topic, "Relevanz finden")
# Relevanz fertig; nur noch Frage-Muster offen?
# Relevanz fertig; Gliederung (Bausteine-Artefakt für den Guide) offen?
if not _gliederung_komplett(files):
return _step_idx(topic, "Gliederung")
# Gliederung fertig; Frage-Muster offen?
if not _frage_muster_komplett(topic):
return _step_idx(topic, "Fragen finden")
# Fragen fertig; Lern-Artefakte (Karteikarten/Beispiele/Diagramme) offen?
if not _artefakte_komplett(files):
return _step_idx(topic, "Karteikarten")
return len(_bausteine_steps(topic))
if _sub_roh_schema(_json_datei(files["sub_roh"])) is None:
return _step_idx(topic, "Subbausteine finden")
# Subbausteine fertig; Fakten noch offen? (Fakten kommen vor den Stufen.)
if not _fakten_komplett(files):
return _step_idx(topic, "Fakten finden")
return _step_idx(topic, "Stufen finden")
@@ -356,12 +401,19 @@ def _reset_ab_phase(topic: str, label: str) -> None:
for p in arbeit.glob(pat):
p.unlink(missing_ok=True)
if idx <= 5: # Fragen
# Phasen-Index: Quelle=0 · Inventar=1 · Subbausteine=2 · Fakten=3 · Stufen=4 · Relevanz=5 · Gliederung=6 · Fragen=7 · Artefakte=8
if idx <= 8: # Artefakte (Karteikarten/Beispiele/Diagramme)
files["artefakte"].unlink(missing_ok=True)
glob_del("artefakt-*")
if idx <= 7: # Fragen
files["frage_muster"].unlink(missing_ok=True)
glob_del("frage-muster-*")
if idx <= 4: # Relevanz
if idx <= 6: # Gliederung
files["gliederung"].unlink(missing_ok=True)
glob_del("gliederung-*")
if idx <= 5: # Relevanz
glob_del("relevanz-*")
if idx <= 3: # Stufen + Relevanz teilen die Sidecar → ab Stufen ganz neu
if idx <= 4: # Stufen + Relevanz teilen die Sidecar → ab Stufen ganz neu
files["sidecar"].unlink(missing_ok=True)
glob_del("stufe-*")
else: # ab Relevanz: Stufen behalten, nur Relevanz-Felder strippen
@@ -372,6 +424,9 @@ def _reset_ab_phase(topic: str, label: str) -> None:
if isinstance(s, dict):
s.pop("relevanz", None)
atomic_write_json(files["sidecar"], sc, indent=1)
if idx <= 3: # Fakten (vor den Stufen) — Fakten-Map + Arbeitsdateien weg
files["fakten"].unlink(missing_ok=True)
glob_del("fakten-*")
if idx <= 2: # Subbausteine
files["sub_roh"].unlink(missing_ok=True)
glob_del("subbaustein-*")
@@ -474,7 +529,7 @@ def _sidecar_schema(data):
if not isinstance(v, list) or not v:
return None
for s in v:
if not isinstance(s, dict) or not str(s.get("titel", "")).strip() or s.get("stufe") not in ("einfach", "mittel", "schwer"):
if not isinstance(s, dict) or not str(s.get("titel", "")).strip() or s.get("stufe") not in _STUFEN:
return None
return data
@@ -493,10 +548,10 @@ def _relevanz_komplett(data) -> bool:
def _frage_muster_chunk_schema(data) -> list[dict] | None:
"""{"muster": [{baustein, subbaustein, typ, frage}, …]} → Liste valider Einträge · sonst None.
"""{"muster": [{baustein, subbaustein, frage}, …]} → Liste valider Einträge · sonst None.
Mit `baustein` (Zuordnung im 10er-Chunk). Ungültige Einzel-Einträge werden
übersprungen (nicht die ganze Liste verworfen)."""
Ein Muster je Subbaustein (kein Typ-Kreuzprodukt — die Schwierigkeit kommt erst bei der
Prüfung aus dem Lerner-Niveau). Ungültige Einzel-Einträge werden übersprungen."""
if not isinstance(data, dict) or not isinstance(data.get("muster"), list):
return None
out = []
@@ -505,11 +560,10 @@ def _frage_muster_chunk_schema(data) -> list[dict] | None:
continue
bau = str(e.get("baustein", "")).strip()
sub = str(e.get("subbaustein", "")).strip()
typ = str(e.get("typ", "")).strip().casefold()
frage = str(e.get("frage", "")).strip()
if not bau or not sub or typ not in FRAGETYPEN or not frage:
if not bau or not sub or not frage:
continue
out.append({"baustein": bau, "subbaustein": sub, "typ": typ, "frage": frage})
out.append({"baustein": bau, "subbaustein": sub, "frage": frage})
return out or None
@@ -801,10 +855,10 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
timeout=_timeout("stufe_check", len(strittig)),
)
if status == FAILED:
_log(topic, f"Stufen-Klärung Paket {c} fehlgeschlagen — Default 'mittel'")
_log(topic, f"Stufen-Klärung Paket {c} fehlgeschlagen — Default 'fortgeschritten'")
entsch = entsch if isinstance(entsch, dict) else {}
# Strittige ohne Entscheid → 'mittel'; Vote-Gewinner bleiben; Judge überschreibt.
ergebnis = {**{k: "mittel" for k in strittig}, **ergebnis, **entsch}
# Strittige ohne Entscheid → 'fortgeschritten'; Vote-Gewinner bleiben; Judge überschreibt.
ergebnis = {**{k: "fortgeschritten" for k in strittig}, **ergebnis, **entsch}
return {item_idxs[k - 1] + 1: stufe for k, stufe in ergebnis.items()}
parts = await _gather_fortschritt([_klaere(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _melde_p(set_p, topic, "Stufen klären"))
@@ -813,12 +867,12 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
stufe_by_id: dict[int, str] = {}
for c, part in enumerate(parts, 1):
if not isinstance(part, dict):
# Klärung ist nicht fatal: Vote-Ergebnis + Default 'mittel' für Strittige.
# Klärung ist nicht fatal: Vote-Ergebnis + Default 'fortgeschritten' für Strittige.
if isinstance(part, BaseException):
_log(topic, f"Stufen-Klärung Paket {c}: {type(part).__name__}: {part}")
ergebnis, strittig = vote_by_c[c]
item_idxs = chunks[c - 1]
merged = {**{k: "mittel" for k in strittig}, **ergebnis}
merged = {**{k: "fortgeschritten" for k in strittig}, **ergebnis}
part = {item_idxs[k - 1] + 1: s for k, s in merged.items()}
stufe_by_id.update(part)
@@ -829,11 +883,196 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
lst = []
for sub in subs:
gid += 1
lst.append({"titel": sub, "stufe": stufe_by_id.get(gid, "mittel")})
lst.append({"titel": sub, "stufe": stufe_by_id.get(gid, "fortgeschritten")})
sidecar[titel] = lst
return sidecar
_FAKTEN_FELDER = ("kernpunkte", "voraussetzungen", "huerden", "belegte_fakten", "beispiel_idee")
def _fakten_schema(data) -> list[dict] | None:
"""{"fakten": [{baustein, subbaustein, …}]} → valide Liste · sonst None.
Trennt belegte_fakten (mit Quelle) hart von beispiel_idee (generativ)."""
if not isinstance(data, dict) or not isinstance(data.get("fakten"), list):
return None
out = []
for e in data["fakten"]:
if not isinstance(e, dict):
continue
bau = str(e.get("baustein", "")).strip()
sub = str(e.get("subbaustein", "")).strip()
if not bau or not sub:
continue
bf = [{"text": t, "quelle": str(f.get("quelle", "")).strip()}
for f in (e.get("belegte_fakten") or []) if isinstance(f, dict) and (t := str(f.get("text", "")).strip())]
out.append({
"baustein": bau, "subbaustein": sub,
"kernpunkte": [k for x in (e.get("kernpunkte") or []) if (k := str(x).strip())],
"voraussetzungen": str(e.get("voraussetzungen", "")).strip(),
"huerden": str(e.get("huerden", "")).strip(),
"belegte_fakten": bf,
"beispiel_idee": str(e.get("beispiel_idee", "")).strip(),
})
return out or None
def _fakten_check_schema(data) -> list[str] | None:
"""Fakten-Check → beanstandete sub_norms · {ok:true}→[] · None bei ungültig."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return []
pr = data.get("probleme")
if not isinstance(pr, list):
return None
return [sn for p in pr if isinstance(p, dict) and (sn := _norm_titel(str(p.get("subbaustein", ""))))]
def _fakten_zeilen(fk: dict) -> str:
z = []
if fk.get("kernpunkte"):
z.append("Kernpunkte: " + " · ".join(fk["kernpunkte"]))
if fk.get("voraussetzungen"):
z.append("Voraussetzung: " + fk["voraussetzungen"])
if fk.get("huerden"):
z.append("Hürde: " + fk["huerden"])
for bf in fk.get("belegte_fakten", []):
z.append(f"FAKT: {bf['text']} (Quelle: {bf.get('quelle', '?')})")
if fk.get("beispiel_idee"):
z.append("Beispiel: " + fk["beispiel_idee"])
return "\n".join(z)
def _fakten_komplett(files: dict) -> bool:
"""Fakten-Map existiert (Block durch)? {Baustein: {sub_norm: {...}}}."""
d = _json_datei(files["fakten"])
return isinstance(d, dict) and bool(d)
async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, instructions: str) -> dict | None:
"""Block: je Sub Quell-Fakten extrahieren (finden) → verifizieren (prüfen) → korrigieren (fix).
Extract-once-Grounding: das Ergebnis nährt Stufe/Relevanz/Fragen/Guide.
{Baustein-Titel: {sub_norm: fakten-dict}} oder None bei Abbruch/Fehler."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
arbeit = files["arbeit"]
caps = "files" if ordner else "full"
typ = q.get("type", "thema")
source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner) if typ in _QUELLE_TEMPLATE else _prompt("Bausteine-Quelle-Thema", topic=topic)
bausteine = [(titel, [str(s).strip() for s in subs if str(s).strip()]) for titel, subs in roh.items() if subs]
if not bausteine:
return {}
chunks = _lpt_chunks([len(subs) for _, subs in bausteine], FAKTEN_CHUNK_SUBS)
def roh_path(ci): return arbeit / f"fakten-c{ci}.json"
def chk_path(ci, j): return arbeit / f"fakten-check-c{ci}-j{j}.json"
def fix_path(ci): return arbeit / f"fakten-fix-c{ci}.json"
def ctitel(idxs): return [bausteine[i][0] for i in idxs]
def block_text(idxs):
return "\n\n".join(
f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in bausteine[i][1])
for i in idxs)
# Roh-Fakten eines Chunks → {Baustein: {sub_norm: {sub, …felder}}}, auf Chunk-Titel gematcht.
def roh_map(ci, path):
idxs = chunks[ci]
rel_by = {bausteine[i][0]: bausteine[i][1] for i in idxs}
ct = ctitel(idxs)
out: dict[str, dict] = {}
for e in _fakten_schema(_json_datei(path)) or []:
bt = _match_sub(e["baustein"], ct)
if bt not in rel_by:
continue
sub = _match_sub(e["subbaustein"], rel_by[bt])
out.setdefault(bt, {})[_norm_titel(sub)] = {"sub": sub, **{k: e[k] for k in _FAKTEN_FELDER}}
return out
# Phase „Fakten finden": 1 Generator je Chunk.
async def _finde(ci, idxs):
fp = roh_path(ci)
if _fakten_schema(_json_datei(fp)):
return True
subs_total = sum(len(bausteine[i][1]) for i in idxs)
status, _r = await run_single_slot(
ctx, f"Fakten {ci}", key=f"bausteine-{topic}-fakten-c{ci}",
prompt=_prompt("Fakten-Recherche", topic=topic, source=source, bausteine=block_text(idxs), out_path=fp, extra=_extra(instructions)),
role="guide", capabilities=caps,
payload=lambda result, p=fp: _fakten_schema(_json_datei(p)),
timeout=_timeout("inhalt", subs_total))
return status != FAILED and _fakten_schema(_json_datei(fp)) is not None
oks = await _gather_fortschritt([_finde(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, "Fakten finden"))
if is_cancelled():
return None
if not any(ok is True for ok in oks):
_bausteine_errors[topic] = "Fakten-Extraktion fehlgeschlagen"
return None
# Phase „Fakten prüfen": FAKTEN_CHECK_PANEL Judges je Chunk → beanstandete sub_norms (Mehrheit).
async def _pruefe(ci, idxs):
per = roh_map(ci, roh_path(ci))
if not per:
return ci, set()
fakten_text = "\n\n".join(f"SUBBAUSTEIN: {fk['sub']}\n{_fakten_zeilen(fk)}" for fm in per.values() for fk in fm.values())
offen = [j for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if _fakten_check_schema(_json_datei(chk_path(ci, j))) is None]
await asyncio.gather(*[
run_agent(f"bausteine-{topic}-fakten-check-c{ci}-j{j}",
_prompt("Fakten-Check", topic=topic, source=source, fakten=fakten_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
_timeout("inhalt_check", len(per)), provider=provider, role="judge", capabilities=caps)
for j in offen], return_exceptions=True)
outs = [s for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if (s := _fakten_check_schema(_json_datei(chk_path(ci, j)))) is not None]
votes: dict[str, int] = {}
for s in outs:
for sn in set(s):
votes[sn] = votes.get(sn, 0) + 1
schwelle = len(outs) / 2 if outs else 99
return ci, {sn for sn, v in votes.items() if v > schwelle}
pruef = await _gather_fortschritt([_pruefe(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, "Fakten prüfen"))
if is_cancelled():
return None
beanstandet = {ci: subs for r in pruef if isinstance(r, tuple) for ci, subs in [r]}
# Phase „Fakten fix": je Chunk mit beanstandeten Subs → diese neu extrahieren (frische Quelle).
n_problem = sum(len(s) for s in beanstandet.values())
if n_problem:
set_p(f"Fakten korrigieren ({n_problem})…", step=_step_idx(topic, "Fakten fix"))
async def _fix(ci):
subs_norm = beanstandet.get(ci, set())
if not subs_norm or _fakten_schema(_json_datei(fix_path(ci))):
return
idxs = chunks[ci]
rel_by = {bausteine[i][0]: bausteine[i][1] for i in idxs}
# nur die beanstandeten Subs als Block
ziel = []
for bt, subs in rel_by.items():
betroffen = [s for s in subs if _norm_titel(s) in subs_norm]
if betroffen:
ziel.append(f"BAUSTEIN: {bt}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in betroffen))
if not ziel:
return
await run_single_slot(
ctx, f"Fakten-Fix {ci}", key=f"bausteine-{topic}-fakten-fix-c{ci}",
prompt=_prompt("Fakten-Recherche", topic=topic, source=source, bausteine="\n\n".join(ziel), out_path=fix_path(ci), extra=_extra(instructions)),
role="guide", capabilities=caps,
payload=lambda result, p=fix_path(ci): _fakten_schema(_json_datei(p)),
timeout=_timeout("inhalt", len(subs_norm)))
await _gather_fortschritt([_fix(ci) for ci in beanstandet], len(beanstandet), _melde_p(set_p, topic, "Fakten fix"))
if is_cancelled():
return None
# Zusammensetzen: roh + Fix-Overrides für beanstandete (Vollständigkeits-Guard: fehlt der Fix, bleibt roh).
ergebnis: dict[str, dict] = {}
for ci in range(len(chunks)):
per = roh_map(ci, roh_path(ci))
fix = roh_map(ci, fix_path(ci)) if fix_path(ci).exists() else {}
for bt, fm in per.items():
for sn, fk in fm.items():
gewinner = fix.get(bt, {}).get(sn, fk) if sn in beanstandet.get(ci, set()) else fk
ergebnis.setdefault(bt, {})[sn] = {k: gewinner[k] for k in _FAKTEN_FELDER}
return ergebnis
async def _relevanz_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None:
"""Block D: drei Phasen mit Barriere — Finden (relevant/rand), Wählen (Vote), Klären.
Items aus der Sidecar; lokale IDs 1..n pro Paket → globale gid.
@@ -963,18 +1202,17 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
"""Block E (10er-Chunks): Finden (1 Generator je ~10 Bausteine, parallel), Wählen (Code:
je Baustein gruppieren + Dedup), Klären (1 Kritiker je Chunk), Prüfen (Nachrunde).
Zuordnung je Eintrag über das `baustein`-Feld (Chunk-Datei trägt mehrere Bausteine).
{Baustein-Titel: [{subbaustein, typ, frage}, …]} oder None bei Abbruch."""
{Baustein-Titel: [{subbaustein, frage}, …]} oder None bei Abbruch."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
arbeit = files["arbeit"]
# Je Baustein nur RELEVANTE Subbausteine (relevanz != 'rand'; None zählt als relevant).
# ALLE Subbausteine (auch rand) bekommen ein Muster — Rand ist in der FGuide-Ebene prüfbar.
bausteine = []
for titel, subs in sidecar.items():
rel = [s["titel"] for s in subs if isinstance(s, dict) and s.get("relevanz") != "rand" and str(s.get("titel", "")).strip()]
if rel:
bausteine.append((titel, rel))
alle = [s["titel"] for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip()]
if alle:
bausteine.append((titel, alle))
if not bausteine:
return {}
typen_block = "\n".join(f"- {k}: {v}" for k, v in FRAGETYPEN.items())
chunks = _lpt_chunks([len(rel) for _, rel in bausteine], FRAGE_CHUNK_SUBS) # lastbalanciert nach Sub-Zahl
def roh_path(ci):
@@ -1000,7 +1238,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
ctx, f"Frage-Muster {ci}",
key=f"bausteine-{topic}-frage-muster-c{ci}",
prompt=_prompt("Frage-Muster-Recherche", topic=topic, bausteine=block,
typen=typen_block, out_path=fp, extra=_extra(instructions)),
out_path=fp, extra=_extra(instructions)),
role="fast", capabilities="files",
payload=lambda result, p=fp: _frage_muster_chunk_schema(_json_datei(p)),
timeout=_timeout("frage_muster", subs_total),
@@ -1027,13 +1265,12 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
titel = _match_sub(e["baustein"], ctitel)
if titel not in rel_by:
continue # nicht zuordenbar → verwerfen
norm = _norm_frage(e["frage"])
sub = _match_sub(e["subbaustein"], rel_by[titel])
seen = gesehen.setdefault(titel, set())
if norm in seen:
continue
seen.add(norm)
out.setdefault(titel, []).append(
{"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "typ": e["typ"], "frage": e["frage"]})
if sub in seen:
continue # genau ein Muster je Subbaustein
seen.add(sub)
out.setdefault(titel, []).append({"subbaustein": sub, "frage": e["frage"]})
return out
def _waehle_all(ci_list):
@@ -1057,7 +1294,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
eintraege = roh_by_titel.get(t) or []
if not eintraege:
continue
zeilen = "\n".join(f"- [{e['typ']}] ({e['subbaustein']}) {e['frage']}" for e in eintraege)
zeilen = "\n".join(f"- ({e['subbaustein']}) {e['frage']}" for e in eintraege)
bloecke.append(f"BAUSTEIN: {t}\n{zeilen}")
if not bloecke:
return # nichts zu klären in diesem Chunk
@@ -1093,7 +1330,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
if titel not in rel_by:
continue
out.setdefault(titel, []).append(
{"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "typ": e["typ"], "frage": e["frage"]})
{"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "frage": e["frage"]})
return out
final_by_titel = _final_by_titel(range(len(chunks)))
@@ -1415,6 +1652,226 @@ async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool:
return True
# --- Gliederung (Bausteine-Artefakt: Kapitel-Struktur, vom Guide nur gelesen) ---
def _gliederung_komplett(files: dict) -> bool:
"""Gliederung steht (Kapitel-Liste vorhanden)?"""
d = _json_datei(files["gliederung"])
return isinstance(d, dict) and isinstance(d.get("kapitel"), list) and bool(d.get("kapitel"))
def _gliederung_schema(data, valid: set[int]):
"""{"kapitel":[{titel,nummern}]} → bereinigt (valide Nummern, je genau einmal) ·
None bei <80 % Abdeckung (Agent/Judge hat zu viel weggelassen)."""
if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list):
return None
out, seen = [], set()
for ch in data["kapitel"]:
if not isinstance(ch, dict):
continue
titel = str(ch.get("titel", "")).strip() or "Kapitel"
nums = []
for n in (ch.get("nummern") or []):
try:
n = int(n)
except (ValueError, TypeError):
continue
if n in valid and n not in seen:
seen.add(n)
nums.append(n)
if nums:
out.append({"titel": titel, "nummern": nums})
if not out or len(seen) < 0.8 * len(valid):
return None
return {"kapitel": out}
async def _gliederung_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str) -> dict:
"""Format-agnostische Gliederung über ALLE Bausteine — 3 Vorschläge → Judge merged.
Bricht nie ab: 0 gültige → ein Kapitel mit allem; fehlende Bausteine landen in „Weitere".
{"kapitel":[{titel,nummern}]} (auch in files["gliederung"])."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
valid = set(entries)
liste = "\n".join(f"{n}. {t}" for n, t in entries.items())
step = _step_idx(topic, "Gliederung")
set_p("Gliederung — Vorschläge…", step=step)
async def _vorschlag(i, path):
if _gliederung_schema(_json_datei(path), valid):
return True
await run_single_slot(
ctx, f"Gliederung {i}", key=f"bausteine-{topic}-gliederung-{i}",
prompt=_prompt("Guide-Gliederung", topic=topic, bausteine=liste, out_path=path, extra=_extra(instructions)),
role="guide", capabilities="files",
payload=lambda result, p=path: _gliederung_schema(_json_datei(p), valid),
timeout=_timeout("plan", len(entries)))
return _gliederung_schema(_json_datei(path), valid) is not None
slots = files["gliederung_slots"]
await _gather_fortschritt([_vorschlag(i, p) for i, p in enumerate(slots, 1)], len(slots), _melde_p(set_p, topic, "Gliederung"))
if is_cancelled():
return {}
vorschlaege = [v for p in slots if (v := _gliederung_schema(_json_datei(p), valid))]
if not vorschlaege:
plan = {"kapitel": [{"titel": "Inhalte", "nummern": list(entries)}]}
elif len(vorschlaege) == 1:
plan = vorschlaege[0]
else:
set_p("Gliederung zusammenführen…", step=step)
bloecke = "\n\n".join(
f"### Vorschlag {i}\n" + "\n".join(
f"KAPITEL: {ch['titel']}\n Nummern: {', '.join(str(n) for n in ch['nummern'])}" for ch in v["kapitel"])
for i, v in enumerate(vorschlaege, 1))
await run_single_slot(
ctx, "Gliederung-Judge", key=f"bausteine-{topic}-gliederung-judge",
prompt=_prompt("Guide-Gliederung-Judge", topic=topic, format_name="den Guide",
zweck="alle Bausteine in einem roten Faden", n=len(vorschlaege),
bausteine=liste, gliederungen=bloecke, out_path=files["gliederung"], extra=_extra(instructions)),
role="judge", capabilities="files",
payload=lambda result: _gliederung_schema(_json_datei(files["gliederung"]), valid),
timeout=_timeout("plan_judge", len(entries)))
plan = _gliederung_schema(_json_datei(files["gliederung"]), valid) or vorschlaege[0]
# Vollständigkeit: jeder Baustein kommt vor — fehlende in „Weitere" (gegen weglassende Agenten/Judge).
drin = {n for ch in plan["kapitel"] for n in ch["nummern"]}
fehlen = [n for n in entries if n not in drin]
if fehlen:
plan["kapitel"].append({"titel": "Weitere", "nummern": fehlen})
atomic_write_json(files["gliederung"], plan, indent=1)
return plan
# --- Lern-Artefakte (Karteikarten/Beispiele/Diagramme aus den Fakten) ---
def _karten_schema(data):
"""{"karten":[{baustein,subbaustein,frage,antwort}]} → Liste (auch leer) · None bei kaputt."""
if not isinstance(data, dict) or not isinstance(data.get("karten"), list):
return None
out = []
for e in data["karten"]:
if isinstance(e, dict) and (f := str(e.get("frage", "")).strip()) and (a := str(e.get("antwort", "")).strip()):
out.append({"baustein": str(e.get("baustein", "")).strip(), "subbaustein": str(e.get("subbaustein", "")).strip(),
"frage": f, "antwort": a})
return out
def _beispiel_schema(data):
"""{"beispiele":[{baustein,subbaustein,problem,schritte,ergebnis}]} → Liste (auch leer) · None bei kaputt."""
if not isinstance(data, dict) or not isinstance(data.get("beispiele"), list):
return None
out = []
for e in data["beispiele"]:
if not isinstance(e, dict):
continue
problem = str(e.get("problem", "")).strip()
schritte = [s for x in (e.get("schritte") or []) if (s := str(x).strip())]
if problem and schritte:
out.append({"baustein": str(e.get("baustein", "")).strip(), "subbaustein": str(e.get("subbaustein", "")).strip(),
"problem": problem, "schritte": schritte, "ergebnis": str(e.get("ergebnis", "")).strip()})
return out
def _diagramm_schema(data):
"""{"diagramme":[{baustein,mermaid}]} → Liste (auch leer) · None bei kaputt."""
if not isinstance(data, dict) or not isinstance(data.get("diagramme"), list):
return None
out = []
for e in data["diagramme"]:
if isinstance(e, dict) and (m := str(e.get("mermaid", "")).strip()):
out.append({"baustein": str(e.get("baustein", "")).strip(), "mermaid": m})
return out
_ARTEFAKT_SCHEMA = {"karteikarte": _karten_schema, "beispiel": _beispiel_schema, "diagramm": _diagramm_schema}
_ARTEFAKT_PROMPT = {"karteikarte": "Artefakt-Karteikarte", "beispiel": "Artefakt-Beispiel", "diagramm": "Artefakt-Diagramm"}
_ARTEFAKT_SCHRITT = {"karteikarte": "Karteikarten", "beispiel": "Beispiele", "diagramm": "Diagramme"}
def _artefakte_komplett(files: dict) -> bool:
"""Artefakt-Map steht (alle Typen erzeugt)? Werte dürfen leer sein (content-aware)."""
d = _json_datei(files["artefakte"])
return isinstance(d, dict) and all(t in d for t in ARTEFAKT_TYPEN)
async def _artefakte_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None:
"""Lern-Artefakte je Typ aus den gespeicherten Fakten erzeugen — ein Generierungs-Durchlauf
je Typ über Chunks, keine Verifikation (Bonus-Material). → {typ: [eintraege]} (auch in files)."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
arbeit = files["arbeit"]
# Bausteine mit Subs + Fakten-Zeilen als Input-Block (extract-once aus den Fakten).
bausteine = []
for btitel, subs in sidecar.items():
if not isinstance(subs, list):
continue
zeilen = []
for s in subs:
if not isinstance(s, dict) or not (st := str(s.get("titel", "")).strip()):
continue
fk = s.get("fakten") if isinstance(s.get("fakten"), dict) else {}
zeile = f"- {st}"
if fk and (fk_text := _fakten_zeilen(fk)):
zeile += "\n" + "\n".join(" " + l for l in fk_text.split("\n"))
zeilen.append(zeile)
if zeilen:
bausteine.append((btitel, zeilen))
if not bausteine:
leer = {t: [] for t in ARTEFAKT_TYPEN}
atomic_write_json(files["artefakte"], leer, indent=1)
return leer
chunks = _lpt_chunks([len(z) for _, z in bausteine], FAKTEN_CHUNK_SUBS)
def block_text(idxs):
return "\n\n".join(f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(bausteine[i][1]) for i in idxs)
ergebnis: dict[str, list] = {}
for typ in ARTEFAKT_TYPEN:
schema = _ARTEFAKT_SCHEMA[typ]
def apath(ci, t=typ): return arbeit / f"artefakt-{t}-c{ci}.json"
async def _gen(ci, idxs, t=typ, schema=schema):
p = apath(ci, t)
if schema(_json_datei(p)) is not None:
return True
await run_single_slot(
ctx, f"{_ARTEFAKT_SCHRITT[t]} {ci}", key=f"bausteine-{topic}-artefakt-{t}-c{ci}",
prompt=_prompt(_ARTEFAKT_PROMPT[t], topic=topic, bausteine=block_text(idxs), out_path=p, extra=_extra(instructions)),
role="guide", capabilities="files",
payload=lambda result, p=p, schema=schema: schema(_json_datei(p)),
timeout=_timeout("inhalt", sum(len(bausteine[i][1]) for i in idxs)))
return schema(_json_datei(p)) is not None
await _gather_fortschritt([_gen(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, _ARTEFAKT_SCHRITT[typ]))
if is_cancelled():
return None
eintraege: list = []
for ci in range(len(chunks)):
eintraege += schema(_json_datei(apath(ci))) or []
ergebnis[typ] = eintraege
atomic_write_json(files["artefakte"], ergebnis, indent=1)
return ergebnis
async def _mirror_artefakte_db(topic: str, sidecar: dict, artefakte: dict) -> None:
"""Artefakte in die DB spiegeln. Karteikarte/Beispiel je Sub (sub_norm), Diagramm je Baustein (sub_norm='')."""
await db.delete_sub_artefakte(topic)
btitel_list = list(sidecar.keys())
for typ in ("karteikarte", "beispiel"):
for e in artefakte.get(typ, []):
bt = _match_sub(e.get("baustein", ""), btitel_list)
bnorm, sn = _norm_titel(bt), _norm_titel(e.get("subbaustein", ""))
if not bnorm or not sn:
continue
daten = json.dumps({k: v for k, v in e.items() if k not in ("baustein", "subbaustein")}, ensure_ascii=False)
await db.put_sub_artefakt(topic, bnorm, sn, typ, daten, bt, e.get("subbaustein", ""))
for e in artefakte.get("diagramm", []):
bt = _match_sub(e.get("baustein", ""), btitel_list)
if not (bnorm := _norm_titel(bt)):
continue
daten = json.dumps({"mermaid": e.get("mermaid", "")}, ensure_ascii=False)
await db.put_sub_artefakt(topic, bnorm, "", "diagramm", daten, bt, "")
async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
"""Sidecar {Baustein-Titel: [{titel, stufe, relevanz}]} in die DB-Tabelle subbausteine spiegeln."""
for btitel, subs in sidecar.items():
@@ -1428,12 +1885,14 @@ async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
sn = _norm_titel(st)
if not sn:
continue
fakten = json.dumps(s["fakten"], ensure_ascii=False) if isinstance(s.get("fakten"), dict) else None
await db.put_subbaustein(topic, bnorm, sn, btitel, st,
stufe=s.get("stufe"), relevanz=s.get("relevanz"), status="konsens")
stufe=s.get("stufe"), relevanz=s.get("relevanz"),
fakten=fakten, status="konsens")
async def _mirror_frage_muster_db(topic: str, muster: dict) -> None:
"""Frage-Muster {Baustein-Titel: [{subbaustein, typ, frage}]} in die DB-Tabelle frage_muster spiegeln."""
"""Frage-Muster {Baustein-Titel: [{subbaustein, frage}]} in die DB-Tabelle frage_muster spiegeln."""
await db.delete_frage_muster(topic)
for btitel, eintraege in muster.items():
bnorm = _norm_titel(btitel)
@@ -1444,19 +1903,22 @@ async def _mirror_frage_muster_db(topic: str, muster: dict) -> None:
continue
sub = str(e.get("subbaustein", "")).strip()
sn = _norm_titel(sub)
typ = str(e.get("typ", "")).strip()
frage = str(e.get("frage", "")).strip()
if not (sn and typ and frage):
if not (sn and frage):
continue
await db.upsert_frage_muster(topic, bnorm, sn, btitel, sub, typ, frage)
await db.upsert_frage_muster(topic, bnorm, sn, btitel, sub, frage)
async def _reset_db_ab_phase(topic: str, label: str) -> None:
"""DB-Inhalt der Phasen ≥ `label` verwerfen (kanonische Reihenfolge Quelle…Fragen)."""
"""DB-Inhalt der Phasen ≥ `label` verwerfen (kanonische Reihenfolge Quelle…Artefakte)."""
idx = _phase_idx(label)
if idx <= 5: # Fragen
if idx <= 8: # Artefakte (Karteikarten/Beispiele/Diagramme)
await db.delete_sub_artefakte(topic)
if idx <= 7: # Fragen
await db.delete_frage_muster(topic)
if idx <= 2: # Subbausteine (Stufen/Relevanz greifen über Sidecar→Mirror)
if idx <= 6: # Gliederung
await db.delete_gliederung(topic)
if idx <= 2: # Subbausteine (Fakten/Stufen/Relevanz greifen über Sidecar→Mirror)
await db.delete_subbausteine(topic)
if idx <= 1: # Inventar: Inventar + Recherche-Schritte — Sichtung bleibt
await db.delete_bausteine(topic)
@@ -1519,6 +1981,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
await db.delete_subbausteine(topic)
await db.delete_frage_muster(topic)
await db.delete_coverage(topic)
await db.delete_gliederung(topic)
await db.delete_sub_artefakte(topic)
# Inventar (DB): Recherche-Loop → Konsolidierung → Klärung.
if not await _recherche_batch(ctx, set_p, files, q, ordner, instructions):
@@ -1590,12 +2054,30 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
if roh is None:
return # Fehler ist gesetzt
atomic_write_json(files["sub_roh"], roh, indent=1)
# Fakten je Sub (VOR der Stufe): Quell-Fakten extrahieren + verifizieren → fakten.json.
# Extract-once-Grounding — Stufe/Relevanz/Fragen/Guide nähren sich daraus.
if not _fakten_komplett(files):
fakten_map = await _fakten_block(ctx, set_p, files, roh, q, ordner, instructions)
if is_cancelled():
abgebrochen()
return
if fakten_map is None:
return # Fehler ist gesetzt
atomic_write_json(files["fakten"], fakten_map, indent=1)
sidecar = await _stufen_block(ctx, set_p, files, roh, instructions)
if is_cancelled():
abgebrochen()
return
if sidecar is None:
return
# Fakten in die Sidecar-Subs mergen (DB-Spiegel + Guide-Nutzung).
fakten_map = _json_datei(files["fakten"])
if isinstance(fakten_map, dict):
for btitel, subs in sidecar.items():
fm = fakten_map.get(btitel, {})
for sub in subs:
if (fk := fm.get(_norm_titel(sub["titel"]))):
sub["fakten"] = fk
atomic_write_json(files["sidecar"], sidecar, indent=1)
# Block D: Relevanz je Subbaustein (relevant/rand) → in die Sidecar mergen.
@@ -1616,6 +2098,14 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
sub["relevanz"] = relevanz_by_id.get(gid, "relevant")
atomic_write_json(files["sidecar"], sidecar, indent=1)
# Block D.5: Gliederung (Bausteine-Artefakt) — Kapitel-Struktur über ALLE Bausteine,
# vom Guide nur noch gelesen. Format-agnostisch; der Guide filtert je Format.
if not _gliederung_komplett(files):
await _gliederung_block(ctx, set_p, files, entries, instructions)
if is_cancelled():
abgebrochen()
return
# Block E: Frage-Muster je relevantem Subbaustein × Typ → eigenes Sidecar.
# Zur Prüfungszeit zieht jeder Agent ein Muster ohne Zurücklegen und formuliert
# daraus eine Frage — distinkte Saat verhindert die Doppelfragen der Live-Generierung.
@@ -1629,6 +2119,17 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
return # Abbruch
atomic_write_json(files["frage_muster"], muster, indent=1)
# Block F: Lern-Artefakte (Karteikarten/Beispiele/Diagramme) aus den Fakten — Bonus,
# vom Frontend präsentiert. Bricht den Lauf nicht ab (Artefakte sind optional).
sidecar = _json_datei(files["sidecar"])
if _sidecar_schema(sidecar) is not None and not _artefakte_komplett(files):
artefakte = await _artefakte_block(ctx, set_p, files, sidecar, instructions)
if is_cancelled():
abgebrochen()
return
if artefakte is None:
return # Abbruch (Fehler/Cancel)
# DB-Spiegel (Brücke): finalen Sidecar- + Frage-Muster-Stand in die DB schreiben.
sidecar = _json_datei(files["sidecar"])
if _sidecar_schema(sidecar) is not None:
@@ -1636,6 +2137,19 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
muster = _json_datei(files["frage_muster"])
if isinstance(muster, dict) and muster:
await _mirror_frage_muster_db(topic, muster)
# Gliederung (Titel-basiert, robust gegen Nummern-Drift) → DB.
plan = _json_datei(files["gliederung"])
if isinstance(plan, dict) and plan.get("kapitel"):
kapitel = [
{"titel": ch.get("titel", "Kapitel"),
"bausteine": [_titel(entries[n]) for n in ch.get("nummern", []) if n in entries]}
for ch in plan["kapitel"]
]
await db.set_gliederung(topic, json.dumps({"kapitel": kapitel}, ensure_ascii=False))
# Artefakte → DB (Karteikarte/Beispiel je Sub, Diagramm je Baustein).
artefakte = _json_datei(files["artefakte"])
if isinstance(artefakte, dict) and _sidecar_schema(sidecar) is not None:
await _mirror_artefakte_db(topic, sidecar, artefakte)
except Exception as e:
log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic)
_bausteine_errors[topic] = str(e)[:2000]