update
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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", []):
|
||||
|
||||
102
backend/guide.py
102
backend/guide.py
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
FormatType = Literal[
|
||||
"OnePager",
|
||||
"Roadmap",
|
||||
"Guide",
|
||||
]
|
||||
|
||||
|
||||
@@ -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}]
|
||||
@@ -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 0–30). 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
127
backend/roadmap.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user