This commit is contained in:
team3
2026-06-22 22:21:23 +02:00
parent b3b5dbf37d
commit 28d0b494cd
7 changed files with 875 additions and 232 deletions

View File

@@ -15,8 +15,10 @@ import logging
import math
import shutil
import subprocess
import time
from pathlib import Path
import database as db
from agents import kill_process, cancel_scope, clear_scope
from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER
from fsutil import atomic_write_text, atomic_write_json
@@ -39,6 +41,13 @@ SUBBAUSTEIN_MAX = 40
# Einstufen ist billig (kurzes Urteil, keine Websuche) → größere Pakete, weniger Dateien/Agenten.
STUFE_CHUNK = 100
# Recherche-Loop: 5 Agenten je Runde, Runden bis volle Crawl-Abdeckung / 0 neue / Zeit-Kappe.
RECHERCHE_AGENTEN = 5
RECHERCHE_QUORUM = 3 # je Runde mind. so viele gültige Slot-Ergebnisse
RECHERCHE_KAPPE = 1800 # Loop-Gesamt-Sekunden (Notbremse, 30 min)
SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min)
KONSOLIDIERUNG_CHUNK = 150 # Konsolidierung ab so vielen Kandidaten chunken
log = logging.getLogger("creator.bausteine")
_bausteine_progress: dict[str, str] = {}
@@ -83,8 +92,12 @@ def _crawl_fertig(topic: str) -> bool:
_STUFEN = ("einfach", "mittel", "schwer")
def subbausteine_titel(topic: str, baustein: str) -> list[str]:
"""Subbaustein-Titel eines Bausteins aus der Sidecar (leer, wenn keine)."""
async def subbausteine_titel(topic: str, baustein: str) -> list[str]:
"""Subbaustein-Titel eines Bausteins — DB-first (Konsens), Fallback Sidecar-Datei."""
rows = [s["sub_titel"] for s in await db.list_subbausteine(topic, _norm_titel(baustein))
if s["status"] == "konsens" and s["sub_titel"]]
if rows:
return rows
sc = _json_datei(subbausteine_path(topic))
if not isinstance(sc, dict):
return []
@@ -94,8 +107,11 @@ def subbausteine_titel(topic: str, baustein: str) -> list[str]:
]
def lade_frage_muster(topic: str, baustein: str) -> list[dict]:
"""Vordefinierte Frage-Muster eines Bausteins aus dem Sidecar (leer = Fallback auf Live)."""
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"]]
fm = _json_datei(frage_muster_path(topic))
if not isinstance(fm, dict):
return []
@@ -108,12 +124,24 @@ def lade_frage_muster(topic: str, baustein: str) -> list[dict]:
]
def lade_uebersicht(topic: str) -> list[dict]:
"""Strukturierte Baustein-Liste für die Übersicht: Titel + Beschreibung + Subbausteine/Stufen.
Verbindet bausteine.md (Nummer/Titel/Beschreibung) mit der Sidecar subbausteine.json
(Key = Titel). Fehlt die Sidecar, sind die Subbaustein-Listen leer.
"""
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)."""
bs = await db.list_bausteine(topic, status="konsens")
if bs:
out = []
for num, b in enumerate(bs, 1):
subs = [s for s in await db.list_subbausteine(topic, b["titel_norm"]) if s["status"] == "konsens"]
out.append({
"num": num, "titel": b["titel"], "beschreibung": b["beschreibung"],
"subbausteine": [
{"titel": s["sub_titel"],
"stufe": s["stufe"] if s["stufe"] in _STUFEN else "mittel",
"relevanz": s["relevanz"] if s["relevanz"] in ("relevant", "rand") else None}
for s in subs if s["sub_titel"]
],
})
return out
entries = _lade_bausteine(_read(bausteine_path(topic)))
sidecar = _json_datei(subbausteine_path(topic))
sidecar = sidecar if isinstance(sidecar, dict) else {}
@@ -238,22 +266,14 @@ def cancel_bausteine(topic: str) -> bool:
def _resume_step(topic: str) -> int:
"""Erster noch offener Schritt anhand der persistierten Zwischendateien."""
"""Erster noch offener Schritt anhand der persistierten Artefakte.
Inventar (Recherche→Konsolidierung→Klärung) gilt als fertig, sobald bausteine.md vorliegt."""
files = _bausteine_files(topic)
q = lade_quelle(topic)
if q["type"] == "link" and not _crawl_fertig(topic):
return _step_idx(topic, "Quelle laden")
if sum(p.exists() for p in files["recherche"]) < 3:
if not files["final"].exists(): # Inventar (DB-Loop) noch offen
return _step_idx(topic, "Recherche")
if not files["recherche_mapping"].exists():
return _step_idx(topic, "Konsolidierung")
mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
geklaert = mapping is not None and (
not mapping[1] # kein strittiger Rest
or any((r := _runde_schema(_json_datei(p))) is not None and not r[1] for p in files["mapping"].values())
)
if not geklaert:
return _step_idx(topic, "Klärung")
if q["type"] == "projekt" and not files["ergaenzung"].exists():
return _step_idx(topic, "Ergänzung")
sidecar = _json_datei(files["sidecar"])
@@ -339,9 +359,10 @@ def _reset_ab_phase(topic: str, phase: int) -> None:
if phase <= 2: # Subbausteine
files["sub_roh"].unlink(missing_ok=True)
glob_del("subbaustein-*")
if phase <= 1: # Inventar = kompletter Frischstart (alle Zwischendateien)
if phase <= 1: # Inventar = kompletter Frischstart (alle Zwischendateien + bausteine.md)
for p_alt in _alle_slot_dateien(files):
p_alt.unlink(missing_ok=True)
files["final"].unlink(missing_ok=True)
def _ergaenzung_schema(data):
@@ -386,14 +407,14 @@ def _pdfs_konvertieren(project: Path) -> None:
_QUELLE_TEMPLATE = {"projekt": "Bausteine-Quelle-Projekt", "uni": "Bausteine-Quelle-Uni", "link": "Bausteine-Quelle-Link"}
def _build_recherche_prompt(topic: str, out_path: Path, instructions: str, typ: str, ordner: Path | None) -> str:
def _build_recherche_prompt(topic: str, out_path: Path, instructions: str, typ: str, ordner: Path | None, fokus: str = "") -> str:
if typ in _QUELLE_TEMPLATE:
source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner)
else:
source = _prompt("Bausteine-Quelle-Thema", topic=topic)
return _prompt(
"Bausteine-Recherche",
topic=topic, source=source, bausteine_path=out_path, extra=_extra(instructions),
topic=topic, source=source, bausteine_path=out_path, fokus=fokus, extra=_extra(instructions),
)
@@ -566,88 +587,143 @@ def _judge_block(chunk: list[int], entries: dict, daten: dict) -> str:
async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str) -> dict | None:
"""Block B: drei Phasen mit Barriere — Finden, Wählen (Code-Merge), Klären.
Pro Phase laufen alle Pakete parallel; der Schritt bleibt, bis das letzte fertig ist.
{Baustein-Titel: [Subbaustein, …]} oder None bei Abbruch/Fehler."""
"""Block B (DB + Loop): je Paket Subbausteine in Runden finden (3 Finder, bis 0 neue/Kappe),
in der DB sammeln (≥2 Nennungen = Konsens, 1× verworfen), Judge bereinigt je Paket.
{Baustein-Titel: [Subbaustein, …]} (Konsens) oder None. Befüllt DB-Tabelle `subbausteine`."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
arbeit = files["arbeit"]
idx = _titel_index(entries)
caps = "files" if quelle_ordner(topic) else "full"
nums = list(entries)
chunks = _chunk_nums(nums, _n_chunks(len(nums)))
n = len(chunks)
titel_by_num = {num: _titel(entries[num]) for num in nums}
norm_by_num = {num: _norm_titel(titel_by_num[num]) for num in nums}
await db.delete_subbausteine(topic) # Frischstart des Blocks (idempotenter Zähler)
def finder_paths(c):
return [arbeit / f"subbaustein-c{c}-{i}.md" for i in (1, 2, 3)]
async def _bekannt_block(chunk):
bl = []
for num in chunk:
subs = [s["sub_titel"] for s in await db.list_subbausteine(topic, norm_by_num[num])]
if subs:
bl.append(f"<!-- baustein: {titel_by_num[num]} -->\n" + "\n".join(f"- {s}" for s in subs))
if not bl:
return ""
return "\n\nBEREITS GEFUNDEN — bestätige diese Subbausteine erneut UND ergänze fehlende:\n" + "\n".join(bl)
def final_path(c):
return arbeit / f"subbaustein-final-c{c}.md"
# Phase „Subbausteine finden": pro Paket 3 Finder (min. 2), alle Pakete parallel.
# Phase „Subbausteine finden": je Paket Loop bis 0 neue Subs / Zeit-Kappe.
async def _finde(c, chunk):
paths = finder_paths(c)
vorhanden = sum(1 for p in paths if _parse_subbausteine(_read(p)))
if vorhanden >= 2:
return True
zuteilung = "\n".join(f"- {entries[num]}" for num in chunk)
offen = [(i, p) for i, p in enumerate(paths, 1) if not _parse_subbausteine(_read(p))]
slots = [{
"key": f"bausteine-{topic}-subbaustein-c{c}-{i}",
"prompt": _prompt("Subbaustein-Recherche", topic=topic, zuteilung=zuteilung, out_path=p, extra=_extra(instructions)),
"role": "quick", "capabilities": caps,
"payload": (lambda result, p=p: _parse_subbausteine(_read(p)) or None),
} for i, p in offen]
neu = await _race(topic, f"Subbausteine Paket {c}", slots, 2 - vorhanden, _timeout("subbaustein", len(chunk)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE)
return not is_cancelled() and neu is not None
chunk_idx = _titel_index({num: titel_by_num[num] for num in chunk})
start = time.monotonic()
runde = 0
while not is_cancelled():
runde += 1
bekannt = await _bekannt_block(chunk) if runde > 1 else ""
paths = [arbeit / f"subbaustein-c{c}-r{runde}-{i}.md" for i in (1, 2, 3)]
for p in paths:
p.unlink(missing_ok=True)
slots = [{
"key": f"bausteine-{topic}-subbaustein-c{c}-r{runde}-{i}",
"prompt": _prompt("Subbaustein-Recherche", topic=topic, zuteilung=zuteilung, bekannt=bekannt, out_path=p, extra=_extra(instructions)),
"role": "quick", "capabilities": caps,
"payload": (lambda result, p=p: _parse_subbausteine(_read(p)) or None),
} for i, p in enumerate(paths, 1)]
texte = await _race(topic, f"Subbausteine Paket {c} R{runde}", slots, 2, _timeout("subbaustein", len(chunk)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE)
if is_cancelled():
return False
if not texte:
return runde > 1 # Runde 1 ohne Ergebnis = Fehler; spätere = einfach Ende
vorhanden = {num: {s["sub_norm"] for s in await db.list_subbausteine(topic, norm_by_num[num])} for num in chunk}
neu = 0
for d in texte:
for marker, subs in d.items():
num = _titel_aufloesen(chunk_idx, marker)
if num is None:
continue
gesehen = set()
for sub in subs:
sn = _norm_titel(sub)
if not sn or sn in gesehen:
continue
gesehen.add(sn)
if sn not in vorhanden[num]:
neu += 1
vorhanden[num].add(sn)
await db.upsert_subbaustein(topic, norm_by_num[num], sn, titel_by_num[num], sub)
if neu == 0:
break
if time.monotonic() - start > SUBBAUSTEIN_KAPPE:
_log(topic, f"Subbausteine Paket {c}: Zeit-Kappe erreicht (Runde {runde})")
break
return True
oks = await _gather_fortschritt([_finde(c, chunk) for c, chunk in enumerate(chunks, 1)], len(chunks), _melde_p(set_p, topic, "Subbausteine finden"))
oks = await _gather_fortschritt([_finde(c, chunk) for c, chunk in enumerate(chunks, 1)], n, _melde_p(set_p, topic, "Subbausteine finden"))
if is_cancelled():
return None
if not all(ok is True for ok in oks):
_bausteine_errors[topic] = "Subbausteine fehlgeschlagen (Recherche)"
return None
# Phase „Subbausteine wählen": Code-Merge je Paket (instant, kein Agent).
# Phase „Subbausteine wählen": ≥2 Nennungen = Konsens, 1× verworfen (Code).
set_p(f"Subbausteine wählen ({n} Pakete)…", step=_step_idx(topic, "Subbausteine wählen"))
daten_by_c = {}
for c, chunk in enumerate(chunks, 1):
finder = [d for p in finder_paths(c) if (d := _parse_subbausteine(_read(p)))]
daten_by_c[c] = {num: _merge_finder(num, idx, finder) for num in chunk}
for num in nums:
for s in await db.list_subbausteine(topic, norm_by_num[num]):
await db.set_subbaustein_felder(topic, norm_by_num[num], s["sub_norm"],
status=("konsens" if s["nennungen"] >= 2 else "verworfen"))
# Phase „Subbausteine klären": Judge je Paket mit Strittigem, alle parallel.
# Phase „Subbausteine klären": Judge je Paket bereinigt die Konsens-Liste.
async def _klaere(c, chunk):
daten = daten_by_c[c]
fp = final_path(c)
fp = arbeit / f"subbaustein-final-c{c}.md"
if _parse_subbausteine(_read(fp)):
return
if not any(daten[num][1] for num in chunk):
atomic_write_text(fp, _final_text(chunk, entries, daten))
bloecke, hat = [], False
for num in chunk:
subs = [s["sub_titel"] for s in await db.list_subbausteine(topic, norm_by_num[num]) if s["status"] == "konsens"]
zeilen = "\n".join(f"- {s}" for s in subs) if subs else "- (keiner)"
bloecke.append(f"BAUSTEIN: {titel_by_num[num]}\nKonsens:\n{zeilen}")
if subs:
hat = True
if not hat:
return
status, _ = await run_single_slot(
ctx, f"Subbaustein-Klärung {c}",
key=f"bausteine-{topic}-subbaustein-final-c{c}",
prompt=_prompt("Subbaustein-Mapping", topic=topic, bausteine=_judge_block(chunk, entries, daten), out_path=fp, extra=_extra(instructions)),
prompt=_prompt("Subbaustein-Mapping", topic=topic, bausteine="\n\n".join(bloecke), out_path=fp, extra=_extra(instructions)),
role="judge", capabilities="files",
payload=lambda result, p=fp: _parse_subbausteine(_read(p)) or None,
timeout=_timeout("subbaustein_check", len(chunk)),
)
if status == FAILED:
_log(topic, f"Subbaustein-Klärung Paket {c} fehlgeschlagen — nur Konsens übernommen")
_log(topic, f"Subbaustein-Klärung Paket {c} fehlgeschlagen — Konsens übernommen")
await _gather_fortschritt([_klaere(c, chunk) for c, chunk in enumerate(chunks, 1)], len(chunks), _melde_p(set_p, topic, "Subbausteine klären"))
await _gather_fortschritt([_klaere(c, chunk) for c, chunk in enumerate(chunks, 1)], n, _melde_p(set_p, topic, "Subbausteine klären"))
if is_cancelled():
return None
# Fehlende finale Dateien → Konsens-Fallback; dann alle parsen.
# Finale Liste je Baustein: Judge-Ausgabe, sonst Konsens-Fallback. DB reconcilen + roh bauen.
roh: dict[str, list[str]] = {}
for c, chunk in enumerate(chunks, 1):
fp = final_path(c)
if not _parse_subbausteine(_read(fp)):
atomic_write_text(fp, _final_text(chunk, entries, daten_by_c[c]))
for marker, subs in (_parse_subbausteine(_read(fp)) or {}).items():
num = _titel_aufloesen(idx, marker)
if num is not None:
roh[_titel(entries[num])] = subs
final = _parse_subbausteine(_read(arbeit / f"subbaustein-final-c{c}.md")) or {}
chunk_idx = _titel_index({num: titel_by_num[num] for num in chunk})
final_by_num = {_titel_aufloesen(chunk_idx, m): subs for m, subs in final.items() if _titel_aufloesen(chunk_idx, m) is not None}
for num in chunk:
titel = titel_by_num[num]
konsens = [s["sub_titel"] for s in await db.list_subbausteine(topic, norm_by_num[num]) if s["status"] == "konsens"]
subs = final_by_num.get(num) or konsens
if not subs:
continue
roh[titel] = subs
# DB an die finale Liste angleichen: finale = konsens, Rest verworfen, Neues ergänzen.
final_norms = {_norm_titel(s) for s in subs}
have = {s["sub_norm"] for s in await db.list_subbausteine(topic, norm_by_num[num])}
for s in await db.list_subbausteine(topic, norm_by_num[num]):
await db.set_subbaustein_felder(topic, norm_by_num[num], s["sub_norm"],
status=("konsens" if s["sub_norm"] in final_norms else "verworfen"))
for s in subs:
sn = _norm_titel(s)
if sn and sn not in have:
await db.upsert_subbaustein(topic, norm_by_num[num], sn, titel, s)
await db.set_subbaustein_felder(topic, norm_by_num[num], sn, status="konsens")
if not roh:
_bausteine_errors[topic] = "Keine Subbausteine ermittelt"
return None
@@ -1064,6 +1140,282 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
return ergebnis
# ── Inventar in der DB: Recherche-Loop · Konsolidierung · Klärung ────────────
def _cited_sources(text: str) -> set[str]:
"""Zitierte Quellen je Eintrag = 3. ''-Segment (URL bzw. Dateiname), kleingeschrieben."""
out = set()
for eintrag in _parse_auswahl(text).values():
teile = [t.strip() for t in eintrag.split("")]
if len(teile) >= 3 and teile[-1]:
out.add(teile[-1].lower())
return out
def _crawl_index(ordner) -> dict[str, str]:
"""Alias (Dateiname ODER QUELLE:-URL, klein) → kanonischer Seiten-Key (Dateiname)."""
idx: dict[str, str] = {}
if not ordner or not Path(ordner).is_dir():
return idx
for p in sorted(Path(ordner).glob("*.txt")):
key = p.name
idx[key.lower()] = key
try:
erste = p.read_text(encoding="utf-8").splitlines()[0]
except (OSError, IndexError):
erste = ""
if erste.startswith("QUELLE:"):
url = erste[len("QUELLE:"):].strip()
if url:
idx[url.lower()] = key
idx[url.rstrip("/").lower()] = key
return idx
def _abgedeckt(zitiert: set[str], crawl_idx: dict[str, str]) -> set[str]:
"""Zitierte Quellen → Menge kanonischer Crawl-Seiten-Keys (was nicht passt, fällt weg)."""
out = set()
for z in zitiert:
key = crawl_idx.get(z) or crawl_idx.get(z.rstrip("/"))
if key:
out.add(key)
return out
def _fokus_text(bereits: list[str], offen: list[str]) -> str:
"""Re-Prompt-Block: noch nicht abgedeckte Crawl-Seiten + bereits gefundene Titel."""
teile = []
if offen:
liste = "\n".join(f"- {n}" for n in offen[:150])
teile.append(
"NOCH NICHT ABGEDECKTE QUELLEN-DATEIEN — lies ZUERST genau diese im Quell-Ordner "
f"und ergänze daraus die noch fehlenden Bausteine:\n{liste}"
)
if bereits:
liste = "\n".join(f"- {t}" for t in bereits)
teile.append("BEREITS GEFUNDEN (NICHT wiederholen — liefere nur NEUE Bausteine):\n" + liste)
return ("\n\n" + "\n\n".join(teile)) if teile else ""
async def _set_inventar(topic: str, eintrag: str, status: str) -> None:
"""Einen Inventar-Eintrag ('Titel — Beschreibung') mit Status in die DB schreiben."""
titel = _titel(eintrag)
norm = _norm_titel(titel)
if not norm:
return
teile = [t.strip() for t in eintrag.split("")]
besch = teile[1] if len(teile) >= 2 else ""
await db.upsert_baustein(topic, norm, titel, besch)
await db.set_baustein_status(topic, norm, status)
async def _recherche_loop(ctx: GenContext, set_p, files: dict, q: dict, ordner, instructions: str) -> bool:
"""Füllt DB-Tabelle `bausteine` mit Kandidaten (+ Nennungszähler). Loop: je Runde
RECHERCHE_AGENTEN Agenten; re-promptet mit noch offenen Crawl-Seiten, bis alle gelesen /
0 neue / Zeit-Kappe. Resume: fertiger Schritt wird übersprungen. → True (ok) / False (Abbruch)."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
if await db.get_step_status(topic, "Recherche") == "fertig":
return True
arbeit = files["arbeit"]
caps = "files" if ordner else "full"
crawl_idx = _crawl_index(ordner)
alle_seiten = set(crawl_idx.values())
await db.delete_bausteine(topic)
await db.delete_coverage(topic)
await db.set_step_status(topic, "Recherche", "laufend")
set_p("Recherche läuft (Runde 1)…", step=_step_idx(topic, "Recherche"))
start = time.monotonic()
runde = 0
while not is_cancelled():
runde += 1
vorhandene = {b["titel_norm"] for b in await db.list_bausteine(topic)}
gelesen = set(await db.list_coverage(topic))
offen = sorted(alle_seiten - gelesen)
if runde > 1 and alle_seiten and not offen:
break # volle Abdeckung erreicht
fokus = _fokus_text([b["titel"] for b in await db.list_bausteine(topic)], offen)
paths = [arbeit / f"recherche-r{runde}-{i}.md" for i in range(1, RECHERCHE_AGENTEN + 1)]
for p in paths:
p.unlink(missing_ok=True)
slots = [{
"key": f"bausteine-{topic}-recherche-r{runde}-{i}",
"prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner, fokus=fokus),
"role": "quick", "capabilities": caps,
"payload": (lambda result, p=p: _file_payload(p)),
} for i, p in enumerate(paths, 1)]
set_p(f"Recherche läuft (Runde {runde})…")
texte = await _race(topic, f"Recherche R{runde}", slots, RECHERCHE_QUORUM,
_timeout("recherche"), provider, cancelled=is_cancelled, grace=RECHERCHE_GRACE)
if is_cancelled():
return False
if not texte:
if runde == 1:
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)"
return False
break # keine neuen Ergebnisse mehr
neu, zitiert = 0, set()
for text in texte:
zitiert |= _cited_sources(text)
gesehen = set()
for eintrag in _parse_auswahl(text).values():
titel = _titel(eintrag)
norm = _norm_titel(titel)
if not norm or norm in gesehen:
continue
gesehen.add(norm)
teile = [t.strip() for t in eintrag.split("")]
besch = teile[1] if len(teile) >= 2 else ""
quelle = [teile[2]] if len(teile) >= 3 and teile[2] else []
if norm not in vorhandene:
neu += 1
vorhandene.add(norm)
await db.upsert_baustein(topic, norm, titel, besch, quelle)
await db.mark_quellen_gelesen(topic, sorted(_abgedeckt(zitiert, crawl_idx)))
gesamt = len(await db.list_bausteine(topic))
deckung = len(await db.list_coverage(topic))
_log(topic, f"Recherche R{runde}: +{neu} neu (gesamt {gesamt}), Abdeckung {deckung}/{len(alle_seiten) or '?'}")
if neu == 0:
break
if time.monotonic() - start > RECHERCHE_KAPPE:
_log(topic, f"Recherche: Zeit-Kappe {RECHERCHE_KAPPE}s erreicht (Runde {runde})")
break
await db.set_step_status(topic, "Recherche", "fertig")
return True
async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool:
"""Judge mergt Kandidaten semantisch + teilt in Konsens (≥2)/Rest (1×); Status in DB."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
if await db.get_step_status(topic, "Konsolidierung") == "fertig":
return True
set_p("Konsolidiere Recherche…", step=_step_idx(topic, "Konsolidierung"))
kandidaten = await db.list_bausteine(topic)
if not kandidaten:
_bausteine_errors[topic] = "Konsolidierung: keine Kandidaten"
return False
arbeit = files["arbeit"]
chunks = _chunk_nums(kandidaten, max(1, math.ceil(len(kandidaten) / KONSOLIDIERUNG_CHUNK)))
konsens, rest = [], []
for c, chunk in enumerate(chunks, 1):
fp = arbeit / f"konsolidierung-c{c}.json"
fp.unlink(missing_ok=True)
eintraege = "\n".join(
f"{i}. {b['titel']}{b['beschreibung']} ({b['nennungen']}× genannt)" for i, b in enumerate(chunk, 1)
)
status, mapping = await run_single_slot(
ctx, f"Konsolidierung {c}",
key=f"bausteine-{topic}-konsolidierung-c{c}",
prompt=_prompt("Bausteine-Recherche-Mapping", topic=topic, n=RECHERCHE_AGENTEN, eintraege=eintraege, out_path=fp),
role="judge", capabilities="files",
payload=lambda result, p=fp: _mapping_schema(_json_datei(p)),
timeout=_timeout("recherche_mapping", len(chunk)),
)
if status == CANCELLED:
return False
if status == FAILED:
_bausteine_errors[topic] = "Recherche-Mapping fehlgeschlagen"
return False
k, r = mapping
konsens += k
rest += r
# Judge-Ausgabe ist maßgeblich → Inventar in der DB neu setzen.
await db.delete_bausteine(topic)
for t in konsens:
await _set_inventar(topic, t, "konsens")
for t in rest:
await _set_inventar(topic, t, "rest")
await db.set_step_status(topic, "Konsolidierung", "fertig")
return True
async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool:
"""1 Judge entscheidet über den Rest (1×-Genannte): aufnehmen → Konsens, sonst verworfen."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
if await db.get_step_status(topic, "Klärung") == "fertig":
return True
set_p("Klärung läuft…", step=_step_idx(topic, "Klärung"))
rest_rows = await db.list_bausteine(topic, status="rest")
if rest_rows:
konsens = [b["titel"] for b in await db.list_bausteine(topic, status="konsens")]
fp = files["arbeit"] / "klaerung.json"
fp.unlink(missing_ok=True)
status, ergebnis = await run_single_slot(
ctx, "Klärung",
key=f"bausteine-{topic}-klaerung",
prompt=_prompt(
"Bausteine-Klaerung", topic=topic,
konsens="\n".join(f"- {t}" for t in konsens) or "(noch leer)",
rest="\n".join(f"- {b['titel']}" for b in rest_rows),
final="\n- Entscheide JEDEN Eintrag. `rest` MUSS leer sein.",
out_path=fp,
),
role="judge", capabilities="files",
payload=lambda result, p=fp: _runde_schema(_json_datei(p), final=True),
timeout=_timeout("auswahl_mapping", len(rest_rows)),
)
if status == CANCELLED:
return False
if status == FAILED:
_bausteine_errors[topic] = "Klärung fehlgeschlagen"
return False
aufnehmen, _ = ergebnis
auf_norm = {_norm_titel(_titel(t)) for t in aufnehmen}
for b in rest_rows:
await db.set_baustein_status(topic, b["titel_norm"], "konsens" if b["titel_norm"] in auf_norm else "verworfen")
await db.set_step_status(topic, "Klärung", "fertig")
return True
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():
bnorm = _norm_titel(btitel)
if not bnorm or not isinstance(subs, list):
continue
for s in subs:
if not isinstance(s, dict):
continue
st = str(s.get("titel", "")).strip()
sn = _norm_titel(st)
if not sn:
continue
await db.put_subbaustein(topic, bnorm, sn, btitel, st,
stufe=s.get("stufe"), relevanz=s.get("relevanz"), 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."""
await db.delete_frage_muster(topic)
for btitel, eintraege in muster.items():
bnorm = _norm_titel(btitel)
if not bnorm or not isinstance(eintraege, list):
continue
for e in eintraege:
if not isinstance(e, dict):
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):
continue
await db.upsert_frage_muster(topic, bnorm, sn, btitel, sub, typ, frage)
async def _reset_db_ab_phase(topic: str, ab_phase: int) -> None:
"""DB-Inhalt der Phasen ≥ ab_phase verwerfen (Inventar=1 … Fragen=5)."""
if ab_phase <= 1:
await db.delete_bausteine(topic)
await db.delete_coverage(topic)
await db.delete_pipeline_state(topic, ["Recherche", "Konsolidierung", "Klärung"])
if ab_phase <= 2:
await db.delete_subbausteine(topic)
if ab_phase <= 5:
await db.delete_frage_muster(topic)
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_phase: int | None = None) -> None:
if topic in _bausteine_progress:
return
@@ -1096,6 +1448,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
# unten wird übersprungen (er würde bei erhaltener Sidecar sonst alles wischen).
if ab_phase is not None:
_reset_ab_phase(topic, ab_phase)
await _reset_db_ab_phase(topic, ab_phase)
# Link-Quelle: erst crawlen (gleiche Domain, begrenzt) → wird zur Ordner-Quelle.
if q["type"] == "link" and not _crawl_fertig(topic):
set_p("Quelle laden (Crawl)…", step=_step_idx(topic, "Quelle laden"))
@@ -1116,160 +1469,30 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
if fertig:
for p_alt in _alle_slot_dateien(files):
p_alt.unlink(missing_ok=True)
await db.delete_pipeline_state(topic)
await db.delete_bausteine(topic)
await db.delete_subbausteine(topic)
await db.delete_frage_muster(topic)
await db.delete_coverage(topic)
# Schritt 1: 5 Recherche-Agenten, min. 3 mit Grace-Fenster — alle gültigen
# Slot-Dateien fließen ins Mapping (kein Kappen mehr bei 3)
recherchen: list[str] = []
offen = []
for i, path in enumerate(files["recherche"], 1):
text = _file_payload(path)
if text is not None:
recherchen.append(text)
else:
offen.append((i, path))
vorhanden = len(recherchen)
set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=_step_idx(topic, "Recherche"))
if vorhanden < 3:
caps = "files" if ordner else "full"
slots = [
{
"key": f"bausteine-{topic}-recherche-{i}",
"prompt": _build_recherche_prompt(topic, path, instructions, q["type"], ordner),
"role": "quick", "capabilities": caps,
"payload": (lambda result, p=path: _file_payload(p)),
}
for i, path in offen
]
neue = await _race(
topic, "Recherche", slots, 3 - vorhanden, _timeout("recherche"), provider,
on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c} gültig, min. 3)…"),
cancelled=is_cancelled, grace=RECHERCHE_GRACE,
)
# Inventar (DB): Recherche-Loop → Konsolidierung → Klärung.
if not await _recherche_loop(ctx, set_p, files, q, ordner, instructions):
if is_cancelled():
abgebrochen()
return
if neue is None:
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)"
return
recherchen += neue
# Schritt 2: Recherche-Mapping — Code-Vormerge (exakte Titel) + 1 Agent
# für semantische Dubletten und Konsens/Rest-Teilung (fatal)
mapping = _mapping_schema(_json_datei(files["recherche_mapping"]))
if mapping is None:
set_p("Konsolidiere Recherche…", step=_step_idx(topic, "Konsolidierung"))
files["recherche_mapping"].unlink(missing_ok=True)
gemergt = _vormerge([_parse_auswahl(t) for t in recherchen])
eintraege = "\n".join(f"{i}. {text} ({n}× genannt)" for i, (text, n) in enumerate(gemergt, 1))
status, mapping = await run_single_slot(
ctx, "Recherche-Mapping",
key=f"bausteine-{topic}-recherche-mapping",
prompt=_prompt(
"Bausteine-Recherche-Mapping",
topic=topic, n=len(recherchen), eintraege=eintraege,
out_path=files["recherche_mapping"],
),
role="judge", capabilities="files",
payload=lambda result: _mapping_schema(_json_datei(files["recherche_mapping"])),
timeout=_timeout("recherche_mapping", len(gemergt)),
)
if status == CANCELLED:
return
if not await _konsolidiere(ctx, set_p, files):
if is_cancelled():
abgebrochen()
return
if status == FAILED:
_bausteine_errors[topic] = "Recherche-Mapping fehlgeschlagen"
return
konsens, rest = mapping
# Klärungs-Loop: 3 Auswahl-Agenten entscheiden über den Rest, ein
# Mapping-Agent sortiert in aufnehmen/verwerfen/weiter strittig.
# Leerer Rest beendet den Loop; Runde KONSENS_MAX_RUNDEN muss
# alles entscheiden. Der Konsens wächst nur hier im Code.
runde = 0
while rest and runde < KONSENS_MAX_RUNDEN:
runde += 1
final_runde = runde == KONSENS_MAX_RUNDEN
set_p(f"Klärung läuft (Runde {runde}/{KONSENS_MAX_RUNDEN})…", step=_step_idx(topic, "Klärung"))
mapping_path = files["mapping"][runde]
# Resume: fertiges Runden-Mapping wird direkt übernommen
ergebnis = _runde_schema(_json_datei(mapping_path), final=final_runde)
if ergebnis is None:
mapping_path.unlink(missing_ok=True)
konsens_block = "\n".join(f"- {t}" for t in konsens)
rest_block = "\n".join(f"- {t}" for t in rest)
# 3 Auswahl-Agenten, min. 2 mit Grace-Fenster
entscheidungen = []
offen = []
for i, path in enumerate(files["auswahl"][runde], 1):
res = _rest_schema(_json_datei(path))
if res is not None:
entscheidungen.append(res)
else:
offen.append((i, path))
if len(entscheidungen) < 2:
slots = [
{
"key": f"bausteine-{topic}-auswahl-r{runde}-{i}",
"prompt": _prompt(
"Bausteine-Auswahl",
topic=topic, konsens=konsens_block, rest=rest_block, out_path=path,
),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=path: _rest_schema(_json_datei(p))),
}
for i, path in offen
]
neue = await _race(
topic, f"Auswahl r{runde}", slots, 2 - len(entscheidungen),
_timeout("auswahl", len(rest)), provider,
cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
abgebrochen()
return
if neue is None:
_bausteine_errors[topic] = f"Auswahl fehlgeschlagen (Runde {runde}, Minimum nicht erreicht)"
return
entscheidungen += neue
# Votum pro Rest-Eintrag deterministisch zählen
indizes = [_titel_index(dict(enumerate(e, 1))) for e in entscheidungen]
voten = "\n".join(
f"{i}. {text} (von {sum(1 for idx in indizes if _titel_aufloesen(idx, text) is not None)}"
f"/{len(entscheidungen)} Agenten übernommen)"
for i, text in enumerate(rest, 1)
)
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"bausteine-{topic}-auswahl-mapping-r{runde}",
prompt=_prompt(
"Bausteine-Auswahl-Mapping",
topic=topic, n=len(entscheidungen), konsens=konsens_block,
rest=voten, final=final_zusatz, out_path=mapping_path,
),
role="judge", capabilities="files",
payload=lambda result, p=mapping_path, f=final_runde: _runde_schema(_json_datei(p), final=f),
timeout=_timeout("auswahl_mapping", len(rest)),
)
if status == CANCELLED:
abgebrochen()
return
if status == FAILED:
_bausteine_errors[topic] = f"Auswahl-Mapping fehlgeschlagen (Runde {runde})"
return
aufnehmen, rest = ergebnis
_log(topic, f"Klärung Runde {runde}: {len(aufnehmen)} aufgenommen, {len(rest)} weiter strittig")
konsens = konsens + aufnehmen
entries = {i: t for i, t in enumerate(konsens, 1)}
return
if not await _klaere_inventar(ctx, set_p, files):
if is_cancelled():
abgebrochen()
return
konsens_rows = await db.list_bausteine(topic, status="konsens")
entries = {
i: (f"{b['titel']}{b['beschreibung']}" if b["beschreibung"] else b["titel"])
for i, b in enumerate(konsens_rows, 1)
}
# Nur Projekte: Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt,
# ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung].
@@ -1360,6 +1583,14 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
if muster is None:
return # Abbruch
atomic_write_json(files["frage_muster"], muster, indent=1)
# 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:
await _mirror_sidecar_db(topic, sidecar)
muster = _json_datei(files["frage_muster"])
if isinstance(muster, dict) and muster:
await _mirror_frage_muster_db(topic, muster)
except Exception as e:
log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic)
_bausteine_errors[topic] = str(e)[:2000]