This commit is contained in:
team3
2026-06-21 22:53:54 +02:00
parent b4c686f74f
commit 2e33fa5c47
23 changed files with 442 additions and 560 deletions

127
backend/roadmap.py Normal file
View File

@@ -0,0 +1,127 @@
"""Roadmap-Generierung: ein read-only Übersichts-Graph pro Thema (Advance Organizer).
Quelle = vorhandene Bausteine (keine Web-Recherche). Zwei Schritte:
Bauen (1 Agent synthetisiert aus der Baustein-Übersicht einen gerichteten Graph aus
Bereichen) und Prüfung (1 Judge fixt Struktur/Knappheit). Schritt-Dateien bleiben liegen →
Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
"""
import json
from pathlib import Path
from bausteine import lade_uebersicht
from config import KONSENS_MAX_RUNDEN
from jsonio import read_json_file as _json_datei
from pipeline import (
CANCELLED, FAILED, GenContext, _extra, _fail, _log,
_prompt, _set_step, _timeout, is_guide_cancelled, run_single_slot,
)
ROADMAP_STEPS = ("Bauen", "Prüfung")
_PRIO = ("grund", "kern", "vertiefung")
def _roadmap_schema(data) -> dict | None:
"""{"richtung", "knoten":[{id,title,text,prioritaet}], "kanten":[{von,nach,typ}]} →
bereinigtes Graph-Dict · sonst None. IDs eindeutig, Kanten referenzieren echte Knoten."""
if not isinstance(data, dict) or not isinstance(data.get("knoten"), list):
return None
knoten, ids = [], set()
for k in data["knoten"]:
if not isinstance(k, dict):
return None
kid = str(k.get("id", "")).strip()
title = str(k.get("title", "")).strip()
text = str(k.get("text", "")).strip()
if not kid or not title or kid in ids:
continue
ids.add(kid)
prio = k.get("prioritaet") if k.get("prioritaet") in _PRIO else "kern"
knoten.append({"id": kid, "title": title, "text": text, "prioritaet": prio})
if not knoten:
return None
kanten = []
for e in (data.get("kanten") or []):
if not isinstance(e, dict):
continue
von, nach = str(e.get("von", "")).strip(), str(e.get("nach", "")).strip()
if von not in ids or nach not in ids or von == nach:
continue # hängende/Selbst-Kante verwerfen
typ = e.get("typ") if e.get("typ") in ("prereq", "optional") else "prereq"
kanten.append({"von": von, "nach": nach, "typ": typ})
richtung = data.get("richtung") if data.get("richtung") in ("TB", "LR") else "TB"
return {"richtung": richtung, "knoten": knoten, "kanten": kanten}
def _uebersicht_block(topic: str) -> str:
"""Baustein-Übersicht (nur Bausteine mit ≥1 relevantem Subbaustein) als Prompt-Text."""
zeilen = []
for b in lade_uebersicht(topic):
rel = [s["titel"] for s in b["subbausteine"] if s.get("relevanz") != "rand"]
if not rel:
continue
kopf = f"{b['num']}. {b['titel']}"
if b.get("beschreibung"):
kopf += f"{b['beschreibung']}"
zeilen.append(kopf)
zeilen += [f" - {t}" for t in rel]
return "\n".join(zeilen)
async def _generate_roadmap(
guide_id: str, topic: str, instructions: str, provider: str, content_path: Path,
) -> dict | None:
is_cancelled = lambda: is_guide_cancelled(guide_id)
ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id)
uebersicht = _uebersicht_block(topic)
if not uebersicht.strip():
await _fail(guide_id, "Keine relevanten Bausteine für die Roadmap")
return None
d, stem = content_path.parent, content_path.stem
bau_path = d / f"{stem}.roadmap-bau.json"
check_path = d / f"{stem}.roadmap-check.json"
# Schritt 0: Bauen — 1 Agent synthetisiert den Graph. Gültige Datei überspringt.
graph = _roadmap_schema(_json_datei(bau_path))
if graph is None:
await _set_step(guide_id, 0, "Baue Roadmap…")
status, _ = await run_single_slot(
ctx, "Roadmap-Bauen",
key=f"{guide_id}-roadmap-bau",
prompt=_prompt("Roadmap-Bauen", topic=topic, uebersicht=uebersicht,
out_path=bau_path, extra=_extra(instructions)),
role="guide", capabilities="files",
payload=lambda result: _roadmap_schema(_json_datei(bau_path)),
timeout=_timeout("roadmap"),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Roadmap-Bau fehlgeschlagen")
return None
graph = _roadmap_schema(_json_datei(bau_path))
# Schritt 1: Prüfung — Judge fixt Struktur/Knappheit; Fallback = Bau-Graph.
geprueft = _roadmap_schema(_json_datei(check_path))
if geprueft is None:
await _set_step(guide_id, 1, "Prüfe Roadmap…")
status, _ = await run_single_slot(
ctx, "Roadmap-Prüfung",
key=f"{guide_id}-roadmap-check",
prompt=_prompt("Roadmap-Check", topic=topic, uebersicht=uebersicht,
graph=json.dumps(graph, ensure_ascii=False, indent=1),
out_path=check_path, extra=_extra(instructions)),
role="judge", capabilities="files",
payload=lambda result: _roadmap_schema(_json_datei(check_path)),
timeout=_timeout("roadmap_check"),
)
if status == CANCELLED:
return None
if status == FAILED:
_log(topic, "Roadmap-Prüfung fehlgeschlagen — Bau-Graph bleibt")
geprueft = _roadmap_schema(_json_datei(check_path))
return geprueft or graph