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

View File

@@ -62,11 +62,8 @@ TIMEOUTS = {
"lese_check": (300, 10), # pro Section im Paket
"grafik": (600, 60), # Graph-Grafik je Baustein (Knoten/Kanten), n = Subbausteine
"grafik_check": (300, 15), # Grafik-Prüfung/Fix je Baustein, n = Subbausteine
"onepager_recherche": (900, 0),
"onepager_mapping": (600, 0), # Konsolidierung der Recherchen
"onepager_bauen": (300, 0),
"onepager_judge": (600, 0), # Judge über die Karten-Sätze
"onepager_verify": (300, 0),
"roadmap": (600, 0), # Roadmap-Graph aus der Baustein-Übersicht bauen
"roadmap_check": (300, 0), # Judge prüft/fixt den Roadmap-Graph
}
# Auswahl-Auftrag je Format: (Mindest-Anteil, Maximal-Anteil, Mindestanzahl, Zweck).

View File

@@ -71,7 +71,7 @@ def _topic_context(topic: str, limit: int = 12000) -> str:
bp = bausteine_path(topic)
if bp.exists():
parts.append(bp.read_text(encoding="utf-8"))
for fmt in ("Guide", "OnePager"):
for fmt in ("Guide",): # Roadmap ist ein Graph ohne Prosa-Sections → kein Text-Kontext
content = _json_datei(guide_content_path(topic, fmt))
if content:
for ch in content.get("chapters", []):

View File

@@ -1,4 +1,4 @@
"""Guide-Generierung als Konsens-Pipeline (OnePager hat einen eigenen Weg).
"""Guide-Generierung als Konsens-Pipeline (Roadmap hat einen eigenen Weg).
Auswahl: 5 Agenten (min. 3, Grace) → Code-Voting (Mehrheit = Konsens) →
Mapping-Agent sortiert Strittiges → Klärungs-Loop (max. KONSENS_MAX_RUNDEN).
@@ -24,7 +24,7 @@ import lesbarkeit
from database import list_guides, update_guide
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_datei
from onepager import _generate_onepager
from roadmap import _generate_roadmap
from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path
from pipeline import (
CANCELLED, FAILED, GenContext, _claude_error, _extra,
@@ -172,6 +172,20 @@ def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max:
return chapters
def _fallback_gliederung(entries: dict[int, str]) -> list[dict]:
"""Deterministische Gliederung, wenn die Agenten keine liefern: ein Kapitel mit
allen gewählten Bausteinen in Reihenfolge. Garantiert vollständige Abdeckung."""
return [{"title": "Inhalte", "nums": list(entries)}]
def _mit_resten(plan: list[dict], entries: dict[int, str]) -> list[dict]:
"""Stellt sicher, dass JEDER gewählte Baustein im Plan steht — fehlende landen in
einem Kapitel „Weitere" (gegen Agenten/Judge, die Bausteine weglassen)."""
drin = {num for ch in plan for num in ch.get("nums", [])}
fehlen = [num for num in entries if num not in drin]
return [*plan, {"title": "Weitere", "nums": fehlen}] if fehlen else plan
def _voting(stimmen: list[list[int]]) -> tuple[list[int], dict[int, int]]:
"""Mehrheit (> Hälfte der Stimmen) → Konsens; ≥1 Stimme → Rest mit Votenzahl."""
zaehler: dict[int, int] = {}
@@ -433,7 +447,8 @@ async def _generate_sections(
soll = len(sel_entries)
sel_liste = "\n".join(f"- {t}" for t in sel_entries.values())
# Schritt 1: Gliederung — 5 Vorschläge (min. 3, Grace), ein Judge wählt.
# Schritt 1: Gliederung — bis zu 5 Vorschläge (Grace), ein Judge wählt. Bricht NIE ab:
# 0 gültige → Code-Fallback, 1 → direkt, ≥2 → Judge (mit Vorschlag als Rückfall).
# Gültiges gliederung.json (auch aus Altläufen) überspringt den Schritt.
plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
if plan is None:
@@ -447,7 +462,7 @@ async def _generate_sections(
vorschlaege.append(res)
else:
offen.append((i, path))
if len(vorschlaege) < 3:
if len(vorschlaege) < 3 and offen:
slots = [
{
"key": f"{guide_id}-gliederung-{i}",
@@ -461,41 +476,48 @@ async def _generate_sections(
}
for i, path in offen
]
# Quorum 1: nimm, was kommt — kein Mindestzwang, kein Abbruch.
neue = await _race(
topic, "Gliederung", slots, 3 - len(vorschlaege), _timeout("plan", soll),
topic, "Gliederung", slots, 1, _timeout("plan", soll),
provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, "Gliederung fehlgeschlagen (Minimum nicht erreicht)")
return None
vorschlaege += neue
vorschlaege += neue or []
await _set_step(guide_id, 1, "Wähle beste Gliederung…")
bloecke = "\n\n".join(
f"### Vorschlag {i}\n"
+ "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in v)
for i, v in enumerate(vorschlaege, 1)
)
status, plan = await run_single_slot(
ctx, "Gliederungs-Judge",
key=f"{guide_id}-gliederung-judge",
prompt=_prompt(
"Guide-Gliederung-Judge",
topic=topic, format_name=format_name, zweck=zweck, n=len(vorschlaege),
bausteine=sel_liste, gliederungen=bloecke,
out_path=files["gliederung"], extra=_extra(instructions),
),
role="judge", capabilities="files",
payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
timeout=_timeout("plan_judge", soll),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Gliederung fehlgeschlagen (Judge ohne gültiges Ergebnis)")
return None
if not vorschlaege:
_log(topic, "Gliederung: kein gültiger Vorschlag — deterministischer Fallback")
plan = _fallback_gliederung(sel_entries)
elif len(vorschlaege) == 1:
plan = vorschlaege[0] # ein Vorschlag → kein Judge nötig
else:
await _set_step(guide_id, 1, "Wähle beste Gliederung…")
bloecke = "\n\n".join(
f"### Vorschlag {i}\n"
+ "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in v)
for i, v in enumerate(vorschlaege, 1)
)
status, plan = await run_single_slot(
ctx, "Gliederungs-Judge",
key=f"{guide_id}-gliederung-judge",
prompt=_prompt(
"Guide-Gliederung-Judge",
topic=topic, format_name=format_name, zweck=zweck, n=len(vorschlaege),
bausteine=sel_liste, gliederungen=bloecke,
out_path=files["gliederung"], extra=_extra(instructions),
),
role="judge", capabilities="files",
payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
timeout=_timeout("plan_judge", soll),
)
if status == CANCELLED:
return None
if status == FAILED or plan is None:
_log(topic, "Gliederung-Judge ohne Ergebnis — bester Vorschlag bleibt")
plan = vorschlaege[0]
# Garantie: jeder gewählte Baustein steht im Plan (gegen weglassende Agenten/Judges).
plan = _mit_resten(plan, sel_entries)
# Chunks festlegen — Inhalte, Inhalts-Prüfung, Writer und Lese-Check nutzen
# dieselbe Aufteilung: 1 Agent je ~30 Bausteine (gedeckelt).
@@ -817,8 +839,11 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
for p_alt in guide_slot_dateien(content_path):
p_alt.unlink(missing_ok=True)
if format_name == "OnePager":
chapters = await _generate_onepager(guide_id, topic, instructions, provider, project, content_path)
if format_name == "Roadmap":
graph = await _generate_roadmap(guide_id, topic, instructions, provider, content_path)
if graph is None or is_guide_cancelled(guide_id):
return
content = {"topic": topic, "format": format_name, "graph": graph}
else:
alle = _lade_bausteine(bausteine_path(topic).read_text(encoding="utf-8"))
if not alle:
@@ -830,10 +855,11 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
guide_id, topic, format_name, entries,
facts, instructions, provider, content_path,
)
if chapters is None or is_guide_cancelled(guide_id):
return
if chapters is None or is_guide_cancelled(guide_id):
return
content = {"topic": topic, "format": format_name, "chapters": chapters}
atomic_write_json(content_path, {"topic": topic, "format": format_name, "chapters": chapters}, indent=1)
atomic_write_json(content_path, content, indent=1)
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="done", progress=None, step=None, updated_at=now)

View File

@@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
from typing import Literal
FormatType = Literal[
"OnePager",
"Roadmap",
"Guide",
]

View File

@@ -1,244 +0,0 @@
"""OnePager-Pipeline als Konsens-Kette (7 Karten im 3×3-Raster).
Recherche: 3 Agenten (min. 2, Grace) → Mapping konsolidiert zu EINER Faktenbasis.
Bauen: 3 Agenten bauen je einen Karten-Satz → ein Judge wählt und kombiniert.
Prüfung: Verify→Fix-Loop (max. KONSENS_MAX_RUNDEN); Runde 1 ist fatal, danach
bleibt bei Fehlern die letzte gültige Version. Schritt-Dateien bleiben liegen →
Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
"""
from pathlib import Path
from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN
from jsonio import read_json_file as _json_datei
from pipeline import (
CANCELLED, FAILED, GenContext, _extra, _fail, _log, _probleme_schema,
_prompt, _race, _set_step, _timeout, is_guide_cancelled, run_single_slot,
)
ONEPAGER_STEPS = ("Recherche", "Bauen", "Prüfung")
async def _generate_onepager(
guide_id: str, topic: str, instructions: str, provider: str,
project: Path | None, content_path: Path,
) -> list[dict] | None:
is_cancelled = lambda: is_guide_cancelled(guide_id)
ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id)
# 3×3-Raster: 7 Karten mit festen Schlüsseln (Reihenfolge = Lesereihenfolge mobil)
KARTEN_KEYS = ("info", "eigenschaften", "beispiel", "zusammenhaenge", "voraussetzungen", "modern", "veraltet")
def karten_schema(data):
"""{"karten": {key: {titel, md}}} → Liste · sonst None."""
if not isinstance(data, dict):
return None
karten = data.get("karten")
if not isinstance(karten, dict):
return None
out = []
for key in KARTEN_KEYS:
k = karten.get(key)
if not isinstance(k, dict) or not isinstance(k.get("titel"), str) or not isinstance(k.get("md"), str):
return None
titel, md = k["titel"].strip(), k["md"].strip()
if not titel or len(md) < 5: # abgebrochene/leere Karten sind ungültig
return None
out.append({"key": key, "titel": titel, "md": md})
return out
d, stem = content_path.parent, content_path.stem
recherche_slots = [d / f"{stem}.recherche-{i}.md" for i in (1, 2, 3)]
recherche_path = d / f"{stem}.recherche.md" # konsolidierte Faktenbasis
karten_slots = [d / f"{stem}.karten-{i}.json" for i in (1, 2, 3)]
karten_path = d / f"{stem}.karten.json" # Judge-Ausgabe
verify_paths = {n: d / f"{stem}.verify-r{n}.json" for n in range(1, KONSENS_MAX_RUNDEN + 1)}
fix_paths = {n: d / f"{stem}.karten-fix-r{n}.json" for n in range(1, KONSENS_MAX_RUNDEN + 1)}
# Projekte bekommen eigene Recherche-Dimensionen — Produkt-Fragen
# (Version, Lizenz, Alternativen) laufen dort ins Leere.
if project:
source = _prompt("OnePager-Quelle-Projekt", project=project)
recherche_template = "OnePager-Recherche-Projekt"
else:
source = _prompt("OnePager-Quelle-Thema", topic=topic)
recherche_template = "OnePager-Recherche"
def text_payload(path: Path):
if not path.exists():
return None
text = path.read_text(encoding="utf-8").strip()
return text or None
# Schritt 0: Recherche — 3 Agenten (min. 2, Grace), Mapping konsolidiert.
# Eine gültige recherche.md (auch aus Altläufen) überspringt den Schritt.
recherche = text_payload(recherche_path)
if recherche is None:
await _set_step(guide_id, 0, "Recherchiere (3 Agenten)…")
recherchen = []
offen = []
for i, path in enumerate(recherche_slots, 1):
text = text_payload(path)
if text is not None:
recherchen.append(text)
else:
offen.append((i, path))
if len(recherchen) < 2:
slots = [
{
"key": f"{guide_id}-recherche-{i}",
"prompt": _prompt(recherche_template, topic=topic, source=source, out_path=path, extra=_extra(instructions)),
"role": "quick", "capabilities": "files" if project else "full",
"payload": (lambda result, p=path: text_payload(p)),
}
for i, path in offen
]
neue = await _race(
topic, "OnePager-Recherche", slots, 2 - len(recherchen),
_timeout("onepager_recherche"), provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, "OnePager-Recherche fehlgeschlagen (Minimum nicht erreicht)")
return None
recherchen += neue
await _set_step(guide_id, 0, "Konsolidiere Recherche…")
recherchen_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1))
status, recherche = await run_single_slot(
ctx, "Recherche-Mapping",
key=f"{guide_id}-recherche-mapping",
prompt=_prompt(
"OnePager-Recherche-Mapping",
topic=topic, n=len(recherchen), recherchen=recherchen_block, out_path=recherche_path,
),
role="judge", capabilities="files",
payload=lambda result: text_payload(recherche_path),
timeout=_timeout("onepager_mapping"),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Recherche-Konsolidierung fehlgeschlagen")
return None
# Schritt 1: Bauen — 3 Entwürfe (min. 2, Grace), ein Judge kombiniert.
# Gültiges karten.json (auch aus Altläufen) überspringt den Schritt.
karten = karten_schema(_json_datei(karten_path))
if karten is None:
await _set_step(guide_id, 1, "Baue OnePager (3 Entwürfe)…")
entwuerfe = []
offen = []
for i, path in enumerate(karten_slots, 1):
res = karten_schema(_json_datei(path))
if res is not None:
entwuerfe.append(res)
else:
offen.append((i, path))
if len(entwuerfe) < 2:
slots = [
{
"key": f"{guide_id}-bauen-{i}",
"prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=path, extra=_extra(instructions)),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=path: karten_schema(_json_datei(p))),
}
for i, path in offen
]
neue = await _race(
topic, "OnePager-Bauen", slots, 2 - len(entwuerfe),
_timeout("onepager_bauen"), provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
)
if is_cancelled():
return None
if neue is None:
await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Minimum nicht erreicht)")
return None
entwuerfe += neue
await _set_step(guide_id, 1, "Wähle besten Entwurf…")
saetze_block = "\n\n".join(
f"## Entwurf {i}\n\n" + "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in satz)
for i, satz in enumerate(entwuerfe, 1)
)
status, karten = await run_single_slot(
ctx, "Bauen-Judge",
key=f"{guide_id}-bauen-judge",
prompt=_prompt(
"OnePager-Bauen-Judge",
topic=topic, n=len(entwuerfe), recherche=recherche, kartensaetze=saetze_block,
out_path=karten_path, extra=_extra(instructions),
),
role="judge", capabilities="files",
payload=lambda result: karten_schema(_json_datei(karten_path)),
timeout=_timeout("onepager_judge"),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "OnePager-Bau fehlgeschlagen (Judge ohne gültiges Ergebnis)")
return None
def karten_block() -> str:
return "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in karten)
# Schritt 2: Prüf-Loop — Verify notiert Probleme, Fix behebt; max. Runden-Cap.
# Runde 1 ist fatal (wie früher der Einzel-Check), danach bleibt bei Fehlern
# die letzte gültige Version stehen.
for runde in range(1, KONSENS_MAX_RUNDEN + 1):
probleme = _probleme_schema(_json_datei(verify_paths[runde]))
if probleme is None:
await _set_step(guide_id, 2, f"Prüfe OnePager (Runde {runde}/{KONSENS_MAX_RUNDEN})…")
verify_paths[runde].unlink(missing_ok=True)
status, probleme = await run_single_slot(
ctx, f"OnePager-Prüfung r{runde}",
key=f"{guide_id}-verify-r{runde}",
prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=verify_paths[runde]),
role="judge", capabilities="files",
payload=lambda result, p=verify_paths[runde]: _probleme_schema(_json_datei(p)),
timeout=_timeout("onepager_verify"),
)
if status == CANCELLED:
return None
if status == FAILED:
if runde == 1:
await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen")
return None
_log(topic, f"OnePager-Prüfung Runde {runde} fehlgeschlagen — letzte gültige Version bleibt")
break
if not probleme:
break
if runde == KONSENS_MAX_RUNDEN:
_log(topic, f"OnePager-Prüfung: {len(probleme)} Problem(e) bleiben nach Runde {runde} stehen")
break
_log(topic, f"OnePager-Prüfung Runde {runde}: {len(probleme)} Problem(e) notiert")
fixed = karten_schema(_json_datei(fix_paths[runde])) # Resume
if fixed is None:
await _set_step(guide_id, 2, f"Überarbeite OnePager (Runde {runde})…")
status, fixed = await run_single_slot(
ctx, f"OnePager-Fix r{runde}",
key=f"{guide_id}-karten-fix-r{runde}",
prompt=_prompt(
"OnePager-Fix",
topic=topic, recherche=recherche, karten=karten_block(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=fix_paths[runde], extra=_extra(instructions),
),
role="fast", capabilities="files",
payload=lambda result, p=fix_paths[runde]: karten_schema(_json_datei(p)),
timeout=_timeout("onepager_bauen"),
)
if status == CANCELLED:
return None
if status == FAILED:
_log(topic, f"OnePager-Fix Runde {runde} ungültig — letzte gültige Version bleibt")
break
karten = fixed
sections = [
{"num": i, "title": k["titel"], "md": k["md"], "key": k["key"]}
for i, k in enumerate(karten, 1)
]
return [{"title": topic, "sections": sections}]

View File

@@ -1,11 +1,10 @@
"""Lernschulden-Regeln: Progression und Deckel für offene Guides — die EINZIGE Quelle.
Regeln (nur Neu-Erstellungen; Themen, Bausteine, OnePager unbegrenzt):
- JE Format (MiniGuide/Guide/FullGuide) höchstens 3 erstellte, nicht absolvierte Guides
- Progression pro Thema: Guide erst nach absolviertem MiniGuide, FullGuide erst nach absolviertem Guide
- Absolviert (Mini/Guide/FullGuide): ALLE Bausteine (Section-Titel) des neuesten
fertigen Guides haben eine bestandene Prüfung (baustein_progress). Das
Kapitel-Häkchen ist nur noch eine Lese-Markierung. OnePager: Kapitel-Häkchen.
Regeln (nur Neu-Erstellungen; Themen, Bausteine, Roadmap unbegrenzt):
- Format „Guide": höchstens 3 erstellte, nicht absolvierte Guides
- Keine Progression/Vorstufe mehr (nur ein Guide-Format).
- Absolviert: ALLE Bausteine (Section-Titel) des neuesten fertigen Guides haben
eine bestandene Prüfung. Roadmap ist read-only (kein Fortschritt, keine Prüfung).
Alle Funktionen arbeiten auf einmal geladenen Daten (lade_lernstand) — keine
Query-Schleifen mehr pro Guide.
"""
@@ -19,7 +18,7 @@ from textkit import _norm_titel
MAX_OFFENE_GUIDES = 3
# Nur noch EIN Format „Guide" (alle relevanten Bausteine, Prüfung 030). Keine Progression,
# keine Vorstufe → der Guide ist immer freischaltbar. „Rest"/OnePager separat.
# keine Vorstufe → der Guide ist immer freischaltbar. „Rest"/Roadmap separat.
VORSTUFE: dict[str, str] = {}
FREISCHALT_LEVEL: dict[str, str] = {}
FORMATE = ("Guide",)
@@ -85,10 +84,7 @@ def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]:
def _guide_alle(g: dict, progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
"""Sind ALLE Bausteine (bzw. OnePager-Kapitel) des Guides auf dem geforderten Niveau?"""
if g["format"] == "OnePager":
titles = _kapitel_titel(g["topic"], g["format"])
return bool(titles) and titles <= progress.get(g["id"], set())
"""Sind ALLE Bausteine des Guides auf dem geforderten Niveau?"""
sections = _section_titel(g["topic"], g["format"])
return bool(sections) and sections <= levelset.get(g["topic"], set())
@@ -125,13 +121,13 @@ def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set
Exakt die Regeln aus POST /guides: Bausteine nötig, kein Duplikat-Start,
Lernschulden nur für echte Neu-Erstellungen (Resume/Regenerieren frei).
"""
if fmt != "OnePager" and not bausteine_path(topic).exists():
if not bausteine_path(topic).exists():
return "Erst Bausteine erstellen"
for g in guides:
if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"):
return "Generierung läuft bereits"
content = guide_content_path(topic, fmt)
if fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content):
if not content.exists() and not guide_slot_dateien(content):
vorstufe = VORSTUFE.get(fmt)
if vorstufe:
stufe = FREISCHALT_LEVEL[fmt] # absolviert=10 · verstanden=20 · gemeistert=30

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

View File

@@ -478,7 +478,7 @@ async def list_all():
async def guide_locks(topic: str):
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar."""
guides, progress, levels = await lade_lernstand()
return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("OnePager", *FORMATE)}
return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("Roadmap", *FORMATE)}
@router.get("/guides/{guide_id}", response_model=GuideResponse)