This commit is contained in:
team3
2026-06-18 00:00:24 +02:00
parent 3c839861e7
commit 229880b7e6
15 changed files with 374 additions and 42 deletions

View File

@@ -21,7 +21,8 @@ from agents import kill_process
from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER
from fsutil import atomic_write_text, atomic_write_json
from jsonio import read_json_file as _json_datei
from paths import arbeit_dir, bausteine_path, project_dir, subbausteine_path
from paths import arbeit_dir, bausteine_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner
from crawl import crawl
from pipeline import (
CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _rest_schema,
_runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot,
@@ -52,20 +53,45 @@ BAUSTEINE_STEPS = (
)
def lade_quelle(topic: str) -> dict:
"""Persistierte Quellen-Wahl lesen. Fallback (Alt-Themen ohne quelle.json):
existiert projects/<topic> → projekt, sonst thema."""
q = _json_datei(quelle_path(topic))
if isinstance(q, dict) and q.get("type") in ("thema", "projekt", "uni", "link"):
return q
if project_dir(topic).is_dir():
return {"type": "projekt", "ort": f"projects/{topic}", "spec": ""}
return {"type": "thema", "ort": "", "spec": ""}
def quelle_ordner(topic: str) -> Path | None:
"""Ordner-Quelle (projekt/uni → Pfad, link → Crawl-Ordner) — sonst None (thema)."""
q = lade_quelle(topic)
if q["type"] == "link":
return quelle_crawl_dir(topic)
if q["type"] in ("projekt", "uni"):
return safe_ordner(q.get("ort", ""))
return None
def _crawl_fertig(topic: str) -> bool:
return (quelle_crawl_dir(topic) / ".done").exists() # Marker erst bei sauberem Abschluss
def _bausteine_steps(topic: str) -> tuple:
"""Projekte haben einen zusätzlichen Schritt (Ergänzung), nach der Klärung eingefügt.
"""Schritte je Quelle: link bekommt vorne „Quelle laden", projekt zusätzlich „Ergänzung".
Subbausteine + Stufen sind je drei Phasen (Finden, Wählen, Klären). Pro Phase
laufen alle Pakete parallel; der Schritt bleibt, bis das letzte Paket fertig ist.
"""
q = lade_quelle(topic)
base = ("Recherche", "Konsolidierung", "Klärung")
rest = (
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
"Stufen finden", "Stufen wählen", "Stufen klären",
)
if project_dir(topic).is_dir():
return base + ("Ergänzung",) + rest
return base + rest
mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest
return (("Quelle laden",) if q["type"] == "link" else ()) + mitte
def _step_idx(topic: str, name: str) -> int:
@@ -111,18 +137,21 @@ def cancel_bausteine(topic: str) -> bool:
def _resume_step(topic: str) -> int:
"""Erster noch offener Schritt anhand der persistierten Zwischendateien."""
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:
return 0
return _step_idx(topic, "Recherche")
if not files["recherche_mapping"].exists():
return 1
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 2
if project_dir(topic).is_dir() and not files["ergaenzung"].exists():
return _step_idx(topic, "Klärung")
if q["type"] == "projekt" and not files["ergaenzung"].exists():
return _step_idx(topic, "Ergänzung")
if _sidecar_schema(_json_datei(files["sidecar"])) is not None:
return len(_bausteine_steps(topic))
@@ -166,6 +195,8 @@ def reset_bausteine(topic: str) -> None:
files = _bausteine_files(topic)
files["final"].unlink(missing_ok=True)
files["sidecar"].unlink(missing_ok=True) # liegt im Themen-Root, nicht in arbeit/
quelle_path(topic).unlink(missing_ok=True)
shutil.rmtree(quelle_crawl_dir(topic), ignore_errors=True) # gecrawlte Link-Quelle
shutil.rmtree(files["arbeit"], ignore_errors=True)
_bausteine_errors.pop(topic, None)
@@ -209,9 +240,12 @@ def _pdfs_konvertieren(project: Path) -> None:
raise RuntimeError(f"PDF-Konvertierung fehlgeschlagen ({pdf.name}): {e}") from e
def _build_recherche_prompt(topic: str, out_path: Path, instructions: str = "", project: Path | None = None) -> str:
if project:
source = _prompt("Bausteine-Quelle-Projekt", project=project)
_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:
if typ in _QUELLE_TEMPLATE:
source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner)
else:
source = _prompt("Bausteine-Quelle-Thema", topic=topic)
return _prompt(
@@ -337,7 +371,7 @@ async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
arbeit = files["arbeit"]
idx = _titel_index(entries)
caps = "files" if project_dir(topic).is_dir() else "full"
caps = "files" if quelle_ordner(topic) else "full"
nums = list(entries)
chunks = _chunk_nums(nums, _n_chunks(len(nums)))
n = len(chunks)
@@ -548,7 +582,9 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
files = _bausteine_files(topic)
final_path = files["final"]
project = project_dir(topic) if project_dir(topic).is_dir() else None
q = lade_quelle(topic)
ordner = quelle_ordner(topic) # projekt/uni/link → Ordner, thema → None
instructions = q.get("spec") or instructions # persistierte Spezifikation bevorzugen (auch bei Resume)
def set_p(msg: str, step: int | None = None) -> None:
_bausteine_progress[topic] = msg
@@ -566,8 +602,18 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
try:
async with _semaphore:
files["arbeit"].mkdir(parents=True, exist_ok=True)
if project:
await asyncio.to_thread(_pdfs_konvertieren, project)
# 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"))
n = await asyncio.to_thread(crawl, q["ort"], ordner, cancelled=is_cancelled)
if is_cancelled():
abgebrochen()
return
if not n:
_bausteine_errors[topic] = "Crawl ergab keine Inhalte — Link/Domain prüfen"
return
if ordner:
await asyncio.to_thread(_pdfs_konvertieren, ordner)
# „Neu erstellen": NUR wenn wirklich alles fertig ist (bausteine.md UND
# Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor,
# ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen.
@@ -587,13 +633,13 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
else:
offen.append((i, path))
vorhanden = len(recherchen)
set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=0)
set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=_step_idx(topic, "Recherche"))
if vorhanden < 3:
caps = "files" if project else "full"
caps = "files" if ordner else "full"
slots = [
{
"key": f"bausteine-{topic}-recherche-{i}",
"prompt": _build_recherche_prompt(topic, path, instructions, project),
"prompt": _build_recherche_prompt(topic, path, instructions, q["type"], ordner),
"role": "quick", "capabilities": caps,
"payload": (lambda result, p=path: _file_payload(p)),
}
@@ -616,7 +662,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
# 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=1)
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))
@@ -648,7 +694,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
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=2)
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
@@ -732,8 +778,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
# Nur Projekte: Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt,
# ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung].
if project:
set_p("Ergänze Themenfeld…", step=3)
if q["type"] == "projekt":
set_p("Ergänze Themenfeld…", step=_step_idx(topic, "Ergänzung"))
erg_path = files["ergaenzung"]
ergaenzungen = _ergaenzung_schema(_json_datei(erg_path))
if ergaenzungen is None: