This commit is contained in:
team3
2026-06-22 12:24:44 +02:00
parent 2d1a3d3e37
commit 04900eef55
23 changed files with 53 additions and 1076 deletions

View File

@@ -11,7 +11,6 @@ die Gesamtliste.
""" """
import asyncio import asyncio
import json
import logging import logging
import math import math
import shutil import shutil
@@ -23,7 +22,7 @@ from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER
from fsutil import atomic_write_text, atomic_write_json from fsutil import atomic_write_text, atomic_write_json
from jsonio import read_json_file as _json_datei from jsonio import read_json_file as _json_datei
from lernen import FRAGETYPEN from lernen import FRAGETYPEN
from paths import arbeit_dir, bausteine_path, frage_muster_path, grafik_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner from paths import arbeit_dir, bausteine_path, frage_muster_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner
from crawl import crawl from crawl import crawl
from pipeline import ( from pipeline import (
CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _relevanz_schema, _rest_schema, CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _relevanz_schema, _rest_schema,
@@ -54,7 +53,6 @@ BAUSTEINE_STEPS = (
"Stufen finden", "Stufen wählen", "Stufen klären", "Stufen finden", "Stufen wählen", "Stufen klären",
"Relevanz finden", "Relevanz wählen", "Relevanz klären", "Relevanz finden", "Relevanz wählen", "Relevanz klären",
"Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen", "Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen",
"Grafik finden", "Grafik prüfen",
) )
@@ -111,19 +109,6 @@ def lade_frage_muster(topic: str, baustein: str) -> list[dict]:
] ]
def lade_grafiken(topic: str) -> dict:
"""Alle Baustein-Grafiken eines Themas: {Baustein-Titel: {knoten, kanten, richtung}}.
Leere/ungültige Datei → {} (Frontend zeigt Fallback)."""
g = _json_datei(grafik_path(topic))
return g if isinstance(g, dict) else {}
def lade_grafik(topic: str, baustein: str) -> dict | None:
"""Grafik eines Bausteins (None = keine → Fallback)."""
g = lade_grafiken(topic).get(baustein)
return g if isinstance(g, dict) and g.get("knoten") else None
def lade_uebersicht(topic: str) -> list[dict]: def lade_uebersicht(topic: str) -> list[dict]:
"""Strukturierte Baustein-Liste für die Übersicht: Titel + Beschreibung + Subbausteine/Stufen. """Strukturierte Baustein-Liste für die Übersicht: Titel + Beschreibung + Subbausteine/Stufen.
@@ -164,7 +149,6 @@ def _bausteine_steps(topic: str) -> tuple:
"Stufen finden", "Stufen wählen", "Stufen klären", "Stufen finden", "Stufen wählen", "Stufen klären",
"Relevanz finden", "Relevanz wählen", "Relevanz klären", "Relevanz finden", "Relevanz wählen", "Relevanz klären",
"Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen", "Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen",
"Grafik finden", "Grafik prüfen",
) )
mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest
return (("Quelle laden",) if q["type"] == "link" else ()) + mitte return (("Quelle laden",) if q["type"] == "link" else ()) + mitte
@@ -182,7 +166,6 @@ PHASEN = (
("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")), ("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")),
("Relevanz", ("Relevanz finden", "Relevanz wählen", "Relevanz klären")), ("Relevanz", ("Relevanz finden", "Relevanz wählen", "Relevanz klären")),
("Fragen", ("Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen")), ("Fragen", ("Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen")),
("Grafiken", ("Grafik finden", "Grafik prüfen")),
) )
@@ -223,19 +206,18 @@ def _bausteine_files(topic: str) -> dict:
"sub_roh": arbeit / "subbausteine-roh.json", "sub_roh": arbeit / "subbausteine-roh.json",
"sidecar": subbausteine_path(topic), "sidecar": subbausteine_path(topic),
"frage_muster": frage_muster_path(topic), "frage_muster": frage_muster_path(topic),
"grafik": grafik_path(topic),
} }
def _alle_slot_dateien(files: dict) -> list[Path]: def _alle_slot_dateien(files: dict) -> list[Path]:
arbeit = files["arbeit"] arbeit = files["arbeit"]
# Subbaustein-/Stufen-Slots sind pro Chunk dynamisch — per Glob einsammeln. # Subbaustein-/Stufen-Slots sind pro Chunk dynamisch — per Glob einsammeln.
dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*")) + list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("grafik-*"))) if arbeit.is_dir() else [] dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*")) + list(arbeit.glob("frage-muster-*"))) if arbeit.is_dir() else []
return [ return [
*files["recherche"], files["recherche_mapping"], *files["recherche"], files["recherche_mapping"],
*(p for slots in files["auswahl"].values() for p in slots), *(p for slots in files["auswahl"].values() for p in slots),
*files["mapping"].values(), files["ergaenzung"], *files["mapping"].values(), files["ergaenzung"],
files["sub_roh"], files["sidecar"], files["frage_muster"], files["grafik"], *dyn, files["sub_roh"], files["sidecar"], files["frage_muster"], *dyn,
] ]
@@ -275,9 +257,6 @@ def _resume_step(topic: str) -> int:
# Relevanz fertig; nur noch Frage-Muster offen? # Relevanz fertig; nur noch Frage-Muster offen?
if not _frage_muster_komplett(topic): if not _frage_muster_komplett(topic):
return _step_idx(topic, "Fragen finden") return _step_idx(topic, "Fragen finden")
# Frage-Muster fertig; nur noch Grafiken offen?
if not _grafik_komplett(topic):
return _step_idx(topic, "Grafik finden")
return len(_bausteine_steps(topic)) return len(_bausteine_steps(topic))
if _sub_roh_schema(_json_datei(files["sub_roh"])) is None: if _sub_roh_schema(_json_datei(files["sub_roh"])) is None:
return _step_idx(topic, "Subbausteine finden") return _step_idx(topic, "Subbausteine finden")
@@ -316,7 +295,6 @@ def reset_bausteine(topic: str) -> None:
files["final"].unlink(missing_ok=True) files["final"].unlink(missing_ok=True)
files["sidecar"].unlink(missing_ok=True) # liegt im Themen-Root, nicht in arbeit/ files["sidecar"].unlink(missing_ok=True) # liegt im Themen-Root, nicht in arbeit/
files["frage_muster"].unlink(missing_ok=True) # ebenfalls im Themen-Root files["frage_muster"].unlink(missing_ok=True) # ebenfalls im Themen-Root
files["grafik"].unlink(missing_ok=True) # ebenfalls im Themen-Root
quelle_path(topic).unlink(missing_ok=True) quelle_path(topic).unlink(missing_ok=True)
shutil.rmtree(quelle_crawl_dir(topic), ignore_errors=True) # gecrawlte Link-Quelle shutil.rmtree(quelle_crawl_dir(topic), ignore_errors=True) # gecrawlte Link-Quelle
shutil.rmtree(files["arbeit"], ignore_errors=True) shutil.rmtree(files["arbeit"], ignore_errors=True)
@@ -335,9 +313,6 @@ def _reset_ab_phase(topic: str, phase: int) -> None:
for p in arbeit.glob(pat): for p in arbeit.glob(pat):
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
if phase <= 6: # Grafiken
files["grafik"].unlink(missing_ok=True)
glob_del("grafik-*")
if phase <= 5: # Fragen if phase <= 5: # Fragen
files["frage_muster"].unlink(missing_ok=True) files["frage_muster"].unlink(missing_ok=True)
glob_del("frage-muster-*") glob_del("frage-muster-*")
@@ -497,45 +472,6 @@ def _frage_muster_komplett(topic: str) -> bool:
return isinstance(_json_datei(frage_muster_path(topic)), dict) return isinstance(_json_datei(frage_muster_path(topic)), dict)
def _grafik_schema(data) -> dict | None:
"""{"knoten": [{id, text}], "kanten": [{von, nach, text?}], "richtung"?} → bereinigtes
Grafik-Dict · sonst None. Knoten nicht leer, IDs eindeutig, jede Kante referenziert
existierende Knoten (sonst verworfen). Leere Knotenliste → None."""
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()
text = str(k.get("text", "")).strip()
if not kid or not text or kid in ids:
continue
ids.add(kid)
knoten.append({"id": kid, "text": text})
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:
continue # hängende Kante (halluzinierte ID) verwerfen
kante = {"von": von, "nach": nach}
if (t := str(e.get("text", "")).strip()):
kante["text"] = t
kanten.append(kante)
richtung = data.get("richtung") if data.get("richtung") in ("TB", "LR") else "TB"
return {"richtung": richtung, "knoten": knoten, "kanten": kanten}
def _grafik_komplett(topic: str) -> bool:
"""Grafik-Sidecar existiert (Build gelaufen)? Einzelne leere Bausteine fallen im
Frontend auf den Fallback-Hinweis zurück — daher genügt die Datei."""
return isinstance(_json_datei(grafik_path(topic)), dict)
def _read(p: Path) -> str: def _read(p: Path) -> str:
return p.read_text(encoding="utf-8") if p.exists() else "" return p.read_text(encoding="utf-8") if p.exists() else ""
@@ -1054,109 +990,6 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
return ergebnis return ergebnis
async def _grafik_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None:
"""Block F: zwei Phasen — Finden (1 Generator je Baustein, parallel) erzeugt eine
Graph-Grafik (Knoten/Kanten) als JSON, Prüfen (Kritiker je Baustein) fixt Struktur +
Inhalt; Bausteine ohne Grafik eine Runde nachholen.
{Baustein-Titel: {knoten, kanten, richtung}} oder None bei Abbruch."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
arbeit = files["arbeit"]
beschr = {b["titel"]: b["beschreibung"] for b in lade_uebersicht(topic)}
# Je Baustein die relevanten Subbausteine (relevanz != 'rand') als Graph-Grundlage.
bausteine = []
for titel, subs in sidecar.items():
rel = [s["titel"] for s in subs if isinstance(s, dict) and s.get("relevanz") != "rand" and str(s.get("titel", "")).strip()]
if rel:
bausteine.append((titel, rel))
if not bausteine:
return {}
def roh_path(c):
return arbeit / f"grafik-c{c}.json"
def final_path(c):
return arbeit / f"grafik-final-c{c}.json"
# Phase „Grafik finden": pro Baustein 1 Generator, alle parallel.
async def _finde(c, titel, rel):
fp = roh_path(c)
if _grafik_schema(_json_datei(fp)):
return # Resume
sub_block = "\n".join(f"- {s}" for s in rel)
status, _ = await run_single_slot(
ctx, f"Grafik {c}",
key=f"bausteine-{topic}-grafik-c{c}",
prompt=_prompt("Baustein-Grafik", topic=topic, baustein=titel,
beschreibung=beschr.get(titel, ""), subbausteine=sub_block,
out_path=fp, extra=_extra(instructions)),
role="guide", capabilities="files",
payload=lambda result, p=fp: _grafik_schema(_json_datei(p)),
timeout=_timeout("grafik", len(rel)),
)
if status == FAILED:
_log(topic, f"Grafik Baustein {c} fehlgeschlagen — keine Grafik (Fallback)")
set_p(f"Grafik finden ({len(bausteine)} Bausteine)…", step=_step_idx(topic, "Grafik finden"))
await asyncio.gather(*[_finde(c, t, r) for c, (t, r) in enumerate(bausteine, 1)], return_exceptions=True)
if is_cancelled():
return None
# Phase „Grafik prüfen": Kritiker je Baustein fixt Struktur/Inhalt; sonst Roh übernehmen.
async def _pruefe(c, titel, rel):
roh = _grafik_schema(_json_datei(roh_path(c)))
fp = final_path(c)
if _grafik_schema(_json_datei(fp)) or not roh:
return
sub_block = "\n".join(f"- {s}" for s in rel)
status, _ = await run_single_slot(
ctx, f"Grafik-Prüfung {c}",
key=f"bausteine-{topic}-grafik-final-c{c}",
prompt=_prompt("Baustein-Grafik-Check", topic=topic, baustein=titel,
subbausteine=sub_block,
grafik=json.dumps(roh, ensure_ascii=False, indent=1),
out_path=fp, extra=_extra(instructions)),
role="judge", capabilities="files",
payload=lambda result, p=fp: _grafik_schema(_json_datei(p)),
timeout=_timeout("grafik_check", len(rel)),
)
if status == FAILED:
_log(topic, f"Grafik-Prüfung Baustein {c} fehlgeschlagen — Roh-Grafik übernommen")
set_p(f"Grafik prüfen ({len(bausteine)} Bausteine)…", step=_step_idx(topic, "Grafik prüfen"))
await asyncio.gather(*[_pruefe(c, t, r) for c, (t, r) in enumerate(bausteine, 1)], return_exceptions=True)
if is_cancelled():
return None
def _finalisiere(c):
return _grafik_schema(_json_datei(final_path(c))) or _grafik_schema(_json_datei(roh_path(c)))
ergebnis = {}
for c, (titel, rel) in enumerate(bausteine, 1):
if (g := _finalisiere(c)):
ergebnis[titel] = g
# Bausteine ohne Grafik eine Runde nachholen.
leer = [(c, t, r) for c, (t, r) in enumerate(bausteine, 1) if t not in ergebnis]
if leer:
_log(topic, f"Grafik: {len(leer)} Baustein(e) ohne Grafik — Nachrunde")
for c, t, r in leer:
roh_path(c).unlink(missing_ok=True)
final_path(c).unlink(missing_ok=True)
await asyncio.gather(*[_finde(c, t, r) for c, t, r in leer], return_exceptions=True)
if is_cancelled():
return None
await asyncio.gather(*[_pruefe(c, t, r) for c, t, r in leer], return_exceptions=True)
if is_cancelled():
return None
for c, t, r in leer:
if (g := _finalisiere(c)):
ergebnis[t] = g
rest = [t for c, t, r in leer if t not in ergebnis]
if rest:
_log(topic, f"Grafik: {len(rest)} Baustein(e) bleiben leer (Fallback): {rest[:5]}")
return ergebnis
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_phase: int | None = None) -> None: async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_phase: int | None = None) -> None:
if topic in _bausteine_progress: if topic in _bausteine_progress:
return return
@@ -1453,19 +1286,6 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
if muster is None: if muster is None:
return # Abbruch return # Abbruch
atomic_write_json(files["frage_muster"], muster, indent=1) atomic_write_json(files["frage_muster"], muster, indent=1)
# Block F: pro Baustein eine Graph-Grafik (Knoten/Kanten) der relevanten
# Subbausteine → eigenes Sidecar. Von allen Guide-Formaten geteilt; das
# Frontend rendert sie mit Auto-Layout (dagre) + KaTeX.
sidecar = _json_datei(files["sidecar"])
if _sidecar_schema(sidecar) is not None and _relevanz_komplett(sidecar) and _frage_muster_komplett(topic) and not _grafik_komplett(topic):
grafik = await _grafik_block(ctx, set_p, files, sidecar, instructions)
if is_cancelled():
abgebrochen()
return
if grafik is None:
return # Abbruch
atomic_write_json(files["grafik"], grafik, indent=1)
except Exception as e: except Exception as e:
log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic) log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic)
_bausteine_errors[topic] = str(e)[:2000] _bausteine_errors[topic] = str(e)[:2000]

View File

@@ -60,10 +60,6 @@ TIMEOUTS = {
"frage_muster_check": (300, 10), # Kritiker bereinigt die Muster-Tabelle je Baustein "frage_muster_check": (300, 10), # Kritiker bereinigt die Muster-Tabelle je Baustein
"writer": (600, 120), # pro Section im Chunk "writer": (600, 120), # pro Section im Chunk
"lese_check": (300, 10), # pro Section im Paket "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
"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). # 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) bp = bausteine_path(topic)
if bp.exists(): if bp.exists():
parts.append(bp.read_text(encoding="utf-8")) parts.append(bp.read_text(encoding="utf-8"))
for fmt in ("Guide",): # Roadmap ist ein Graph ohne Prosa-Sections → kein Text-Kontext for fmt in ("Guide", "FullGuide"): # bester verfügbarer Prosa-Guide als Chat-Kontext
content = _json_datei(guide_content_path(topic, fmt)) content = _json_datei(guide_content_path(topic, fmt))
if content: if content:
for ch in content.get("chapters", []): for ch in content.get("chapters", []):

View File

@@ -1,4 +1,4 @@
"""Guide-Generierung als Konsens-Pipeline (Roadmap hat einen eigenen Weg). """Guide-Generierung als Konsens-Pipeline.
Auswahl: 5 Agenten (min. 3, Grace) → Code-Voting (Mehrheit = Konsens) → Auswahl: 5 Agenten (min. 3, Grace) → Code-Voting (Mehrheit = Konsens) →
Mapping-Agent sortiert Strittiges → Klärungs-Loop (max. KONSENS_MAX_RUNDEN). Mapping-Agent sortiert Strittiges → Klärungs-Loop (max. KONSENS_MAX_RUNDEN).
@@ -24,7 +24,6 @@ import lesbarkeit
from database import list_guides, update_guide from database import list_guides, update_guide
from fsutil import atomic_write_json from fsutil import atomic_write_json
from jsonio import read_json_file as _json_datei from jsonio import read_json_file as _json_datei
from roadmap import _generate_roadmap
from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path
from pipeline import ( from pipeline import (
CANCELLED, FAILED, GenContext, _claude_error, _extra, CANCELLED, FAILED, GenContext, _claude_error, _extra,
@@ -429,7 +428,7 @@ async def _generate_sections(
def _hat_relevanz(num, art): def _hat_relevanz(num, art):
return any(isinstance(s, dict) and s.get("relevanz") == art for s in subs_raw.get(_titel(entries[num]), [])) return any(isinstance(s, dict) and s.get("relevanz") == art for s in subs_raw.get(_titel(entries[num]), []))
# Schritt 0: Auswahl. Deterministisch je Format (Roadmap hat einen eigenen Weg): # Schritt 0: Auswahl. Deterministisch je Format:
# Guide=relevante Bausteine · FullGuide=alle · Rest=Rand-Bausteine. # Guide=relevante Bausteine · FullGuide=alle · Rest=Rand-Bausteine.
if format_name == "FullGuide": if format_name == "FullGuide":
auswahl = list(entries) auswahl = list(entries)
@@ -528,10 +527,10 @@ async def _generate_sections(
# Garantie: jeder gewählte Baustein steht im Plan (gegen weglassende Agenten/Judges). # Garantie: jeder gewählte Baustein steht im Plan (gegen weglassende Agenten/Judges).
plan = _mit_resten(plan, sel_entries) plan = _mit_resten(plan, sel_entries)
# Chunks festlegen — Inhalte, Inhalts-Prüfung, Writer und Lese-Check nutzen # Chunks festlegen: 1 Chunk = 1 Baustein. So läuft JEDER Section-Schritt (Inhalte,
# dieselbe Aufteilung: 1 Agent je ~30 Bausteine (gedeckelt). # Inhalts-Prüfung, Writer, Lese-Check) mit genau einem Agent je Baustein. Der Writer
total_sections = sum(len(c["nums"]) for c in plan) # rationiert sein Output-Budget dann nicht über viele Sections → längere, variable Texte.
chunks = _split_chunks(plan, min(WRITER_MAX, max(1, math.ceil(total_sections / WRITER_SECTIONS)))) chunks = [[{"title": ch["title"], "nums": [num]}] for ch in plan for num in ch["nums"]]
# Subbausteine je Baustein, je Format gefiltert: Guide=relevant · FullGuide=alle · Rest=Rand. # Subbausteine je Baustein, je Format gefiltert: Guide=relevant · FullGuide=alle · Rest=Rand.
# Subs ohne Relevanz-Feld (Alt-Themen) zählen als „nicht Rand" (→ Guide/FullGuide). # Subs ohne Relevanz-Feld (Alt-Themen) zählen als „nicht Rand" (→ Guide/FullGuide).
if format_name == "FullGuide": if format_name == "FullGuide":
@@ -727,10 +726,9 @@ async def _generate_sections(
if is_cancelled(): if is_cancelled():
return None return None
if res is None: if res is None:
if runde == 1: # Nicht hart failen: bei 1 Agent je Baustein darf ein einzelner Check-Ausfall
await _fail(guide_id, "Lese-Prüfung fehlgeschlagen") # nicht den ganzen Guide kippen. Sections sind geschrieben; Lese-Prüfung ist Politur.
return None _log(topic, f"Lese-Prüfung Runde {runde} ohne vollständiges Ergebnis — Stand bleibt")
_log(topic, f"Lese-Prüfung Runde {runde} fehlgeschlagen — Stand bleibt")
break break
probleme_by_num: dict[int, str] = {} probleme_by_num: dict[int, str] = {}
@@ -859,25 +857,19 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
for p_alt in guide_slot_dateien(content_path): for p_alt in guide_slot_dateien(content_path):
p_alt.unlink(missing_ok=True) p_alt.unlink(missing_ok=True)
if format_name == "Roadmap": alle = _lade_bausteine(bausteine_path(topic).read_text(encoding="utf-8"))
graph = await _generate_roadmap(guide_id, topic, instructions, provider, content_path) if not alle:
if graph is None or is_guide_cancelled(guide_id): await _fail(guide_id, "Keine Bausteine gefunden")
return return
content = {"topic": topic, "format": format_name, "graph": graph} entries = _eindeutige_titel(alle)
else: facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
alle = _lade_bausteine(bausteine_path(topic).read_text(encoding="utf-8")) chapters = await _generate_sections(
if not alle: guide_id, topic, format_name, entries,
await _fail(guide_id, "Keine Bausteine gefunden") facts, instructions, provider, content_path,
return )
entries = _eindeutige_titel(alle) if chapters is None or is_guide_cancelled(guide_id):
facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema") return
chapters = await _generate_sections( content = {"topic": topic, "format": format_name, "chapters": chapters}
guide_id, topic, format_name, entries,
facts, instructions, provider, content_path,
)
if chapters is None or is_guide_cancelled(guide_id):
return
content = {"topic": topic, "format": format_name, "chapters": chapters}
atomic_write_json(content_path, content, indent=1) atomic_write_json(content_path, content, indent=1)

View File

@@ -2,7 +2,6 @@ from pydantic import BaseModel, Field
from typing import Literal from typing import Literal
FormatType = Literal[ FormatType = Literal[
"Roadmap",
"Guide", "Guide",
"FullGuide", "FullGuide",
"Rest", "Rest",
@@ -30,7 +29,7 @@ class BausteineCreateRequest(BaseModel):
provider: ProviderType = "claude" provider: ProviderType = "claude"
source_type: SourceType = "thema" source_type: SourceType = "thema"
source_ort: str = Field(default="", max_length=2000) source_ort: str = Field(default="", max_length=2000)
ab_phase: int | None = Field(default=None, ge=1, le=6) # Re-Run ab grober Phase (1 Inventar … 6 Grafiken); None = Resume/Fortsetzen ohne Löschen ab_phase: int | None = Field(default=None, ge=1, le=5) # Re-Run ab grober Phase (1 Inventar … 5 Fragen); None = Resume/Fortsetzen ohne Löschen
class BausteineStep(BaseModel): class BausteineStep(BaseModel):

View File

@@ -31,11 +31,6 @@ def frage_muster_path(topic: str) -> Path:
return topic_dir(topic) / "frage_muster.json" return topic_dir(topic) / "frage_muster.json"
def grafik_path(topic: str) -> Path:
"""Sidecar: pro Baustein eine Graph-Grafik (Knoten/Kanten), von allen Guides geteilt."""
return topic_dir(topic) / "grafik.json"
def quelle_path(topic: str) -> Path: def quelle_path(topic: str) -> Path:
"""Persistierte Quellen-Wahl pro Thema: {type, ort, spec}.""" """Persistierte Quellen-Wahl pro Thema: {type, ort, spec}."""
return topic_dir(topic) / "quelle.json" return topic_dir(topic) / "quelle.json"

View File

@@ -1,10 +1,10 @@
"""Lernschulden-Regeln: Progression und Deckel für offene Guides — die EINZIGE Quelle. """Lernschulden-Regeln: Progression und Deckel für offene Guides — die EINZIGE Quelle.
Regeln (nur Neu-Erstellungen; Themen, Bausteine, Roadmap unbegrenzt): Regeln (nur Neu-Erstellungen; Themen + Bausteine unbegrenzt):
- Format „Guide": höchstens 3 erstellte, nicht absolvierte Guides - Format „Guide": höchstens 3 erstellte, nicht absolvierte Guides
- Keine Progression/Vorstufe mehr (nur ein Guide-Format). - Keine Progression/Vorstufe mehr (nur ein Guide-Format).
- Absolviert: ALLE Bausteine (Section-Titel) des neuesten fertigen Guides haben - Absolviert: ALLE Bausteine (Section-Titel) des neuesten fertigen Guides haben
eine bestandene Prüfung. Roadmap ist read-only (kein Fortschritt, keine Prüfung). eine bestandene Prüfung. Rest ist read-only (kein Fortschritt, keine Prüfung).
Alle Funktionen arbeiten auf einmal geladenen Daten (lade_lernstand) — keine Alle Funktionen arbeiten auf einmal geladenen Daten (lade_lernstand) — keine
Query-Schleifen mehr pro Guide. Query-Schleifen mehr pro Guide.
""" """
@@ -18,7 +18,7 @@ from textkit import _norm_titel
MAX_OFFENE_GUIDES = 3 MAX_OFFENE_GUIDES = 3
# Nur noch EIN Format „Guide" (alle relevanten Bausteine, Prüfung 030). Keine Progression, # Nur noch EIN Format „Guide" (alle relevanten Bausteine, Prüfung 030). Keine Progression,
# keine Vorstufe → der Guide ist immer freischaltbar. „Rest"/Roadmap separat. # keine Vorstufe → der Guide ist immer freischaltbar. „Rest"/FullGuide separat.
VORSTUFE: dict[str, str] = {} VORSTUFE: dict[str, str] = {}
FREISCHALT_LEVEL: dict[str, str] = {} FREISCHALT_LEVEL: dict[str, str] = {}
FORMATE = ("Guide",) FORMATE = ("Guide",)

View File

@@ -1,127 +0,0 @@
"""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

@@ -16,7 +16,7 @@ from database import (
list_baustein_progress, get_baustein_progress, set_offene_frage, list_baustein_progress, get_baustein_progress, set_offene_frage,
set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten, set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten,
) )
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster, lade_grafiken from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from lernen import NOETIG, MASTERY, MEISTERN, NIVEAUS, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, score_berechnen, floor_aus_meilensteinen, _zufall_malus from lernen import NOETIG, MASTERY, MEISTERN, NIVEAUS, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, score_berechnen, floor_aus_meilensteinen, _zufall_malus
from guide import generate_guide, guide_slot_dateien from guide import generate_guide, guide_slot_dateien
@@ -212,13 +212,6 @@ async def get_frage_muster(topic: str, baustein: str):
return {"muster": lade_frage_muster(topic, baustein)} return {"muster": lade_frage_muster(topic, baustein)}
@router.get("/bausteine/grafiken")
async def get_grafiken(topic: str):
"""Alle Baustein-Grafiken eines Themas: {Baustein-Titel: {knoten, kanten, richtung}}.
Einmal je Thema geladen; das Frontend schlägt pro Baustein nach dem Titel nach."""
return {"grafiken": lade_grafiken(topic)}
# --- Baustein-Lernen: Chat, Prüfung --- # --- Baustein-Lernen: Chat, Prüfung ---
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse) @router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
@@ -478,7 +471,7 @@ async def list_all():
async def guide_locks(topic: str): async def guide_locks(topic: str):
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar.""" """Sperr-Gründe pro Format für den ▶-Button — None = erstellbar."""
guides, progress, levels = await lade_lernstand() guides, progress, levels = await lade_lernstand()
return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("Roadmap", "FullGuide", "Rest", *FORMATE)} return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("FullGuide", "Rest", *FORMATE)}
@router.get("/guides/{guide_id}", response_model=GuideResponse) @router.get("/guides/{guide_id}", response_model=GuideResponse)

View File

@@ -8,8 +8,6 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@vue-flow/core": "^1.48.2",
"dompurify": "^3.4.7", "dompurify": "^3.4.7",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.17.0", "katex": "^0.17.0",
@@ -481,21 +479,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dagrejs/dagre": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
"integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "4.0.1"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz",
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
"license": "MIT"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -897,12 +880,6 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.7", "version": "6.0.7",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
@@ -920,22 +897,6 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@vue-flow/core": {
"version": "1.48.2",
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz",
"integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==",
"license": "MIT",
"dependencies": {
"@vueuse/core": "^10.5.0",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
},
"peerDependencies": {
"vue": "^3.3.0"
}
},
"node_modules/@vue/babel-helper-vue-transform-on": { "node_modules/@vue/babel-helper-vue-transform-on": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz",
@@ -1123,94 +1084,6 @@
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"license": "MIT",
"dependencies": {
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/ansis": { "node_modules/ansis": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.0.tgz",
@@ -1337,111 +1210,6 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -9,8 +9,6 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@vue-flow/core": "^1.48.2",
"dompurify": "^3.4.7", "dompurify": "^3.4.7",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.17.0", "katex": "^0.17.0",

View File

@@ -181,7 +181,7 @@ async function loadBausteine() {
} }
} }
const FORMAT_ORDER = ['Guide', 'FullGuide', 'Roadmap', 'Rest'] const FORMAT_ORDER = ['Guide', 'FullGuide', 'Rest']
function autoPreview() { function autoPreview() {
const map = doneByFormat.value const map = doneByFormat.value
@@ -202,7 +202,7 @@ function selectTopic(topic) {
elementsView.value = false elementsView.value = false
bausteineView.value = false bausteineView.value = false
elementOpenId.value = null elementOpenId.value = null
ansichtModus.value = localStorage.getItem('ansicht_' + topic) || 'kompakt' ansichtModus.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'kompakt'
localStorage.setItem('lastTopic', topic) localStorage.setItem('lastTopic', topic)
loadBausteine() loadBausteine()
nextTick(autoPreview) nextTick(autoPreview)

View File

@@ -94,11 +94,6 @@ export async function fetchFrageMuster(topic, baustein) {
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function fetchGrafiken(topic) {
const res = await fetch(`${BASE}/bausteine/grafiken?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export async function fetchTopicFortschritt(topic) { export async function fetchTopicFortschritt(topic) {
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
return res.json() return res.json()

View File

@@ -1,11 +1,9 @@
<script setup> <script setup>
import BausteinPanel from './BausteinPanel.vue' import BausteinPanel from './BausteinPanel.vue'
import GraphView from './GraphView.vue'
import { renderMarkdown } from '../markdown.js' import { renderMarkdown } from '../markdown.js'
const props = defineProps({ const props = defineProps({
baustein: { type: Object, required: true }, // { title, md, num } baustein: { type: Object, required: true }, // { title, md, num }
graph: { type: Object, default: null }, // {knoten,kanten,richtung} aus dem Grafik-Sidecar
topic: { type: String, required: true }, topic: { type: String, required: true },
provider: { type: String, default: 'claude' }, provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, status: { type: Object, default: null },
@@ -91,8 +89,7 @@ const standFarbe = computed(() => {
</div> </div>
<div class="fokus-body"> <div class="fokus-body">
<div ref="guideEl" class="fokus-col left"> <div ref="guideEl" class="fokus-col left">
<GraphView v-if="ansicht === 'grafik'" :graph="graph" height="70vh" /> <div class="markdown" v-html="renderMarkdown(ansicht === 'kompakt' ? (baustein.kompakt || baustein.md) : baustein.md)"></div>
<div v-else class="markdown" v-html="renderMarkdown(ansicht === 'kompakt' ? (baustein.kompakt || baustein.md) : baustein.md)"></div>
</div> </div>
<div ref="rightEl" class="fokus-col right"> <div ref="rightEl" class="fokus-col right">
<BausteinPanel <BausteinPanel

View File

@@ -629,8 +629,7 @@ function onPruefKey(e) {
} }
if (e.code === 'KeyQ') { if (e.code === 'KeyQ') {
e.preventDefault() e.preventDefault()
const r = ['kompakt', 'erklärend', 'grafik'] emit('setAnsicht', props.ansicht === 'kompakt' ? 'erklärend' : 'kompakt')
emit('setAnsicht', r[(r.indexOf(props.ansicht) + 1) % 3])
return return
} }
if (e.code === 'KeyE') { e.preventDefault(); variantWechseln(); return } if (e.code === 'KeyE') { e.preventDefault(); variantWechseln(); return }
@@ -648,7 +647,6 @@ function onPruefKey(e) {
<div v-if="mode === 'full'" class="bp-ansicht"> <div v-if="mode === 'full'" class="bp-ansicht">
<button :class="{ active: ansicht === 'kompakt' }" title="Merksätze" @click="emit('setAnsicht', 'kompakt')">Kompakt</button> <button :class="{ active: ansicht === 'kompakt' }" title="Merksätze" @click="emit('setAnsicht', 'kompakt')">Kompakt</button>
<button :class="{ active: ansicht === 'erklärend' }" title="Ausführliche Erklärung" @click="emit('setAnsicht', 'erklärend')">Erklärend</button> <button :class="{ active: ansicht === 'erklärend' }" title="Ausführliche Erklärung" @click="emit('setAnsicht', 'erklärend')">Erklärend</button>
<button :class="{ active: ansicht === 'grafik' }" title="Diagramm" @click="emit('setAnsicht', 'grafik')">Grafik</button>
</div> </div>
<button :class="{ active: activeTab === 'chat' }" @click="tabKlick('chat')"> <button :class="{ active: activeTab === 'chat' }" @click="tabKlick('chat')">
Chat Chat
@@ -848,7 +846,7 @@ function onPruefKey(e) {
.bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); } .bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); }
.bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } .bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
/* Ansicht-Umschalter (Kompakt/Erklärend/Grafik) — segmentiert, links vor den Tabs */ /* Ansicht-Umschalter (Kompakt/Erklärend) — segmentiert, links vor den Tabs */
.bp-ansicht { display: inline-flex; margin-right: 0.5rem; } .bp-ansicht { display: inline-flex; margin-right: 0.5rem; }
.bp-ansicht button { .bp-ansicht button {
padding: 0.25rem 0.6rem; padding: 0.25rem 0.6rem;

View File

@@ -1,137 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { VueFlow, useVueFlow, Handle, Position, MarkerType } from '@vue-flow/core'
import '@vue-flow/core/dist/style.css'
import dagre from '@dagrejs/dagre'
import { renderMarkdownInline } from '../markdown.js'
// Grafik = {knoten:[{id,text}], kanten:[{von,nach,text?}], richtung:'TB'|'LR'}.
// KI liefert nur die Semantik; Layout macht dagre, Mathe rendert KaTeX im Knoten.
const props = defineProps({
graph: { type: Object, default: null },
height: { type: String, default: '380px' },
})
let _seq = 0
const flowId = `gv-${++_seq}-${Math.floor(performance.now())}`
const { findNode, fitView, onNodesInitialized } = useVueFlow(flowId)
const nodes = ref([])
const edges = ref([])
const bereit = ref(false) // erst nach dem Layout sichtbar (sonst kurz bei 0,0 gestapelt)
const fehler = ref(false)
const richtung = computed(() => (props.graph?.richtung === 'LR' ? 'LR' : 'TB'))
const istLR = computed(() => richtung.value === 'LR')
const targetPos = computed(() => (istLR.value ? Position.Left : Position.Top))
const sourcePos = computed(() => (istLR.value ? Position.Right : Position.Bottom))
function build() {
bereit.value = false
const g = props.graph
const kn = g && Array.isArray(g.knoten) ? g.knoten : []
if (!kn.length) {
fehler.value = true
nodes.value = []
edges.value = []
return
}
fehler.value = false
const ids = new Set(kn.map((k) => String(k.id)))
nodes.value = kn.map((k) => ({
id: String(k.id),
type: 'math',
position: { x: 0, y: 0 },
data: { text: String(k.text || '') },
}))
edges.value = (g.kanten || [])
.filter((e) => ids.has(String(e.von)) && ids.has(String(e.nach)))
.map((e, i) => ({
id: `e${i}`,
source: String(e.von),
target: String(e.nach),
label: e.text ? String(e.text) : undefined,
type: 'smoothstep',
markerEnd: MarkerType.ArrowClosed,
}))
}
// Two-Pass: Vue Flow misst die Knoten (KaTeX!), erst dann rechnet dagre das Layout.
function layout() {
const g = new dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({}))
g.setGraph({ rankdir: richtung.value, nodesep: 40, ranksep: 55 })
for (const n of nodes.value) {
const dim = findNode(n.id)?.dimensions
g.setNode(n.id, { width: dim?.width || 160, height: dim?.height || 40 })
}
for (const e of edges.value) g.setEdge(e.source, e.target)
dagre.layout(g)
nodes.value = nodes.value.map((n) => {
const p = g.node(n.id)
return { ...n, position: { x: p.x - p.width / 2, y: p.y - p.height / 2 } }
})
}
onNodesInitialized(async () => {
if (!nodes.value.length) return
await document.fonts.ready // sonst misst der Browser Fallback-Font → falsche Breite
layout()
bereit.value = true
await nextTick()
fitView({ padding: 0.2 })
})
watch(() => props.graph, build, { immediate: true })
</script>
<template>
<div class="graph-view" :style="{ height }">
<p v-if="fehler" class="graph-fallback">Grafik nicht verfügbar — Bausteine ab „Grafiken" neu bauen.</p>
<VueFlow
v-else
:id="flowId"
:nodes="nodes"
:edges="edges"
:style="{ opacity: bereit ? 1 : 0 }"
:nodes-draggable="false"
:nodes-connectable="false"
:elements-selectable="false"
:zoom-on-scroll="false"
:zoom-on-double-click="false"
:min-zoom="0.2"
:max-zoom="2"
fit-view-on-init
>
<template #node-math="{ data }">
<Handle type="target" :position="targetPos" />
<div class="gv-node markdown" v-html="renderMarkdownInline(data.text)"></div>
<Handle type="source" :position="sourcePos" />
</template>
</VueFlow>
</div>
</template>
<style scoped>
.graph-view { width: 100%; position: relative; }
.graph-fallback { color: var(--text-muted); font-size: 0.85rem; padding: 1rem 0; }
.gv-node {
padding: 0.4rem 0.65rem;
border: 1px solid var(--border-strong, #999);
border-radius: 8px;
background: var(--panel, #fff);
color: var(--text, #111);
font-size: 0.8rem;
line-height: 1.35;
text-align: center;
max-width: 300px;
/* Langer Text/Formel umbricht, statt im Knoten abgeschnitten zu werden. */
white-space: normal;
overflow-wrap: anywhere;
}
/* KaTeX im Knoten umbrechen lassen + geerbtes Klippen (markdown .katex-display) neutralisieren. */
.gv-node :deep(.katex) { white-space: normal; }
.gv-node :deep(.katex-display) { overflow: visible; margin: 0; }
.graph-view :deep(.vue-flow__edge-text) { font-size: 0.72rem; fill: var(--text-muted, #666); }
.graph-view :deep(.vue-flow__edge-textbg) { fill: var(--panel, #fff); }
</style>

View File

@@ -1,146 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { VueFlow, useVueFlow, Handle, Position, MarkerType } from '@vue-flow/core'
import '@vue-flow/core/dist/style.css'
import dagre from '@dagrejs/dagre'
import { renderMarkdownInline } from '../markdown.js'
// Roadmap = read-only Übersichts-Graph: {knoten:[{id,title,text,prioritaet}],
// kanten:[{von,nach,typ}], richtung}. Layout via dagre, Knoten-Text via KaTeX.
const props = defineProps({
graph: { type: Object, default: null },
height: { type: String, default: '78vh' },
})
let _seq = 0
const flowId = `rm-${++_seq}-${Math.floor(performance.now())}`
const { findNode, fitView, onNodesInitialized } = useVueFlow(flowId)
const nodes = ref([])
const edges = ref([])
const bereit = ref(false)
const fehler = ref(false)
const richtung = computed(() => (props.graph?.richtung === 'LR' ? 'LR' : 'TB'))
const istLR = computed(() => richtung.value === 'LR')
const targetPos = computed(() => (istLR.value ? Position.Left : Position.Top))
const sourcePos = computed(() => (istLR.value ? Position.Right : Position.Bottom))
function build() {
bereit.value = false
const g = props.graph
const kn = g && Array.isArray(g.knoten) ? g.knoten : []
if (!kn.length) {
fehler.value = true
nodes.value = []
edges.value = []
return
}
fehler.value = false
const ids = new Set(kn.map((k) => String(k.id)))
nodes.value = kn.map((k) => ({
id: String(k.id),
type: 'roadmap',
position: { x: 0, y: 0 },
data: {
title: String(k.title || ''),
text: String(k.text || ''),
prioritaet: ['grund', 'kern', 'vertiefung'].includes(k.prioritaet) ? k.prioritaet : 'kern',
},
}))
edges.value = (g.kanten || [])
.filter((e) => ids.has(String(e.von)) && ids.has(String(e.nach)))
.map((e, i) => {
const optional = e.typ === 'optional'
return {
id: `e${i}`,
source: String(e.von),
target: String(e.nach),
type: 'smoothstep',
markerEnd: MarkerType.ArrowClosed,
style: optional ? { strokeDasharray: '6 4', opacity: 0.7 } : {},
}
})
}
// Two-Pass: Vue Flow misst die Knoten, dann rechnet dagre das Layout.
function layout() {
const g = new dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({}))
g.setGraph({ rankdir: richtung.value, nodesep: 50, ranksep: 70 })
for (const n of nodes.value) {
const dim = findNode(n.id)?.dimensions
g.setNode(n.id, { width: dim?.width || 180, height: dim?.height || 56 })
}
for (const e of edges.value) g.setEdge(e.source, e.target)
dagre.layout(g)
nodes.value = nodes.value.map((n) => {
const p = g.node(n.id)
return { ...n, position: { x: p.x - p.width / 2, y: p.y - p.height / 2 } }
})
}
onNodesInitialized(async () => {
if (!nodes.value.length) return
await document.fonts.ready
layout()
bereit.value = true
await nextTick()
fitView({ padding: 0.2 })
})
watch(() => props.graph, build, { immediate: true })
</script>
<template>
<div class="roadmap-view" :style="{ height }">
<p v-if="fehler" class="rm-fallback">Roadmap nicht verfügbar neu generieren.</p>
<VueFlow
v-else
:id="flowId"
:nodes="nodes"
:edges="edges"
:style="{ opacity: bereit ? 1 : 0 }"
:nodes-draggable="false"
:nodes-connectable="false"
:elements-selectable="false"
:zoom-on-scroll="false"
:zoom-on-double-click="false"
:min-zoom="0.2"
:max-zoom="2"
fit-view-on-init
>
<template #node-roadmap="{ data }">
<Handle type="target" :position="targetPos" />
<div class="rm-node" :class="'prio-' + data.prioritaet">
<div class="rm-title">{{ data.title }}</div>
<div v-if="data.text" class="rm-text markdown" v-html="renderMarkdownInline(data.text)"></div>
</div>
<Handle type="source" :position="sourcePos" />
</template>
</VueFlow>
</div>
</template>
<style scoped>
.roadmap-view { width: 100%; position: relative; }
.rm-fallback { color: var(--text-muted); font-size: 0.85rem; padding: 1rem 0; }
.rm-node {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-strong, #999);
border-left: 4px solid var(--rm-accent, #8b5cf6);
border-radius: 8px;
background: color-mix(in srgb, var(--rm-accent, #8b5cf6) 7%, var(--panel, #fff));
color: var(--text, #111);
max-width: 260px;
white-space: normal;
overflow-wrap: anywhere;
}
.rm-title { font-weight: 600; font-size: 0.85rem; line-height: 1.25; }
.rm-text { font-size: 0.74rem; color: var(--text-muted, #555); line-height: 1.3; margin-top: 0.2rem; }
.rm-text :deep(.katex) { white-space: normal; }
/* Prioritäts-Farben: grund=grün (Fundament), kern=lila (Hauptstoff), vertiefung=grau (später). */
.prio-grund { --rm-accent: #22c55e; }
.prio-kern { --rm-accent: #8b5cf6; }
.prio-vertiefung { --rm-accent: #94a3b8; }
</style>

View File

@@ -1,12 +1,10 @@
<script setup> <script setup>
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue' import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBausteinLernstand, fetchGrafiken } from '../api.js' import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
import { renderMarkdown } from '../markdown.js' import { renderMarkdown } from '../markdown.js'
import { useChat } from '../composables/useChat.js' import { useChat } from '../composables/useChat.js'
import BausteinPanel from './BausteinPanel.vue' import BausteinPanel from './BausteinPanel.vue'
import BausteinFokus from './BausteinFokus.vue' import BausteinFokus from './BausteinFokus.vue'
import GraphView from './GraphView.vue'
import RoadmapView from './RoadmapView.vue'
const props = defineProps({ const props = defineProps({
previewGuide: { type: Object, default: null }, previewGuide: { type: Object, default: null },
@@ -20,8 +18,6 @@ const props = defineProps({
const emit = defineEmits(['progressChanged', 'setAnsicht']) const emit = defineEmits(['progressChanged', 'setAnsicht'])
const isRoadmap = computed(() => props.previewGuide?.format === 'Roadmap')
// Rotierende Kapitel-Akzentfarben (ohne Rot) // Rotierende Kapitel-Akzentfarben (ohne Rot)
const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366f1'] const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366f1']
@@ -29,7 +25,6 @@ const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366
const content = ref(null) const content = ref(null)
const loadError = ref(null) const loadError = ref(null)
const scrollEl = ref(null) const scrollEl = ref(null)
const grafiken = ref({}) // Baustein-Titel → {knoten,kanten,richtung} (Sidecar, von allen Guides geteilt)
const lernstand = ref({}) // Prüfungs-Stand pro Baustein-Titel — VOR dem immediate-Watch (loadContent nutzt es) const lernstand = ref({}) // Prüfungs-Stand pro Baustein-Titel — VOR dem immediate-Watch (loadContent nutzt es)
watch(() => props.previewGuide?.id, loadContent, { immediate: true }) watch(() => props.previewGuide?.id, loadContent, { immediate: true })
@@ -38,7 +33,6 @@ async function loadContent() {
content.value = null content.value = null
loadError.value = null loadError.value = null
lernstand.value = {} lernstand.value = {}
grafiken.value = {}
const g = props.previewGuide const g = props.previewGuide
if (!g || g.status !== 'done') return if (!g || g.status !== 'done') return
try { try {
@@ -48,14 +42,9 @@ async function loadContent() {
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).' loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
return return
} }
if (g.format !== 'Roadmap') { try {
try { lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {} } catch { /* offline → leer */ }
} catch { /* offline → leer */ }
try {
grafiken.value = (await fetchGrafiken(g.topic)).grafiken || {}
} catch { /* keine Grafiken → Fallback */ }
}
} }
// --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel (lernstand oben deklariert) --- // --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel (lernstand oben deklariert) ---
@@ -69,7 +58,7 @@ function onBausteinStatus(baustein, status) {
const fokusIndex = ref(null) // Index in der flachen Baustein-Liste; null = zu const fokusIndex = ref(null) // Index in der flachen Baustein-Liste; null = zu
const fokusTab = ref('pruefung') const fokusTab = ref('pruefung')
const bausteine = computed(() => const bausteine = computed(() =>
isRoadmap.value || !content.value ? [] : content.value.chapters.flatMap((ch) => ch.sections), !content.value ? [] : content.value.chapters.flatMap((ch) => ch.sections),
) )
const fokusBaustein = computed(() => (fokusIndex.value === null ? null : bausteine.value[fokusIndex.value])) const fokusBaustein = computed(() => (fokusIndex.value === null ? null : bausteine.value[fokusIndex.value]))
@@ -191,18 +180,15 @@ function extractContext() {
<template> <template>
<div class="detail"> <div class="detail">
<div v-if="previewGuide && content" ref="scrollEl" class="guide-scroll"> <div v-if="previewGuide && content" ref="scrollEl" class="guide-scroll">
<div class="guide-content" :class="{ roadmap: isRoadmap }"> <div class="guide-content">
<header class="guide-head"> <header class="guide-head">
<h1>{{ previewGuide.topic }}</h1> <h1>{{ previewGuide.topic }}</h1>
<span class="guide-format">{{ previewGuide.format }}</span> <span class="guide-format">{{ previewGuide.format }}</span>
<span v-if="themaAbgeschlossen" class="thema-done" title="Alle Bausteine gemeistert (18/18)"> Thema abgeschlossen</span> <span v-if="themaAbgeschlossen" class="thema-done" title="Alle Bausteine gemeistert (18/18)"> Thema abgeschlossen</span>
</header> </header>
<RoadmapView v-if="isRoadmap" :graph="content.graph || null" height="100%" />
<section <section
v-for="(ch, ci) in content.chapters" v-for="(ch, ci) in content.chapters"
v-else
:key="ch.title" :key="ch.title"
class="chapter" class="chapter"
:style="{ '--ch-accent': CH_COLORS[ci % CH_COLORS.length] }" :style="{ '--ch-accent': CH_COLORS[ci % CH_COLORS.length] }"
@@ -222,8 +208,7 @@ function extractContext() {
<span v-else-if="lernstand[s.title]?.absolviert" class="baustein-done" title="Prüfung bestanden"> Absolviert</span> <span v-else-if="lernstand[s.title]?.absolviert" class="baustein-done" title="Prüfung bestanden"> Absolviert</span>
</template> </template>
</h3> </h3>
<GraphView v-if="ansichtModus === 'grafik'" :graph="grafiken[s.title] || null" /> <div class="section-body markdown" v-html="renderMarkdown(ansichtModus === 'kompakt' ? (s.kompakt || s.md) : s.md)"></div>
<div v-else class="section-body markdown" v-html="renderMarkdown(ansichtModus === 'kompakt' ? (s.kompakt || s.md) : s.md)"></div>
<BausteinPanel <BausteinPanel
v-if="istPruefbar(s)" v-if="istPruefbar(s)"
mode="trigger" mode="trigger"
@@ -250,7 +235,6 @@ function extractContext() {
<BausteinFokus <BausteinFokus
v-if="fokusBaustein" v-if="fokusBaustein"
:baustein="fokusBaustein" :baustein="fokusBaustein"
:graph="grafiken[fokusBaustein.title] || null"
:topic="previewGuide.topic" :topic="previewGuide.topic"
:provider="provider" :provider="provider"
:fortschritt="fortschritt" :fortschritt="fortschritt"
@@ -412,7 +396,7 @@ function extractContext() {
} }
/* Absolvierte Bausteine: Karte kippt sichtbar auf Grün */ /* Absolvierte Bausteine: Karte kippt sichtbar auf Grün */
.guide-content:not(.roadmap) .section-card.absolviert { .guide-content .section-card.absolviert {
border-color: var(--success-border); border-color: var(--success-border);
border-top: 3px solid var(--success); border-top: 3px solid var(--success);
background: color-mix(in srgb, var(--success) 5%, var(--panel)); background: color-mix(in srgb, var(--success) 5%, var(--panel));
@@ -424,7 +408,7 @@ function extractContext() {
border-color: #8b5cf6; border-color: #8b5cf6;
color: #6d28d9; color: #6d28d9;
} }
.guide-content:not(.roadmap) .section-card.verstanden { .guide-content .section-card.verstanden {
border-color: #8b5cf6; border-color: #8b5cf6;
border-top: 3px solid #8b5cf6; border-top: 3px solid #8b5cf6;
background: color-mix(in srgb, #8b5cf6 7%, var(--panel)); background: color-mix(in srgb, #8b5cf6 7%, var(--panel));
@@ -436,14 +420,14 @@ function extractContext() {
border-color: #d4af37; border-color: #d4af37;
color: #8a6d12; color: #8a6d12;
} }
.guide-content:not(.roadmap) .section-card.gemeistert { .guide-content .section-card.gemeistert {
border-color: #d4af37; border-color: #d4af37;
border-top: 3px solid #d4af37; border-top: 3px solid #d4af37;
background: color-mix(in srgb, #d4af37 8%, var(--panel)); background: color-mix(in srgb, #d4af37 8%, var(--panel));
} }
/* Guides: Karten tragen die Kapitel-Akzentfarbe */ /* Guides: Karten tragen die Kapitel-Akzentfarbe */
.guide-content:not(.roadmap) .section-card { .guide-content .section-card {
border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent); border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent);
background: color-mix(in srgb, var(--ch-accent, var(--accent)) 3%, var(--panel)); background: color-mix(in srgb, var(--ch-accent, var(--accent)) 3%, var(--panel));
} }
@@ -460,20 +444,6 @@ function extractContext() {
.section-card h3.baustein-klick { cursor: pointer; width: fit-content; } .section-card h3.baustein-klick { cursor: pointer; width: fit-content; }
.section-card h3.baustein-klick:hover { color: var(--accent); } .section-card h3.baustein-klick:hover { color: var(--accent); }
/* Roadmap: read-only Übersichts-Graph über die volle Fläche, kein Lese-Zoom. */
.guide-content.roadmap {
max-width: none;
height: 100%;
zoom: 1;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
}
.guide-content.roadmap .roadmap-view {
flex: 1;
min-height: 0;
}
.empty-preview { .empty-preview {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -52,7 +52,6 @@ const trackerItems = computed(() => {
}) })
const formats = [ const formats = [
{ key: 'Roadmap', label: 'Roadmap' },
{ key: 'Guide', label: 'Guide' }, { key: 'Guide', label: 'Guide' },
{ key: 'FullGuide', label: 'FullGuide' }, { key: 'FullGuide', label: 'FullGuide' },
{ key: 'Rest', label: 'Rest' }, { key: 'Rest', label: 'Rest' },
@@ -119,14 +118,13 @@ function guideStatus(format) {
return latest.status return latest.status
} }
// Schritt-Kugeln der Guide-Pipelines // Schritt-Kugeln der Guide-Pipeline
const GUIDE_STEPS = ['Auswahl', 'Gliederung', 'Inhalte', 'Inhalts-Check', 'Schreiben', 'Lese-Prüfung'] const GUIDE_STEPS = ['Auswahl', 'Gliederung', 'Inhalte', 'Inhalts-Check', 'Schreiben', 'Lese-Prüfung']
const ROADMAP_STEPS = ['Bauen', 'Prüfung']
// Kugeln werden wie bei den Bausteinen immer angezeigt: // Kugeln werden wie bei den Bausteinen immer angezeigt:
// fertig = alle grün, laufend = live, abgebrochen = Teilfortschritt, sonst grau // fertig = alle grün, laufend = live, abgebrochen = Teilfortschritt, sonst grau
function guideSteps(format) { function guideSteps(format) {
const labels = format === 'Roadmap' ? ROADMAP_STEPS : GUIDE_STEPS const labels = GUIDE_STEPS
const st = guideStatus(format) const st = guideStatus(format)
if (st === 'generating' || st === 'queued') { if (st === 'generating' || st === 'queued') {
// Clamp: alte DB-Läufe können step-Werte oberhalb der neuen Listen haben // Clamp: alte DB-Läufe können step-Werte oberhalb der neuen Listen haben
@@ -308,14 +306,6 @@ function saveQuelle() {
<rect x="2" y="12.9" width="8" height="1.4" rx="0.7" /> <rect x="2" y="12.9" width="8" height="1.4" rx="0.7" />
</svg> </svg>
</button> </button>
<button :class="{ active: ansichtModus === 'grafik' }" title="Grafik (Diagramm)" @click="emit('setAnsicht', 'grafik')">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<circle cx="4" cy="4" r="2" />
<circle cx="12" cy="4" r="2" />
<circle cx="8" cy="12" r="2" />
<path d="M4 6 L8 10 M12 6 L8 10" stroke="currentColor" stroke-width="1.3" fill="none" />
</svg>
</button>
</div> </div>
<button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button> <button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button>
</div> </div>
@@ -411,8 +401,8 @@ function saveQuelle() {
<span class="format-error-text">{{ bausteine.error }}</span> <span class="format-error-text">{{ bausteine.error }}</span>
</div> </div>
</div> </div>
<!-- Roadmap (Überblick zuerst lesen) steht per CSS-order vor der Bausteine-Zeile --> <!-- Formate stehen per CSS-order nach der Bausteine-Zeile (order 2) -->
<div v-for="f in formats" :key="f.key" :style="{ order: f.key === 'Roadmap' ? 1 : 3 }"> <div v-for="f in formats" :key="f.key" :style="{ order: 3 }">
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': abgebrochen(f.key), 'row-open': isOpen('fmt-' + f.key) }]"> <div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': abgebrochen(f.key), 'row-open': isOpen('fmt-' + f.key) }]">
<button class="format-name" @click="handleFormatClick(f.key)"> <button class="format-name" @click="handleFormatClick(f.key)">
<span class="format-label">{{ f.label }}</span> <span class="format-label">{{ f.label }}</span>
@@ -790,7 +780,7 @@ function saveQuelle() {
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
padding: 0.5rem 0; padding: 0.5rem 0;
/* flex + order: Roadmap (order 1) vor Bausteine (order 2) vor den restlichen Formaten (order 3) */ /* flex + order: Bausteine (order 2) vor den Formaten (order 3) */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }

View File

@@ -1,30 +0,0 @@
Du prüfst und korrigierst eine **Grafik** (gerichteter Graph als JSON) zum Thema "{topic}", Baustein "{baustein}". Die Grafik soll die Subbausteine als Muster/Ablauf/Struktur zeigen.
RELEVANTE SUBBAUSTEINE (sollen abgedeckt sein):
{subbausteine}
AKTUELLE GRAFIK (JSON):
{grafik}
PRÜFE auf:
- **Struktur:** jede Kante verweist mit `von`/`nach` auf eine existierende Knoten-`id`; keine hängenden Kanten; `id`s eindeutig; Graph zusammenhängend (keine losen Knoten).
- **Inhalt:** die relevanten Subbausteine kommen vor; Richtung/Kanten geben die Beziehungen korrekt wieder; nichts Erfundenes.
- **Mathe:** Symbole stehen als echte KaTeX-Notation in Dollarzeichen (`$\Sigma$`, `$w \in L$`), nicht als Wort ausgeschrieben. Korrigiere Verstöße.
- **Kanten-Label:** nur ein kurzes Wort (ja/nein/kodiert), **keine Formeln/Dollarzeichen** in Kanten — Formeln gehören in die Knoten.
- **Knappheit:** Knotentexte SEHR kurz (≤ 6 Wörter / eine kompakte Inline-Formel). **Verboten:** `\\`, `\text{{…}}`, `$$…$$`, mehrteilige Formeln. Kürze lange Knoten (z.B. volle Formaldefinitionen) auf den Kern ein.
Korrigiere alle Mängel. Ist die Grafik bereits gut, gib sie unverändert zurück.
{extra}
Schreibe die geprüfte Grafik als JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
{{
"richtung": "TB",
"knoten": [
{{"id": "a", "text": "Begriff $(L, \\Sigma)$"}}
],
"kanten": [
{{"von": "a", "nach": "b", "text": "ja"}}
]
}}
Gib sonst keinen Text aus.

View File

@@ -1,32 +0,0 @@
Du erstellst für einen Lern-Guide zum Thema "{topic}" eine **Grafik** zum Baustein "{baustein}". Ziel: Der Lernende erkennt auf einen Blick das Muster, den Ablauf oder die Struktur der Subbausteine — kein Fließtext, sondern ein Graph aus Knoten und Kanten.
BAUSTEIN-BESCHREIBUNG:
{beschreibung}
RELEVANTE SUBBAUSTEINE (die Grafik soll diese zeigen):
{subbausteine}
AUFGABE:
- Baue EINEN gerichteten Graphen: **Knoten** = die Subbausteine (plus ggf. Verbindungs-Knoten wie Start/Ende/Verzweigung), **Kanten** = ihre Beziehungen (Reihenfolge, Abhängigkeit, Ober-/Unterbegriff, Ja/Nein).
- Decke die relevanten Subbausteine ab. Erfinde keine Inhalte außerhalb des Bausteins.
- Wähle die Richtung passend: `TB` (oben→unten, für Abläufe/Hierarchien) oder `LR` (links→rechts, für Ketten).
- **Knotentext SEHR kurz** (ein Stichwort oder eine **kompakte** Inline-Formel, Richtwert ≤ 6 Wörter). Lange Formaldefinitionen gehören NICHT in den Graph — die stehen im Guide-Text.
- Mathematik als **echte Notation** mit KaTeX in Dollarzeichen: `$\Sigma$`, `$(L, \Sigma)$`, `$w \in L$`, `$2^n$`. Niemals Symbole als Wort ausschreiben (nicht „Sigma", nicht „Vereinigung").
- **Verboten im Knotentext:** `\\` (Zeilenumbruch), `\text{{…}}`, `$$…$$` (Display-Mathe), mehrteilige/verkettete Formeln. Eine Formel pro Knoten, knapp.
- Kanten-Label (`text`) ist optional und **nur ein kurzes Wort** (z.B. „kodiert", „ja", „nein") — **keine Formeln/Dollarzeichen** in Kanten. Formeln gehören in die Knoten.
- Jede `id` ist ein kurzes Kürzel (az, 09), eindeutig. Jede Kante verweist mit `von`/`nach` auf existierende Knoten-`id`s.
{extra}
Schreibe das Ergebnis als JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
{{
"richtung": "TB",
"knoten": [
{{"id": "a", "text": "Erster Begriff $(L, \\Sigma)$"}},
{{"id": "b", "text": "Zweiter Begriff"}}
],
"kanten": [
{{"von": "a", "nach": "b", "text": "ja"}}
]
}}
Gib sonst keinen Text aus.

View File

@@ -1,31 +0,0 @@
Du baust eine **Roadmap** zum Thema "{topic}": einen gerichteten Übersichts-Graphen, den ein Lerner VOR dem Detaillernen liest. Ziel ist NICHT, Fakten zu vermitteln, sondern **Architektur, Flows und Andockpunkte** zu zeigen — die große Struktur auf einen Blick. Der Lerner soll Ankerpunkte bilden, bevor er die Bausteine durcharbeitet.
BAUSTEIN-ÜBERSICHT (Quelle — daraus die Struktur ableiten, KEINE neuen Inhalte erfinden):
{uebersicht}
AUFGABE:
- Fasse die Bausteine zu **thematischen Bereichen** (Knoten) zusammen — **wenige große Knoten**, Richtwert 510, höchstens 12. Keine atomaren Einzelpunkte.
- Verbinde die Bereiche mit **gerichteten Kanten** „baut auf auf" (`typ: "prereq"`). Was nur ergänzend/optional ist: `typ: "optional"`.
- Reihenfolge sichtbar machen: Einstieg oben, Aufbauendes darunter (`richtung: "TB"`).
- Pro Knoten:
- `title`: kurzer Bereichsname (13 Wörter).
- `text`: EINE Zeile — was der Bereich abdeckt; nenne die zentralen Bausteine als **Andockpunkte**.
- `prioritaet`: `grund` (Fundament, zuerst) | `kern` (Hauptstoff) | `vertiefung` (später/optional).
- **Schlank halten.** Keine Deko, keine Fakten-Details, keine Wiederholung. Beziehungen zählen, nicht Stichwort-Listen.
MATHE: kompakte Symbole als KaTeX in Dollarzeichen erlaubt (`$\Sigma$`, `$\Sigma^*$`). **Verboten:** `\\`, `\text{{…}}`, `$$…$$`, mehrteilige Formeln.
{extra}
Schreibe das Ergebnis als JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
{{
"richtung": "TB",
"knoten": [
{{"id": "grund", "title": "Grundlagen", "text": "Alphabet, Sprache, $\\Sigma^*$", "prioritaet": "grund"}},
{{"id": "ent", "title": "Entscheidung", "text": "Entscheidungsproblem, Sprachen als Mengen", "prioritaet": "kern"}}
],
"kanten": [
{{"von": "grund", "nach": "ent", "typ": "prereq"}}
]
}}
Gib sonst keinen Text aus.

View File

@@ -1,31 +0,0 @@
Du prüfst und korrigierst eine **Roadmap** (gerichteter Übersichts-Graph als JSON) zum Thema "{topic}". Sie soll einem Lerner VOR dem Detaillernen Architektur, Flows und Andockpunkte zeigen — schlank und sofort lesbar.
BAUSTEIN-ÜBERSICHT (Quelle):
{uebersicht}
AKTUELLE ROADMAP (JSON):
{graph}
PRÜFE und korrigiere:
- **Struktur:** jede Kante referenziert mit `von`/`nach` gültige Knoten-`id`s; keine hängenden Kanten; keine Zyklen (gerichtet, azyklisch); IDs eindeutig.
- **Abdeckung:** die wichtigen Bereiche der Übersicht kommen vor; nichts Erfundenes.
- **Flows/Andockpunkte:** Kanten geben „baut auf auf" korrekt wieder; `text` nennt die zentralen Bausteine.
- **Knappheit:** wenige große Knoten (≤ 12); `title` 13 Wörter; `text` genau eine Zeile. Kürze überladene Knoten.
- **Mathe:** kompakte KaTeX-Symbole in `$…$`; **verboten** `\\`, `\text{{…}}`, `$$…$$`, mehrteilige Formeln — korrigiere Verstöße.
- `prioritaet` ∈ grund | kern | vertiefung; `typ` ∈ prereq | optional.
Ist die Roadmap bereits gut, gib sie unverändert zurück.
{extra}
Schreibe die geprüfte Roadmap als JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
{{
"richtung": "TB",
"knoten": [
{{"id": "grund", "title": "Grundlagen", "text": "Alphabet, Sprache, $\\Sigma^*$", "prioritaet": "grund"}}
],
"kanten": [
{{"von": "grund", "nach": "ent", "typ": "prereq"}}
]
}}
Gib sonst keinen Text aus.