This commit is contained in:
team3
2026-06-27 00:58:46 +02:00
parent 2a2026c9ae
commit f018ca5048
44 changed files with 3070 additions and 379 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all projects searxng ollama .PHONY: install dev prod stop logs remove auth sync sync-projects sync-all sync-all-reverse projects searxng ollama
COMPOSE = docker compose COMPOSE = docker compose
@@ -79,4 +79,19 @@ sync-projects:
sync-all: sync sync-projects sync-all: sync sync-projects
@echo "Voller Sync (Themen + Projekte) abgeschlossen." @echo "Voller Sync (Themen + Projekte) abgeschlossen."
# Gegenstück zu sync-all: lokalen Stand auf den Server schieben (Local → Remote).
# Stoppt den Remote-Server (DB-Sicherheit), pusht DB + Themen + Projekte, startet ihn neu.
# --delete = exakter Spiegel: Remote-Dateien ohne lokales Pendant werden gelöscht.
sync-all-reverse: stop
@echo "ACHTUNG: überschreibt den Remote-Stand (DB, themen/, projects/) mit dem lokalen."
@[ -f storage/creator.db ] || { echo "Keine lokale DB — abgebrochen."; exit 1; }
ssh root@178.104.67.87 'cd /var/www/creator && docker compose down'
ssh root@178.104.67.87 'rm -f /var/www/creator/storage/creator.db-shm /var/www/creator/storage/creator.db-wal'
rsync -avz --progress storage/creator.db root@178.104.67.87:/var/www/creator/storage/
@[ -f storage/creator.db-wal ] && rsync -avz --progress storage/creator.db-wal root@178.104.67.87:/var/www/creator/storage/ || true
rsync -avz --progress --delete storage/themen/ root@178.104.67.87:/var/www/creator/storage/themen/
rsync -avz --progress --delete projects/ root@178.104.67.87:/var/www/creator/projects/
ssh root@178.104.67.87 'cd /var/www/creator && docker compose up -d --build'
@echo "Reverse-Sync abgeschlossen — Remote läuft wieder."
projects: sync-projects projects: sync-projects

View File

@@ -11,6 +11,7 @@ die Gesamtliste.
""" """
import asyncio import asyncio
import json
import logging import logging
import math import math
import shutil import shutil
@@ -19,11 +20,10 @@ import time
from pathlib import Path from pathlib import Path
import database as db import database as db
from agents import kill_process, cancel_scope, clear_scope from agents import kill_process, cancel_scope, clear_scope, run_agent
from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS
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 paths import arbeit_dir, bausteine_path, frage_muster_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 (
@@ -51,6 +51,8 @@ SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min)
KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass
# Frage-Muster-Chunks per LPT nach Sub-Last balancieren (Makespan), statt nach Baustein-Anzahl. # Frage-Muster-Chunks per LPT nach Sub-Last balancieren (Makespan), statt nach Baustein-Anzahl.
FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk
FAKTEN_CHUNK_SUBS = 25 # Fakten-Extraktion: kleinere Chunks (Fakten sind umfangreicher als Muster)
FAKTEN_CHECK_PANEL = 3 # Judges je Chunk im Fakten-Check (Mehrheit beanstandet)
log = logging.getLogger("creator.bausteine") log = logging.getLogger("creator.bausteine")
@@ -62,10 +64,14 @@ _bausteine_step: dict[str, int] = {}
BAUSTEINE_STEPS = ( BAUSTEINE_STEPS = (
"Recherche", "Konsolidierung", "Klärung", "Recherche", "Konsolidierung", "Klärung",
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären", "Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
"Fakten finden", "Fakten prüfen", "Fakten fix",
"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",
"Gliederung",
"Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen", "Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen",
"Karteikarten", "Beispiele", "Diagramme",
) )
ARTEFAKT_TYPEN = ("karteikarte", "beispiel", "diagramm")
def lade_quelle(topic: str) -> dict: def lade_quelle(topic: str) -> dict:
@@ -93,7 +99,8 @@ def _crawl_fertig(topic: str) -> bool:
return (quelle_crawl_dir(topic) / ".done").exists() # Marker erst bei sauberem Abschluss return (quelle_crawl_dir(topic) / ".done").exists() # Marker erst bei sauberem Abschluss
_STUFEN = ("einfach", "mittel", "schwer") # Lernpfad-Stufen (anfaenger/fortgeschritten/experte); alte Schwierigkeits-Werte abwärtskompatibel.
_STUFEN = ("anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer")
async def subbausteine_titel(topic: str, baustein: str) -> list[str]: async def subbausteine_titel(topic: str, baustein: str) -> list[str]:
@@ -115,19 +122,36 @@ async def lade_frage_muster(topic: str, baustein: str) -> list[dict]:
"""Vordefinierte Frage-Muster eines Bausteins — DB-first, Fallback Sidecar (leer = Live).""" """Vordefinierte Frage-Muster eines Bausteins — DB-first, Fallback Sidecar (leer = Live)."""
rows = await db.list_frage_muster(topic, _norm_titel(baustein)) rows = await db.list_frage_muster(topic, _norm_titel(baustein))
if rows: if rows:
return [{"subbaustein": r["sub_titel"], "typ": r["typ"], "frage": r["frage"]} for r in rows if r["frage"]] return [{"subbaustein": r["sub_titel"], "frage": r["frage"]} for r in rows if r["frage"]]
fm = _json_datei(frage_muster_path(topic)) fm = _json_datei(frage_muster_path(topic))
if not isinstance(fm, dict): if not isinstance(fm, dict):
return [] return []
return [ return [
{"subbaustein": str(e.get("subbaustein", "")).strip(), {"subbaustein": str(e.get("subbaustein", "")).strip(), "frage": frage}
"typ": str(e.get("typ", "")).strip(),
"frage": frage}
for e in (fm.get(baustein) or []) for e in (fm.get(baustein) or [])
if isinstance(e, dict) and (frage := str(e.get("frage", "")).strip()) if isinstance(e, dict) and (frage := str(e.get("frage", "")).strip())
] ]
async def subbausteine_frei(topic: str, baustein: str, max_ebene: int) -> list[str]:
"""Subbaustein-Titel bis zur freigeschalteten Ebene (≤ max_ebene). Fallback ohne
Ebenen-Wissen (Altbestand/Sidecar): alle Subbaustein-Titel."""
rows = await db.subs_mit_ebene(topic, baustein)
if not rows:
return await subbausteine_titel(topic, baustein)
return [s["titel"] for s in rows if s["ebene"] <= max_ebene and s["titel"]]
async def lade_frage_muster_frei(topic: str, baustein: str, max_ebene: int) -> list[dict]:
"""Frage-Muster, gefiltert auf Subbausteine bis zur freigeschalteten Ebene. Ohne
Ebenen-Wissen (Altbestand/Sidecar) ungefiltert."""
rows = await db.subs_mit_ebene(topic, baustein)
if not rows:
return await lade_frage_muster(topic, baustein)
frei = {s["norm"] for s in rows if s["ebene"] <= max_ebene}
return [m for m in await lade_frage_muster(topic, baustein) if _norm_titel(m["subbaustein"]) in frei]
async def lade_uebersicht(topic: str) -> list[dict]: async def lade_uebersicht(topic: str) -> list[dict]:
"""Strukturierte Baustein-Liste für die Übersicht — DB-first (Konsens + Subs/Stufen/Relevanz), """Strukturierte Baustein-Liste für die Übersicht — DB-first (Konsens + Subs/Stufen/Relevanz),
Fallback bausteine.md + Sidecar (Alt-Themen).""" Fallback bausteine.md + Sidecar (Alt-Themen)."""
@@ -140,7 +164,7 @@ async def lade_uebersicht(topic: str) -> list[dict]:
"num": num, "titel": b["titel"], "beschreibung": b["beschreibung"], "num": num, "titel": b["titel"], "beschreibung": b["beschreibung"],
"subbausteine": [ "subbausteine": [
{"titel": s["sub_titel"], {"titel": s["sub_titel"],
"stufe": s["stufe"] if s["stufe"] in _STUFEN else "mittel", "stufe": s["stufe"] if s["stufe"] in _STUFEN else "fortgeschritten",
"relevanz": s["relevanz"] if s["relevanz"] in ("relevant", "rand") else None} "relevanz": s["relevanz"] if s["relevanz"] in ("relevant", "rand") else None}
for s in subs if s["sub_titel"] for s in subs if s["sub_titel"]
], ],
@@ -157,7 +181,7 @@ async def lade_uebersicht(topic: str) -> list[dict]:
subbausteine = [ subbausteine = [
{ {
"titel": t, "titel": t,
"stufe": s.get("stufe") if s.get("stufe") in _STUFEN else "mittel", "stufe": s.get("stufe") if s.get("stufe") in _STUFEN else "fortgeschritten",
"relevanz": s.get("relevanz") if s.get("relevanz") in ("relevant", "rand") else None, "relevanz": s.get("relevanz") if s.get("relevanz") in ("relevant", "rand") else None,
} }
for s in (sidecar.get(titel) or []) for s in (sidecar.get(titel) or [])
@@ -177,9 +201,12 @@ def _bausteine_steps(topic: str) -> tuple:
base = ("Recherche", "Konsolidierung", "Klärung") base = ("Recherche", "Konsolidierung", "Klärung")
rest = ( rest = (
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären", "Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
"Fakten finden", "Fakten prüfen", "Fakten fix",
"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",
"Gliederung",
"Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen", "Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen",
"Karteikarten", "Beispiele", "Diagramme",
) )
mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest
return (("Quelle aufbereiten",) if q["type"] == "link" else ()) + mitte return (("Quelle aufbereiten",) if q["type"] == "link" else ()) + mitte
@@ -203,9 +230,12 @@ PHASEN = (
("Quelle", ("Quelle aufbereiten",)), ("Quelle", ("Quelle aufbereiten",)),
("Inventar", ("Recherche", "Konsolidierung", "Klärung", "Ergänzung")), ("Inventar", ("Recherche", "Konsolidierung", "Klärung", "Ergänzung")),
("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")), ("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")),
("Fakten", ("Fakten finden", "Fakten prüfen", "Fakten fix")),
("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")),
("Gliederung", ("Gliederung",)),
("Fragen", ("Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen")), ("Fragen", ("Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen")),
("Artefakte", ("Karteikarten", "Beispiele", "Diagramme")),
) )
@@ -244,22 +274,28 @@ def _bausteine_files(topic: str) -> dict:
"mapping": {n: arbeit / f"auswahl-mapping-r{n}.json" for n in runden}, "mapping": {n: arbeit / f"auswahl-mapping-r{n}.json" for n in runden},
"ergaenzung": arbeit / "ergaenzung.json", "ergaenzung": arbeit / "ergaenzung.json",
"sub_roh": arbeit / "subbausteine-roh.json", "sub_roh": arbeit / "subbausteine-roh.json",
"fakten": arbeit / "subbausteine-fakten.json",
"sidecar": subbausteine_path(topic), "sidecar": subbausteine_path(topic),
"frage_muster": frage_muster_path(topic), "frage_muster": frage_muster_path(topic),
"gliederung": arbeit / "gliederung.json",
"gliederung_slots": [arbeit / f"gliederung-{i}.json" for i in (1, 2, 3)],
"artefakte": arbeit / "artefakte.json",
} }
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-*")) dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("fakten-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*"))
+ list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*")) + list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("gliederung-*")) + list(arbeit.glob("artefakt-*"))
+ list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*"))
+ list(arbeit.glob("klaerung*"))) if arbeit.is_dir() else [] + list(arbeit.glob("klaerung*"))) 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"], *dyn, files["sub_roh"], files["sidecar"], files["frage_muster"],
files["fakten"], files["gliederung"], files["artefakte"], *dyn,
] ]
@@ -288,12 +324,21 @@ def _resume_step(topic: str) -> int:
# Stufen fertig; nur noch Relevanz offen? # Stufen fertig; nur noch Relevanz offen?
if not _relevanz_komplett(sidecar): if not _relevanz_komplett(sidecar):
return _step_idx(topic, "Relevanz finden") return _step_idx(topic, "Relevanz finden")
# Relevanz fertig; nur noch Frage-Muster offen? # Relevanz fertig; Gliederung (Bausteine-Artefakt für den Guide) offen?
if not _gliederung_komplett(files):
return _step_idx(topic, "Gliederung")
# Gliederung fertig; 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")
# Fragen fertig; Lern-Artefakte (Karteikarten/Beispiele/Diagramme) offen?
if not _artefakte_komplett(files):
return _step_idx(topic, "Karteikarten")
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")
# Subbausteine fertig; Fakten noch offen? (Fakten kommen vor den Stufen.)
if not _fakten_komplett(files):
return _step_idx(topic, "Fakten finden")
return _step_idx(topic, "Stufen finden") return _step_idx(topic, "Stufen finden")
@@ -356,12 +401,19 @@ def _reset_ab_phase(topic: str, label: str) -> None:
for p in arbeit.glob(pat): for p in arbeit.glob(pat):
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
if idx <= 5: # Fragen # Phasen-Index: Quelle=0 · Inventar=1 · Subbausteine=2 · Fakten=3 · Stufen=4 · Relevanz=5 · Gliederung=6 · Fragen=7 · Artefakte=8
if idx <= 8: # Artefakte (Karteikarten/Beispiele/Diagramme)
files["artefakte"].unlink(missing_ok=True)
glob_del("artefakt-*")
if idx <= 7: # Fragen
files["frage_muster"].unlink(missing_ok=True) files["frage_muster"].unlink(missing_ok=True)
glob_del("frage-muster-*") glob_del("frage-muster-*")
if idx <= 4: # Relevanz if idx <= 6: # Gliederung
files["gliederung"].unlink(missing_ok=True)
glob_del("gliederung-*")
if idx <= 5: # Relevanz
glob_del("relevanz-*") glob_del("relevanz-*")
if idx <= 3: # Stufen + Relevanz teilen die Sidecar → ab Stufen ganz neu if idx <= 4: # Stufen + Relevanz teilen die Sidecar → ab Stufen ganz neu
files["sidecar"].unlink(missing_ok=True) files["sidecar"].unlink(missing_ok=True)
glob_del("stufe-*") glob_del("stufe-*")
else: # ab Relevanz: Stufen behalten, nur Relevanz-Felder strippen else: # ab Relevanz: Stufen behalten, nur Relevanz-Felder strippen
@@ -372,6 +424,9 @@ def _reset_ab_phase(topic: str, label: str) -> None:
if isinstance(s, dict): if isinstance(s, dict):
s.pop("relevanz", None) s.pop("relevanz", None)
atomic_write_json(files["sidecar"], sc, indent=1) atomic_write_json(files["sidecar"], sc, indent=1)
if idx <= 3: # Fakten (vor den Stufen) — Fakten-Map + Arbeitsdateien weg
files["fakten"].unlink(missing_ok=True)
glob_del("fakten-*")
if idx <= 2: # Subbausteine if idx <= 2: # Subbausteine
files["sub_roh"].unlink(missing_ok=True) files["sub_roh"].unlink(missing_ok=True)
glob_del("subbaustein-*") glob_del("subbaustein-*")
@@ -474,7 +529,7 @@ def _sidecar_schema(data):
if not isinstance(v, list) or not v: if not isinstance(v, list) or not v:
return None return None
for s in v: for s in v:
if not isinstance(s, dict) or not str(s.get("titel", "")).strip() or s.get("stufe") not in ("einfach", "mittel", "schwer"): if not isinstance(s, dict) or not str(s.get("titel", "")).strip() or s.get("stufe") not in _STUFEN:
return None return None
return data return data
@@ -493,10 +548,10 @@ def _relevanz_komplett(data) -> bool:
def _frage_muster_chunk_schema(data) -> list[dict] | None: def _frage_muster_chunk_schema(data) -> list[dict] | None:
"""{"muster": [{baustein, subbaustein, typ, frage}, …]} → Liste valider Einträge · sonst None. """{"muster": [{baustein, subbaustein, frage}, …]} → Liste valider Einträge · sonst None.
Mit `baustein` (Zuordnung im 10er-Chunk). Ungültige Einzel-Einträge werden Ein Muster je Subbaustein (kein Typ-Kreuzprodukt — die Schwierigkeit kommt erst bei der
übersprungen (nicht die ganze Liste verworfen).""" Prüfung aus dem Lerner-Niveau). Ungültige Einzel-Einträge werden übersprungen."""
if not isinstance(data, dict) or not isinstance(data.get("muster"), list): if not isinstance(data, dict) or not isinstance(data.get("muster"), list):
return None return None
out = [] out = []
@@ -505,11 +560,10 @@ def _frage_muster_chunk_schema(data) -> list[dict] | None:
continue continue
bau = str(e.get("baustein", "")).strip() bau = str(e.get("baustein", "")).strip()
sub = str(e.get("subbaustein", "")).strip() sub = str(e.get("subbaustein", "")).strip()
typ = str(e.get("typ", "")).strip().casefold()
frage = str(e.get("frage", "")).strip() frage = str(e.get("frage", "")).strip()
if not bau or not sub or typ not in FRAGETYPEN or not frage: if not bau or not sub or not frage:
continue continue
out.append({"baustein": bau, "subbaustein": sub, "typ": typ, "frage": frage}) out.append({"baustein": bau, "subbaustein": sub, "frage": frage})
return out or None return out or None
@@ -801,10 +855,10 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
timeout=_timeout("stufe_check", len(strittig)), timeout=_timeout("stufe_check", len(strittig)),
) )
if status == FAILED: if status == FAILED:
_log(topic, f"Stufen-Klärung Paket {c} fehlgeschlagen — Default 'mittel'") _log(topic, f"Stufen-Klärung Paket {c} fehlgeschlagen — Default 'fortgeschritten'")
entsch = entsch if isinstance(entsch, dict) else {} entsch = entsch if isinstance(entsch, dict) else {}
# Strittige ohne Entscheid → 'mittel'; Vote-Gewinner bleiben; Judge überschreibt. # Strittige ohne Entscheid → 'fortgeschritten'; Vote-Gewinner bleiben; Judge überschreibt.
ergebnis = {**{k: "mittel" for k in strittig}, **ergebnis, **entsch} ergebnis = {**{k: "fortgeschritten" for k in strittig}, **ergebnis, **entsch}
return {item_idxs[k - 1] + 1: stufe for k, stufe in ergebnis.items()} return {item_idxs[k - 1] + 1: stufe for k, stufe in ergebnis.items()}
parts = await _gather_fortschritt([_klaere(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _melde_p(set_p, topic, "Stufen klären")) parts = await _gather_fortschritt([_klaere(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _melde_p(set_p, topic, "Stufen klären"))
@@ -813,12 +867,12 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
stufe_by_id: dict[int, str] = {} stufe_by_id: dict[int, str] = {}
for c, part in enumerate(parts, 1): for c, part in enumerate(parts, 1):
if not isinstance(part, dict): if not isinstance(part, dict):
# Klärung ist nicht fatal: Vote-Ergebnis + Default 'mittel' für Strittige. # Klärung ist nicht fatal: Vote-Ergebnis + Default 'fortgeschritten' für Strittige.
if isinstance(part, BaseException): if isinstance(part, BaseException):
_log(topic, f"Stufen-Klärung Paket {c}: {type(part).__name__}: {part}") _log(topic, f"Stufen-Klärung Paket {c}: {type(part).__name__}: {part}")
ergebnis, strittig = vote_by_c[c] ergebnis, strittig = vote_by_c[c]
item_idxs = chunks[c - 1] item_idxs = chunks[c - 1]
merged = {**{k: "mittel" for k in strittig}, **ergebnis} merged = {**{k: "fortgeschritten" for k in strittig}, **ergebnis}
part = {item_idxs[k - 1] + 1: s for k, s in merged.items()} part = {item_idxs[k - 1] + 1: s for k, s in merged.items()}
stufe_by_id.update(part) stufe_by_id.update(part)
@@ -829,11 +883,196 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
lst = [] lst = []
for sub in subs: for sub in subs:
gid += 1 gid += 1
lst.append({"titel": sub, "stufe": stufe_by_id.get(gid, "mittel")}) lst.append({"titel": sub, "stufe": stufe_by_id.get(gid, "fortgeschritten")})
sidecar[titel] = lst sidecar[titel] = lst
return sidecar return sidecar
_FAKTEN_FELDER = ("kernpunkte", "voraussetzungen", "huerden", "belegte_fakten", "beispiel_idee")
def _fakten_schema(data) -> list[dict] | None:
"""{"fakten": [{baustein, subbaustein, …}]} → valide Liste · sonst None.
Trennt belegte_fakten (mit Quelle) hart von beispiel_idee (generativ)."""
if not isinstance(data, dict) or not isinstance(data.get("fakten"), list):
return None
out = []
for e in data["fakten"]:
if not isinstance(e, dict):
continue
bau = str(e.get("baustein", "")).strip()
sub = str(e.get("subbaustein", "")).strip()
if not bau or not sub:
continue
bf = [{"text": t, "quelle": str(f.get("quelle", "")).strip()}
for f in (e.get("belegte_fakten") or []) if isinstance(f, dict) and (t := str(f.get("text", "")).strip())]
out.append({
"baustein": bau, "subbaustein": sub,
"kernpunkte": [k for x in (e.get("kernpunkte") or []) if (k := str(x).strip())],
"voraussetzungen": str(e.get("voraussetzungen", "")).strip(),
"huerden": str(e.get("huerden", "")).strip(),
"belegte_fakten": bf,
"beispiel_idee": str(e.get("beispiel_idee", "")).strip(),
})
return out or None
def _fakten_check_schema(data) -> list[str] | None:
"""Fakten-Check → beanstandete sub_norms · {ok:true}→[] · None bei ungültig."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return []
pr = data.get("probleme")
if not isinstance(pr, list):
return None
return [sn for p in pr if isinstance(p, dict) and (sn := _norm_titel(str(p.get("subbaustein", ""))))]
def _fakten_zeilen(fk: dict) -> str:
z = []
if fk.get("kernpunkte"):
z.append("Kernpunkte: " + " · ".join(fk["kernpunkte"]))
if fk.get("voraussetzungen"):
z.append("Voraussetzung: " + fk["voraussetzungen"])
if fk.get("huerden"):
z.append("Hürde: " + fk["huerden"])
for bf in fk.get("belegte_fakten", []):
z.append(f"FAKT: {bf['text']} (Quelle: {bf.get('quelle', '?')})")
if fk.get("beispiel_idee"):
z.append("Beispiel: " + fk["beispiel_idee"])
return "\n".join(z)
def _fakten_komplett(files: dict) -> bool:
"""Fakten-Map existiert (Block durch)? {Baustein: {sub_norm: {...}}}."""
d = _json_datei(files["fakten"])
return isinstance(d, dict) and bool(d)
async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, instructions: str) -> dict | None:
"""Block: je Sub Quell-Fakten extrahieren (finden) → verifizieren (prüfen) → korrigieren (fix).
Extract-once-Grounding: das Ergebnis nährt Stufe/Relevanz/Fragen/Guide.
{Baustein-Titel: {sub_norm: fakten-dict}} oder None bei Abbruch/Fehler."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
arbeit = files["arbeit"]
caps = "files" if ordner else "full"
typ = q.get("type", "thema")
source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner) if typ in _QUELLE_TEMPLATE else _prompt("Bausteine-Quelle-Thema", topic=topic)
bausteine = [(titel, [str(s).strip() for s in subs if str(s).strip()]) for titel, subs in roh.items() if subs]
if not bausteine:
return {}
chunks = _lpt_chunks([len(subs) for _, subs in bausteine], FAKTEN_CHUNK_SUBS)
def roh_path(ci): return arbeit / f"fakten-c{ci}.json"
def chk_path(ci, j): return arbeit / f"fakten-check-c{ci}-j{j}.json"
def fix_path(ci): return arbeit / f"fakten-fix-c{ci}.json"
def ctitel(idxs): return [bausteine[i][0] for i in idxs]
def block_text(idxs):
return "\n\n".join(
f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in bausteine[i][1])
for i in idxs)
# Roh-Fakten eines Chunks → {Baustein: {sub_norm: {sub, …felder}}}, auf Chunk-Titel gematcht.
def roh_map(ci, path):
idxs = chunks[ci]
rel_by = {bausteine[i][0]: bausteine[i][1] for i in idxs}
ct = ctitel(idxs)
out: dict[str, dict] = {}
for e in _fakten_schema(_json_datei(path)) or []:
bt = _match_sub(e["baustein"], ct)
if bt not in rel_by:
continue
sub = _match_sub(e["subbaustein"], rel_by[bt])
out.setdefault(bt, {})[_norm_titel(sub)] = {"sub": sub, **{k: e[k] for k in _FAKTEN_FELDER}}
return out
# Phase „Fakten finden": 1 Generator je Chunk.
async def _finde(ci, idxs):
fp = roh_path(ci)
if _fakten_schema(_json_datei(fp)):
return True
subs_total = sum(len(bausteine[i][1]) for i in idxs)
status, _r = await run_single_slot(
ctx, f"Fakten {ci}", key=f"bausteine-{topic}-fakten-c{ci}",
prompt=_prompt("Fakten-Recherche", topic=topic, source=source, bausteine=block_text(idxs), out_path=fp, extra=_extra(instructions)),
role="guide", capabilities=caps,
payload=lambda result, p=fp: _fakten_schema(_json_datei(p)),
timeout=_timeout("inhalt", subs_total))
return status != FAILED and _fakten_schema(_json_datei(fp)) is not None
oks = await _gather_fortschritt([_finde(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, "Fakten finden"))
if is_cancelled():
return None
if not any(ok is True for ok in oks):
_bausteine_errors[topic] = "Fakten-Extraktion fehlgeschlagen"
return None
# Phase „Fakten prüfen": FAKTEN_CHECK_PANEL Judges je Chunk → beanstandete sub_norms (Mehrheit).
async def _pruefe(ci, idxs):
per = roh_map(ci, roh_path(ci))
if not per:
return ci, set()
fakten_text = "\n\n".join(f"SUBBAUSTEIN: {fk['sub']}\n{_fakten_zeilen(fk)}" for fm in per.values() for fk in fm.values())
offen = [j for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if _fakten_check_schema(_json_datei(chk_path(ci, j))) is None]
await asyncio.gather(*[
run_agent(f"bausteine-{topic}-fakten-check-c{ci}-j{j}",
_prompt("Fakten-Check", topic=topic, source=source, fakten=fakten_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
_timeout("inhalt_check", len(per)), provider=provider, role="judge", capabilities=caps)
for j in offen], return_exceptions=True)
outs = [s for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if (s := _fakten_check_schema(_json_datei(chk_path(ci, j)))) is not None]
votes: dict[str, int] = {}
for s in outs:
for sn in set(s):
votes[sn] = votes.get(sn, 0) + 1
schwelle = len(outs) / 2 if outs else 99
return ci, {sn for sn, v in votes.items() if v > schwelle}
pruef = await _gather_fortschritt([_pruefe(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, "Fakten prüfen"))
if is_cancelled():
return None
beanstandet = {ci: subs for r in pruef if isinstance(r, tuple) for ci, subs in [r]}
# Phase „Fakten fix": je Chunk mit beanstandeten Subs → diese neu extrahieren (frische Quelle).
n_problem = sum(len(s) for s in beanstandet.values())
if n_problem:
set_p(f"Fakten korrigieren ({n_problem})…", step=_step_idx(topic, "Fakten fix"))
async def _fix(ci):
subs_norm = beanstandet.get(ci, set())
if not subs_norm or _fakten_schema(_json_datei(fix_path(ci))):
return
idxs = chunks[ci]
rel_by = {bausteine[i][0]: bausteine[i][1] for i in idxs}
# nur die beanstandeten Subs als Block
ziel = []
for bt, subs in rel_by.items():
betroffen = [s for s in subs if _norm_titel(s) in subs_norm]
if betroffen:
ziel.append(f"BAUSTEIN: {bt}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in betroffen))
if not ziel:
return
await run_single_slot(
ctx, f"Fakten-Fix {ci}", key=f"bausteine-{topic}-fakten-fix-c{ci}",
prompt=_prompt("Fakten-Recherche", topic=topic, source=source, bausteine="\n\n".join(ziel), out_path=fix_path(ci), extra=_extra(instructions)),
role="guide", capabilities=caps,
payload=lambda result, p=fix_path(ci): _fakten_schema(_json_datei(p)),
timeout=_timeout("inhalt", len(subs_norm)))
await _gather_fortschritt([_fix(ci) for ci in beanstandet], len(beanstandet), _melde_p(set_p, topic, "Fakten fix"))
if is_cancelled():
return None
# Zusammensetzen: roh + Fix-Overrides für beanstandete (Vollständigkeits-Guard: fehlt der Fix, bleibt roh).
ergebnis: dict[str, dict] = {}
for ci in range(len(chunks)):
per = roh_map(ci, roh_path(ci))
fix = roh_map(ci, fix_path(ci)) if fix_path(ci).exists() else {}
for bt, fm in per.items():
for sn, fk in fm.items():
gewinner = fix.get(bt, {}).get(sn, fk) if sn in beanstandet.get(ci, set()) else fk
ergebnis.setdefault(bt, {})[sn] = {k: gewinner[k] for k in _FAKTEN_FELDER}
return ergebnis
async def _relevanz_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None: async def _relevanz_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None:
"""Block D: drei Phasen mit Barriere — Finden (relevant/rand), Wählen (Vote), Klären. """Block D: drei Phasen mit Barriere — Finden (relevant/rand), Wählen (Vote), Klären.
Items aus der Sidecar; lokale IDs 1..n pro Paket → globale gid. Items aus der Sidecar; lokale IDs 1..n pro Paket → globale gid.
@@ -963,18 +1202,17 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
"""Block E (10er-Chunks): Finden (1 Generator je ~10 Bausteine, parallel), Wählen (Code: """Block E (10er-Chunks): Finden (1 Generator je ~10 Bausteine, parallel), Wählen (Code:
je Baustein gruppieren + Dedup), Klären (1 Kritiker je Chunk), Prüfen (Nachrunde). je Baustein gruppieren + Dedup), Klären (1 Kritiker je Chunk), Prüfen (Nachrunde).
Zuordnung je Eintrag über das `baustein`-Feld (Chunk-Datei trägt mehrere Bausteine). Zuordnung je Eintrag über das `baustein`-Feld (Chunk-Datei trägt mehrere Bausteine).
{Baustein-Titel: [{subbaustein, typ, frage}, …]} oder None bei Abbruch.""" {Baustein-Titel: [{subbaustein, frage}, …]} oder None bei Abbruch."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled topic, is_cancelled = ctx.topic, ctx.is_cancelled
arbeit = files["arbeit"] arbeit = files["arbeit"]
# Je Baustein nur RELEVANTE Subbausteine (relevanz != 'rand'; None zählt als relevant). # ALLE Subbausteine (auch rand) bekommen ein Muster — Rand ist in der FGuide-Ebene prüfbar.
bausteine = [] bausteine = []
for titel, subs in sidecar.items(): 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()] alle = [s["titel"] for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip()]
if rel: if alle:
bausteine.append((titel, rel)) bausteine.append((titel, alle))
if not bausteine: if not bausteine:
return {} return {}
typen_block = "\n".join(f"- {k}: {v}" for k, v in FRAGETYPEN.items())
chunks = _lpt_chunks([len(rel) for _, rel in bausteine], FRAGE_CHUNK_SUBS) # lastbalanciert nach Sub-Zahl chunks = _lpt_chunks([len(rel) for _, rel in bausteine], FRAGE_CHUNK_SUBS) # lastbalanciert nach Sub-Zahl
def roh_path(ci): def roh_path(ci):
@@ -1000,7 +1238,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
ctx, f"Frage-Muster {ci}", ctx, f"Frage-Muster {ci}",
key=f"bausteine-{topic}-frage-muster-c{ci}", key=f"bausteine-{topic}-frage-muster-c{ci}",
prompt=_prompt("Frage-Muster-Recherche", topic=topic, bausteine=block, prompt=_prompt("Frage-Muster-Recherche", topic=topic, bausteine=block,
typen=typen_block, out_path=fp, extra=_extra(instructions)), out_path=fp, extra=_extra(instructions)),
role="fast", capabilities="files", role="fast", capabilities="files",
payload=lambda result, p=fp: _frage_muster_chunk_schema(_json_datei(p)), payload=lambda result, p=fp: _frage_muster_chunk_schema(_json_datei(p)),
timeout=_timeout("frage_muster", subs_total), timeout=_timeout("frage_muster", subs_total),
@@ -1027,13 +1265,12 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
titel = _match_sub(e["baustein"], ctitel) titel = _match_sub(e["baustein"], ctitel)
if titel not in rel_by: if titel not in rel_by:
continue # nicht zuordenbar → verwerfen continue # nicht zuordenbar → verwerfen
norm = _norm_frage(e["frage"]) sub = _match_sub(e["subbaustein"], rel_by[titel])
seen = gesehen.setdefault(titel, set()) seen = gesehen.setdefault(titel, set())
if norm in seen: if sub in seen:
continue continue # genau ein Muster je Subbaustein
seen.add(norm) seen.add(sub)
out.setdefault(titel, []).append( out.setdefault(titel, []).append({"subbaustein": sub, "frage": e["frage"]})
{"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "typ": e["typ"], "frage": e["frage"]})
return out return out
def _waehle_all(ci_list): def _waehle_all(ci_list):
@@ -1057,7 +1294,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
eintraege = roh_by_titel.get(t) or [] eintraege = roh_by_titel.get(t) or []
if not eintraege: if not eintraege:
continue continue
zeilen = "\n".join(f"- [{e['typ']}] ({e['subbaustein']}) {e['frage']}" for e in eintraege) zeilen = "\n".join(f"- ({e['subbaustein']}) {e['frage']}" for e in eintraege)
bloecke.append(f"BAUSTEIN: {t}\n{zeilen}") bloecke.append(f"BAUSTEIN: {t}\n{zeilen}")
if not bloecke: if not bloecke:
return # nichts zu klären in diesem Chunk return # nichts zu klären in diesem Chunk
@@ -1093,7 +1330,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
if titel not in rel_by: if titel not in rel_by:
continue continue
out.setdefault(titel, []).append( out.setdefault(titel, []).append(
{"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "typ": e["typ"], "frage": e["frage"]}) {"subbaustein": _match_sub(e["subbaustein"], rel_by[titel]), "frage": e["frage"]})
return out return out
final_by_titel = _final_by_titel(range(len(chunks))) final_by_titel = _final_by_titel(range(len(chunks)))
@@ -1415,6 +1652,226 @@ async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool:
return True return True
# --- Gliederung (Bausteine-Artefakt: Kapitel-Struktur, vom Guide nur gelesen) ---
def _gliederung_komplett(files: dict) -> bool:
"""Gliederung steht (Kapitel-Liste vorhanden)?"""
d = _json_datei(files["gliederung"])
return isinstance(d, dict) and isinstance(d.get("kapitel"), list) and bool(d.get("kapitel"))
def _gliederung_schema(data, valid: set[int]):
"""{"kapitel":[{titel,nummern}]} → bereinigt (valide Nummern, je genau einmal) ·
None bei <80 % Abdeckung (Agent/Judge hat zu viel weggelassen)."""
if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list):
return None
out, seen = [], set()
for ch in data["kapitel"]:
if not isinstance(ch, dict):
continue
titel = str(ch.get("titel", "")).strip() or "Kapitel"
nums = []
for n in (ch.get("nummern") or []):
try:
n = int(n)
except (ValueError, TypeError):
continue
if n in valid and n not in seen:
seen.add(n)
nums.append(n)
if nums:
out.append({"titel": titel, "nummern": nums})
if not out or len(seen) < 0.8 * len(valid):
return None
return {"kapitel": out}
async def _gliederung_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str) -> dict:
"""Format-agnostische Gliederung über ALLE Bausteine — 3 Vorschläge → Judge merged.
Bricht nie ab: 0 gültige → ein Kapitel mit allem; fehlende Bausteine landen in „Weitere".
{"kapitel":[{titel,nummern}]} (auch in files["gliederung"])."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
valid = set(entries)
liste = "\n".join(f"{n}. {t}" for n, t in entries.items())
step = _step_idx(topic, "Gliederung")
set_p("Gliederung — Vorschläge…", step=step)
async def _vorschlag(i, path):
if _gliederung_schema(_json_datei(path), valid):
return True
await run_single_slot(
ctx, f"Gliederung {i}", key=f"bausteine-{topic}-gliederung-{i}",
prompt=_prompt("Guide-Gliederung", topic=topic, bausteine=liste, out_path=path, extra=_extra(instructions)),
role="guide", capabilities="files",
payload=lambda result, p=path: _gliederung_schema(_json_datei(p), valid),
timeout=_timeout("plan", len(entries)))
return _gliederung_schema(_json_datei(path), valid) is not None
slots = files["gliederung_slots"]
await _gather_fortschritt([_vorschlag(i, p) for i, p in enumerate(slots, 1)], len(slots), _melde_p(set_p, topic, "Gliederung"))
if is_cancelled():
return {}
vorschlaege = [v for p in slots if (v := _gliederung_schema(_json_datei(p), valid))]
if not vorschlaege:
plan = {"kapitel": [{"titel": "Inhalte", "nummern": list(entries)}]}
elif len(vorschlaege) == 1:
plan = vorschlaege[0]
else:
set_p("Gliederung zusammenführen…", step=step)
bloecke = "\n\n".join(
f"### Vorschlag {i}\n" + "\n".join(
f"KAPITEL: {ch['titel']}\n Nummern: {', '.join(str(n) for n in ch['nummern'])}" for ch in v["kapitel"])
for i, v in enumerate(vorschlaege, 1))
await run_single_slot(
ctx, "Gliederung-Judge", key=f"bausteine-{topic}-gliederung-judge",
prompt=_prompt("Guide-Gliederung-Judge", topic=topic, format_name="den Guide",
zweck="alle Bausteine in einem roten Faden", n=len(vorschlaege),
bausteine=liste, gliederungen=bloecke, out_path=files["gliederung"], extra=_extra(instructions)),
role="judge", capabilities="files",
payload=lambda result: _gliederung_schema(_json_datei(files["gliederung"]), valid),
timeout=_timeout("plan_judge", len(entries)))
plan = _gliederung_schema(_json_datei(files["gliederung"]), valid) or vorschlaege[0]
# Vollständigkeit: jeder Baustein kommt vor — fehlende in „Weitere" (gegen weglassende Agenten/Judge).
drin = {n for ch in plan["kapitel"] for n in ch["nummern"]}
fehlen = [n for n in entries if n not in drin]
if fehlen:
plan["kapitel"].append({"titel": "Weitere", "nummern": fehlen})
atomic_write_json(files["gliederung"], plan, indent=1)
return plan
# --- Lern-Artefakte (Karteikarten/Beispiele/Diagramme aus den Fakten) ---
def _karten_schema(data):
"""{"karten":[{baustein,subbaustein,frage,antwort}]} → Liste (auch leer) · None bei kaputt."""
if not isinstance(data, dict) or not isinstance(data.get("karten"), list):
return None
out = []
for e in data["karten"]:
if isinstance(e, dict) and (f := str(e.get("frage", "")).strip()) and (a := str(e.get("antwort", "")).strip()):
out.append({"baustein": str(e.get("baustein", "")).strip(), "subbaustein": str(e.get("subbaustein", "")).strip(),
"frage": f, "antwort": a})
return out
def _beispiel_schema(data):
"""{"beispiele":[{baustein,subbaustein,problem,schritte,ergebnis}]} → Liste (auch leer) · None bei kaputt."""
if not isinstance(data, dict) or not isinstance(data.get("beispiele"), list):
return None
out = []
for e in data["beispiele"]:
if not isinstance(e, dict):
continue
problem = str(e.get("problem", "")).strip()
schritte = [s for x in (e.get("schritte") or []) if (s := str(x).strip())]
if problem and schritte:
out.append({"baustein": str(e.get("baustein", "")).strip(), "subbaustein": str(e.get("subbaustein", "")).strip(),
"problem": problem, "schritte": schritte, "ergebnis": str(e.get("ergebnis", "")).strip()})
return out
def _diagramm_schema(data):
"""{"diagramme":[{baustein,mermaid}]} → Liste (auch leer) · None bei kaputt."""
if not isinstance(data, dict) or not isinstance(data.get("diagramme"), list):
return None
out = []
for e in data["diagramme"]:
if isinstance(e, dict) and (m := str(e.get("mermaid", "")).strip()):
out.append({"baustein": str(e.get("baustein", "")).strip(), "mermaid": m})
return out
_ARTEFAKT_SCHEMA = {"karteikarte": _karten_schema, "beispiel": _beispiel_schema, "diagramm": _diagramm_schema}
_ARTEFAKT_PROMPT = {"karteikarte": "Artefakt-Karteikarte", "beispiel": "Artefakt-Beispiel", "diagramm": "Artefakt-Diagramm"}
_ARTEFAKT_SCHRITT = {"karteikarte": "Karteikarten", "beispiel": "Beispiele", "diagramm": "Diagramme"}
def _artefakte_komplett(files: dict) -> bool:
"""Artefakt-Map steht (alle Typen erzeugt)? Werte dürfen leer sein (content-aware)."""
d = _json_datei(files["artefakte"])
return isinstance(d, dict) and all(t in d for t in ARTEFAKT_TYPEN)
async def _artefakte_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None:
"""Lern-Artefakte je Typ aus den gespeicherten Fakten erzeugen — ein Generierungs-Durchlauf
je Typ über Chunks, keine Verifikation (Bonus-Material). → {typ: [eintraege]} (auch in files)."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
arbeit = files["arbeit"]
# Bausteine mit Subs + Fakten-Zeilen als Input-Block (extract-once aus den Fakten).
bausteine = []
for btitel, subs in sidecar.items():
if not isinstance(subs, list):
continue
zeilen = []
for s in subs:
if not isinstance(s, dict) or not (st := str(s.get("titel", "")).strip()):
continue
fk = s.get("fakten") if isinstance(s.get("fakten"), dict) else {}
zeile = f"- {st}"
if fk and (fk_text := _fakten_zeilen(fk)):
zeile += "\n" + "\n".join(" " + l for l in fk_text.split("\n"))
zeilen.append(zeile)
if zeilen:
bausteine.append((btitel, zeilen))
if not bausteine:
leer = {t: [] for t in ARTEFAKT_TYPEN}
atomic_write_json(files["artefakte"], leer, indent=1)
return leer
chunks = _lpt_chunks([len(z) for _, z in bausteine], FAKTEN_CHUNK_SUBS)
def block_text(idxs):
return "\n\n".join(f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(bausteine[i][1]) for i in idxs)
ergebnis: dict[str, list] = {}
for typ in ARTEFAKT_TYPEN:
schema = _ARTEFAKT_SCHEMA[typ]
def apath(ci, t=typ): return arbeit / f"artefakt-{t}-c{ci}.json"
async def _gen(ci, idxs, t=typ, schema=schema):
p = apath(ci, t)
if schema(_json_datei(p)) is not None:
return True
await run_single_slot(
ctx, f"{_ARTEFAKT_SCHRITT[t]} {ci}", key=f"bausteine-{topic}-artefakt-{t}-c{ci}",
prompt=_prompt(_ARTEFAKT_PROMPT[t], topic=topic, bausteine=block_text(idxs), out_path=p, extra=_extra(instructions)),
role="guide", capabilities="files",
payload=lambda result, p=p, schema=schema: schema(_json_datei(p)),
timeout=_timeout("inhalt", sum(len(bausteine[i][1]) for i in idxs)))
return schema(_json_datei(p)) is not None
await _gather_fortschritt([_gen(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, _ARTEFAKT_SCHRITT[typ]))
if is_cancelled():
return None
eintraege: list = []
for ci in range(len(chunks)):
eintraege += schema(_json_datei(apath(ci))) or []
ergebnis[typ] = eintraege
atomic_write_json(files["artefakte"], ergebnis, indent=1)
return ergebnis
async def _mirror_artefakte_db(topic: str, sidecar: dict, artefakte: dict) -> None:
"""Artefakte in die DB spiegeln. Karteikarte/Beispiel je Sub (sub_norm), Diagramm je Baustein (sub_norm='')."""
await db.delete_sub_artefakte(topic)
btitel_list = list(sidecar.keys())
for typ in ("karteikarte", "beispiel"):
for e in artefakte.get(typ, []):
bt = _match_sub(e.get("baustein", ""), btitel_list)
bnorm, sn = _norm_titel(bt), _norm_titel(e.get("subbaustein", ""))
if not bnorm or not sn:
continue
daten = json.dumps({k: v for k, v in e.items() if k not in ("baustein", "subbaustein")}, ensure_ascii=False)
await db.put_sub_artefakt(topic, bnorm, sn, typ, daten, bt, e.get("subbaustein", ""))
for e in artefakte.get("diagramm", []):
bt = _match_sub(e.get("baustein", ""), btitel_list)
if not (bnorm := _norm_titel(bt)):
continue
daten = json.dumps({"mermaid": e.get("mermaid", "")}, ensure_ascii=False)
await db.put_sub_artefakt(topic, bnorm, "", "diagramm", daten, bt, "")
async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None: async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
"""Sidecar {Baustein-Titel: [{titel, stufe, relevanz}]} in die DB-Tabelle subbausteine spiegeln.""" """Sidecar {Baustein-Titel: [{titel, stufe, relevanz}]} in die DB-Tabelle subbausteine spiegeln."""
for btitel, subs in sidecar.items(): for btitel, subs in sidecar.items():
@@ -1428,12 +1885,14 @@ async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
sn = _norm_titel(st) sn = _norm_titel(st)
if not sn: if not sn:
continue continue
fakten = json.dumps(s["fakten"], ensure_ascii=False) if isinstance(s.get("fakten"), dict) else None
await db.put_subbaustein(topic, bnorm, sn, btitel, st, await db.put_subbaustein(topic, bnorm, sn, btitel, st,
stufe=s.get("stufe"), relevanz=s.get("relevanz"), status="konsens") stufe=s.get("stufe"), relevanz=s.get("relevanz"),
fakten=fakten, status="konsens")
async def _mirror_frage_muster_db(topic: str, muster: dict) -> None: async def _mirror_frage_muster_db(topic: str, muster: dict) -> None:
"""Frage-Muster {Baustein-Titel: [{subbaustein, typ, frage}]} in die DB-Tabelle frage_muster spiegeln.""" """Frage-Muster {Baustein-Titel: [{subbaustein, frage}]} in die DB-Tabelle frage_muster spiegeln."""
await db.delete_frage_muster(topic) await db.delete_frage_muster(topic)
for btitel, eintraege in muster.items(): for btitel, eintraege in muster.items():
bnorm = _norm_titel(btitel) bnorm = _norm_titel(btitel)
@@ -1444,19 +1903,22 @@ async def _mirror_frage_muster_db(topic: str, muster: dict) -> None:
continue continue
sub = str(e.get("subbaustein", "")).strip() sub = str(e.get("subbaustein", "")).strip()
sn = _norm_titel(sub) sn = _norm_titel(sub)
typ = str(e.get("typ", "")).strip()
frage = str(e.get("frage", "")).strip() frage = str(e.get("frage", "")).strip()
if not (sn and typ and frage): if not (sn and frage):
continue continue
await db.upsert_frage_muster(topic, bnorm, sn, btitel, sub, typ, frage) await db.upsert_frage_muster(topic, bnorm, sn, btitel, sub, frage)
async def _reset_db_ab_phase(topic: str, label: str) -> None: async def _reset_db_ab_phase(topic: str, label: str) -> None:
"""DB-Inhalt der Phasen ≥ `label` verwerfen (kanonische Reihenfolge Quelle…Fragen).""" """DB-Inhalt der Phasen ≥ `label` verwerfen (kanonische Reihenfolge Quelle…Artefakte)."""
idx = _phase_idx(label) idx = _phase_idx(label)
if idx <= 5: # Fragen if idx <= 8: # Artefakte (Karteikarten/Beispiele/Diagramme)
await db.delete_sub_artefakte(topic)
if idx <= 7: # Fragen
await db.delete_frage_muster(topic) await db.delete_frage_muster(topic)
if idx <= 2: # Subbausteine (Stufen/Relevanz greifen über Sidecar→Mirror) if idx <= 6: # Gliederung
await db.delete_gliederung(topic)
if idx <= 2: # Subbausteine (Fakten/Stufen/Relevanz greifen über Sidecar→Mirror)
await db.delete_subbausteine(topic) await db.delete_subbausteine(topic)
if idx <= 1: # Inventar: Inventar + Recherche-Schritte — Sichtung bleibt if idx <= 1: # Inventar: Inventar + Recherche-Schritte — Sichtung bleibt
await db.delete_bausteine(topic) await db.delete_bausteine(topic)
@@ -1519,6 +1981,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
await db.delete_subbausteine(topic) await db.delete_subbausteine(topic)
await db.delete_frage_muster(topic) await db.delete_frage_muster(topic)
await db.delete_coverage(topic) await db.delete_coverage(topic)
await db.delete_gliederung(topic)
await db.delete_sub_artefakte(topic)
# Inventar (DB): Recherche-Loop → Konsolidierung → Klärung. # Inventar (DB): Recherche-Loop → Konsolidierung → Klärung.
if not await _recherche_batch(ctx, set_p, files, q, ordner, instructions): if not await _recherche_batch(ctx, set_p, files, q, ordner, instructions):
@@ -1590,12 +2054,30 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
if roh is None: if roh is None:
return # Fehler ist gesetzt return # Fehler ist gesetzt
atomic_write_json(files["sub_roh"], roh, indent=1) atomic_write_json(files["sub_roh"], roh, indent=1)
# Fakten je Sub (VOR der Stufe): Quell-Fakten extrahieren + verifizieren → fakten.json.
# Extract-once-Grounding — Stufe/Relevanz/Fragen/Guide nähren sich daraus.
if not _fakten_komplett(files):
fakten_map = await _fakten_block(ctx, set_p, files, roh, q, ordner, instructions)
if is_cancelled():
abgebrochen()
return
if fakten_map is None:
return # Fehler ist gesetzt
atomic_write_json(files["fakten"], fakten_map, indent=1)
sidecar = await _stufen_block(ctx, set_p, files, roh, instructions) sidecar = await _stufen_block(ctx, set_p, files, roh, instructions)
if is_cancelled(): if is_cancelled():
abgebrochen() abgebrochen()
return return
if sidecar is None: if sidecar is None:
return return
# Fakten in die Sidecar-Subs mergen (DB-Spiegel + Guide-Nutzung).
fakten_map = _json_datei(files["fakten"])
if isinstance(fakten_map, dict):
for btitel, subs in sidecar.items():
fm = fakten_map.get(btitel, {})
for sub in subs:
if (fk := fm.get(_norm_titel(sub["titel"]))):
sub["fakten"] = fk
atomic_write_json(files["sidecar"], sidecar, indent=1) atomic_write_json(files["sidecar"], sidecar, indent=1)
# Block D: Relevanz je Subbaustein (relevant/rand) → in die Sidecar mergen. # Block D: Relevanz je Subbaustein (relevant/rand) → in die Sidecar mergen.
@@ -1616,6 +2098,14 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
sub["relevanz"] = relevanz_by_id.get(gid, "relevant") sub["relevanz"] = relevanz_by_id.get(gid, "relevant")
atomic_write_json(files["sidecar"], sidecar, indent=1) atomic_write_json(files["sidecar"], sidecar, indent=1)
# Block D.5: Gliederung (Bausteine-Artefakt) — Kapitel-Struktur über ALLE Bausteine,
# vom Guide nur noch gelesen. Format-agnostisch; der Guide filtert je Format.
if not _gliederung_komplett(files):
await _gliederung_block(ctx, set_p, files, entries, instructions)
if is_cancelled():
abgebrochen()
return
# Block E: Frage-Muster je relevantem Subbaustein × Typ → eigenes Sidecar. # Block E: Frage-Muster je relevantem Subbaustein × Typ → eigenes Sidecar.
# Zur Prüfungszeit zieht jeder Agent ein Muster ohne Zurücklegen und formuliert # Zur Prüfungszeit zieht jeder Agent ein Muster ohne Zurücklegen und formuliert
# daraus eine Frage — distinkte Saat verhindert die Doppelfragen der Live-Generierung. # daraus eine Frage — distinkte Saat verhindert die Doppelfragen der Live-Generierung.
@@ -1629,6 +2119,17 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
return # Abbruch return # Abbruch
atomic_write_json(files["frage_muster"], muster, indent=1) atomic_write_json(files["frage_muster"], muster, indent=1)
# Block F: Lern-Artefakte (Karteikarten/Beispiele/Diagramme) aus den Fakten — Bonus,
# vom Frontend präsentiert. Bricht den Lauf nicht ab (Artefakte sind optional).
sidecar = _json_datei(files["sidecar"])
if _sidecar_schema(sidecar) is not None and not _artefakte_komplett(files):
artefakte = await _artefakte_block(ctx, set_p, files, sidecar, instructions)
if is_cancelled():
abgebrochen()
return
if artefakte is None:
return # Abbruch (Fehler/Cancel)
# DB-Spiegel (Brücke): finalen Sidecar- + Frage-Muster-Stand in die DB schreiben. # DB-Spiegel (Brücke): finalen Sidecar- + Frage-Muster-Stand in die DB schreiben.
sidecar = _json_datei(files["sidecar"]) sidecar = _json_datei(files["sidecar"])
if _sidecar_schema(sidecar) is not None: if _sidecar_schema(sidecar) is not None:
@@ -1636,6 +2137,19 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
muster = _json_datei(files["frage_muster"]) muster = _json_datei(files["frage_muster"])
if isinstance(muster, dict) and muster: if isinstance(muster, dict) and muster:
await _mirror_frage_muster_db(topic, muster) await _mirror_frage_muster_db(topic, muster)
# Gliederung (Titel-basiert, robust gegen Nummern-Drift) → DB.
plan = _json_datei(files["gliederung"])
if isinstance(plan, dict) and plan.get("kapitel"):
kapitel = [
{"titel": ch.get("titel", "Kapitel"),
"bausteine": [_titel(entries[n]) for n in ch.get("nummern", []) if n in entries]}
for ch in plan["kapitel"]
]
await db.set_gliederung(topic, json.dumps({"kapitel": kapitel}, ensure_ascii=False))
# Artefakte → DB (Karteikarte/Beispiel je Sub, Diagramm je Baustein).
artefakte = _json_datei(files["artefakte"])
if isinstance(artefakte, dict) and _sidecar_schema(sidecar) is not None:
await _mirror_artefakte_db(topic, sidecar, artefakte)
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

@@ -103,13 +103,15 @@ CREATE TABLE IF NOT EXISTS subbausteine (
nennungen INTEGER NOT NULL DEFAULT 1, nennungen INTEGER NOT NULL DEFAULT 1,
stufe TEXT, stufe TEXT,
relevanz TEXT, relevanz TEXT,
fakten TEXT,
status TEXT NOT NULL DEFAULT 'kandidat', status TEXT NOT NULL DEFAULT 'kandidat',
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (topic, baustein_norm, sub_norm) PRIMARY KEY (topic, baustein_norm, sub_norm)
) )
""" """
# Ein Frage-Muster je (Baustein, Subbaustein, Typ). # Ein Frage-Muster je (Baustein, Subbaustein). Schwierigkeit kommt erst bei der Prüfung
# aus dem Lerner-Niveau, nicht aus dem Muster — daher kein Typ-Kreuzprodukt mehr.
CREATE_FRAGE_MUSTER = """ CREATE_FRAGE_MUSTER = """
CREATE TABLE IF NOT EXISTS frage_muster ( CREATE TABLE IF NOT EXISTS frage_muster (
topic TEXT NOT NULL, topic TEXT NOT NULL,
@@ -117,10 +119,9 @@ CREATE TABLE IF NOT EXISTS frage_muster (
sub_norm TEXT NOT NULL, sub_norm TEXT NOT NULL,
baustein TEXT NOT NULL, baustein TEXT NOT NULL,
sub_titel TEXT NOT NULL, sub_titel TEXT NOT NULL,
typ TEXT NOT NULL,
frage TEXT NOT NULL, frage TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (topic, baustein_norm, sub_norm, typ) PRIMARY KEY (topic, baustein_norm, sub_norm)
) )
""" """
@@ -170,6 +171,33 @@ CREATE TABLE IF NOT EXISTS quelle (
) )
""" """
# Gliederung (Kapitel → Baustein-Nummern) je Thema als JSON. In der Bausteine-Phase erzeugt,
# vom Guide nur noch gelesen + je Format gefiltert. Format-agnostisch (alle Bausteine).
CREATE_GUIDE_GLIEDERUNG = """
CREATE TABLE IF NOT EXISTS guide_gliederung (
topic TEXT PRIMARY KEY,
json TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
# Generischer Lern-Artefakt-Layer: je (Baustein, Subbaustein, Typ) ein Artefakt (JSON in `daten`).
# typ: karteikarte | beispiel | diagramm. sub_norm='' = Baustein-Ebene (z. B. Diagramm).
# In der Bausteine-Phase aus den Fakten erzeugt, vom Frontend präsentiert (Trennung Artefakt/Anzeige).
CREATE_SUB_ARTEFAKTE = """
CREATE TABLE IF NOT EXISTS sub_artefakte (
topic TEXT NOT NULL,
baustein_norm TEXT NOT NULL,
sub_norm TEXT NOT NULL DEFAULT '',
typ TEXT NOT NULL,
baustein TEXT NOT NULL DEFAULT '',
sub_titel TEXT NOT NULL DEFAULT '',
daten TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, baustein_norm, sub_norm, typ)
)
"""
_db: aiosqlite.Connection | None = None _db: aiosqlite.Connection | None = None
@@ -199,6 +227,8 @@ async def init_db():
await db.execute(CREATE_PIPELINE_STATE) await db.execute(CREATE_PIPELINE_STATE)
await db.execute(CREATE_GUIDE_CONTENT) await db.execute(CREATE_GUIDE_CONTENT)
await db.execute(CREATE_QUELLE) await db.execute(CREATE_QUELLE)
await db.execute(CREATE_GUIDE_GLIEDERUNG)
await db.execute(CREATE_SUB_ARTEFAKTE)
try: # Migration für Bestands-DBs ohne step-Spalte try: # Migration für Bestands-DBs ohne step-Spalte
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER") await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
except aiosqlite.OperationalError: except aiosqlite.OperationalError:
@@ -226,6 +256,16 @@ async def init_db():
await db.execute(f"ALTER TABLE baustein_progress ADD COLUMN {_spalte} {_typ}") await db.execute(f"ALTER TABLE baustein_progress ADD COLUMN {_spalte} {_typ}")
except aiosqlite.OperationalError: except aiosqlite.OperationalError:
pass pass
# Migration: frage_muster ohne typ-Spalte (1 Muster je Sub statt Sub×Typ-Kreuzprodukt).
# PK-Änderung → Tabelle einmalig neu bauen. Bestands-Muster gehen verloren (bewusst, kein Mapping).
cursor = await db.execute("PRAGMA table_info(frage_muster)")
if any(_r[1] == "typ" for _r in await cursor.fetchall()):
await db.execute("DROP TABLE frage_muster")
await db.execute(CREATE_FRAGE_MUSTER)
try: # Migration: subbausteine.fakten (Quell-Fakten je Sub, JSON-Blob) — Extract-once-Grounding.
await db.execute("ALTER TABLE subbausteine ADD COLUMN fakten TEXT") # nullable: COALESCE schützt Bestand
except aiosqlite.OperationalError:
pass
# Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive') # Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive')
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'") cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'")
if await cursor.fetchone(): if await cursor.fetchone():
@@ -482,6 +522,21 @@ async def set_baustein_score(topic: str, baustein: str, score: int) -> int:
return score return score
async def set_baustein_score_and_streak(topic: str, baustein: str, score: int, streak: int) -> tuple[int, int]:
"""Setzt Score + Streak atomar (vom Aufrufer geclampt). Liefert (score, streak)."""
db = await get_db()
await db.execute(
"""INSERT INTO baustein_progress (topic, baustein, gute_antworten, streak, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(topic, baustein) DO UPDATE SET
gute_antworten = excluded.gute_antworten, streak = excluded.streak,
updated_at = excluded.updated_at""",
(topic, baustein, score, streak, _now()),
)
await db.commit()
return score, streak
async def delete_baustein_progress(topic: str, baustein: str) -> None: async def delete_baustein_progress(topic: str, baustein: str) -> None:
"""Fortschritt EINES Bausteins zurücksetzen: Zeile löschen (Score/Streak/Flags/offene Frage). """Fortschritt EINES Bausteins zurücksetzen: Zeile löschen (Score/Streak/Flags/offene Frage).
Fehlt die Zeile, liefert get_baustein_progress Defaults (0) — also voller Reset.""" Fehlt die Zeile, liefert get_baustein_progress Defaults (0) — also voller Reset."""
@@ -574,6 +629,75 @@ async def subs_count_alle() -> dict[tuple[str, str], int]:
return {(t, bn): n for t, bn, n in await cursor.fetchall()} return {(t, bn): n for t, bn, n in await cursor.fetchall()}
# Sub-Ebene aus den zwei orthogonalen Spalten: rand → 4 (V), sonst Stufe (Lernpfad-Position):
# anfaenger/NULL → 1, fortgeschritten → 2, experte → 3. Alte Werte (einfach/mittel/schwer) werden
# abwärtskompatibel mitgemappt. Steuert Guide-Ansicht A/F/E/V + cap (freigeschaltete Subs × 25).
_EBENE_CASE = """CASE
WHEN relevanz = 'rand' THEN 4
WHEN stufe IN ('fortgeschritten', 'mittel') THEN 2
WHEN stufe IN ('experte', 'schwer') THEN 3
ELSE 1
END"""
def _leere_ebenen() -> dict[int, int]:
return {1: 0, 2: 0, 3: 0, 4: 0}
async def subs_je_ebene(topic: str, baustein: str) -> dict[int, int]:
"""Konsens-Subbausteine eines Bausteins je Ebene 14. roher Baustein-Titel rein."""
from textkit import _norm_titel
db = await get_db()
cursor = await db.execute(
f"SELECT {_EBENE_CASE} AS ebene, COUNT(*) FROM subbausteine "
"WHERE topic = ? AND baustein_norm = ? AND status = 'konsens' GROUP BY ebene",
(topic, _norm_titel(baustein)),
)
out = _leere_ebenen()
for ebene, n in await cursor.fetchall():
out[ebene] = n
return out
async def subs_je_ebene_roh(topic: str) -> dict[str, dict[int, int]]:
"""Subbausteine je Ebene, gruppiert nach ROHEM Baustein-Titel (= Guide-Section-Titel)."""
db = await get_db()
cursor = await db.execute(
f"SELECT baustein, {_EBENE_CASE} AS ebene, COUNT(*) FROM subbausteine "
"WHERE topic = ? AND status = 'konsens' GROUP BY baustein, ebene",
(topic,),
)
out: dict[str, dict[int, int]] = {}
for b, ebene, n in await cursor.fetchall():
out.setdefault(b, _leere_ebenen())[ebene] = n
return out
async def subs_je_ebene_alle() -> dict[tuple[str, str], dict[int, int]]:
"""Subbausteine je Ebene je (topic, baustein_norm) — für die themenweite Stufen-Ableitung."""
db = await get_db()
cursor = await db.execute(
f"SELECT topic, baustein_norm, {_EBENE_CASE} AS ebene, COUNT(*) FROM subbausteine "
"WHERE status = 'konsens' GROUP BY topic, baustein_norm, ebene"
)
out: dict[tuple[str, str], dict[int, int]] = {}
for t, bn, ebene, n in await cursor.fetchall():
out.setdefault((t, bn), _leere_ebenen())[ebene] = n
return out
async def subs_mit_ebene(topic: str, baustein: str) -> list[dict]:
"""Konsens-Subbausteine eines Bausteins mit Titel, sub_norm und Ebene 14."""
from textkit import _norm_titel
db = await get_db()
cursor = await db.execute(
f"SELECT sub_titel, sub_norm, {_EBENE_CASE} AS ebene FROM subbausteine "
"WHERE topic = ? AND baustein_norm = ? AND status = 'konsens'",
(topic, _norm_titel(baustein)),
)
return [{"titel": t, "norm": sn, "ebene": e} for t, sn, e in await cursor.fetchall()]
async def list_baustein_scores_all() -> list[tuple[str, str, int]]: async def list_baustein_scores_all() -> list[tuple[str, str, int]]:
"""(topic, baustein, gute_antworten) je Baustein — Rohdaten für die Stufen-Ableitung.""" """(topic, baustein, gute_antworten) je Baustein — Rohdaten für die Stufen-Ableitung."""
db = await get_db() db = await get_db()
@@ -653,19 +777,21 @@ async def upsert_subbaustein(topic: str, baustein_norm: str, sub_norm: str, baus
async def put_subbaustein(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str, async def put_subbaustein(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str,
stufe: str | None = None, relevanz: str | None = None, status: str = "konsens") -> None: stufe: str | None = None, relevanz: str | None = None,
"""Insert/Update OHNE Nennungszähler (Spiegel aus dem Sidecar). stufe/relevanz nur überschreiben, fakten: str | None = None, status: str = "konsens") -> None:
wenn ein neuer Wert übergeben wird (COALESCE schützt Bestehendes).""" """Insert/Update OHNE Nennungszähler (Spiegel aus dem Sidecar). stufe/relevanz/fakten nur
überschreiben, wenn ein neuer Wert übergeben wird (COALESCE schützt Bestehendes)."""
db = await get_db() db = await get_db()
await db.execute( await db.execute(
"""INSERT INTO subbausteine (topic, baustein_norm, sub_norm, baustein, sub_titel, nennungen, stufe, relevanz, status, updated_at) """INSERT INTO subbausteine (topic, baustein_norm, sub_norm, baustein, sub_titel, nennungen, stufe, relevanz, fakten, status, updated_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
ON CONFLICT(topic, baustein_norm, sub_norm) DO UPDATE SET ON CONFLICT(topic, baustein_norm, sub_norm) DO UPDATE SET
baustein = excluded.baustein, sub_titel = excluded.sub_titel, baustein = excluded.baustein, sub_titel = excluded.sub_titel,
stufe = COALESCE(excluded.stufe, subbausteine.stufe), stufe = COALESCE(excluded.stufe, subbausteine.stufe),
relevanz = COALESCE(excluded.relevanz, subbausteine.relevanz), relevanz = COALESCE(excluded.relevanz, subbausteine.relevanz),
fakten = COALESCE(excluded.fakten, subbausteine.fakten),
status = excluded.status, updated_at = excluded.updated_at""", status = excluded.status, updated_at = excluded.updated_at""",
(topic, baustein_norm, sub_norm, baustein, sub_titel, stufe, relevanz, status, _now()), (topic, baustein_norm, sub_norm, baustein, sub_titel, stufe, relevanz, fakten, status, _now()),
) )
await db.commit() await db.commit()
@@ -700,14 +826,14 @@ async def delete_subbausteine(topic: str) -> None:
await db.commit() await db.commit()
async def upsert_frage_muster(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str, typ: str, frage: str) -> None: async def upsert_frage_muster(topic: str, baustein_norm: str, sub_norm: str, baustein: str, sub_titel: str, frage: str) -> None:
db = await get_db() db = await get_db()
await db.execute( await db.execute(
"""INSERT INTO frage_muster (topic, baustein_norm, sub_norm, baustein, sub_titel, typ, frage, updated_at) """INSERT INTO frage_muster (topic, baustein_norm, sub_norm, baustein, sub_titel, frage, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(topic, baustein_norm, sub_norm, typ) DO UPDATE SET ON CONFLICT(topic, baustein_norm, sub_norm) DO UPDATE SET
frage = excluded.frage, updated_at = excluded.updated_at""", sub_titel = excluded.sub_titel, frage = excluded.frage, updated_at = excluded.updated_at""",
(topic, baustein_norm, sub_norm, baustein, sub_titel, typ, frage, _now()), (topic, baustein_norm, sub_norm, baustein, sub_titel, frage, _now()),
) )
await db.commit() await db.commit()
@@ -849,10 +975,90 @@ async def delete_guide_content(topic: str, format: str | None = None) -> None:
await db.commit() await db.commit()
async def set_gliederung(topic: str, gliederung_json: str) -> None:
"""Gliederung (Kapitel→Nummern, JSON) je Thema speichern — Bausteine-Artefakt für den Guide."""
db = await get_db()
await db.execute(
"""INSERT INTO guide_gliederung (topic, json, updated_at) VALUES (?, ?, ?)
ON CONFLICT(topic) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""",
(topic, gliederung_json, _now()),
)
await db.commit()
async def get_gliederung(topic: str) -> str | None:
db = await get_db()
cursor = await db.execute("SELECT json FROM guide_gliederung WHERE topic = ?", (topic,))
row = await cursor.fetchone()
return row[0] if row else None
async def delete_gliederung(topic: str) -> None:
db = await get_db()
await db.execute("DELETE FROM guide_gliederung WHERE topic = ?", (topic,))
await db.commit()
async def put_sub_artefakt(topic: str, baustein_norm: str, sub_norm: str, typ: str,
daten: str, baustein: str = "", sub_titel: str = "") -> None:
"""Ein Lern-Artefakt (karteikarte/beispiel/diagramm) als JSON in `daten` speichern."""
db = await get_db()
await db.execute(
"""INSERT INTO sub_artefakte (topic, baustein_norm, sub_norm, typ, baustein, sub_titel, daten, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(topic, baustein_norm, sub_norm, typ) DO UPDATE SET
baustein = excluded.baustein, sub_titel = excluded.sub_titel,
daten = excluded.daten, updated_at = excluded.updated_at""",
(topic, baustein_norm, sub_norm, typ, baustein, sub_titel, daten, _now()),
)
await db.commit()
async def get_sub_artefakte(topic: str, typ: str | None = None) -> list[dict]:
db = await get_db()
if typ is None:
cursor = await db.execute("SELECT * FROM sub_artefakte WHERE topic = ? ORDER BY rowid", (topic,))
else:
cursor = await db.execute(
"SELECT * FROM sub_artefakte WHERE topic = ? AND typ = ? ORDER BY rowid", (topic, typ)
)
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def delete_sub_artefakte(topic: str) -> None:
db = await get_db()
await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,))
await db.commit()
async def get_baustein_huerden(topic: str, baustein_norm: str) -> list[str]:
"""Typische Irrtümer (huerden) aller Subs eines Bausteins — als Distraktor-Pool fürs Quiz.
Liest aus subbausteine.fakten (JSON-Blob); leere/fehlende werden übersprungen."""
db = await get_db()
cursor = await db.execute(
"SELECT fakten FROM subbausteine WHERE topic = ? AND baustein_norm = ?", (topic, baustein_norm)
)
rows = await cursor.fetchall()
huerden = []
for (fakten,) in rows:
if not fakten:
continue
try:
fk = json.loads(fakten)
except (ValueError, TypeError):
continue
h = (fk.get("huerden") or "").strip() if isinstance(fk, dict) else ""
if h:
huerden.append(h)
return huerden
async def delete_topic_pipeline(topic: str) -> None: async def delete_topic_pipeline(topic: str) -> None:
"""Bausteine-Bereich eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State). """Bausteine-Bereich eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State/Artefakte).
NICHT die Themen-Config `quelle` — die wird separat verwaltet (delete_quelle).""" NICHT die Themen-Config `quelle` — die wird separat verwaltet (delete_quelle)."""
db = await get_db() db = await get_db()
for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage", "pipeline_state"): for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage",
"pipeline_state", "guide_gliederung", "sub_artefakte"):
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,)) await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
await db.commit() await db.commit()

View File

@@ -24,7 +24,7 @@ from config import (
LESBARKEIT_AKTIV, TEMPLATES_DIR, LESBARKEIT_AKTIV, TEMPLATES_DIR,
) )
import lesbarkeit import lesbarkeit
from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content, get_guide_content from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content, get_guide_content, get_gliederung
from fsutil import atomic_write_json, atomic_write_text from fsutil import atomic_write_json, atomic_write_text
from jsonio import read_json_file as _json_datei, parse_json_text as _parse_json_text from jsonio import read_json_file as _json_datei, parse_json_text as _parse_json_text
from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path
@@ -35,7 +35,7 @@ from pipeline import (
is_guide_cancelled, run_single_slot, is_guide_cancelled, run_single_slot,
) )
from textkit import ( from textkit import (
_eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks, _eindeutige_titel, _lade_bausteine, _norm_titel, _parse_fragment, _split_chunks,
_titel, _titel_aufloesen, _titel_index, _titel, _titel_aufloesen, _titel_index,
) )
@@ -57,14 +57,22 @@ CHECK_PANEL = 3
LESE_RUNDEN = 1 LESE_RUNDEN = 1
# Gültige Stufen-Werte: neu (Lernpfad) + alt (Schwierigkeit) abwärtskompatibel.
_STUFEN_OK = ("anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer")
async def _load_subbausteine(topic: str) -> dict[str, list[dict]]: async def _load_subbausteine(topic: str) -> dict[str, list[dict]]:
"""Subbausteine je Baustein — DB-first ({titel, stufe, relevanz}), Fallback Sidecar-Datei. """Subbausteine je Baustein — DB-first ({titel, stufe, relevanz}), Fallback Sidecar-Datei.
Fehlt beides → {} (Guide nimmt alles).""" Fehlt beides → {} (Guide nimmt alles)."""
out: dict[str, list[dict]] = {} out: dict[str, list[dict]] = {}
for r in await list_subbausteine(topic): for r in await list_subbausteine(topic):
if r["status"] == "konsens" and r["sub_titel"] and r["stufe"] in ("einfach", "mittel", "schwer"): if r["status"] == "konsens" and r["sub_titel"] and r["stufe"] in _STUFEN_OK:
try:
fakten = json.loads(r["fakten"]) if r.get("fakten") else {}
except (ValueError, TypeError):
fakten = {}
out.setdefault(r["baustein"], []).append( out.setdefault(r["baustein"], []).append(
{"titel": r["sub_titel"], "stufe": r["stufe"], "relevanz": r["relevanz"]}) {"titel": r["sub_titel"], "stufe": r["stufe"], "relevanz": r["relevanz"], "fakten": fakten})
if out: if out:
return out return out
data = _json_datei(subbausteine_path(topic)) data = _json_datei(subbausteine_path(topic))
@@ -74,21 +82,26 @@ async def _load_subbausteine(topic: str) -> dict[str, list[dict]]:
if not isinstance(subs, list): if not isinstance(subs, list):
continue continue
gut = [s for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip() gut = [s for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip()
and s.get("stufe") in ("einfach", "mittel", "schwer")] and s.get("stufe") in _STUFEN_OK]
if gut: if gut:
out[titel] = gut out[titel] = gut
return out return out
def _ebene_label(s: dict) -> str:
"""Ansichts-Ebene eines Subbausteins: rand → 'rand' (Ebene 4), sonst die Stufe (13)."""
return "rand" if s.get("relevanz") == "rand" else (s.get("stufe") or "anfaenger")
def _zuteilung_subs(chunk: list[dict], entries: dict[int, str], subs_by_titel: dict[str, list[dict]]) -> str: def _zuteilung_subs(chunk: list[dict], entries: dict[int, str], subs_by_titel: dict[str, list[dict]]) -> str:
"""Listet je Kapitel die Bausteine, darunter ihre Subbausteine mit Stufe.""" """Listet je Kapitel die Bausteine, darunter ihre Subbausteine mit Ebenen-Label."""
lines: list[str] = [] lines: list[str] = []
for ch in chunk: for ch in chunk:
lines.append(f"KAPITEL: {ch['title']}") lines.append(f"KAPITEL: {ch['title']}")
for num in ch["nums"]: for num in ch["nums"]:
lines.append(f"- {entries[num]}") lines.append(f"- {entries[num]}")
for s in subs_by_titel.get(_titel(entries[num]), []): for s in subs_by_titel.get(_titel(entries[num]), []):
lines.append(f" [{s['stufe']}] {s['titel']}") lines.append(f" [{_ebene_label(s)}] {s['titel']}")
return "\n".join(lines) return "\n".join(lines)
@@ -249,6 +262,68 @@ def _mit_resten(plan: list[dict], entries: dict[int, str]) -> list[dict]:
return [*plan, {"title": "Weitere", "nums": fehlen}] if fehlen else plan return [*plan, {"title": "Weitere", "nums": fehlen}] if fehlen else plan
def _fakten_grounding(subs_raw: dict[str, list[dict]]) -> str:
"""Verifizierte Sub-Fakten (extract-once aus der Bausteine-Phase) als Grounding-Block für den
Inhalts-Agent. Leer, wenn keine Fakten gespeichert (Altbestand → Fallback auf Quelle-Hinweis)."""
bloecke = []
for titel, subs in subs_raw.items():
zeilen = []
for s in subs:
fk = s.get("fakten") if isinstance(s.get("fakten"), dict) else None
if not fk:
continue
teile = []
if fk.get("kernpunkte"):
teile.append("Kern: " + " · ".join(fk["kernpunkte"]))
for bf in fk.get("belegte_fakten", []):
teile.append(f"FAKT[{bf.get('quelle', '?')}]: {bf.get('text', '')}")
if fk.get("voraussetzungen"):
teile.append("Voraussetzung: " + fk["voraussetzungen"])
if fk.get("huerden"):
teile.append("Hürde: " + fk["huerden"])
if fk.get("beispiel_idee"):
teile.append("Beispiel: " + fk["beispiel_idee"])
if teile:
zeilen.append(f"- {s['titel']}: " + " | ".join(teile))
if zeilen:
bloecke.append(f"BAUSTEIN: {titel}\n" + "\n".join(zeilen))
if not bloecke:
return ""
return ("VERIFIZIERTE FAKTEN je Subbaustein — verbindliche Grundlage. Belegte Fakten (FAKT[Quelle]) "
"WÖRTLICH übernehmen, nichts dazu erfinden, NICHT neu recherchieren. Beispiele als Beispiel "
"nutzen, nie als Fakt.\n\n" + "\n\n".join(bloecke))
async def _gliederung_aus_db(topic: str, sel_entries: dict[int, str]) -> list[dict] | None:
"""Gliederung aus dem Bausteine-Artefakt (DB) lesen und auf die gewählten Bausteine mappen.
Titel-basiert (robust gegen Nummern-Drift): Bausteine außerhalb der Auswahl werden ignoriert
(Format-Filter), fehlende ergänzt später _mit_resten. None → kein Artefakt (Altbestand)."""
raw = await get_gliederung(topic)
if not raw:
return None
try:
data = json.loads(raw)
except (ValueError, TypeError):
return None
kapitel = data.get("kapitel") if isinstance(data, dict) else None
if not isinstance(kapitel, list):
return None
norm_to_num = {_norm_titel(_titel(t)): num for num, t in sel_entries.items()}
plan, seen = [], set()
for ch in kapitel:
if not isinstance(ch, dict):
continue
nums = []
for bt in ch.get("bausteine", []):
num = norm_to_num.get(_norm_titel(str(bt)))
if num is not None and num not in seen:
seen.add(num)
nums.append(num)
if nums:
plan.append({"title": str(ch.get("titel", "")).strip() or "Kapitel", "nums": nums})
return plan or None
async def _generate_sections( async def _generate_sections(
guide_id: str, topic: str, format_name: str, entries: dict[int, str], guide_id: str, topic: str, format_name: str, entries: dict[int, str],
facts: str, instructions: str, provider: str, facts: str, instructions: str, provider: str,
@@ -265,20 +340,19 @@ async def _generate_sections(
# Subbausteine je Baustein (DB-first) — früh geladen: steuert Auswahl + Sub-Filter je Format. # Subbausteine je Baustein (DB-first) — früh geladen: steuert Auswahl + Sub-Filter je Format.
# Fehlt sie → {} (Fallback: Guide nimmt alles). # Fehlt sie → {} (Fallback: Guide nimmt alles).
subs_raw = await _load_subbausteine(topic) subs_raw = await _load_subbausteine(topic)
# Extract-once-Grounding: gespeicherte, verifizierte Fakten ersetzen den generischen
# Quelle-Hinweis. Der Inhalts-Agent formuliert daraus, statt die Quelle neu zu lesen.
if (fakten_block := _fakten_grounding(subs_raw)):
facts = fakten_block
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]), []))
# Auswahl (Teil von Gliederung). Deterministisch je Format: # Auswahl: EIN Voll-Dokument mit ALLEN Bausteinen (inkl. Rand). Die Ansichten E/M/S/F
# Guide=relevante Bausteine · FullGuide=alle · Rest=reine Rand-Bausteine (keine im Guide). # filtern später pro Subbaustein-Ebene. (FullGuide/Rest bleiben als Alt-Zweige bestehen.)
if format_name == "FullGuide": if format_name == "Rest":
auswahl = list(entries)
elif format_name == "Rest":
auswahl = [num for num in entries if not _hat_relevanz(num, "relevant")] auswahl = [num for num in entries if not _hat_relevanz(num, "relevant")]
else: # Guide else: # Guide / FullGuide → alle Bausteine
auswahl = [num for num in entries if _hat_relevanz(num, "relevant")]
if not auswahl:
_log(topic, "Guide: keine Relevanz-Daten — alle Bausteine genommen")
auswahl = list(entries) auswahl = list(entries)
if not auswahl: if not auswahl:
await _fail(guide_id, "Keine passenden Bausteine für dieses Format") await _fail(guide_id, "Keine passenden Bausteine für dieses Format")
@@ -289,9 +363,13 @@ async def _generate_sections(
# Nummerierte Liste (ID = Baustein-Nummer aus entries) — Agenten/Judge ordnen per Nummer. # Nummerierte Liste (ID = Baustein-Nummer aus entries) — Agenten/Judge ordnen per Nummer.
sel_liste = "\n".join(f"{num}. {t}" for num, t in sel_entries.items()) sel_liste = "\n".join(f"{num}. {t}" for num, t in sel_entries.items())
# Schritt 0: Gliederung — bis zu 3 Vorschläge (Grace), ein Judge merged sie. Bricht NIE ab: # Schritt 0: Gliederung. Bevorzugt das Bausteine-Artefakt (DB) — der Guide präsentiert nur,
# gliedert nicht mehr selbst. Fehlt es (Altbestand) → bisherige Agenten/Judge-Logik als Fallback.
# 0 gültige → Code-Fallback, 1 → direkt, ≥2 → Judge (mit Vorschlag als Rückfall). # 0 gültige → Code-Fallback, 1 → direkt, ≥2 → Judge (mit Vorschlag als Rückfall).
# Gültiges gliederung.json überspringt den Schritt. plan = await _gliederung_aus_db(topic, sel_entries)
if plan is not None:
_log(topic, f"Gliederung aus Bausteine-Artefakt ({len(plan)} Kapitel)")
if plan is None:
plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll) plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
if plan is None: if plan is None:
await _set_step(guide_id, 0, "Gliederungs-Vorschläge (3 Agenten)…") await _set_step(guide_id, 0, "Gliederungs-Vorschläge (3 Agenten)…")
@@ -366,14 +444,12 @@ async def _generate_sections(
# Der Writer baut darunter pro Baustein (eigene feine Chunks, s.u.) → variable Längen. # Der Writer baut darunter pro Baustein (eigene feine Chunks, s.u.) → variable Längen.
total_sections = sum(len(c["nums"]) for c in plan) total_sections = sum(len(c["nums"]) for c in plan)
chunks = _split_chunks(plan, max(1, math.ceil(total_sections / GUIDE_CHUNK))) chunks = _split_chunks(plan, max(1, math.ceil(total_sections / GUIDE_CHUNK)))
# Subbausteine je Baustein, je Format gefiltert: Guide=relevant · FullGuide=alle · Rest=Rand. # Subbausteine je Baustein: Guide/FullGuide nehmen ALLE (inkl. Rand → Ebene 4 in der Ansicht);
# Subs ohne Relevanz-Feld (Alt-Themen) zählen als „nicht Rand" (→ Guide/FullGuide). # nur der Alt-Zweig Rest filtert auf Rand. So trägt das eine Dokument alle Ebenen.
if format_name == "FullGuide": if format_name == "Rest":
subs_by_titel = {t: list(subs) for t, subs in subs_raw.items()}
elif format_name == "Rest":
subs_by_titel = {t: [s for s in subs if s.get("relevanz") == "rand"] for t, subs in subs_raw.items()} subs_by_titel = {t: [s for s in subs if s.get("relevanz") == "rand"] for t, subs in subs_raw.items()}
else: # Guide else: # Guide / FullGuide
subs_by_titel = {t: [s for s in subs if s.get("relevanz") != "rand"] for t, subs in subs_raw.items()} subs_by_titel = {t: list(subs) for t, subs in subs_raw.items()}
subs_by_titel = {t: subs for t, subs in subs_by_titel.items() if subs} subs_by_titel = {t: subs for t, subs in subs_by_titel.items() if subs}
zuteilungen = [_zuteilung_subs(chunk, entries, subs_by_titel) for chunk in chunks] zuteilungen = [_zuteilung_subs(chunk, entries, subs_by_titel) for chunk in chunks]
chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks] chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks]
@@ -617,9 +693,15 @@ async def _generate_sections(
def sections_text(nums: list[int]) -> str: def sections_text(nums: list[int]) -> str:
return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums) return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)
def _sub_liste(num: int) -> str:
subs = subs_by_titel.get(_titel(entries[num]), [])
return "\n".join(f"- [{_ebene_label(s)}] {s['titel']}" for s in subs) or "(keine)"
def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str: def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str:
return "\n\n".join( return "\n\n".join(
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}" f"SECTION: {_titel(entries[num])}\n"
f"SUBBAUSTEINE (je einen `<!-- sub: LABEL | titel -->`-Marker setzen, Label/Reihenfolge wie hier):\n{_sub_liste(num)}\n"
f"PROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
for num in nums for num in nums
) )
@@ -703,7 +785,13 @@ async def _generate_sections(
continue continue
for sec in _parse_fragment(p.read_text(encoding="utf-8")): for sec in _parse_fragment(p.read_text(encoding="utf-8")):
num = _titel_aufloesen(idx, sec["titel"]) num = _titel_aufloesen(idx, sec["titel"])
if num in probleme_by_num and sec["md"].strip(): if num not in probleme_by_num or not sec["md"].strip():
continue
# Marker-Invariante: Verliert der Fix die Sub-Marker, obwohl das Original welche
# hatte, wird er verworfen — sonst stirbt der Stufen-Filter (E/M/S/F) still.
if by_num[num].get("subs") and not sec.get("subs"):
_log(topic, f"Lese-Fix für '{sec['titel']}' ohne Sub-Marker — verworfen, getaggtes Original bleibt")
continue
by_num[num] = sec by_num[num] = sec
ersetzt.add(num) ersetzt.add(num)
_log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet") _log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet")
@@ -730,7 +818,9 @@ async def _generate_sections(
for ch in plan: for ch in plan:
sections = [ sections = [
{"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"], {"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"],
"kompakt": by_num[num].get("kompakt", ""), "pruefbar": _pruefbar(num)} "kompakt": by_num[num].get("kompakt", ""),
"anker": by_num[num].get("anker", ""), "anker_kompakt": by_num[num].get("anker_kompakt", ""),
"subs": by_num[num].get("subs", []), "pruefbar": _pruefbar(num)}
for num in ch["nums"] if num in by_num for num in ch["nums"] if num in by_num
] ]
if sections: if sections:
@@ -745,6 +835,34 @@ async def _generate_sections(
return chapters return chapters
_STUFE_EBENE = {"anfaenger": 1, "fortgeschritten": 2, "experte": 3, "rand": 4,
"einfach": 1, "mittel": 2, "schwer": 3} # alte Werte abwärtskompatibel
def _section_fuer_ebene(sec: dict, ebene: int) -> dict:
"""md/kompakt einer Section auf Subbausteine bis zur Ebene rekonstruieren (Anker bleibt)."""
subs = sec.get("subs") or []
if not subs:
return sec # keine Sub-Tags (Altbestand) → unverändert sichtbar
sichtbar = [s for s in subs if _STUFE_EBENE.get(s.get("stufe"), 1) <= ebene]
md = "\n\n".join(t for t in [sec.get("anker", ""), *(s.get("md", "") for s in sichtbar)] if t).strip()
kompakt = "\n".join(t for t in [sec.get("anker_kompakt", ""), *(s.get("kompakt", "") for s in sichtbar)] if t).strip()
return {**sec, "md": md, "kompakt": kompakt, "leer": not sichtbar}
def content_fuer_ebene(content: dict, ebene: int) -> dict:
"""Guide-Content auf eine Ansichts-Ebene (1=A · 2=F · 3=E · 4=V) filtern. Ebene 4 = Vollfassung.
Sections ohne sichtbare Subs werden ausgeblendet, leere Kapitel entfallen."""
if not isinstance(content, dict) or ebene >= 4:
return content
kapitel = []
for ch in content.get("chapters", []):
secs = [s for s in (_section_fuer_ebene(x, ebene) for x in ch.get("sections", [])) if not s.get("leer")]
if secs:
kapitel.append({**ch, "sections": secs})
return {**content, "chapters": kapitel}
async def reconcile_guides() -> None: async def reconcile_guides() -> None:
"""DB↔Dateisystem abgleichen: status=done ohne Content-Datei → error. """DB↔Dateisystem abgleichen: status=done ohne Content-Datei → error.
@@ -894,11 +1012,21 @@ async def block_uebernehmen(topic: str, format_name: str, baustein: str, stelle:
sec = _section_finden(content, baustein) if content else None sec = _section_finden(content, baustein) if content else None
if sec is None: if sec is None:
return None return None
feld = "kompakt" if str(stelle).startswith("kompakt") else "md" ist_kompakt = str(stelle).startswith("kompakt")
feld = "kompakt" if ist_kompakt else "md"
aktuell = sec.get(feld, "") or "" aktuell = sec.get(feld, "") or ""
gefunden = alt in aktuell gefunden = alt in aktuell
if gefunden: if gefunden:
sec[feld] = aktuell.replace(alt, neu, 1) sec[feld] = aktuell.replace(alt, neu, 1)
# Auch in Anker + Subs ersetzen (Quellen der gefilterten E/M/S-Ansicht), sonst zeigt
# die gestufte Ansicht weiter den alten Block.
anker_feld = "anker_kompakt" if ist_kompakt else "anker"
if alt in (sec.get(anker_feld) or ""):
sec[anker_feld] = sec[anker_feld].replace(alt, neu, 1)
for sub in sec.get("subs", []):
if alt in (sub.get(feld, "") or ""):
sub[feld] = sub[feld].replace(alt, neu, 1)
break
js = json.dumps(content, ensure_ascii=False) js = json.dumps(content, ensure_ascii=False)
await set_guide_content(topic, format_name, js) await set_guide_content(topic, format_name, js)
atomic_write_json(guide_content_path(topic, format_name), content, indent=1) atomic_write_json(guide_content_path(topic, format_name), content, indent=1)

View File

@@ -13,7 +13,7 @@ from datetime import datetime, timezone
from agents import run_agent from agents import run_agent
from config import DEFAULT_PROVIDER from config import DEFAULT_PROVIDER
from database import create_element, list_elements from database import create_element, list_elements, get_baustein_huerden
from elements import generate_element from elements import generate_element
from jsonio import parse_json_text as _parse_json_text from jsonio import parse_json_text as _parse_json_text
from pipeline import _prompt, _probleme_schema from pipeline import _prompt, _probleme_schema
@@ -27,34 +27,68 @@ log = logging.getLogger("creator.lernen")
STUFEN = (("anfaenger", 0.2), ("fortgeschritten", 0.4), ("experte", 0.6), ("meister", 1.0)) STUFEN = (("anfaenger", 0.2), ("fortgeschritten", 0.4), ("experte", 0.6), ("meister", 1.0))
def cap_fuer(n_subs: int) -> int: PUNKT_BASIS = 25 # Punkte je Subbaustein. Meister-cap = (alle Subs) × 25.
"""Maximal-Score eines Bausteins: 4 Punkte je relevantem Subbaustein, gedeckelt [10, 50]."""
return max(10, min(50, 4 * n_subs))
def _ebenen(n_je_ebene: dict[int, int]) -> list[int]:
return [n_je_ebene.get(k, 0) for k in (1, 2, 3, 4)]
def schwellen(n_je_ebene: dict[int, int]) -> list[int]:
"""Kumulative Ebenen-Schwellen [S_1, S_2, S_3, S_4] = (n_1+…+n_k) × 25.
S_k ist der Score, ab dem Ebene k+1 (E→M→S→F) freigeschaltet ist; S_4 = cap_final."""
out, akk = [], 0
for n in _ebenen(n_je_ebene):
akk += n
out.append(akk * PUNKT_BASIS)
return out
def cap_final(n_je_ebene: dict[int, int]) -> int:
"""Maximal-Score (Meister) = alle Subbausteine × 25."""
return schwellen(n_je_ebene)[-1]
def freie_ebene(score: int, n_je_ebene: dict[int, int]) -> int:
"""Höchste freigeschaltete Sub-Ebene 14. Ebene k+1 frei, sobald score ≥ S_k.
Leere Ebenen (n_k=0) werden automatisch übersprungen (S_k == S_{k-1})."""
s = schwellen(n_je_ebene)
e = 1
for k in range(3): # S_1..S_3 schalten Ebene 2..4 frei
if score >= s[k]:
e = k + 2
return e
def cap_aktuell(score: int, n_je_ebene: dict[int, int]) -> int:
"""Erreichbarer cap der aktuell freigeschalteten Ebene = freigeschaltete Subs × 25."""
return schwellen(n_je_ebene)[freie_ebene(score, n_je_ebene) - 1]
def _schwelle(p: float, cap: int) -> int: def _schwelle(p: float, cap: int) -> int:
return round(p * cap) return round(p * cap)
def stufe_aus_score(score: int, cap: int) -> str | None: def stufe_aus_score(score: int, cap_final_wert: int) -> str | None:
"""Höchste erreichte Stufe (None unter 20 %).""" """Höchste erreichte Lernstufe (None unter 20 %), relativ zum cap_final."""
erreicht = None erreicht = None
for key, p in STUFEN: for key, p in STUFEN:
if score >= _schwelle(p, cap): if score >= _schwelle(p, cap_final_wert):
erreicht = key erreicht = key
return erreicht return erreicht
def progressiver_malus(basis: int, cap: int) -> int: def progressiver_malus(basis: int, cap_akt: int) -> int:
"""Fehler-Strafe nach Fortschritt: 020 %→0 · 2140 %1 · 4160 %2 · 61100 %3.""" """Fehler-Strafe nach Fortschritt in der aktuellen Ebene (gegen cap_aktuell):
pct = (basis / cap) if cap else 0.0 ≤25 %5 · ≤50 %10 · ≤75 %15 · >75 %20."""
if pct <= 0.2: pct = (basis / cap_akt) if cap_akt else 0.0
return 0 if pct <= 0.25:
if pct <= 0.4: return -5
return -1 if pct <= 0.5:
if pct <= 0.6: return -10
return -2 if pct <= 0.75:
return -3 return -15
return -20
CHAT_TIMEOUT = 240 CHAT_TIMEOUT = 240
PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt PRUEFUNG_TIMEOUT = 120 # kurze JSON-Turns; deckelt die Serien-Latenz pro Prüfungs-Schritt
GRUENDLICH_TIMEOUT = 600 # „Gründlich prüfen": starkes Modell (role guide) braucht länger GRUENDLICH_TIMEOUT = 600 # „Gründlich prüfen": starkes Modell (role guide) braucht länger
@@ -70,34 +104,66 @@ FRAGETYPEN = {
} }
# Antwort-Niveau (wie sehr der Kern getroffen ist) → Punkt-Delta. # Antwort-Niveau → Basis-Punkte (neue 25er-Skala). „kaum" = 1 ist nur das Signal für den
# progressiven Malus (echter Wert kommt aus progressiver_malus). Positive Werte werden bei
# Streak hochmoduliert und auf [10, 40] geklemmt.
NIVEAUS = { NIVEAUS = {
"unbeantwortbar": 0, # Frage selbst kaputt (Randthema/Verweis auf Nichtgezeigtes) → kein Malus "unbeantwortbar": 0, # Frage selbst kaputt → keine Änderung
"kaum": -1, # < 25 % richtig "kaum": -1, # < 25 % richtig → Malus
"teilweise": 0, # 2549 % "teilweise": 0, # 2549 % → neutral
"solide": 1, # 5074 % "solide": 16, # 5074 %
"stark": 2, # 7599 % "stark": 24, # 7599 % (Quiz/Lücke-Treffer)
"komplett": 3, # 100 % "komplett": 30, # 100 % (nur freies Erklären erreichbar)
} }
# Reihenfolge schwach→stark (für den Nachfrage-Deckel).
_NIVEAU_RANG = ("kaum", "teilweise", "solide", "stark", "komplett")
def score_berechnen(basis: int, delta: int, floor: int, cap: int) -> int:
"""Neuer Score · driftfrei aus der Basis. `delta` liefert die Route je Prüfungsform def deckel_nachfrage(niveau: str, nachgefragt: bool) -> str:
(Quiz +1/+3, Lückentext +1/+3, Erklären 1…+3; falsch = progressiver_malus). Auf """Mit Nachfrage (Hinweis erhalten) höchstens „solide" — kein Voll-Score erschummeln."""
[floor, cap] geklemmt. floor = höchste erreichte Stufenschwelle (kein Rückfall); if nachgefragt and niveau in ("stark", "komplett"):
cap = 4× relevante Subbausteine [10, 50]. Am Deckel (basis ≥ cap) eingefroren („Max").""" return "solide"
if basis >= cap: return niveau
def streak_punkte(basis_delta: int, streak_basis: int) -> int:
"""Positives Basis-Delta mit Streak hochmodulieren, auf [10, 40] geklemmt."""
faktor = min(1.33, 1 + 0.066 * min(streak_basis, 5))
return max(10, min(40, round(basis_delta * faktor)))
def punkte_delta(niveau: str, streak_basis: int, basis: int, cap_akt: int) -> tuple[int, int]:
"""Antwort-Niveau → (Punkt-Delta, neue Streak). Positiv: streak-moduliert, Streak +1.
Neutral (0): keine Änderung, Streak bleibt. Negativ: progressiver Malus, Streak-Reset 0."""
basis_delta = NIVEAUS.get(niveau, 0)
if basis_delta > 0:
return streak_punkte(basis_delta, streak_basis), streak_basis + 1
if basis_delta == 0:
return 0, streak_basis
return progressiver_malus(basis, cap_akt), 0
def score_berechnen(basis: int, delta: int, floor: int, cap_akt: int, cap_fin: int) -> int:
"""Neuer Score · driftfrei aus der Basis. Klemmt nach oben gegen `cap_akt` (Deckel der
aktuell freigeschalteten Ebene) und nach unten gegen `floor`. Eingefroren NUR am
absoluten Maximum (`basis ≥ cap_fin`) — sonst würde an jeder Ebenen-Schwelle blockiert."""
if basis >= cap_fin:
return basis return basis
return max(floor, min(cap, basis + delta)) return max(floor, min(cap_akt, basis + delta))
def floor_aus_score(basis: int, cap: int) -> int: def floor_aus_score(basis: int, cap_fin: int, s_schwellen: list[int]) -> int:
"""Untergrenze: höchste erreichte Stufenschwelle — kein Rückfall unter die Stufe.""" """Untergrenze (kein Rückfall): höchste erreichte Lernstufen-Schwelle (über cap_final)
UND höchste erreichte Ebenen-Freischalt-Schwelle S_k. max beider Achsen."""
floor = 0 floor = 0
for _, p in STUFEN: for _, p in STUFEN:
s = _schwelle(p, cap) s = _schwelle(p, cap_fin)
if basis >= s: if basis >= s:
floor = s floor = max(floor, s)
for s in s_schwellen:
if basis >= s:
floor = max(floor, s)
return floor return floor
@@ -199,7 +265,8 @@ def _doppelfrage_mangel(frage: str) -> str | None:
async def _frage_mit_kritik( async def _frage_mit_kritik(
topic: str, baustein: str, section_block: str, kompakt_block: str, topic: str, baustein: str, section_block: str, kompakt_block: str,
transcript: str, vermeide_block: str, typ_block: str, fokus_block: str, provider: str, transcript: str, vermeide_block: str, typ_block: str, fokus_block: str,
niveau_block: str, provider: str,
) -> str | None: ) -> str | None:
"""Frage generieren, vom Kritiker prüfen lassen, bei Mängeln neu (max KRITIK_MAX_RUNDEN).""" """Frage generieren, vom Kritiker prüfen lassen, bei Mängeln neu (max KRITIK_MAX_RUNDEN)."""
kritik_block = "(keine)" kritik_block = "(keine)"
@@ -209,7 +276,7 @@ async def _frage_mit_kritik(
"Baustein-Frage", "guide", _frage_schema, provider, lane="batch", "Baustein-Frage", "guide", _frage_schema, provider, lane="batch",
topic=topic, baustein=baustein, section_block=section_block, topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block, kompakt_block=kompakt_block, transcript=transcript, vermeide_block=vermeide_block,
typ_block=typ_block, fokus_block=fokus_block, kritik_block=kritik_block, typ_block=typ_block, fokus_block=fokus_block, niveau_block=niveau_block, kritik_block=kritik_block,
) )
if data is None: if data is None:
return None return None
@@ -277,13 +344,27 @@ def _vermeide_block(vermeide: list[str] | None) -> str:
return "\n".join(f"- {f}" for f in eintraege) or "(keine)" return "\n".join(f"- {f}" for f in eintraege) or "(keine)"
# Lerner-Niveau (aus dem Score abgeleitet) → Adressaten-Rolle für die Frage. So entsteht die
# Schwierigkeit: nicht „extra schwer machen", sondern „für einen Anfänger/Experten fragen".
NIVEAU_ROLLE = {
"anfaenger": "Der Lerner ist ANFÄNGER. Frage nach dem Grundverständnis — das Kernkonzept, einfach und direkt.",
"fortgeschritten": "Der Lerner ist FORTGESCHRITTEN. Frage etwas tiefer — Zusammenhänge und die typische Anwendung.",
"experte": "Der Lerner ist EXPERTE. Frage anspruchsvoll — Sonderfälle, Abgrenzungen, typische Tücken.",
"meister": "Der Lerner ist auf MEISTER-Niveau. Frage sehr anspruchsvoll — Edge-Cases, präzise Begründung, Transfer auf Neues.",
}
def _niveau_block(niveau: str | None) -> str:
return NIVEAU_ROLLE.get(niveau or "", NIVEAU_ROLLE["anfaenger"])
async def pruefung_frage( async def pruefung_frage(
topic: str, baustein: str, section: str, kompakt: str | None, topic: str, baustein: str, section: str, kompakt: str | None,
messages: list[dict], subbausteine: list[str] | None = None, messages: list[dict], subbausteine: list[str] | None = None,
vermeide: list[str] | None = None, provider: str = DEFAULT_PROVIDER, vermeide: list[str] | None = None, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
) -> str | None: ) -> str | None:
"""Aktion 'frage': eine Frage generieren — zufälliger Typ zu einem zufälligen Subbaustein, """Aktion 'frage': eine Frage generieren — zufälliger Typ zu einem zufälligen Subbaustein,
dann Kritiker (Generator → Kritiker sequenziell) · None bei Fehler.""" in der Adressaten-Rolle des Niveaus, dann Kritiker (sequenziell) · None bei Fehler."""
try: try:
section_block, kompakt_block = _bloecke(section, kompakt) section_block, kompakt_block = _bloecke(section, kompakt)
transcript = _transcript(messages) if messages else "(leer)" transcript = _transcript(messages) if messages else "(leer)"
@@ -296,7 +377,7 @@ async def pruefung_frage(
) )
return await _frage_mit_kritik( return await _frage_mit_kritik(
topic, baustein, section_block, kompakt_block, transcript, topic, baustein, section_block, kompakt_block, transcript,
_vermeide_block(vermeide), typ_block, fokus_block, provider, _vermeide_block(vermeide), typ_block, fokus_block, _niveau_block(niveau), provider,
) )
except Exception: except Exception:
log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True) log.warning("[%s] Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
@@ -305,17 +386,17 @@ async def pruefung_frage(
async def pruefung_frage_variante( async def pruefung_frage_variante(
topic: str, baustein: str, section: str, kompakt: str | None, topic: str, baustein: str, section: str, kompakt: str | None,
muster: str, provider: str = DEFAULT_PROVIDER, muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
) -> str | None: ) -> str | None:
"""Aktion 'frage' mit Muster: aus einem vordefinierten Muster eine konkrete, im """Aktion 'frage' mit Muster: aus einem vordefinierten Muster eine konkrete Frage in der
Wortlaut leicht andere Frage formulieren. Kein Kritiker (Muster ist build-geprüft). Adressaten-Rolle des Niveaus formulieren. Kein Kritiker (Muster ist build-geprüft).
Stil-Guard bleibt als billiger Schutz gegen Doppelfragen · None bei Fehler.""" Stil-Guard bleibt als billiger Schutz gegen Doppelfragen · None bei Fehler."""
try: try:
section_block, kompakt_block = _bloecke(section, kompakt) section_block, kompakt_block = _bloecke(section, kompakt)
data = await _gen_call( data = await _gen_call(
"Baustein-Frage-Variante", "guide", _frage_schema, provider, lane="batch", "Baustein-Frage-Variante", "guide", _frage_schema, provider, lane="batch",
topic=topic, baustein=baustein, section_block=section_block, topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, muster=muster, kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
) )
if data is None: if data is None:
return None return None
@@ -341,17 +422,16 @@ def _optionen_schema(opts) -> list[dict] | None:
return out return out
def _quiz_schema(data, schwer: bool = False) -> dict | None: def _quiz_schema(data) -> dict | None:
"""{"frage": str, "optionen": [{text, korrekt}]×4} → validiert · sonst None. """{"frage": str, "optionen": [{text, korrekt}]×4} → validiert · sonst None.
leicht: genau 1 richtig (Single) · schwer: mindestens 2 richtig (mehrdeutig).""" Single-Choice: genau 1 richtig. Die Schwierigkeit steckt im Niveau, nicht in der Anzahl."""
if not isinstance(data, dict): if not isinstance(data, dict):
return None return None
frage = str(data.get("frage", "")).strip() frage = str(data.get("frage", "")).strip()
out = _optionen_schema(data.get("optionen")) out = _optionen_schema(data.get("optionen"))
if not frage or out is None: if not frage or out is None:
return None return None
n_korrekt = sum(o["korrekt"] for o in out) if sum(o["korrekt"] for o in out) != 1:
if (n_korrekt < 2 if schwer else n_korrekt != 1):
return None return None
return {"frage": frage, "optionen": out} return {"frage": frage, "optionen": out}
@@ -367,24 +447,35 @@ def _lueckwahl_schema(data) -> dict | None:
return {"satz": satz, "optionen": out} return {"satz": satz, "optionen": out}
async def huerden_distraktor_block(topic: str, baustein: str) -> str:
"""Typische Irrtümer (Fakten-Hürden) des Bausteins als Distraktor-Quelle für Quiz/Lückenwahl.
Leer, wenn keine vorhanden (Altbestand) → der Prompt-Platzhalter verschwindet rückstandslos."""
try:
huerden = await get_baustein_huerden(topic, _norm_titel(baustein))
except Exception:
return ""
if not huerden:
return ""
zeilen = "\n".join(f"- {h}" for h in huerden[:8])
return ("TYPISCHE IRRTÜMER zu diesem Baustein (nutze sie als Distraktoren, wenn sie zur Frage passen):\n"
+ zeilen + "\n")
async def quiz_generieren( async def quiz_generieren(
topic: str, baustein: str, section: str, kompakt: str | None, topic: str, baustein: str, section: str, kompakt: str | None,
muster: str, schwer: bool = False, provider: str = DEFAULT_PROVIDER, muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
distraktor_block: str = "",
) -> dict | None: ) -> dict | None:
"""Aus einem Muster eine Multiple-Choice-Frage. schwer: 04 richtig (Multi). """Aus einem Muster eine Single-Choice-Frage (genau 1 richtig), im Anspruch des Niveaus.
leicht: genau 1 richtig (Single). Starkes Modell (role guide) für korrekte Flags. Starkes Modell (role guide) für korrekte Flags.{frage, optionen} · None bei Fehler.
{frage, optionen:[{text,korrekt}]} · None bei Fehler.""" distraktor_block: optionale typische Irrtümer (aus den Fakten-Hürden) als Distraktor-Quelle."""
try: try:
section_block, kompakt_block = _bloecke(section, kompakt) section_block, kompakt_block = _bloecke(section, kompakt)
anzahl_block = (
"MINDESTENS ZWEI Optionen sind richtig (gern auch drei) — NIEMALS nur eine. Mische bewusst, welche."
if schwer else
"GENAU EINE Option ist richtig, die anderen drei sind klar falsch."
)
return await _gen_call( return await _gen_call(
"Baustein-Quiz", "guide", (lambda d: _quiz_schema(d, schwer=schwer)), provider, lane="batch", "Baustein-Quiz", "guide", _quiz_schema, provider, lane="batch",
topic=topic, baustein=baustein, section_block=section_block, topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, muster=muster, anzahl_block=anzahl_block, kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
distraktor_block=distraktor_block,
) )
except Exception: except Exception:
log.warning("[%s] Quiz-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True) log.warning("[%s] Quiz-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)
@@ -393,16 +484,19 @@ async def quiz_generieren(
async def lueckwahl_generieren( async def lueckwahl_generieren(
topic: str, baustein: str, section: str, kompakt: str | None, topic: str, baustein: str, section: str, kompakt: str | None,
muster: str, provider: str = DEFAULT_PROVIDER, muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
distraktor_block: str = "",
) -> dict | None: ) -> dict | None:
"""Lückentext mit Auswahl (leichte Variante): Satz mit ___ + 4 Begriffe, genau 1 richtig. """Lückentext mit Auswahl: Satz mit ___ + 4 Begriffe, genau 1 richtig — im Anspruch des Niveaus.
{satz, optionen:[{text,korrekt}]} · None bei Fehler.""" {satz, optionen:[{text,korrekt}]} · None bei Fehler.
distraktor_block: optionale typische Irrtümer (aus den Fakten-Hürden) als Distraktor-Quelle."""
try: try:
section_block, kompakt_block = _bloecke(section, kompakt) section_block, kompakt_block = _bloecke(section, kompakt)
return await _gen_call( return await _gen_call(
"Baustein-Lueckwahl", "guide", _lueckwahl_schema, provider, lane="batch", "Baustein-Lueckwahl", "guide", _lueckwahl_schema, provider, lane="batch",
topic=topic, baustein=baustein, section_block=section_block, topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, muster=muster, kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
distraktor_block=distraktor_block,
) )
except Exception: except Exception:
log.warning("[%s] Lückentext-Auswahl fehlgeschlagen (%s)", topic, baustein, exc_info=True) log.warning("[%s] Lückentext-Auswahl fehlgeschlagen (%s)", topic, baustein, exc_info=True)
@@ -424,16 +518,16 @@ def _lueck_schema(data) -> dict | None:
async def lueckentext_generieren( async def lueckentext_generieren(
topic: str, baustein: str, section: str, kompakt: str | None, topic: str, baustein: str, section: str, kompakt: str | None,
muster: str, provider: str = DEFAULT_PROVIDER, muster: str, niveau: str = "anfaenger", provider: str = DEFAULT_PROVIDER,
) -> dict | None: ) -> dict | None:
"""Aus einem Muster eine Lückentext-Aufgabe (Satz mit ___, Lösung, Synonyme). """Aus einem Muster eine Lückentext-Aufgabe (Satz mit ___, Lösung, Synonyme), im Anspruch
{satz, loesung, alternativen} · None bei Fehler.""" des Niveaus.{satz, loesung, alternativen} · None bei Fehler."""
try: try:
section_block, kompakt_block = _bloecke(section, kompakt) section_block, kompakt_block = _bloecke(section, kompakt)
return await _gen_call( return await _gen_call(
"Baustein-Lueckentext", "guide", _lueck_schema, provider, lane="batch", "Baustein-Lueckentext", "guide", _lueck_schema, provider, lane="batch",
topic=topic, baustein=baustein, section_block=section_block, topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, muster=muster, kompakt_block=kompakt_block, muster=muster, niveau_block=_niveau_block(niveau),
) )
except Exception: except Exception:
log.warning("[%s] Lückentext-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True) log.warning("[%s] Lückentext-Frage fehlgeschlagen (%s)", topic, baustein, exc_info=True)

View File

@@ -71,7 +71,7 @@ class BausteineQuelleResponse(BaseModel):
class SubbausteinInfo(BaseModel): class SubbausteinInfo(BaseModel):
titel: str titel: str
stufe: Literal["einfach", "mittel", "schwer"] stufe: Literal["anfaenger", "fortgeschritten", "experte", "einfach", "mittel", "schwer"]
relevanz: Literal["relevant", "rand"] | None = None relevanz: Literal["relevant", "rand"] | None = None
@@ -230,8 +230,8 @@ class BausteinPruefungRequest(BaseModel):
nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt
begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden
muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leer → Live-Generierung (Fallback) muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leer → Live-Generierung (Fallback)
# Basis + cap werden serverseitig geführt (Anker bzw. 4×Subs) — Client-cap nur Hinweis. # Basis + cap werden serverseitig geführt (Anker bzw. Subs×25) — Client-cap nur Hinweis.
cap: int = Field(default=10, ge=1, le=50) # Score-Deckel = clamp(4×relevante Subbausteine, 10, 50) cap: int = Field(default=10, ge=1, le=10000) # Score-Deckel = freigeschaltete Subbausteine × 25
messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage messages: list[ChatMessage] = [] # Dialog bisher; leer = erste Frage
provider: ProviderType = "claude" provider: ProviderType = "claude"
gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide) gruendlich: bool = False # „Gründlich prüfen": Bewertung mit starkem Modell (role guide)
@@ -253,13 +253,16 @@ class BausteinPruefungResponse(BaseModel):
loesung: str | None = None # Lückentext: erwarteter Begriff loesung: str | None = None # Lückentext: erwarteter Begriff
alternativen: list[str] | None = None # Lückentext: akzeptierte Synonyme alternativen: list[str] | None = None # Lückentext: akzeptierte Synonyme
gute_antworten: int gute_antworten: int
cap: int = 10 # Maximal-Score dieses Bausteins (4×Subs, geklemmt) — Frontend leitet die Stufe ab streak: int = 0 # aktuelle Serie korrekter Antworten (je Baustein)
cap: int = 10 # cap_final = alle Subs × 25 — Frontend leitet die Lernstufe ab
class BausteinLernstand(BaseModel): class BausteinLernstand(BaseModel):
gute_antworten: int gute_antworten: int
streak: int = 0 streak: int = 0
cap: int = 10 # 4×relevante Subbausteine, geklemmt [10, 50] cap: int = 0 # cap_final = alle Subbausteine × 25
cap_aktuell: int = 0 # erreichbarer cap der aktuell freigeschalteten Ebene
freie_ebene: int = 1 # 1=A · 2=F · 3=E · 4=V
class BausteinLernstandResponse(BaseModel): class BausteinLernstandResponse(BaseModel):

View File

@@ -133,10 +133,10 @@ def _runde_schema(data, final: bool = False):
def _stufen_schema(data, ids: set[int] | None = None): def _stufen_schema(data, ids: set[int] | None = None):
"""{"stufen": {"1": "einfach", …}} → {id: stufe} · sonst None. """{"stufen": {"1": "anfaenger", …}} → {id: stufe} · sonst None.
Stufe ∈ {einfach, mittel, schwer}. Sind `ids` gegeben, müssen mindestens Stufe ∈ {anfaenger, fortgeschritten, experte} (alte Werte abwärtskompatibel). Sind `ids`
diese abgedeckt sein (Extras erlaubt); der Aufrufer filtert dann auf `ids`. gegeben, müssen mindestens diese abgedeckt sein (Extras erlaubt); der Aufrufer filtert auf `ids`.
""" """
if not isinstance(data, dict) or not isinstance(data.get("stufen"), dict) or not data["stufen"]: if not isinstance(data, dict) or not isinstance(data.get("stufen"), dict) or not data["stufen"]:
return None return None

View File

@@ -11,9 +11,9 @@ Query-Schleifen mehr pro Guide.
import json import json
from database import list_baustein_scores_all, subs_count_alle, list_guides, list_progress_all from database import list_baustein_scores_all, subs_je_ebene_alle, list_guides, list_progress_all
from guide import guide_slot_dateien from guide import guide_slot_dateien
from lernen import cap_fuer, STUFEN, _schwelle from lernen import cap_final, STUFEN, _schwelle
from paths import bausteine_path, guide_content_path from paths import bausteine_path, guide_content_path
from textkit import _norm_titel from textkit import _norm_titel
@@ -40,12 +40,12 @@ async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]], dict[str, d
Stufe je Baustein wird aus Score + cap (4×relevante Subs) abgeleitet. Stufe je Baustein wird aus Score + cap (4×relevante Subs) abgeleitet.
""" """
scores = await list_baustein_scores_all() scores = await list_baustein_scores_all()
caps = await subs_count_alle() ebenen = await subs_je_ebene_alle()
levels: dict[str, dict[str, set[str]]] = {key: {} for key, _ in STUFEN} levels: dict[str, dict[str, set[str]]] = {key: {} for key, _ in STUFEN}
for topic, baustein, score in scores: for topic, baustein, score in scores:
cap = cap_fuer(caps.get((topic, _norm_titel(baustein)), 0)) cf = cap_final(ebenen.get((topic, _norm_titel(baustein)), {}))
for key, p in STUFEN: for key, p in STUFEN:
if score >= _schwelle(p, cap): if cf and score >= _schwelle(p, cf):
levels[key].setdefault(topic, set()).add(_norm_titel(baustein)) levels[key].setdefault(topic, set()).add(_norm_titel(baustein))
return await list_guides(), await list_progress_all(), levels return await list_guides(), await list_progress_all(), levels

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import json
import shutil import shutil
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -14,13 +15,15 @@ from database import (
list_progress, set_progress, delete_progress, list_progress, set_progress, delete_progress,
create_element, list_elements, get_element, update_element, delete_element, create_element, list_elements, get_element, update_element, delete_element,
list_baustein_progress, get_baustein_progress, set_offene_frage, list_baustein_progress, get_baustein_progress, set_offene_frage,
set_baustein_score, set_baustein_absolviert, delete_baustein_daten, delete_baustein_progress, count_relevant_subs, subs_count_roh, set_baustein_score, set_baustein_score_and_streak, set_baustein_absolviert,
delete_baustein_daten, delete_baustein_progress, subs_je_ebene, subs_je_ebene_roh,
delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content, delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content,
get_sub_artefakte,
) )
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, subbausteine_frei, lade_frage_muster, lade_frage_muster_frei
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 NIVEAUS, cap_fuer, 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_score, progressiver_malus, stufe_aus_score from lernen import 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, huerden_distraktor_block, score_berechnen, floor_aus_score, stufe_aus_score, cap_final, cap_aktuell, freie_ebene, schwellen, punkte_delta, deckel_nachfrage
from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen, content_fuer_ebene
from pipeline import cancel_guide from pipeline import cancel_guide
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
from models import ( from models import (
@@ -221,30 +224,59 @@ async def get_bausteine_uebersicht(topic: str):
@router.get("/bausteine/frage-muster") @router.get("/bausteine/frage-muster")
async def get_frage_muster(topic: str, baustein: str): async def get_frage_muster(topic: str, baustein: str):
"""Vordefinierte Frage-Muster eines Bausteins (leer = Fallback auf Live-Generierung).""" """Freigeschaltete Frage-Muster eines Bausteins (bis zur aktuellen Ebene; leer = Live)."""
return {"muster": await lade_frage_muster(topic, baustein)} stand = await get_baustein_progress(topic, baustein)
fe = freie_ebene(stand["gute_antworten"], await subs_je_ebene(topic, baustein))
return {"muster": await lade_frage_muster_frei(topic, baustein, fe)}
@router.get("/bausteine/artefakte")
async def get_artefakte(topic: str, typ: str | None = None):
"""Lern-Artefakte (Karteikarten/Beispiele/Diagramme) je Thema, gruppiert nach Baustein-Norm.
Diagramm liegt auf Baustein-Ebene (sub_norm=''), Karteikarte/Beispiel je Subbaustein."""
rows = await get_sub_artefakte(topic, typ)
out: dict[str, dict] = {}
for r in rows:
b = out.setdefault(r["baustein_norm"], {"baustein": r["baustein"], "karteikarte": [], "beispiel": [], "diagramm": None})
if r["baustein"] and not b["baustein"]:
b["baustein"] = r["baustein"]
try:
daten = json.loads(r["daten"])
except (ValueError, TypeError):
continue
if r["typ"] == "diagramm":
b["diagramm"] = daten
elif r["typ"] in ("karteikarte", "beispiel"):
b[r["typ"]].append({"subbaustein": r["sub_titel"], **daten})
return {"artefakte": out}
# --- Baustein-Lernen: Chat, Prüfung --- # --- Baustein-Lernen: Chat, Prüfung ---
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse) @router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
async def baustein_lernstand(topic: str): async def baustein_lernstand(topic: str):
"""Prüfungs-Stand pro Baustein (roher Titel als Key). cap = 4×relevante Subs [10,50] """Prüfungs-Stand pro Baustein (roher Titel als Key). cap_final = alle Subs × 25;
— für ALLE Bausteine (auch noch nicht geprüfte), damit der cap sofort stimmt.""" cap_aktuell + freie_ebene aus dem Score — für ALLE Bausteine (auch ungeprüfte)."""
progress = {p["baustein"]: p for p in await list_baustein_progress(topic)} progress = {p["baustein"]: p for p in await list_baustein_progress(topic)}
caps = await subs_count_roh(topic) ebenen = await subs_je_ebene_roh(topic)
def _stand(score: int, streak: int, n_je_ebene: dict[int, int]) -> dict:
return {
"gute_antworten": score, "streak": streak,
"cap": cap_final(n_je_ebene),
"cap_aktuell": cap_aktuell(score, n_je_ebene),
"freie_ebene": freie_ebene(score, n_je_ebene),
}
bausteine = { bausteine = {
b: { b: _stand(progress[b]["gute_antworten"] if b in progress else 0,
"gute_antworten": progress[b]["gute_antworten"] if b in progress else 0, progress[b]["streak"] if b in progress else 0, n)
"streak": progress[b]["streak"] if b in progress else 0, for b, n in ebenen.items()
"cap": cap_fuer(n),
} }
for b, n in caps.items() # Altbestand-Bausteine mit Prüfung, aber ohne Subs → leere Ebenen (cap 0).
}
# Altbestand-Bausteine mit Prüfung, aber ohne (relevante) Subs → Default-cap.
for b, p in progress.items(): for b, p in progress.items():
if b not in bausteine: if b not in bausteine:
bausteine[b] = {"gute_antworten": p["gute_antworten"], "streak": p["streak"], "cap": 10} bausteine[b] = _stand(p["gute_antworten"], p["streak"], {})
return {"bausteine": bausteine} return {"bausteine": bausteine}
@@ -282,54 +314,52 @@ def _farbe(punkte: int) -> str:
return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht") return "gut" if punkte > 0 else ("neutral" if punkte == 0 else "schlecht")
def _erklaeren_delta(niveau: str, nachgefragt: bool) -> int: async def _buche(req, frage: str, niveau: str, n_je_ebene: dict[int, int]) -> dict:
"""Erklären-Form: Punkt-Delta aus dem Niveau (1…+3). Mit Nachfrage höchstens +1.""" """Score+Streak driftfrei buchen (Lock + offene_frage/offene_streak-Anker). Niveau →
delta = NIVEAUS.get(niveau, 0) Punkt-Delta (streak-moduliert) bzw. progressiver Malus bei Fehler. cap_aktuell wird aus
return min(delta, 1) if nachgefragt else delta der Basis abgeleitet (verzögerte Freischaltung an der Ebenen-Schwelle); Element einmalig
ab Anfänger-Stufe. Re-Bewertung derselben Frage nutzt den offenen Streak-Anker → idempotent."""
async def _cap_fuer(topic: str, baustein: str) -> int:
"""Maximal-Score des Bausteins = clamp(4×relevante Subbausteine, 10, 50)."""
return cap_fuer(await count_relevant_subs(topic, baustein))
async def _buche(req, frage: str, delta: int, cap: int) -> dict:
"""Score driftfrei buchen (Lock + offene_frage-Anker). Fehler (delta<0) → progressiver
Malus nach Fortschritt. Element einmalig anlegen, sobald Anfänger (20 %) erreicht."""
async with _pruef_lock(req.topic, req.baustein): async with _pruef_lock(req.topic, req.baustein):
stand = await get_baustein_progress(req.topic, req.baustein) stand = await get_baustein_progress(req.topic, req.baustein)
war_stufe = stand["absolviert"] is not None # Element-Guard: schon je angelegt? war_stufe = stand["absolviert"] is not None # Element-Guard: schon je angelegt?
basis, re_bewertung = _basis(stand, frage) basis, re_bewertung = _basis(stand, frage)
streak_basis = stand["offene_streak"] if re_bewertung else stand["streak"]
if not re_bewertung: if not re_bewertung:
await set_offene_frage(req.topic, req.baustein, frage, basis, 0) await set_offene_frage(req.topic, req.baustein, frage, basis, stand["streak"])
floor = floor_aus_score(basis, cap) s = schwellen(n_je_ebene)
d = progressiver_malus(basis, cap) if delta < 0 else delta cf = s[-1]
score = score_berechnen(basis, d, floor, cap) ca = cap_aktuell(basis, n_je_ebene)
floor = floor_aus_score(basis, cf, s)
d, neue_streak = punkte_delta(niveau, streak_basis, basis, ca)
score = score_berechnen(basis, d, floor, ca, cf)
punkte = score - basis punkte = score - basis
gute = await set_baustein_score(req.topic, req.baustein, score) gute, streak = await set_baustein_score_and_streak(req.topic, req.baustein, score, neue_streak)
# Lern-Element einmalig anlegen, sobald die erste Stufe (Anfänger) erreicht ist. # Lern-Element einmalig anlegen, sobald die erste Stufe (Anfänger) erreicht ist.
if not war_stufe and stufe_aus_score(score, cap) is not None: if not war_stufe and stufe_aus_score(score, cf) is not None:
if await set_baustein_absolviert(req.topic, req.baustein): if await set_baustein_absolviert(req.topic, req.baustein):
asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider)) asyncio.create_task(baustein_element_anlegen(req.topic, req.baustein, req.section, req.provider))
return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "cap": cap} return {"punkte": punkte, "bewertung": _farbe(punkte), "gute_antworten": gute, "streak": streak, "cap": cf}
@router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse) @router.post("/bausteine/pruefung", response_model=BausteinPruefungResponse)
async def baustein_pruefung_route(req: BausteinPruefungRequest): async def baustein_pruefung_route(req: BausteinPruefungRequest):
stand = await get_baustein_progress(req.topic, req.baustein) stand = await get_baustein_progress(req.topic, req.baustein)
gute = stand["gute_antworten"] gute = stand["gute_antworten"]
cap = await _cap_fuer(req.topic, req.baustein) n_je_ebene = await subs_je_ebene(req.topic, req.baustein)
cap = cap_final(n_je_ebene)
niveau = stufe_aus_score(gute, cap) or "anfaenger" # Adressaten-Rolle der Frage
fe = freie_ebene(gute, n_je_ebene) # nur freigeschaltete Subs prüfen
kompakt = req.section_kompakt kompakt = req.section_kompakt
msgs = [m.model_dump() for m in req.messages] msgs = [m.model_dump() for m in req.messages]
if req.aktion == "frage": if req.aktion == "frage":
if req.muster.strip(): if req.muster.strip():
# Aus gezogenem Muster eine konkrete, leicht andere Frage formulieren (kein Dedup nötig). # Aus gezogenem Muster eine konkrete Frage im Niveau formulieren (kein Dedup nötig).
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider) frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider)
else: else:
# Fallback (kein Muster-Sidecar): Live-Generierung mit zufälligem Typ/Subbaustein. # Fallback (kein Muster-Sidecar): Live-Generierung, Fokus nur auf freigeschaltete Subs.
subs = await subbausteine_titel(req.topic, req.baustein) # Fokus-Kandidaten (zufällig gewählt) subs = await subbausteine_frei(req.topic, req.baustein, fe)
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, provider=req.provider) frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, niveau=niveau, provider=req.provider)
if frage is None: if frage is None:
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
return {"frage": frage, "gute_antworten": gute, "cap": cap} return {"frage": frage, "gute_antworten": gute, "cap": cap}
@@ -349,7 +379,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
if req.aktion == "quiz_frage": if req.aktion == "quiz_frage":
if not req.muster.strip(): if not req.muster.strip():
raise HTTPException(400, "Quiz braucht ein Muster") raise HTTPException(400, "Quiz braucht ein Muster")
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, schwer=req.schwer, provider=req.provider) distraktoren = await huerden_distraktor_block(req.topic, req.baustein)
quiz = await quiz_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren)
if quiz is None: if quiz is None:
raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Quiz-Frage fehlgeschlagen — bitte erneut versuchen")
return {"frage": quiz["frage"], "optionen": quiz["optionen"], return {"frage": quiz["frage"], "optionen": quiz["optionen"],
@@ -359,8 +390,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
if not req.frage.strip(): if not req.frage.strip():
raise HTTPException(400, "Quiz-Antwort braucht eine Frage") raise HTTPException(400, "Quiz-Antwort braucht eine Frage")
getroffen = set(req.auswahl) == set(req.korrekt) # exakt die richtige Menge getroffen = set(req.auswahl) == set(req.korrekt) # exakt die richtige Menge
delta = (3 if req.schwer else 1) if getroffen else -1 res = await _buche(req, req.frage, "stark" if getroffen else "kaum", n_je_ebene)
res = await _buche(req, req.frage, delta, cap)
res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig." res["feedback"] = "Richtig — alle korrekten getroffen." if getroffen else "Nicht ganz — die markierten waren richtig."
return res return res
@@ -369,12 +399,13 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
if not req.muster.strip(): if not req.muster.strip():
raise HTTPException(400, "Lückentext braucht ein Muster") raise HTTPException(400, "Lückentext braucht ein Muster")
if req.schwer: if req.schwer:
lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider) lt = await lueckentext_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider)
if lt is None: if lt is None:
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
return {"satz": lt["satz"], "loesung": lt["loesung"], "alternativen": lt["alternativen"], return {"satz": lt["satz"], "loesung": lt["loesung"], "alternativen": lt["alternativen"],
"gute_antworten": gute, "cap": cap} "gute_antworten": gute, "cap": cap}
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider) distraktoren = await huerden_distraktor_block(req.topic, req.baustein)
lw = await lueckwahl_generieren(req.topic, req.baustein, req.section, kompakt, req.muster, niveau=niveau, provider=req.provider, distraktor_block=distraktoren)
if lw is None: if lw is None:
raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Lückentext fehlgeschlagen — bitte erneut versuchen")
return {"satz": lw["satz"], "optionen": lw["optionen"], return {"satz": lw["satz"], "optionen": lw["optionen"],
@@ -389,7 +420,7 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
else: # Begriff aus 4 gewählt → deterministisch else: # Begriff aus 4 gewählt → deterministisch
ok = set(req.auswahl) == set(req.korrekt) ok = set(req.auswahl) == set(req.korrekt)
feedback = "Richtig!" if ok else "Nicht ganz — der markierte Begriff war richtig." feedback = "Richtig!" if ok else "Nicht ganz — der markierte Begriff war richtig."
res = await _buche(req, req.frage, (3 if req.schwer else 1) if ok else -1, cap) res = await _buche(req, req.frage, "stark" if ok else "kaum", n_je_ebene)
res["feedback"] = feedback res["feedback"] = feedback
return res return res
@@ -406,11 +437,14 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
) )
if data is None: if data is None:
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
basis, _ = _basis(stand, req.frage) basis, re_bew = _basis(stand, req.frage)
floor = floor_aus_score(basis, cap) streak_basis = stand["offene_streak"] if re_bew else stand["streak"]
roh = _erklaeren_delta(data["niveau"], req.nachgefragt) s = schwellen(n_je_ebene)
d = progressiver_malus(basis, cap) if roh < 0 else roh ca = cap_aktuell(basis, n_je_ebene)
score = score_berechnen(basis, d, floor, cap) floor = floor_aus_score(basis, s[-1], s)
niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
d, _ = punkte_delta(niveau, streak_basis, basis, ca)
score = score_berechnen(basis, d, floor, ca, s[-1])
punkte = score - basis punkte = score - basis
return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte), return {"feedback": data["feedback"], "punkte": punkte, "bewertung": _farbe(punkte),
"gute_antworten": gute, "cap": cap} "gute_antworten": gute, "cap": cap}
@@ -424,8 +458,8 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
) )
if data is None: if data is None:
raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Bewertung fehlgeschlagen — bitte erneut versuchen")
roh = _erklaeren_delta(data["niveau"], req.nachgefragt) niveau = deckel_nachfrage(data["niveau"], req.nachgefragt)
res = await _buche(req, req.frage, roh, cap) # kurzer Lock: Basis driftfrei über offene_frage-Anker res = await _buche(req, req.frage, niveau, n_je_ebene) # kurzer Lock: Basis driftfrei über Anker
res["feedback"] = data["feedback"] res["feedback"] = data["feedback"]
return res return res
@@ -483,19 +517,26 @@ async def get_one(guide_id: str):
@router.get("/guides/{guide_id}/content") @router.get("/guides/{guide_id}/content")
async def guide_content(guide_id: str): async def guide_content(guide_id: str, ebene: int = 4):
"""Guide-Inhalt. `ebene` (1=A · 2=F · 3=E · 4=V) filtert auf Subbausteine bis zu dieser
Ebene; 4 = Vollfassung (roh, unverändert)."""
guide = await get_guide(guide_id) guide = await get_guide(guide_id)
if guide is None: if guide is None:
raise HTTPException(404, "Guide nicht gefunden") raise HTTPException(404, "Guide nicht gefunden")
if guide["status"] != "done": if guide["status"] != "done":
raise HTTPException(404, "Inhalt nicht verfügbar") raise HTTPException(404, "Inhalt nicht verfügbar")
stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first
if stored is not None: if stored is None:
return Response(content=stored, media_type="application/json")
path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen) path = guide_content_path(guide["topic"], guide["format"]) # Fallback: Datei (Alt-Themen)
if not path.exists(): if not path.exists():
raise HTTPException(404, "Datei nicht gefunden") raise HTTPException(404, "Datei nicht gefunden")
return FileResponse(path, media_type="application/json") stored = path.read_text(encoding="utf-8")
if ebene >= 4:
return Response(content=stored, media_type="application/json") # Vollfassung roh
try:
return content_fuer_ebene(json.loads(stored), ebene)
except ValueError:
return Response(content=stored, media_type="application/json")
@router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse) @router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse)

View File

@@ -102,16 +102,18 @@ _FRAGMENT_BAUSTEIN_RE = re.compile(r"<!--\s*baustein\s*:\s*(.*?)\s*-->", re.IGNO
# Zwei Lese-Schichten je Section: kompakt (Merksätze) + ausführlich (Erklärung). # Zwei Lese-Schichten je Section: kompakt (Merksätze) + ausführlich (Erklärung).
_FRAGMENT_KOMPAKT_RE = re.compile(r"<!--\s*kompakt\s*-->", re.IGNORECASE) _FRAGMENT_KOMPAKT_RE = re.compile(r"<!--\s*kompakt\s*-->", re.IGNORECASE)
_FRAGMENT_AUSF_RE = re.compile(r"<!--\s*ausf(?:ü|ue)hrlich\s*-->", re.IGNORECASE) _FRAGMENT_AUSF_RE = re.compile(r"<!--\s*ausf(?:ü|ue)hrlich\s*-->", re.IGNORECASE)
_STUFEN = ("einfach", "mittel", "schwer") # Lernpfad-Stufen + Rand; alte Schwierigkeits-Werte abwärtskompatibel akzeptiert.
_STUFEN = ("anfaenger", "fortgeschritten", "experte", "rand", "einfach", "mittel", "schwer")
def _parse_fragment(text: str) -> list[dict]: def _parse_fragment(text: str) -> list[dict]:
"""Parst eine Writer-Datei → [{"kapitel", "titel", "md", "kompakt", "subs"}] in Reihenfolge. """Parst eine Writer-Datei → [{kapitel, titel, md, kompakt, anker, anker_kompakt, subs}].
Zwei optionale Lese-Schichten je Section über `<!-- kompakt -->` / `<!-- ausführlich -->`: Zwei Lese-Schichten je Section über `<!-- kompakt -->` / `<!-- ausführlich -->`. Innerhalb
Zeilen nach `kompakt` landen in `sec["kompakt"]`, sonst in `sec["md"]` (= ausführlich, beider markieren `<!-- sub: stufe | titel -->`-Marker je Subbaustein einen Block; gleicher
auch der Default ohne Marker → rückwärtskompatibel). Optionale `<!-- sub: -->`-Marker Sub-Titel in beiden Schichten wird gemergt → `sec["subs"] = [{stufe, titel, md, kompakt}]`.
sammeln sich weiter in `sec["subs"]`. Text VOR dem ersten Sub-Marker ist der Anker (Einordnung) → `anker`/`anker_kompakt`.
`md`/`kompakt` bleiben die VOLLE Fassung (Anker + alle Subs) — rückwärtskompatibel.
""" """
sections: list[dict] = [] sections: list[dict] = []
kapitel = None kapitel = None
@@ -128,7 +130,8 @@ def _parse_fragment(text: str) -> list[dict]:
continue continue
m = _FRAGMENT_SECTION_RE.match(s) m = _FRAGMENT_SECTION_RE.match(s)
if m: if m:
current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [], "subs": []} current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "kompakt": [],
"anker_md": [], "anker_kompakt": [], "_submap": {}, "_suborder": []}
cur_sub = None cur_sub = None
cur_layer = "md" cur_layer = "md"
sections.append(current) sections.append(current)
@@ -145,24 +148,40 @@ def _parse_fragment(text: str) -> list[dict]:
if m and current is not None: if m and current is not None:
teil = m.group(1).split("|", 1) teil = m.group(1).split("|", 1)
stufe = teil[0].strip().casefold() stufe = teil[0].strip().casefold()
cur_sub = { titel = teil[1].strip() if len(teil) == 2 else ""
"stufe": stufe if stufe in _STUFEN else "einfach", key = titel.casefold() or f"_pos{len(current['_suborder'])}"
"titel": teil[1].strip() if len(teil) == 2 else "", cur_sub = current["_submap"].get(key)
"md": [], if cur_sub is None:
} cur_sub = {"stufe": stufe if stufe in _STUFEN else "anfaenger", "titel": titel, "md": [], "kompakt": []}
current["subs"].append(cur_sub) current["_submap"][key] = cur_sub
current["_suborder"].append(key)
elif stufe in _STUFEN:
cur_sub["stufe"] = stufe
continue continue
if current is not None: if current is not None:
current[cur_layer].append(line) current[cur_layer].append(line)
if cur_sub is not None and cur_layer == "md": if cur_sub is not None:
cur_sub["md"].append(line) cur_sub[cur_layer].append(line)
else:
current["anker_" + cur_layer].append(line)
out: list[dict] = []
for sec in sections: for sec in sections:
sec["md"] = "\n".join(sec["md"]).strip() subs = []
sec["kompakt"] = "\n".join(sec["kompakt"]).strip() for key in sec["_suborder"]:
for sub in sec["subs"]: sub = sec["_submap"][key]
sub["md"] = "\n".join(sub["md"]).strip() sub["md"] = "\n".join(sub["md"]).strip()
sec["subs"] = [sub for sub in sec["subs"] if sub["md"]] sub["kompakt"] = "\n".join(sub["kompakt"]).strip()
return sections if sub["md"] or sub["kompakt"]:
subs.append(sub)
out.append({
"kapitel": sec["kapitel"], "titel": sec["titel"],
"md": "\n".join(sec["md"]).strip(),
"kompakt": "\n".join(sec["kompakt"]).strip(),
"anker": "\n".join(sec["anker_md"]).strip(),
"anker_kompakt": "\n".join(sec["anker_kompakt"]).strip(),
"subs": subs,
})
return out
def _parse_subbausteine(text: str) -> dict[str, list[str]]: def _parse_subbausteine(text: str) -> dict[str, list[str]]:

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"katex": "^0.17.0", "katex": "^0.17.0",
"marked": "^18.0.4", "marked": "^18.0.4",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"mermaid": "^11.16.0",
"vue": "^3.5.32" "vue": "^3.5.32"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -7,6 +7,7 @@ import TopicDetail from './components/TopicDetail.vue'
import BausteineUebersicht from './components/BausteineUebersicht.vue' import BausteineUebersicht from './components/BausteineUebersicht.vue'
import ElementsSidebar from './components/elements/ElementsSidebar.vue' import ElementsSidebar from './components/elements/ElementsSidebar.vue'
import ElementsOverview from './components/ElementsOverview.vue' import ElementsOverview from './components/ElementsOverview.vue'
import GeneralExamPanel from './components/GeneralExamPanel.vue'
const guides = ref([]) const guides = ref([])
const backendTopics = ref([]) const backendTopics = ref([])
@@ -27,7 +28,9 @@ const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([]) const providers = ref([])
const folders = ref({ projekt: [], uni: [] }) // Ordner für die Quellen-Auswahl const folders = ref({ projekt: [], uni: [] }) // Ordner für die Quellen-Auswahl
const bausteineView = ref(false) // Bausteine-Übersicht im Hauptbereich const bausteineView = ref(false) // Bausteine-Übersicht im Hauptbereich
const allgemeinView = ref(false) // Allgemeine Prüfung (themenweit) im Hauptbereich
const ansichtModus = ref('kompakt') // kompakt | erklärend — je Thema, Default kompakt const ansichtModus = ref('kompakt') // kompakt | erklärend — je Thema, Default kompakt
const stufeAnsicht = ref(Number(localStorage.getItem('stufe')) || 4) // 1=A · 2=F · 3=E · 4=V (Stufen-Ansicht)
const stats = ref(null) const stats = ref(null)
const fortschritt = ref({}) const fortschritt = ref({})
const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle) const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle)
@@ -192,6 +195,7 @@ function selectTopic(topic) {
elementsOpen.value = false elementsOpen.value = false
elementsView.value = false elementsView.value = false
bausteineView.value = true // Thema-Klick → Baustein-Übersicht (Guide erst auf Pill-Klick) bausteineView.value = true // Thema-Klick → Baustein-Übersicht (Guide erst auf Pill-Klick)
allgemeinView.value = false
elementOpenId.value = null elementOpenId.value = null
ansichtModus.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'kompakt' ansichtModus.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'kompakt'
localStorage.setItem('lastTopic', topic) localStorage.setItem('lastTopic', topic)
@@ -266,10 +270,16 @@ function setAnsicht(modus) {
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus) if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
} }
function setStufe(k) {
stufeAnsicht.value = k
localStorage.setItem('stufe', String(k))
}
function handleOpenBausteineView() { function handleOpenBausteineView() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
bausteineView.value = true bausteineView.value = true
elementsView.value = false elementsView.value = false
allgemeinView.value = false
previewGuide.value = null previewGuide.value = null
} }
@@ -296,12 +306,22 @@ function handlePreview(guide) {
previewGuide.value = guide previewGuide.value = guide
elementsView.value = false elementsView.value = false
bausteineView.value = false bausteineView.value = false
allgemeinView.value = false
}
function handleGeneralExam() {
if (!selectedTopic.value) return
allgemeinView.value = true
bausteineView.value = false
elementsView.value = false
previewGuide.value = null
} }
function handleOpenElements() { function handleOpenElements() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
elementsView.value = true elementsView.value = true
bausteineView.value = false bausteineView.value = false
allgemeinView.value = false
// Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element. // Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element.
} }
@@ -380,9 +400,12 @@ onMounted(async () => {
:providers="providers" :providers="providers"
:folders="folders" :folders="folders"
:ansichtModus="ansichtModus" :ansichtModus="ansichtModus"
:stufeAnsicht="stufeAnsicht"
@setProvider="setProvider" @setProvider="setProvider"
@toggleDark="toggleDark" @toggleDark="toggleDark"
@setAnsicht="setAnsicht" @setAnsicht="setAnsicht"
@setStufe="setStufe"
@generalExam="handleGeneralExam"
@select="selectTopic" @select="selectTopic"
@create="createTopic" @create="createTopic"
@createThema="handleCreateThema" @createThema="handleCreateThema"
@@ -413,6 +436,13 @@ onMounted(async () => {
:version="elementsVersion" :version="elementsVersion"
@open="handleOpenElementDetail" @open="handleOpenElementDetail"
/> />
<GeneralExamPanel
v-else-if="selectedTopic && allgemeinView"
:topic="selectedTopic"
:provider="provider"
@progressChanged="loadStats(); loadBausteine()"
@fokus-active="fokusOffen = $event"
/>
<TopicDetail <TopicDetail
v-else-if="selectedTopic" v-else-if="selectedTopic"
:previewGuide="previewGuide" :previewGuide="previewGuide"
@@ -422,6 +452,7 @@ onMounted(async () => {
:doneByFormat="doneByFormat" :doneByFormat="doneByFormat"
:themaAbgeschlossen="!!fortschritt.abgeschlossen" :themaAbgeschlossen="!!fortschritt.abgeschlossen"
:ansichtModus="ansichtModus" :ansichtModus="ansichtModus"
:stufeAnsicht="stufeAnsicht"
@progressChanged="loadStats(); loadBausteine()" @progressChanged="loadStats(); loadBausteine()"
@setAnsicht="setAnsicht" @setAnsicht="setAnsicht"
@open-sidebar="sidebarSticky = true" @open-sidebar="sidebarSticky = true"

View File

@@ -146,12 +146,19 @@ export async function deleteGuide(id, slots = false) {
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' }) await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
} }
export async function fetchGuideContent(id) { export async function fetchGuideContent(id, ebene = 4) {
const res = await fetch(`${BASE}/guides/${id}/content`) const res = await fetch(`${BASE}/guides/${id}/content?ebene=${ebene}`)
if (!res.ok) throw new Error(`Inhalt nicht verfügbar (${res.status})`) if (!res.ok) throw new Error(`Inhalt nicht verfügbar (${res.status})`)
return res.json() return res.json()
} }
// Lern-Artefakte (Karteikarten/Beispiele/Diagramme) je Thema, gruppiert nach Baustein-Norm.
export async function fetchArtefakte(topic) {
const res = await fetch(`${BASE}/bausteine/artefakte?topic=${encodeURIComponent(topic)}`)
if (!res.ok) return { artefakte: {} }
return res.json()
}
// Einen Markdown-Block on-demand gegen die Guide-Regeln prüfen (Fokus, Rechtsklick). // Einen Markdown-Block on-demand gegen die Guide-Regeln prüfen (Fokus, Rechtsklick).
export async function pruefeBlock(id, { baustein, stelle, block, hinweis = '', provider }) { export async function pruefeBlock(id, { baustein, stelle, block, hinweis = '', provider }) {
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, { const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {

View File

@@ -25,7 +25,9 @@ const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
// Lernstufen relativ zum cap (stufen.js): grün/blau/lila/gold @ 20/40/60/100 %. // Lernstufen relativ zum cap (stufen.js): grün/blau/lila/gold @ 20/40/60/100 %.
// Prüfung IMMER zufällig aus 5 Formen (mit Muster); cap = 4×relevante Subbausteine. // Prüfung IMMER zufällig aus 5 Formen (mit Muster); cap = 4×relevante Subbausteine.
const MODI = ['quiz_leicht', 'quiz_schwer', 'lueck_leicht', 'lueck_schwer', 'erklaeren'] // Vier Frageformen, zufällig. Kein leicht/schwer mehr — die Schwierigkeit setzt das Backend
// über das Lerner-Niveau (Adressaten-Rolle). lueckwahl = Begriff-Auswahl · lueckentext = frei.
const MODI = ['quiz', 'lueckwahl', 'lueckentext', 'erklaeren']
const st = computed(() => props.status || { gute_antworten: 0, streak: 0, cap: props.cap }) const st = computed(() => props.status || { gute_antworten: 0, streak: 0, cap: props.cap })
const score = computed(() => st.value.gute_antworten || 0) const score = computed(() => st.value.gute_antworten || 0)
const cap = computed(() => st.value.cap || props.cap) const cap = computed(() => st.value.cap || props.cap)
@@ -238,20 +240,20 @@ function naechsterModus() {
// Ein Frage-Objekt eines bestimmten Modus generieren (aus gezogener Saat; Erklären hat Fallback). // Ein Frage-Objekt eines bestimmten Modus generieren (aus gezogener Saat; Erklären hat Fallback).
function frageEinzeln(modus = naechsterModus()) { function frageEinzeln(modus = naechsterModus()) {
const form = modus.startsWith('quiz') ? 'quiz' : modus.startsWith('lueck') ? 'lueckentext' : 'erklaeren' const form = modus === 'quiz' ? 'quiz' : modus.startsWith('lueck') ? 'lueckentext' : 'erklaeren'
const istSchwer = modus.endsWith('_schwer') const istLueckFrei = modus === 'lueckentext' // frei tippen vs. lueckwahl = Begriff aus 4
const muster = musterNehmen() const muster = musterNehmen()
const basis = { const basis = {
topic: props.topic, baustein: props.baustein, section: props.section, topic: props.topic, baustein: props.baustein, section: props.section,
section_kompakt: props.sectionKompakt, provider: props.provider, section_kompakt: props.sectionKompakt, provider: props.provider,
} }
if (form === 'quiz' && muster) { if (form === 'quiz' && muster) {
return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster, schwer: istSchwer }) return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster }) // Single-Choice, Niveau steuert
.then((r) => ({ form, schwer: istSchwer, frage: r.frage, optionen: r.optionen })) .then((r) => ({ form, schwer: false, frage: r.frage, optionen: r.optionen }))
} }
if (form === 'lueckentext' && muster) { if (form === 'lueckentext' && muster) {
return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istSchwer }) return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istLueckFrei })
.then((r) => istSchwer .then((r) => istLueckFrei
? ({ form, schwer: true, frage: r.satz, satz: r.satz, loesung: r.loesung, alternativen: r.alternativen }) ? ({ form, schwer: true, frage: r.satz, satz: r.satz, loesung: r.loesung, alternativen: r.alternativen })
: ({ form, schwer: false, frage: r.satz, satz: r.satz, optionen: r.optionen })) : ({ form, schwer: false, frage: r.satz, satz: r.satz, optionen: r.optionen }))
} }

View File

@@ -11,12 +11,14 @@ const items = ref([])
const loading = ref(true) const loading = ref(true)
const error = ref(null) const error = ref(null)
// Stufen-Reihenfolge + Anzeige-Label (Farbe via CSS-Klasse st-<key>) // Lernpfad-Stufen: Reihenfolge + Label (Farbe via CSS-Klasse st-<key>).
const STUFEN = [ const STUFEN = [
{ key: 'einfach', label: 'Einfach' }, { key: 'anfaenger', label: 'Anfänger' },
{ key: 'mittel', label: 'Mittel' }, { key: 'fortgeschritten', label: 'Fortgeschritten' },
{ key: 'schwer', label: 'Schwer' }, { key: 'experte', label: 'Experte' },
] ]
// Alt-Themen tragen noch einfach/mittel/schwer → auf die neuen Keys mappen.
const ALT_STUFE = { einfach: 'anfaenger', mittel: 'fortgeschritten', schwer: 'experte' }
watch(() => props.topic, load, { immediate: true }) watch(() => props.topic, load, { immediate: true })
@@ -43,7 +45,7 @@ function relevant(b) {
// Nur nicht-leere Stufen-Gruppen je Baustein (v-if + v-for nicht auf einem Element) // Nur nicht-leere Stufen-Gruppen je Baustein (v-if + v-for nicht auf einem Element)
function gruppen(b) { function gruppen(b) {
return STUFEN return STUFEN
.map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => s.stufe === st.key) })) .map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => (ALT_STUFE[s.stufe] || s.stufe) === st.key) }))
.filter((g) => g.subs.length) .filter((g) => g.subs.length)
} }
@@ -185,9 +187,9 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
border-left: 3px solid var(--st); border-left: 3px solid var(--st);
padding-left: 0.6rem; padding-left: 0.6rem;
} }
.bk-stufe.st-einfach { --st: var(--success-border); } .bk-stufe.st-anfaenger { --st: var(--stufe-anfaenger); }
.bk-stufe.st-mittel { --st: var(--warning-border); } .bk-stufe.st-fortgeschritten { --st: var(--stufe-fortgeschritten); }
.bk-stufe.st-schwer { --st: var(--danger); } .bk-stufe.st-experte { --st: var(--stufe-experte); }
.bk-stufe-label { .bk-stufe-label {
font-size: 0.66rem; font-size: 0.66rem;

View File

@@ -0,0 +1,106 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { renderMarkdownInline } from '../markdown.js'
const props = defineProps({ karten: { type: Array, default: () => [] } })
const offen = ref(false)
const reihenfolge = ref([])
const pos = ref(0)
const flipped = ref(false)
function reset() {
reihenfolge.value = props.karten.map((_, i) => i)
pos.value = 0
flipped.value = false
}
watch(() => props.karten, reset, { immediate: true })
const aktuelle = computed(() => props.karten[reihenfolge.value[pos.value]] || null)
const zaehler = computed(() => `${Math.min(pos.value + 1, reihenfolge.value.length)} / ${reihenfolge.value.length}`)
function weiter(gewusst) {
if (gewusst) {
pos.value++
} else {
// „Nochmal" → Karte ans Ende der Runde schieben (leichtes Spacing).
const [k] = reihenfolge.value.splice(pos.value, 1)
reihenfolge.value.push(k)
}
if (pos.value >= reihenfolge.value.length) pos.value = 0
flipped.value = false
}
</script>
<template>
<div v-if="karten.length" class="flashcards">
<button class="art-head" @click="offen = !offen">
<span class="art-icon">🃏</span> Karteikarten
<span class="art-count">{{ karten.length }}</span>
<span class="art-toggle">{{ offen ? '▾' : '▸' }}</span>
</button>
<div v-if="offen && aktuelle" class="fc-body">
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
<div class="fc-zaehler">{{ zaehler }}</div>
<div v-if="!flipped" class="fc-seite">
<span class="fc-label">Frage</span>
<div class="fc-text" v-html="renderMarkdownInline(aktuelle.frage)"></div>
<span class="fc-hint">Klick zum Umdrehen</span>
</div>
<div v-else class="fc-seite">
<span class="fc-label">Antwort</span>
<div class="fc-text" v-html="renderMarkdownInline(aktuelle.antwort)"></div>
</div>
</div>
<div v-if="flipped" class="fc-aktionen">
<button class="fc-btn nochmal" @click="weiter(false)">Nochmal</button>
<button class="fc-btn gewusst" @click="weiter(true)">Gewusst</button>
</div>
</div>
</div>
</template>
<style scoped>
.flashcards { margin-top: 0.75rem; }
.art-head {
display: flex; align-items: center; gap: 8px; width: 100%;
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
}
.art-icon { font-size: 0.95rem; }
.art-count {
background: var(--panel-soft); border: 1px solid var(--border);
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
}
.art-toggle { margin-left: auto; color: var(--text-faint); }
.fc-body { margin-top: 0.5rem; }
.fc-card {
position: relative; min-height: 120px; cursor: pointer;
border: 1px solid var(--border); border-radius: 10px;
padding: 1rem 1.1rem; background: var(--panel-soft);
display: flex; align-items: center; justify-content: center; text-align: center;
transition: border-color 0.15s;
}
.fc-card.flipped { border-color: var(--accent); }
.fc-zaehler {
position: absolute; top: 6px; right: 10px;
font-size: 0.68rem; color: var(--text-faint);
}
.fc-seite { display: flex; flex-direction: column; gap: 0.4rem; align-items: center; }
.fc-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--text-faint); font-weight: 700;
}
.fc-text { font-size: 0.98rem; line-height: 1.45; }
.fc-hint { font-size: 0.7rem; color: var(--text-faint); margin-top: 0.2rem; }
.fc-aktionen { display: flex; gap: 8px; margin-top: 0.5rem; }
.fc-btn {
flex: 1; padding: 0.5rem; border-radius: 8px; cursor: pointer;
font-size: 0.82rem; font-weight: 600; border: 1px solid var(--border-strong);
background: var(--panel);
}
.fc-btn.gewusst { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.fc-btn.nochmal { color: var(--text-muted); }
</style>

View File

@@ -0,0 +1,120 @@
<script setup>
// Themenweite Misch-Prüfung: wählt einen Baustein (priorisiert: noch nicht auf Max), darunter
// zufällig, und nutzt dafür das normale BausteinPanel. Jeder Baustein hat seinen eigenen
// Prüfungs-Slot (pruefungCache), also bleiben die Frage-Pools getrennt. Buchung läuft je
// Baustein über die normale Prüfungs-Route. Gesamt-cap = Σ cap_final aller prüfbaren Bausteine.
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { fetchGuides, fetchGuideContent, fetchBausteinLernstand } from '../api.js'
import BausteinPanel from './BausteinPanel.vue'
const props = defineProps({
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
})
const emit = defineEmits(['progressChanged', 'fokus-active'])
const sections = ref([]) // [{titel, md, kompakt, num}] — prüfbare Sections der Vollfassung
const lernstand = ref({}) // titel -> {gute_antworten, streak, cap, cap_aktuell, freie_ebene}
const aktiv = ref(null) // aktueller Baustein-Titel
const ladefehler = ref(null)
const pruefbare = computed(() => sections.value.filter((s) => (lernstand.value[s.titel]?.cap || 0) > 0))
// Gesamt-Fortschritt: Σ min(score, cap_final) / Σ cap_final über alle prüfbaren Bausteine.
const gesamt = computed(() => {
let score = 0, cap = 0
for (const s of pruefbare.value) {
const l = lernstand.value[s.titel] || {}
score += Math.min(l.gute_antworten || 0, l.cap || 0)
cap += l.cap || 0
}
return { score, cap }
})
const aktivSection = computed(() => sections.value.find((s) => s.titel === aktiv.value) || null)
const aktivStatus = computed(() => lernstand.value[aktiv.value] || null)
// Priorität: Bausteine, die noch nicht auf cap_final sind; darunter zufällig.
function waehleBaustein() {
const offen = pruefbare.value.filter((s) => {
const l = lernstand.value[s.titel] || {}
return (l.gute_antworten || 0) < (l.cap || 0)
})
const pool = offen.length ? offen : pruefbare.value
aktiv.value = pool.length ? pool[Math.floor(Math.random() * pool.length)].titel : null
}
async function ladeLernstand() {
try {
lernstand.value = (await fetchBausteinLernstand(props.topic)).bausteine || {}
} catch { /* offline → alter Stand */ }
}
onMounted(async () => {
emit('fokus-active', true)
try {
const guides = await fetchGuides()
const g = guides
.filter((x) => x.topic === props.topic && x.format === 'Guide' && x.status === 'done')
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0]
if (!g) { ladefehler.value = 'Kein fertiger Guide — erst einen Guide erstellen.'; return }
const content = await fetchGuideContent(g.id, 4) // Vollfassung (alle Subs)
sections.value = (content.chapters || [])
.flatMap((ch) => ch.sections || [])
.filter((s) => s.pruefbar !== false)
.map((s) => ({ titel: s.title, md: s.md, kompakt: s.kompakt, num: s.num }))
await ladeLernstand()
waehleBaustein()
} catch {
ladefehler.value = 'Konnte die Prüfung nicht laden.'
}
})
onUnmounted(() => emit('fokus-active', false))
async function onStatus() {
await ladeLernstand() // cap_aktuell/freie_ebene können nach einer Antwort gewachsen sein
emit('progressChanged')
}
</script>
<template>
<div class="ge-panel">
<div class="ge-head">
<h2>Allgemeine Prüfung</h2>
<span v-if="!ladefehler" class="ge-score">{{ gesamt.score }} / {{ gesamt.cap }}</span>
<button v-if="aktiv" class="ge-next" title="Anderen Baustein prüfen" @click="waehleBaustein"> Anderer Baustein</button>
</div>
<p v-if="ladefehler" class="ge-msg">{{ ladefehler }}</p>
<p v-else-if="!pruefbare.length" class="ge-msg">Noch keine prüfbaren Bausteine.</p>
<p v-else-if="!aktiv" class="ge-msg">Alles auf Max nichts mehr zu prüfen. 🎉</p>
<div v-else class="ge-body">
<div class="ge-baustein">Baustein: <strong>{{ aktiv }}</strong></div>
<BausteinPanel
:key="aktiv"
mode="full"
:topic="topic"
:baustein="aktiv"
:section="aktivSection?.md || ''"
:section-kompakt="aktivSection?.kompakt || ''"
:provider="provider"
:status="aktivStatus"
:cap="aktivStatus?.cap || 0"
@statusChanged="onStatus"
/>
</div>
</div>
</template>
<style scoped>
.ge-panel { padding: 16px 20px; max-width: 900px; margin: 0 auto; }
.ge-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
.ge-head h2 { margin: 0; font-size: 1.1rem; }
.ge-score { font-variant-numeric: tabular-nums; color: var(--text-muted); font-weight: 600; }
.ge-next { margin-left: auto; padding: 5px 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--bg); cursor: pointer; }
.ge-next:hover { color: var(--accent-hover); }
.ge-msg { color: var(--text-muted); }
.ge-baustein { margin-bottom: 8px; color: var(--text-muted); font-size: 0.9rem; }
</style>

View File

@@ -1,11 +1,22 @@
<script setup> <script setup>
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue' import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js' import { fetchGuideContent, chatGuide, fetchBausteinLernstand, fetchArtefakte } from '../api.js'
import { renderMarkdown } from '../markdown.js' import { renderMarkdown, renderMermaid } from '../markdown.js'
import { stufeFuer, schwelle } from '../stufen.js' import { stufeFuer, schwelle } from '../stufen.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 FlashcardWidget from './FlashcardWidget.vue'
import WorkedExampleBlock from './WorkedExampleBlock.vue'
// Titel-Normalisierung wie backend _norm_titel (casefold ≈ toLowerCase + ß→ss) — für das
// Andocken der Artefakte (gekeyt nach baustein_norm) an die Section-Titel.
function normTitel(s) {
return (s || '').normalize('NFKC')
.replace(/[`'"<>„“”‚’«»*_]/g, '').replace(/[–—‐]/g, '-')
.replace(/\s+/g, ' ').trim().replace(/^[.:;]+|[.:;]+$/g, '').trim()
.toLowerCase().replace(/ß/g, 'ss')
}
const props = defineProps({ const props = defineProps({
previewGuide: { type: Object, default: null }, previewGuide: { type: Object, default: null },
@@ -15,6 +26,7 @@ const props = defineProps({
doneByFormat: { type: Object, default: () => ({}) }, // Format → fertiger Guide (Themen-bezogen) doneByFormat: { type: Object, default: () => ({}) }, // Format → fertiger Guide (Themen-bezogen)
themaAbgeschlossen: { type: Boolean, default: false }, themaAbgeschlossen: { type: Boolean, default: false },
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
}) })
const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive']) const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive'])
@@ -27,6 +39,11 @@ const content = ref(null)
const loadError = ref(null) const loadError = ref(null)
const scrollEl = ref(null) const scrollEl = ref(null)
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)
const artefakte = ref({}) // baustein_norm → {karteikarte[], beispiel[], diagramm}
function artefakteVon(title) {
return artefakte.value[normTitel(title)] || null
}
// --- Lazy-Render + Markdown-Cache: nur sichtbare Sections parsen, jede nur einmal. // --- Lazy-Render + Markdown-Cache: nur sichtbare Sections parsen, jede nur einmal.
// Behebt das Blockieren beim Öffnen (160× marked/highlight.js) und Re-Parse bei jedem Update. --- // Behebt das Blockieren beim Öffnen (160× marked/highlight.js) und Re-Parse bei jedem Update. ---
@@ -53,15 +70,20 @@ function setupLazy() {
if (!e.isIntersecting) continue if (!e.isIntersecting) continue
sichtbar[Number(e.target.dataset.num)] = true sichtbar[Number(e.target.dataset.num)] = true
mdObserver.unobserve(e.target) mdObserver.unobserve(e.target)
nextTick(() => renderMermaid(e.target)) // Diagramme der nun sichtbaren Section rendern
} }
}, { root: scrollEl.value, rootMargin: '800px 0px' }) }, { root: scrollEl.value, rootMargin: '800px 0px' })
for (const el of scrollEl.value.querySelectorAll('.section-card')) mdObserver.observe(el) for (const el of scrollEl.value.querySelectorAll('.section-card')) mdObserver.observe(el)
} }
watch(content, () => nextTick(setupLazy)) watch(content, () => nextTick(setupLazy))
// Artefakte kommen asynchron nach dem Content — bereits sichtbare Diagramme nachrendern.
watch(artefakte, () => nextTick(() => renderMermaid(scrollEl.value)))
onUnmounted(() => mdObserver?.disconnect()) onUnmounted(() => mdObserver?.disconnect())
watch(() => props.previewGuide?.id, loadContent, { immediate: true }) watch(() => props.previewGuide?.id, loadContent, { immediate: true })
// Stufen-Ansicht (E/M/S/F) gewechselt → Guide-Inhalt neu mit passendem Ebenen-Filter laden.
watch(() => props.stufeAnsicht, loadContent)
async function loadContent() { async function loadContent() {
content.value = null content.value = null
@@ -72,7 +94,7 @@ async function loadContent() {
const g = props.previewGuide const g = props.previewGuide
if (!g || g.status !== 'done') return if (!g || g.status !== 'done') return
try { try {
content.value = await fetchGuideContent(g.id) content.value = await fetchGuideContent(g.id, props.stufeAnsicht)
} catch (e) { } catch (e) {
console.error('Fehler beim Laden des Guides:', e) console.error('Fehler beim Laden des Guides:', e)
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).' loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
@@ -81,6 +103,9 @@ async function loadContent() {
try { try {
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {} lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
} catch { /* offline → leer */ } } catch { /* offline → leer */ }
try {
artefakte.value = (await fetchArtefakte(g.topic)).artefakte || {}
} catch { artefakte.value = {} }
// Beim Öffnen zum ersten noch nicht gemeisterten prüfbaren Baustein scrollen. // Beim Öffnen zum ersten noch nicht gemeisterten prüfbaren Baustein scrollen.
await nextTick() await nextTick()
const ziel = bausteine.value.find((s) => istPruefbar(s) && stufeVon(s.title)?.key !== 'meister') const ziel = bausteine.value.find((s) => istPruefbar(s) && stufeVon(s.title)?.key !== 'meister')
@@ -277,6 +302,11 @@ function extractContext() {
</h3> </h3>
<div v-if="sichtbar[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div> <div v-if="sichtbar[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
<div v-else class="section-body skeleton"></div> <div v-else class="section-body skeleton"></div>
<template v-if="sichtbar[s.num] && artefakteVon(s.title)">
<pre v-if="artefakteVon(s.title).diagramm" class="mermaid art-diagramm">{{ artefakteVon(s.title).diagramm.mermaid }}</pre>
<WorkedExampleBlock :beispiele="artefakteVon(s.title).beispiel || []" />
<FlashcardWidget :karten="artefakteVon(s.title).karteikarte || []" />
</template>
<BausteinPanel <BausteinPanel
v-if="istPruefbar(s)" v-if="istPruefbar(s)"
mode="trigger" mode="trigger"
@@ -519,6 +549,19 @@ function extractContext() {
damit der IntersectionObserver die folgenden Karten gestaffelt erkennt. */ damit der IntersectionObserver die folgenden Karten gestaffelt erkennt. */
.section-body.skeleton { min-height: 160px; } .section-body.skeleton { min-height: 160px; }
/* Diagramm-Artefakt (Mermaid): zentriert, dezent gerahmt. Vor dem Rendern zeigt
<pre> den Quelltext — danach ersetzt mermaid.run ihn durch SVG. */
.art-diagramm {
margin: 0.75rem 0;
padding: 0.75rem;
background: var(--panel-soft);
border: 1px solid var(--border);
border-radius: 10px;
text-align: center;
overflow-x: auto;
}
.art-diagramm :deep(svg) { max-width: 100%; height: auto; }
.empty-preview { .empty-preview {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -23,9 +23,10 @@ const props = defineProps({
providers: { type: Array, default: () => [] }, providers: { type: Array, default: () => [] },
folders: { type: Object, default: () => ({ projekt: [], uni: [] }) }, folders: { type: Object, default: () => ({ projekt: [], uni: [] }) },
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
}) })
const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setProvider']) const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
// Accordion: höchstens ein Panel offen. IDs: 'bausteine', 'fmt-<Format>', 'topic-<Name>'. // Accordion: höchstens ein Panel offen. IDs: 'bausteine', 'fmt-<Format>', 'topic-<Name>'.
const openPanel = ref(null) const openPanel = ref(null)
@@ -54,8 +55,14 @@ const trackerItems = computed(() => {
const formats = [ const formats = [
{ key: 'Guide', label: 'Guide' }, { key: 'Guide', label: 'Guide' },
{ key: 'FullGuide', label: 'FullGuide' }, ]
{ key: 'Rest', label: 'Rest' },
// Stufen-Ansichten des EINEN Guides (gefiltert nach Subbaustein-Ebene).
const STUFEN_ANSICHT = [
{ k: 1, label: 'A', titel: 'Anfänger' },
{ k: 2, label: 'F', titel: 'Anfänger + Fortgeschritten' },
{ k: 3, label: 'E', titel: 'bis Experte' },
{ k: 4, label: 'V', titel: 'Vollständig (inkl. Rand)' },
] ]
const bausteineState = computed(() => { const bausteineState = computed(() => {
@@ -317,6 +324,11 @@ function saveQuelle() {
</svg> </svg>
</button> </button>
</div> </div>
<div v-if="selectedTopic" class="stufe-toggle" title="Tiefe der Guide-Ansicht">
<button v-for="s in STUFEN_ANSICHT" :key="s.k"
:class="{ active: stufeAnsicht === s.k }" :title="s.titel"
@click="emit('setStufe', s.k)">{{ s.label }}</button>
</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>
@@ -462,6 +474,11 @@ function saveQuelle() {
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button> <button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
</div> </div>
</div> </div>
<div class="format-row ord-pruefung">
<button class="format-name elements-btn" @click="emit('generalExam')">
<span class="format-label">Allgemeine Prüfung</span>
</button>
</div>
<div class="format-row ord-elemente"> <div class="format-row ord-elemente">
<button class="format-name elements-btn" @click="emit('openElements')"> <button class="format-name elements-btn" @click="emit('openElements')">
<span class="format-label">Elemente</span> <span class="format-label">Elemente</span>
@@ -607,6 +624,21 @@ function saveQuelle() {
.new-topic .ansicht-toggle button:hover { color: var(--accent-hover); } .new-topic .ansicht-toggle button:hover { color: var(--accent-hover); }
.new-topic .ansicht-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } .new-topic .ansicht-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.ansicht-toggle svg { fill: currentColor; display: block; } .ansicht-toggle svg { fill: currentColor; display: block; }
.stufe-toggle { display: inline-flex; margin-left: 4px; }
.new-topic .stufe-toggle button {
padding: 4px 6px;
background: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border-strong);
font-size: 11px;
font-weight: 600;
min-width: 20px;
}
.new-topic .stufe-toggle button:first-child { border-radius: 6px 0 0 6px; }
.new-topic .stufe-toggle button:last-child { border-radius: 0 6px 6px 0; }
.new-topic .stufe-toggle button:not(:first-child) { border-left: none; }
.new-topic .stufe-toggle button:hover { color: var(--accent-hover); }
.new-topic .stufe-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.stats-bar { .stats-bar {
display: flex; display: flex;
@@ -800,10 +832,14 @@ function saveQuelle() {
order: 2; order: 2;
} }
.ord-elemente { .ord-pruefung {
order: 4; order: 4;
} }
.ord-elemente {
order: 5;
}
.elements-btn { .elements-btn {
cursor: pointer; cursor: pointer;
color: var(--text); color: var(--text);

View File

@@ -0,0 +1,67 @@
<script setup>
import { ref } from 'vue'
import { renderMarkdownInline } from '../markdown.js'
defineProps({ beispiele: { type: Array, default: () => [] } })
const offen = ref(false)
</script>
<template>
<div v-if="beispiele.length" class="worked">
<button class="art-head" @click="offen = !offen">
<span class="art-icon">📝</span> Beispiele
<span class="art-count">{{ beispiele.length }}</span>
<span class="art-toggle">{{ offen ? '▾' : '▸' }}</span>
</button>
<div v-if="offen" class="we-body">
<div v-for="(b, i) in beispiele" :key="i" class="we-card">
<div v-if="b.subbaustein" class="we-sub">{{ b.subbaustein }}</div>
<div class="we-problem" v-html="renderMarkdownInline(b.problem)"></div>
<ol class="we-schritte">
<li v-for="(s, j) in b.schritte" :key="j" v-html="renderMarkdownInline(s)"></li>
</ol>
<div v-if="b.ergebnis" class="we-ergebnis">
<span class="we-label">Ergebnis</span>
<span v-html="renderMarkdownInline(b.ergebnis)"></span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.worked { margin-top: 0.5rem; }
.art-head {
display: flex; align-items: center; gap: 8px; width: 100%;
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
}
.art-icon { font-size: 0.95rem; }
.art-count {
background: var(--panel-soft); border: 1px solid var(--border);
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
}
.art-toggle { margin-left: auto; color: var(--text-faint); }
.we-body { margin-top: 0.4rem; display: flex; flex-direction: column; gap: 0.6rem; }
.we-card {
border: 1px solid var(--border); border-left: 3px solid var(--accent);
border-radius: 8px; padding: 0.7rem 0.9rem; background: var(--panel-soft);
font-size: 0.92rem; line-height: 1.5;
}
.we-sub {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-faint); font-weight: 700; margin-bottom: 0.3rem;
}
.we-problem { font-weight: 600; margin-bottom: 0.4rem; }
.we-schritte { margin: 0 0 0.4rem 1.1rem; padding: 0; }
.we-schritte li { margin: 0.2rem 0; }
.we-ergebnis {
display: flex; gap: 6px; align-items: baseline;
padding-top: 0.35rem; border-top: 1px dashed var(--border);
}
.we-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--success); font-weight: 700;
}
</style>

View File

@@ -6,6 +6,17 @@ import katex from 'katex'
import 'katex/dist/katex.min.css' import 'katex/dist/katex.min.css'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
// Mermaid lazy laden (schwere Lib): erst beim ersten Diagramm, hält das Initial-Bundle klein.
// startOnLoad:false → wir rendern gezielt; securityLevel strict: kein HTML/JS in Graphen.
let _mermaid = null
async function getMermaid() {
if (!_mermaid) {
_mermaid = (await import('mermaid')).default
_mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', flowchart: { htmlLabels: false } })
}
return _mermaid
}
marked.use(markedHighlight({ marked.use(markedHighlight({
langPrefix: 'hljs language-', langPrefix: 'hljs language-',
highlight(code, lang) { highlight(code, lang) {
@@ -49,7 +60,33 @@ const inlineMath = {
renderer(token) { return renderTex(token.text, false) }, renderer(token) { return renderTex(token.text, false) },
} }
marked.use({ extensions: [blockMath, inlineMath] }) // ```mermaid → <pre class="mermaid">…</pre> (Quelltext escaped; mermaid.run liest den textContent).
const mermaidBlock = {
name: 'mermaidBlock',
level: 'block',
start(src) { const i = src.indexOf('```mermaid'); return i < 0 ? undefined : i },
tokenizer(src) {
const m = /^```mermaid[^\n]*\n([\s\S]+?)\n```/.exec(src)
if (m) return { type: 'mermaidBlock', raw: m[0], text: m[1] }
},
renderer(token) {
const esc = token.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return `<pre class="mermaid">${esc}</pre>`
},
}
marked.use({ extensions: [blockMath, inlineMath, mermaidBlock] })
// Mermaid-Knoten in einem Container rendern (nach v-html). Idempotent: bereits
// verarbeitete (data-processed) überspringt mermaid.run; defekte Graphen bleiben als Text.
export async function renderMermaid(root) {
const nodes = root?.querySelectorAll?.('pre.mermaid:not([data-processed="true"])')
if (!nodes || !nodes.length) return
try {
const m = await getMermaid()
await m.run({ nodes })
} catch { /* defektes Diagramm → Quelltext bleibt sichtbar */ }
}
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output) // Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt. // als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.

View File

@@ -1,5 +1,5 @@
// Lernstufen je Baustein — identisch zum Backend (lernen.py STUFEN). // Lernstufen je Baustein — identisch zum Backend (lernen.py STUFEN).
// Floor in % des cap (cap = clamp(4×relevante Subbausteine, 10, 50)). // Floor in % des cap_final (cap_final = alle Subbausteine × 25).
// grün=Anfänger 20% · blau=Fortgeschritten 40% · lila=Experte 60% · gold=Meister 100%. // grün=Anfänger 20% · blau=Fortgeschritten 40% · lila=Experte 60% · gold=Meister 100%.
export const STUFEN = [ export const STUFEN = [
{ key: 'anfaenger', label: 'Anfänger', kurz: '✓', floor: 0.2, farbe: 'var(--stufe-anfaenger)' }, { key: 'anfaenger', label: 'Anfänger', kurz: '✓', floor: 0.2, farbe: 'var(--stufe-anfaenger)' },
@@ -25,11 +25,11 @@ export function naechste(score, cap) {
return null return null
} }
// Fehler-Strafe-Anzeige nach Fortschritt: 020%→0 · 2140%→1 · 4160%→−2 · 61100%→−3. // Fehler-Strafe-Anzeige nach Fortschritt (gegen cap_aktuell): ≤25%→5 · ≤50%→10 · ≤75%→−15 · >75%→−20.
export function malusRegel(score, cap) { export function malusRegel(score, cap) {
const pct = cap ? score / cap : 0 const pct = cap ? score / cap : 0
if (pct <= 0.2) return '0' if (pct <= 0.25) return '5'
if (pct <= 0.4) return '1' if (pct <= 0.5) return '10'
if (pct <= 0.6) return '2' if (pct <= 0.75) return '15'
return '3' return '20'
} }

View File

@@ -0,0 +1,21 @@
Baue für die Subbausteine des Themas "{topic}" ein ausgearbeitetes Beispiel (Worked Example) — ein durchgearbeiteter Fall, der das Verständnis trägt.
BAUSTEINE MIT SUBBAUSTEINEN UND IHREN FAKTEN (gehe JEDEN Subbaustein durch):
{bausteine}
Ein gutes Worked Example:
- **problem**: eine konkrete, kleine Aufgabe/Frage zum Subbaustein (1 Satz).
- **schritte**: 25 nachvollziehbare Schritte vom Problem zur Lösung. Jeder Schritt ein knapper Satz, in der richtigen Reihenfolge.
- **ergebnis**: das Endergebnis / die Erkenntnis (1 Satz).
- Stütze dich auf `beispiel_idee` und die belegten Fakten des Subbausteins. Rechne sauber; erfinde keine Werte, die den Fakten widersprechen.
- **Nur wo es trägt:** Lässt sich ein Subbaustein nicht sinnvoll an einem Beispiel zeigen (reine Definition, Meta-Wissen), LASSE IHN WEG — kein erzwungenes Beispiel.
- `$…$` NUR für echte Mathematik. Code/Pfade/Bezeichner in Backticks (`` `` ``).
Schreibe die Beispiele als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU so:
{{"beispiele": [
{{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Subbaustein-Titel>",
"problem": "…", "schritte": ["…", "…"], "ergebnis": "…"}}
]}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -0,0 +1,24 @@
Baue für jeden Baustein des Themas "{topic}" EIN Übersichts-Diagramm (Concept-Map) als Mermaid-Code. Es zeigt, wie die Subbausteine/Kernbegriffe des Bausteins zusammenhängen.
BAUSTEINE MIT SUBBAUSTEINEN UND IHREN FAKTEN:
{bausteine}
Regeln für das Diagramm:
- Genau EIN Mermaid-Graph je Baustein, Typ `flowchart TD` (oben→unten) oder `flowchart LR`.
- Knoten = die Subbausteine bzw. ihre Kernbegriffe. Kanten = echte Beziehungen (baut-auf, gehört-zu, führt-zu). Keine erfundenen Beziehungen.
- 38 Knoten. Lieber klar als vollständig. Stütze dich auf Voraussetzungen/Kernpunkte aus den Fakten.
- **Gültige Mermaid-Syntax, sonst rendert nichts:**
- Knoten-IDs sind kurze ASCII-Wörter ohne Leerzeichen (z. B. `dfa`, `nfa1`).
- Label in eckigen Klammern, Text schlicht: `dfa[Deterministischer Automat]`. KEINE Anführungszeichen, runden Klammern, Doppelpunkte oder `#` im Label.
- Kanten mit `-->` (optional Label: `a -->|baut auf| b`).
- Zeilen im `mermaid`-Feld mit `\n` trennen (ein JSON-String).
- Lässt sich ein Baustein nicht sinnvoll als Graph zeigen (ein einziger Begriff), LASSE IHN WEG.
Schreibe die Diagramme als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU so:
{{"diagramme": [
{{"baustein": "<exakter Baustein-Titel>",
"mermaid": "flowchart TD\n a[Alphabet] --> b[Wort]\n b --> c[Sprache]"}}
]}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -0,0 +1,20 @@
Baue für jeden Subbaustein des Themas "{topic}" EINE Karteikarte (Frage→Antwort) zum aktiven Abrufen.
BAUSTEINE MIT SUBBAUSTEINEN UND IHREN FAKTEN (bearbeite JEDEN Subbaustein):
{bausteine}
Eine gute Karteikarte:
- **frage**: eine knappe Abruf-Frage, die genau einen Kernpunkt prüft (kein „Erkläre alles"). Eine Frage, eine Sache.
- **antwort**: die kurze, präzise Antwort — auf den belegten Fakten/Kernpunkten des Subbausteins. Nichts dazu erfinden.
- Stütze dich auf die gelieferten Fakten. Wo ein belegter Fakt existiert, muss die Antwort dazu passen.
- Knapp: Frage ≤ 15 Wörter, Antwort ≤ 25 Wörter. Keine Prosa, kein Vorspann.
- `$…$` NUR für echte Mathematik. Code/Pfade/Bezeichner in Backticks (`` `` ``).
Schreibe ALLE Karten als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU so:
{{"karten": [
{{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Subbaustein-Titel>",
"frage": "…", "antwort": "…"}}
]}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -1,6 +1,6 @@
Du bist Prüfer in einem Lern-Guide zum Thema "{topic}", Baustein "{baustein}". Formuliere dem Lerner EINE Verständnisfrage. Du bekommst dafür ein **Muster** — eine Beispielfrage. Stelle dieselbe Sache, aber im Wortlaut **leicht anders** (andere Formulierung, gleicher Kern und gleiche Schwierigkeit). Du bist Prüfer in einem Lern-Guide zum Thema "{topic}", Baustein "{baustein}". Formuliere dem Lerner EINE Verständnisfrage. Du bekommst dafür ein **Muster** — eine Beispielfrage zum Kernkonzept. Stelle dieselbe Sache, aber im Anspruch passend zum Lerner-Niveau (siehe unten) und im Wortlaut anders.
MUSTER (Vorlage — gleiche Sache, neuer Wortlaut): MUSTER (Vorlage — gleicher Kern, neuer Wortlaut und Anspruch):
{muster} {muster}
BAUSTEIN AUS DEM GUIDE (fachliche Referenz — die Frage darf ihr nie widersprechen): BAUSTEIN AUS DEM GUIDE (fachliche Referenz — die Frage darf ihr nie widersprechen):
@@ -9,6 +9,9 @@ BAUSTEIN AUS DEM GUIDE (fachliche Referenz — die Frage darf ihr nie widersprec
KOMPAKTE FASSUNG (Merksätze, falls vorhanden): KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block} {kompakt_block}
LERNER-NIVEAU (bestimmt den Anspruch der Frage):
{niveau_block}
ERDUNG AM BAUSTEIN (zuerst prüfen): ERDUNG AM BAUSTEIN (zuerst prüfen):
- Das Muster wurde ohne den Guide-Text erstellt — es kann am Inhalt vorbeigehen. - Das Muster wurde ohne den Guide-Text erstellt — es kann am Inhalt vorbeigehen.
- Prüfe das Muster gegen den BAUSTEIN oben: Lässt sich die Sache wirklich aus dem Material beantworten? - Prüfe das Muster gegen den BAUSTEIN oben: Lässt sich die Sache wirklich aus dem Material beantworten?

View File

@@ -18,6 +18,9 @@ GEWÜNSCHTER FRAGETYP:
FOKUS: FOKUS:
{fokus_block} {fokus_block}
LERNER-NIVEAU (bestimmt den Anspruch der Frage — passe Tiefe und Anforderung daran an):
{niveau_block}
HARTE REGELN FÜR DIE FRAGE — wichtiger als alles andere: HARTE REGELN FÜR DIE FRAGE — wichtiger als alles andere:
- GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache. - GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache.
- Maximal 12 Sätze. Kein Szenario-Aufbau, keine Vorrede, kein "Angenommen … und außerdem …". - Maximal 12 Sätze. Kein Szenario-Aufbau, keine Vorrede, kein "Angenommen … und außerdem …".

View File

@@ -9,6 +9,9 @@ BAUSTEIN AUS DEM GUIDE (fachliche Referenz — der Satz darf ihr nie widersprech
KOMPAKTE FASSUNG (Merksätze, falls vorhanden): KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block} {kompakt_block}
LERNER-NIVEAU (bestimmt den Anspruch von Satz und Lücke):
{niveau_block}
AUFGABE: AUFGABE:
- Schreibe EINEN kurzen Aussagesatz (höchstens ~15 Wörter, keine Nebensatz-Kaskade). - Schreibe EINEN kurzen Aussagesatz (höchstens ~15 Wörter, keine Nebensatz-Kaskade).
- Ersetze den **Schlüsselbegriff** durch `___` (genau eine Lücke). - Ersetze den **Schlüsselbegriff** durch `___` (genau eine Lücke).

View File

@@ -9,6 +9,9 @@ BAUSTEIN AUS DEM GUIDE (fachliche Referenz — der Satz darf ihr nie widersprech
KOMPAKTE FASSUNG (Merksätze, falls vorhanden): KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block} {kompakt_block}
LERNER-NIVEAU (bestimmt den Anspruch von Satz und Optionen):
{niveau_block}
{distraktor_block}
AUFGABE: AUFGABE:
- Schreibe EINEN kurzen Aussagesatz (höchstens ~15 Wörter). Ersetze den **Schlüsselbegriff** durch `___` (genau eine Lücke). - Schreibe EINEN kurzen Aussagesatz (höchstens ~15 Wörter). Ersetze den **Schlüsselbegriff** durch `___` (genau eine Lücke).
- Die Lücke ist ein **Fachbegriff als Wort** — KEIN mathematisches Symbol/Formelteil - Die Lücke ist ein **Fachbegriff als Wort** — KEIN mathematisches Symbol/Formelteil

View File

@@ -9,10 +9,13 @@ BAUSTEIN AUS DEM GUIDE (fachliche Referenz — Optionen dürfen ihr nie widerspr
KOMPAKTE FASSUNG (Merksätze, falls vorhanden): KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block} {kompakt_block}
LERNER-NIVEAU (bestimmt den Anspruch von Frage und Distraktoren):
{niveau_block}
{distraktor_block}
AUFGABE: AUFGABE:
- Formuliere EINE klare Frage und GENAU VIER Antwortoptionen. - Formuliere EINE klare Frage und GENAU VIER Antwortoptionen.
- {anzahl_block} - GENAU EINE Option ist richtig, die anderen drei sind klar falsch.
- Jede richtige Option ist sachlich aus dem Material belegbar. Jede falsche ist plausibel, - Die richtige Option ist sachlich aus dem Material belegbar. Jede falsche ist plausibel,
aber klar falsch (typischer Irrtum, Verwechslung, Halbwahrheit) — keine Albernheiten. aber klar falsch (typischer Irrtum, Verwechslung, Halbwahrheit) — keine Albernheiten.
- **Optionen SEHR KURZ:** ein Stichwort, Begriff oder Halbsatz, höchstens ~8 Wörter. - **Optionen SEHR KURZ:** ein Stichwort, Begriff oder Halbsatz, höchstens ~8 Wörter.
KEINE ganzen Sätze, KEIN „Weil …"-Vorspann, keine Begründungs-Prosa. Nur der Kern. KEINE ganzen Sätze, KEIN „Weil …"-Vorspann, keine Begründungs-Prosa. Nur der Kern.
@@ -25,5 +28,5 @@ Gib NUR dieses JSON aus (kein weiterer Text):
{{"text": "Option A", "korrekt": true}}, {{"text": "Option A", "korrekt": true}},
{{"text": "Option B", "korrekt": false}}, {{"text": "Option B", "korrekt": false}},
{{"text": "Option C", "korrekt": false}}, {{"text": "Option C", "korrekt": false}},
{{"text": "Option D", "korrekt": true}} {{"text": "Option D", "korrekt": false}}
]}} ]}}

View File

@@ -0,0 +1,22 @@
Du bist Faktenprüfer für die Lern-Fakten des Themas "{topic}". Ein anderer Agent hat je Subbaustein Fakten extrahiert. Prüfe NUR die **belegten Fakten** auf Wahrheit — nicht die Beispiele.
{source}
ZU PRÜFENDE FAKTEN (je Subbaustein):
{fakten}
Prüfe je Subbaustein:
1. **Beleg-Treue**: Steht jeder `belegte_fakten`-Eintrag so in der Quelle (sinngemäß-genau)? Stimmt die Quellenangabe? Mit Quelldatei: gegen die Datei prüfen. Ohne Quelle: ist es gesichertes Standardwissen?
2. **Sachliche Korrektheit**: Sind Formeln, Werte, Definitionen, Signaturen fachlich richtig? Eine falsche Formel/ein falscher Wert ist ein Mangel.
3. **Fakt vs. Beispiel**: Ist ein selbst gerechnetes/erfundenes Beispiel fälschlich als `belegte_fakten` deklariert? Das ist ein Mangel — es gehört in `beispiel_idee`.
4. Beispiele (`beispiel_idee`) NICHT auf Quellen-Beleg prüfen — sie sind generativ.
Notiere nur ECHTE Mängel (falscher Fakt, falsche Quelle, Beispiel als Fakt getarnt). Geschmacksfragen nicht.
Schreibe NUR die JSON-Datei nach: {out_path}
Format — alles in Ordnung:
{{"ok": true}}
Sonst (Subbaustein-Titel EXAKT wie oben):
{{"probleme": [{{"subbaustein": "<exakter Subbaustein-Titel>", "problem": "…"}}]}}
{extra}

View File

@@ -0,0 +1,30 @@
Extrahiere die Lern-Fakten für jeden Subbaustein des Themas "{topic}". Diese Fakten sind die verbindliche Grundlage, aus der später der Guide-Text, die Stufen und die Prüfungsfragen entstehen — sie müssen **stimmen**.
{source}
BAUSTEINE MIT SUBBAUSTEINEN (bearbeite JEDEN Subbaustein):
{bausteine}
Sammle pro Subbaustein NUR das Wesentliche (überflüssiges Material schadet dem Lernen):
- **kernpunkte**: 13 knappe Aussagen — was muss man an diesem Subbaustein verstehen?
- **voraussetzungen**: was muss man vorher kennen (ein Halbsatz)? Leer, wenn nichts.
- **huerden**: typischer Anfänger-Irrtum (ein Halbsatz). Leer, wenn keiner.
- **belegte_fakten**: harte Fakten (Definitionen, Formeln, Werte, Namen, Signaturen) — **nur was du belegen kannst**. Jeder mit Quelle:
- Mit Quelldatei/Skript: zitiere **sinngemäß-genau** und gib die Stelle an (z. B. „Skript Def. 6.3, Z.66"). Erfinde keine Werte, rechne nichts selbst aus.
- Ohne Quelle (reines Thema): nur gesichertes Standardwissen; Unsicheres per Websuche prüfen; Quelle = „allgemein" oder die URL.
- **beispiel_idee**: EIN Beispiel, das das Verständnis trägt — frei formuliert. **Hier** gehören selbst gebildete Sätze, Mini-Szenarien, durchgerechnete Beispiele hin. Leer, wenn ein Beispiel nichts beiträgt.
HARTE TRENNUNG — wichtig:
- `belegte_fakten` = nur Belegbares aus Quelle/gesichertem Wissen. **NIEMALS** ein selbst gerechnetes oder erfundenes Beispiel als belegten Fakt ausgeben.
- Ein durchgerechnetes Beispiel, ein erdachter Satz, ein konstruierter Fall → gehört in `beispiel_idee`, nicht in `belegte_fakten`.
- Im Zweifel: lieber weglassen als falsch behaupten.
Schreibe ALLE Subbausteine als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
{{"fakten": [
{{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Subbaustein-Titel>",
"kernpunkte": ["…"], "voraussetzungen": "…", "huerden": "…",
"belegte_fakten": [{{"text": "…", "quelle": "…"}}], "beispiel_idee": "…"}}
]}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -1,20 +1,20 @@
Du bist Qualitäts-Prüfer für den Fragen-Vorrat einer Lern-Prüfung zum Thema "{topic}". Ein anderer Agent hat Frage-Muster für **mehrere Bausteine** erstellt. Prüfe sie und gib eine **bereinigte** Endfassung zurück. Du bist Qualitäts-Prüfer für den Fragen-Vorrat einer Lern-Prüfung zum Thema "{topic}". Ein anderer Agent hat Frage-Muster für **mehrere Bausteine** erstellt — genau ein Muster je Subbaustein. Prüfe sie und gib eine **bereinigte** Endfassung zurück.
ZU PRÜFENDE MUSTER (gruppiert je Baustein; Zeilen: [typ] (subbaustein) Frage): ZU PRÜFENDE MUSTER (gruppiert je Baustein; Zeilen: (subbaustein) Frage):
{tabelle} {tabelle}
PRÜFE UND BEREINIGE GEGEN DIESE KRITERIEN (je Baustein getrennt): PRÜFE UND BEREINIGE GEGEN DIESE KRITERIEN (je Baustein getrennt):
- **Stil:** jede Frage GENAU EINE Frage, ein Fragezeichen, eine Sache. Kein "und"/"sowie", keine Aufzählung. Maximal 12 Sätze. Verstöße → umformulieren. - **Stil:** jede Frage GENAU EINE Frage, ein Fragezeichen, eine Sache. Kein "und"/"sowie", keine Aufzählung. Maximal 12 Sätze. Verstöße → umformulieren.
- **Eindeutig beantwortbar:** aus dem Baustein-Wissen klar beantwortbar, keine erfundenen Annahmen. Unbeantwortbares → korrigieren oder entfernen. - **Eindeutig beantwortbar:** aus dem Baustein-Wissen klar beantwortbar, keine erfundenen Annahmen. Unbeantwortbares → korrigieren.
- **Distinkt:** keine zwei Muster desselben Bausteins, die sinngemäß dieselbe Frage stellen (auch nicht über Typen hinweg). Dubletten → eines behalten, das andere entfernen. - **Kern-Treffer:** die Frage trifft das Kernkonzept des Subbausteins, neutral formuliert (nicht extra leicht/schwer — die Schwierigkeit setzt später das Lerner-Niveau). Verfehlt sie den Kern → umformulieren.
- **Typ-Treue:** die Frage passt zum angegebenen Typ; sonst Typ anpassen oder Muster entfernen. - **Genau eins je Subbaustein:** pro Subbaustein bleibt genau ein Muster. Doppelte → eines behalten.
- `baustein`, `subbaustein` und `typ` der Einträge unverändert übernehmen (nur `frage` darf sich ändern); nur entfernen, nicht neu erfinden. - `baustein` und `subbaustein` der Einträge unverändert übernehmen (nur `frage` darf sich ändern); nicht neu erfinden.
Behalte gute Muster unverändert. Ändere nur, was wirklich gegen die Kriterien verstößt. Behalte gute Muster unverändert. Ändere nur, was wirklich gegen die Kriterien verstößt.
Schreibe die bereinigte Endfassung ALLER Bausteine als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format: Schreibe die bereinigte Endfassung ALLER Bausteine als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
{{"muster": [ {{"muster": [
{{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Titel>", "typ": "<Schlüssel>", "frage": "<eine konkrete Frage>"}} {{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Titel>", "frage": "<eine konkrete Frage>"}}
]}} ]}}
Gib sonst keinen Text aus. Gib sonst keinen Text aus.

View File

@@ -1,29 +1,26 @@
Du baust den Fragen-Vorrat für eine Lern-Prüfung zum Thema "{topic}". Erzeuge **Frage-Muster**je eine konkrete Beispielfrage pro Subbaustein und Fragetyp. Aus diesen Mustern werden später bei der Prüfung leicht abgewandelte Fragen formuliert. Du baust den Fragen-Vorrat für eine Lern-Prüfung zum Thema "{topic}". Erzeuge **Frage-Muster****genau eine** konkrete Beispielfrage **pro Subbaustein**. Aus jedem Muster wird später bei der Prüfung eine leicht abgewandelte Frage formuliert — passend zum Niveau des Lerners (Anfänger bis Experte). Die Schwierigkeit steckt also NICHT im Muster, sondern kommt erst bei der Prüfung. Frag deshalb nach dem **Kernkonzept** des Subbausteins, neutral formuliert.
Du bearbeitest **mehrere Bausteine**. Jeder Baustein hat seine eigenen relevanten Subbausteine: Du bearbeitest **mehrere Bausteine**. Jeder Baustein hat seine eigenen Subbausteine:
{bausteine} {bausteine}
FRAGETYPEN (Schlüssel : Bedeutung):
{typen}
AUFGABE (für **jeden** der Bausteine oben): AUFGABE (für **jeden** der Bausteine oben):
- Erzeuge für **jeden** seiner Subbausteine zu **jedem** passenden Fragetyp **ein** Muster (volles Kreuzprodukt). - Erzeuge für **jeden** seiner Subbausteine **genau ein** Muster — eine konkrete, knappe Frage zum Kernkonzept dieses Subbausteins.
- Lasse eine Kombination **weg**, wenn der Typ klar nicht passt (z.B. Code-Vorhersage bei reiner Theorie ohne Code). Nichts erzwingen. - Jedes Muster ist EINE konkrete Frage (12 Sätze, genau ein Fragezeichen, eine Sache) — kein Mehrteiler, keine Aufzählung.
- Jedes Muster ist EINE konkrete, knappe Frage (12 Sätze, genau ein Fragezeichen, eine Sache) — kein Mehrteiler, keine Aufzählung.
- Die Frage muss aus dem Baustein-Wissen beantwortbar sein. Erfinde keine Zusatzannahmen. - Die Frage muss aus dem Baustein-Wissen beantwortbar sein. Erfinde keine Zusatzannahmen.
- WICHTIG: Du siehst nur Titel, NICHT den späteren Guide-Text. Frag deshalb nur nach dem KERNKONZEPT des Subbausteins. Setze NICHTS voraus, was evtl. nicht im Guide steht: keine „gezeigten" Code-Ausschnitte/Beispiele/Diagramme, keine konkreten Werte/Listen/Zahlen, keine Randthemen. Frag das Konzept selbst, nicht ein bestimmtes Artefakt. - WICHTIG: Du siehst nur Titel, NICHT den späteren Guide-Text. Frag deshalb nur nach dem KERNKONZEPT des Subbausteins. Setze NICHTS voraus, was evtl. nicht im Guide steht: keine „gezeigten" Code-Ausschnitte/Beispiele/Diagramme, keine konkreten Werte/Listen/Zahlen.
- Innerhalb eines Subbausteins sollen die Muster verschiedener Typen **wirklich verschiedene** Aspekte treffen — keine Umformulierungen derselben Frage. - Die Frage trifft den **Kern** des Subbausteins — den zentralen Punkt, den man verstanden haben muss. Nicht zu eng auf ein Detail, nicht zu breit über den ganzen Baustein.
- Der `typ` ist exakt einer der Schlüssel oben. `baustein` ist exakt einer der Baustein-Titel oben. `subbaustein` ist exakt einer der Subbaustein-Titel des jeweiligen Bausteins. - `baustein` ist exakt einer der Baustein-Titel oben. `subbaustein` ist exakt einer der Subbaustein-Titel des jeweiligen Bausteins.
HARTE STIL-REGELN JE FRAGE: HARTE STIL-REGELN JE FRAGE:
- Genau EINE Frage, ein Fragezeichen, eine Sache. Kein "und"/"sowie", kein "nenne drei …". - Genau EINE Frage, ein Fragezeichen, eine Sache. Kein "und"/"sowie", kein "nenne drei …".
- Maximal 12 Sätze, keine Vorrede, kein Szenario-Aufbau. - Maximal 12 Sätze, keine Vorrede, kein Szenario-Aufbau.
- Neutral formuliert — nicht extra leicht, nicht extra schwer. Die Schwierigkeit setzt die Prüfung über das Lerner-Niveau.
- Direkte Ansprache, klares Deutsch. - Direkte Ansprache, klares Deutsch.
{extra} {extra}
Schreibe ALLE Muster aller Bausteine als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format: Schreibe ALLE Muster aller Bausteine als EIN JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
{{"muster": [ {{"muster": [
{{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Subbaustein-Titel>", "typ": "<Schlüssel>", "frage": "<eine konkrete Frage>"}} {{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Subbaustein-Titel>", "frage": "<eine konkrete Frage>"}}
]}} ]}}
Gib sonst keinen Text aus. Gib sonst keinen Text aus.

View File

@@ -1,6 +1,6 @@
Plane die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}). Plane die Gliederung eines Lern-Guides zum Thema "{topic}".
GEWÄHLTE BAUSTEINE (jeder mit seiner NUMMER — die Auswahl steht fest, du ordnest nur): BAUSTEINE (jeder mit seiner NUMMER — die Liste steht fest, du ordnest nur):
{bausteine} {bausteine}
REIHENFOLGE — vom Bekannten zum Unbekannten: REIHENFOLGE — vom Bekannten zum Unbekannten:

View File

@@ -1,16 +1,18 @@
Identifiziere für jeden zugeteilten Baustein eines Lern-Guides zum Thema "{topic}", WAS ein Anfänger ohne Vorwissen verstehen muss. Du schreibst noch keinen Guide-Text — du sammelst nur die Inhalte, die später gelehrt werden. Identifiziere für jeden zugeteilten Baustein eines Lern-Guides zum Thema "{topic}", WAS ein Anfänger ohne Vorwissen verstehen muss. Du schreibst noch keinen Guide-Text — du sammelst nur die Inhalte, die später gelehrt werden.
Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jeder zugeteilte Baustein muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Stufe (`[einfach]`/`[mittel]`/`[schwer]`) — die Teilpunkte, die gelehrt werden: Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jeder zugeteilte Baustein muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Lernpfad-Stufe (`[anfaenger]`/`[fortgeschritten]`/`[experte]`) — die Teilpunkte, die gelehrt werden:
{zuteilung} {zuteilung}
{facts} {facts}
Leitprinzip: nur das **Wesentliche**. Überflüssiges Material schadet dem Lernen — sammle, was ein Anfänger WIRKLICH braucht, nicht alles, was man sagen könnte.
Sammle pro Baustein: Sammle pro Baustein:
- Kernpunkte / Lernziele: was muss der Leser begreifen? Decke JEDEN gelisteten Subbaustein mit mindestens einem Kernpunkt ab. Hat ein Baustein keine Subbausteine, sammle 37 knappe Punkte. - Kernpunkte / Lernziele: was muss der Leser begreifen? Decke JEDEN gelisteten Subbaustein mit GENAU dem einen, zentralen Kernpunkt ab — nicht mehrere überlappende. Hat ein Baustein keine Subbausteine, sammle 37 knappe Punkte.
- Voraussetzungen: welche Begriffe/Ideen muss man vorher kennen, um das hier zu verstehen? Jede in einem Halbsatz erklärbar — das sind die Ankerpunkte für Neulinge. - Voraussetzungen: welche Begriffe/Ideen muss man vorher kennen, um das hier zu verstehen? Jede in einem Halbsatz erklärbar — das sind die Ankerpunkte für Neulinge.
- Typische Hürden: wo verstehen Anfänger es erfahrungsgemäß falsch oder stolpern? - Typische Hürden: wo verstehen Anfänger es erfahrungsgemäß falsch oder stolpern?
- Belegte Fakten (Versionen, Namen, Werte) — nichts Unbelegtes; Unsicheres per Websuche prüfen. - Belegte Fakten (Versionen, Namen, Werte) — nichts Unbelegtes; Unsicheres per Websuche prüfen.
- Eine konkrete Beispiel-Idee: was soll das Beispiel zeigen? - Eine konkrete Beispiel-Idee NUR, wo ein Beispiel das Verständnis wirklich trägt — nicht pflichtmäßig bei jedem Subbaustein.
- Scope: nur was DIESER Baustein hergibt. Nicht mehr, nicht weniger. Am Rand Erwähntes nicht zum Thema aufblasen. - Scope: nur was DIESER Baustein hergibt. Nicht mehr, nicht weniger. Am Rand Erwähntes nicht zum Thema aufblasen.
Schreibe NUR die Datei {out_path} — pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darunter die Punkte: Schreibe NUR die Datei {out_path} — pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darunter die Punkte:

View File

@@ -14,8 +14,13 @@ Prüfe jede Section:
- Eine Aufzählung (Schritte/Optionen/Anforderungen) als langer Fließtext-Satz, die eine Markdown-Liste sein sollte. - Eine Aufzählung (Schritte/Optionen/Anforderungen) als langer Fließtext-Satz, die eine Markdown-Liste sein sollte.
- Textwand: ein dichter Block ohne Absätze, der sich in mehrere teilen ließe. - Textwand: ein dichter Block ohne Absätze, der sich in mehrere teilen ließe.
- Mehr als ~4 neue Fachbegriffe ohne Erklärung beim ersten Auftreten. - Mehr als ~4 neue Fachbegriffe ohne Erklärung beim ersten Auftreten.
3. Sind die Beispiele kurz, simpel, plausibel korrekt — und im themengerechten Format laut Spezifikation (kein Codeblock um Prosa-Beispiele, kein Prosa-Pseudo-Beispiel, wo Code gefragt ist)? 3. Knappheit (überflüssiges Material schadet dem Lernen — echte Mängel notieren):
4. Ist das Markdown sauber (keine abgebrochenen Code-Blöcke, keine Platzhalter, kein Fremdtext)? - Wiederholung: dieselbe Aussage mehrfach, nur anders formuliert.
- Breit ausgewalztes Triviales, Füllsätze, Vorreden, Meta-Kommentare ohne neuen Inhalt.
- Ein Beispiel, das nichts zum Verständnis beiträgt.
→ notieren als „zu lang/redundant — kürzen ohne Info-Verlust". WICHTIG: Kürzen heißt nie, einen Subbaustein zu streichen — jeder bleibt erhalten.
4. Sind die Beispiele kurz, simpel, plausibel korrekt — und im themengerechten Format laut Spezifikation (kein Codeblock um Prosa-Beispiele, kein Prosa-Pseudo-Beispiel, wo Code gefragt ist)?
5. Ist das Markdown sauber (keine abgebrochenen Code-Blöcke, keine Platzhalter, kein Fremdtext)?
Du PRÜFST nur und notierst Probleme — du änderst nichts. Nur echte Mängel notieren, keine Geschmacksfragen. Du PRÜFST nur und notierst Probleme — du änderst nichts. Nur echte Mängel notieren, keine Geschmacksfragen.

View File

@@ -5,23 +5,32 @@
SECTION-SPEZIFIKATION: SECTION-SPEZIFIKATION:
{spec} {spec}
ZU ÜBERARBEITEN — je Section der aktuelle Inhalt und das notierte Problem: ZU ÜBERARBEITEN — je Section ihre Subbausteine, das notierte Problem und der aktuelle Inhalt:
{auftraege} {auftraege}
Behebe pro Section NUR das notierte Problem; was in Ordnung ist, bleibt inhaltlich erhalten. Behebe pro Section NUR das notierte Problem; was in Ordnung ist, bleibt inhaltlich erhalten.
Jede Section hat ZWEI Fassungen mit denselben Inhalten: **kompakt** (je Subbaustein ein Merksatz als Stichpunkt) und **ausführlich** (zusammenhängender Anfänger-Text). Gib beide neu aus und halte sie deckungsgleich. VERBINDLICHE STRUKTUR (wie der Writer) — sonst bricht die gestufte Anzeige:
- Jede Section hat ZWEI Fassungen: **kompakt** (je Subbaustein ein Merksatz) und **ausführlich** (zusammenhängender Anfänger-Text).
- In BEIDEN Fassungen steht je Subbaustein ein `<!-- sub: LABEL | Subbaustein-Titel -->`-Marker. LABEL und Titel EXAKT aus der Subbaustein-Liste oben, gleiche Reihenfolge in beiden Blöcken. Der ausführliche Text beginnt mit einem kurzen Anker (Einordnung) VOR dem ersten Marker.
- Die Marker sind unsichtbare Schnittstellen, KEINE sichtbaren Überschriften — schreibe trotzdem flüssig.
Schreibe NUR die Datei {out_path} in GENAU diesem Format — für JEDE beanstandete Section ein section-Marker (Titel EXAKT wie oben) mit `kompakt`- und `ausführlich`-Block: KNAPP HALTEN (falls das Problem „zu lang/redundant" ist):
- Jeder Satz trägt neue Information. Streiche Wiederholungen, Füll- und Meta-Sätze, Vorreden.
- Erkläre Triviales kurz; gib Tiefe nur, wo der Stoff sie braucht. Im Zweifel weglassen, nicht hinzufügen.
- Ein Beispiel nur, wo es das Verständnis wirklich trägt — nicht pflichtmäßig bei jedem Subbaustein.
- Kürzen heißt nie: einen Subbaustein streichen. Jeder bleibt mit seinem Marker erhalten.
Schreibe NUR die Datei {out_path} in GENAU diesem Format — für JEDE beanstandete Section ein section-Marker (Titel EXAKT wie oben):
<!-- section: Exakter Section-Titel --> <!-- section: Exakter Section-Titel -->
<!-- kompakt --> <!-- kompakt -->
<!-- sub: anfaenger | Exakter Subbaustein-Titel -->
- Merksatz je Subbaustein (knapp, ohne Erklärung) - Merksatz je Subbaustein (knapp, ohne Erklärung)
<!-- ausführlich --> <!-- ausführlich -->
Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION… Anker: kurze Einordnung des Bausteins — vor dem ersten Subbaustein.
<!-- sub: anfaenger | Exakter Subbaustein-Titel -->
### Beispiel Anfängerfreundlicher Fließtext zu diesem Subbaustein.
(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario)
Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections. Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections.
{extra} {extra}

View File

@@ -1,23 +1,25 @@
Schreibe Sections für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}). Schreibe Sections für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}).
Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteilte Section muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Stufe (`[einfach]`/`[mittel]`/`[schwer]`): Dir zugeteilt sind folgende Kapitel und Bausteine — verbindlich: jede zugeteilte Section muss vorkommen, keine zusätzlichen erfinden. Unter jedem Baustein stehen seine SUBBAUSTEINE mit Ebenen-Label (`[anfaenger]`/`[fortgeschritten]`/`[experte]`/`[rand]`):
{zuteilung} {zuteilung}
Das Format ({format_name}) beeinflusst NICHT, wie ein Baustein geschrieben wird — nur, wie viele Bausteine das Thema abdecken. Derselbe Baustein bekommt in MiniGuide, Guide und FullGuide denselben Text. WICHTIG — jeder Subbaustein wird mit einem Marker abgegrenzt, damit der Guide später gestuft angezeigt werden kann (Anfänger sehen nur `anfaenger`, Fortgeschrittene mehr). Das Label kommt EXAKT aus der Zuteilung. Trotz Marker schreibst du FLÜSSIG und verwoben — der Marker ist eine unsichtbare Schnittstelle, KEINE sichtbare Überschrift.
Jeder Baustein bekommt ZWEI Fassungen mit DENSELBEN Inhalten (dieselben Subbausteine): Der Inhalt ist UNABHÄNGIG von der Stufe: jeder Subbaustein wird **anfängerfreundlich** erklärt, egal welches Label. Das Label sagt nur, WANN im Lernpfad der Punkt drankommt (Fundament → Feinheit) — NICHT wie kompliziert er geschrieben wird. Ein `[experte]`-Punkt wird genauso simpel erklärt wie ein `[anfaenger]`-Punkt. Kein Perfektionismus, keine künstliche Tiefe: das Wesentliche klar, so dass ein Neuling es versteht.
- **kompakt**: je Subbaustein EIN Merksatz als Stichpunkt (`- …`). Zum Erinnern. Nur benennen, nicht erklären. Gleiche Reihenfolge wie ausführlich.
Jeder Baustein bekommt ZWEI Fassungen mit DENSELBEN Subbausteinen (gleiche Titel, gleiche Reihenfolge):
- **kompakt**: je Subbaustein EIN Merksatz als Stichpunkt (`- …`). Zum Erinnern. Nur benennen, nicht erklären.
- **ausführlich**: der zusammenhängende Anfänger-Lerntext (siehe unten). - **ausführlich**: der zusammenhängende Anfänger-Lerntext (siehe unten).
Beide MÜSSEN dieselben Subbausteine abdecken. Nichts, das nur in einer Fassung steht.
SO SCHREIBST DU die ausführliche Fassung — EIN zusammenhängender Text für einen Junior, der das Thema NEU lernt: SO SCHREIBST DU die ausführliche Fassung — EIN zusammenhängender Text für einen Junior, der das Thema NEU lernt:
- Beginne mit dem Anker (Einordnung): welches Problem/wozu, angeknüpft an etwas Bekanntes — erst dann das Neue. Pflicht, nie weglassen. - Beginne mit dem Anker (Einordnung): welches Problem/wozu, angeknüpft an etwas Bekanntes — erst dann das Neue. Der Anker steht VOR dem ersten Subbaustein-Marker. Pflicht, nie weglassen.
- Löse JEDEN Fachbegriff bei Erstnennung in einem Halbsatz auf. Setze nichts voraus — auch keine Begriffe aus dem Titel oder anderen Bausteinen. Nutze die „Voraussetzung"-Hinweise aus den Inhalten als Anker, die „Hürde"-Hinweise, um Missverständnisse vorweg auszuräumen. - Löse JEDEN Fachbegriff bei Erstnennung in einem Halbsatz auf. Setze nichts voraus — auch keine Begriffe aus dem Titel oder anderen Bausteinen. Nutze die „Voraussetzung"-Hinweise als Anker, die „Hürde"-Hinweise, um Missverständnisse vorweg auszuräumen.
- Die Subbausteine sind deine INHALTS-CHECKLISTE, keine Überschriften: webe sie in den Fließtext ein. Behandle jeden, aber als Teil eines Ganzen — nicht als isolierten Mini-Absatz. - Behandle jeden Subbaustein unter seinem Marker — flüssig formuliert, erklärender Fließtext, kein isolierter Stichpunkt-Block.
- Reihenfolge: erst die `[einfach]`-Punkte (das Fundament), dann `[mittel]` (Details/Varianten), zuletzt `[schwer]` (Sonderfälle/Tücken — knapp, als fortgeschrittener Hinweis). - Reihenfolge der Subbausteine wie in der Zuteilung: erst `[anfaenger]` (Fundament), dann `[fortgeschritten]`, dann `[experte]`, zuletzt `[rand]`.
- Diese Fassung trägt die volle Tiefe selbst — es gibt keine zweite Ausbaustufe. „Wie"-Abläufe Schritt für Schritt zeigen, nicht nur das Ergebnis. - „Wie"-Abläufe Schritt für Schritt zeigen, nicht nur das Ergebnis.
- KEIN festes Längen-Limit. So lang wie nötig, damit ein Neuling es OHNE Vorwissen versteht. Im Zweifel ein Satz mehr und klar. - KNAPP: Jeder Satz trägt neue Information. Keine Wiederholung, keine Füll- oder Meta-Sätze, keine Vorrede. Überflüssiges schadet dem Lernen — im Zweifel weglassen, nicht hinzufügen.
- Beispiele großzügig — auch kleine Mini-Beispiele mitten im Text, wo sie einen Punkt sofort greifbar machen, zusätzlich zum `### Beispiel` am Ende (Format laut Spezifikation). - Länge folgt dem Inhalt: ein triviales Detail ein, zwei Sätze; ein komplexes Konzept so viel, wie es WIRKLICH braucht — nicht mehr. Kein „so lang wie möglich".
- Ein Beispiel NUR, wo es das Verständnis wirklich trägt — nicht pflichtmäßig bei jedem Subbaustein.
GEPRÜFTE INHALTE je Baustein — das ist verbindlich, was gelehrt werden muss: GEPRÜFTE INHALTE je Baustein — das ist verbindlich, was gelehrt werden muss:
{inhalte} {inhalte}
@@ -27,17 +29,21 @@ Recherchiere NICHT und suche NICHT im Web. Alle nötigen Fakten stehen in diesen
SECTION-SPEZIFIKATION (gilt je Baustein): SECTION-SPEZIFIKATION (gilt je Baustein):
{spec} {spec}
Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kapitel-Marker, pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darin ein `kompakt`- und ein `ausführlich`-Block: Schreibe NUR die Datei {out_path} in GENAU diesem Format — pro Kapitel ein kapitel-Marker, pro Baustein ein section-Marker (Titel EXAKT aus der Zuteilung), darin ein `kompakt`- und ein `ausführlich`-Block. In BEIDEN Blöcken steht je Subbaustein ein `<!-- sub: LABEL | Subbaustein-Titel -->`-Marker (LABEL und Titel EXAKT aus der Zuteilung, gleiche Reihenfolge in beiden Blöcken):
<!-- kapitel: Kapiteltitel --> <!-- kapitel: Kapiteltitel -->
<!-- section: Exakter Baustein-Titel --> <!-- section: Exakter Baustein-Titel -->
<!-- kompakt --> <!-- kompakt -->
- Merksatz je Subbaustein (knapp, ohne Erklärung) <!-- sub: anfaenger | Exakter Subbaustein-Titel -->
- Merksatz zu diesem Subbaustein (knapp, ohne Erklärung)
<!-- sub: fortgeschritten | Exakter Subbaustein-Titel -->
- Merksatz zu diesem Subbaustein
<!-- ausführlich --> <!-- ausführlich -->
Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION… Anker: Einordnung des ganzen Bausteins (welches Problem, wozu) — vor dem ersten Subbaustein.
<!-- sub: anfaenger | Exakter Subbaustein-Titel -->
Anfängerfreundlicher Fließtext zu diesem Subbaustein, mit kleinem Beispiel.
<!-- sub: fortgeschritten | Exakter Subbaustein-Titel -->
Anfängerfreundlicher Fließtext zu diesem Subbaustein.
### Beispiel Die Marker-Zeilen exakt so schreiben. Jede Section hat genau einen `<!-- kompakt -->`- und einen `<!-- ausführlich -->`-Block; die Subbaustein-Titel sind in beiden identisch. Kein Text außerhalb der Sections, kein Dokument-Titel, kein Inhaltsverzeichnis.
(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario)
Die Marker-Zeilen exakt so schreiben (jede Section hat genau einen `<!-- kompakt -->`- und einen `<!-- ausführlich -->`-Block). Kein Text außerhalb der Sections, kein Dokument-Titel, kein Inhaltsverzeichnis.
{extra} {extra}

View File

@@ -4,8 +4,8 @@ STRITTIGE SUBBAUSTEINE (mit den abgegebenen Stimmen):
{strittig} {strittig}
Relevanz: Relevanz:
- **relevant**: Kern-/Standardwissen, ein ernsthafter Lerner braucht es. - **relevant**: Kern-/Standardwissen, das das Thema trägt — zentrale Konzepte, Definitionen, Eigenschaften. Bei theoretischen Themen auch zentrale Sätze ohne praktischen Gebrauch.
- **rand**: Randnotiz, Spezial-/Nischenfall, „nice to know" — nur für den Komplett-Guide. - **rand**: Randnotiz, Nischen-/Spezialfall, „nice to know", Deprecated/Exotik, rein historische Details, konkrete Zahlenbeispiele, bloße Querverweise. NICHT rand: ein zentrales Theorem/eine fundamentale Eigenschaft. Nur für den Komplett-Guide.
Regeln: Regeln:
- Wäge die Stimmen ab, entscheide fachlich. Im Zweifel **relevant**. - Wäge die Stimmen ab, entscheide fachlich. Im Zweifel **relevant**.

View File

@@ -4,8 +4,8 @@ SUBBAUSTEINE (Nummer in Klammern der zugehörige Baustein):
{subbausteine} {subbausteine}
Relevanz: Relevanz:
- **relevant**: Kern- oder Standardwissen des Themas. Ein ernsthafter Lerner braucht es im normalen Gebrauch. Was man wirklich kennen sollte. - **relevant**: Kern-/Standardwissen, das das Thema TRÄGT — zentrale Konzepte, Definitionen, Eigenschaften, gängige Praxis. Bei praktischen Themen: was man im Gebrauch braucht. Bei theoretischen Themen: zentrale Sätze/Eigenschaften, AUCH ohne praktischen Gebrauch. Was man wirklich kennen sollte.
- **rand**: Randnotiz, Spezial-/Nischenfall, „nice to know", Deprecated/Exotik. Für die meisten Lerner verzichtbar — gehört nur in einen Komplett-Guide. - **rand**: Randnotiz, Spezial-/Nischenfall, „nice to know", Deprecated/Exotik — PLUS rein historische Details (wer/wann), konkrete Zahlenbeispiele eines allgemeinen Prinzips, bloße Querverweise. NICHT rand: ein zentrales Theorem oder eine fundamentale Eigenschaft, nur weil sie „theoretisch" wirkt. Gehört nur in einen Komplett-Guide.
Regeln: Regeln:
- Bewerte jeden Punkt einzeln, gemessen an SEINEM Baustein und am Thema. - Bewerte jeden Punkt einzeln, gemessen an SEINEM Baustein und am Thema.

View File

@@ -1,19 +1,20 @@
Mehrere Agenten haben die Subbausteine des Themas "{topic}" nach Schwierigkeit eingestuft. Bei einigen sind sie sich uneinig. Entscheide für jeden strittigen Subbaustein die finale Stufe. Mehrere Agenten haben die Subbausteine des Themas "{topic}" nach Lernpfad-Position eingestuft. Bei einigen sind sie sich uneinig. Entscheide für jeden strittigen Subbaustein die finale Stufe.
STRITTIGE SUBBAUSTEINE (mit den abgegebenen Stimmen): STRITTIGE SUBBAUSTEINE (mit den abgegebenen Stimmen):
{strittig} {strittig}
Stufe = Lern-/Verständnisaufwand (nicht Seltenheit/Obskurität): Stufe = Lernpfad-Position (WANN man den Punkt braucht), NICHT Schwierigkeit:
- **einfach**: Basis-Fakt/Definition, sofort verständlich. Auch Versions-Details, Pfade, Konstanten, einzelne Fakten. - **anfaenger**: Fundament — was man zuerst/unbedingt braucht; Voraussetzung für den Rest. Auch wenn gedanklich anspruchsvoll.
- **mittel**: braucht Erklärung — mehrere Teile, Zusammenhang, typische Anwendung. - **fortgeschritten**: baut darauf auf; gängige Varianten, typische Anwendung.
- **schwer**: komplexes Zusammenspiel mehrerer Konzepte, tieferes Verständnis nötig. NICHT „schwer" nur weil selten/neu/Edge-Case. - **experte**: Feinheiten, Sonderfälle, Nischen, seltene Details — auch wenn inhaltlich einfach (z. B. ein obskures Flag, ein Versions-Detail).
Regeln: Regeln:
- Wäge die Stimmen ab, entscheide fachlich nach Lernaufwand. Im Zweifel die niedrigere Stufe. - Ein einfaches Nischen-Detail ist `experte`; ein fundamentales komplexes Konzept ist `anfaenger`.
- Wäge die Stimmen ab, entscheide fachlich nach Lernpfad-Position.
- Für JEDE strittige Nummer genau eine Stufe. - Für JEDE strittige Nummer genau eine Stufe.
Schreibe NUR die JSON-Datei nach: {out_path} Schreibe NUR die JSON-Datei nach: {out_path}
Format (kein weiterer Text): Format (kein weiterer Text):
{{"stufen": {{"1": "einfach", "4": "schwer"}}}} {{"stufen": {{"1": "anfaenger", "4": "experte"}}}}
{extra} {extra}

View File

@@ -1,27 +1,25 @@
Stufe jeden Subbaustein des Themas "{topic}" nach **Lern-/Verständnisaufwand** ein: einfach, mittel oder schwer. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „einfach", Fortgeschrittene alles). Ordne jeden Subbaustein des Themas "{topic}" seiner **Lernpfad-Position** zu: anfaenger, fortgeschritten oder experte. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „anfaenger", Fortgeschrittene mehr).
SUBBAUSTEINE (nach Baustein gruppiert): SUBBAUSTEINE (nach Baustein gruppiert):
{subbausteine} {subbausteine}
Was die Stufe misst — wie viel Aufwand, den Punkt zu VERSTEHEN und ANZUWENDEN: ENTSCHEIDEND — die Stufe misst, WANN man den Punkt im Lernpfad braucht, NICHT wie schwer er zu verstehen ist:
- **einfach**: Basis-Fakt oder Definition. Sofort verständlich. Der Einstieg in den Baustein. - **anfaenger**: Fundament. Was man als Erstes/unbedingt braucht, um den Baustein überhaupt zu verstehen. Voraussetzung für den Rest. Auch wenn das Konzept gedanklich anspruchsvoll ist — ist es das Fundament, gehört es hierher.
- **mittel**: braucht etwas Erklärung. Mehrere Teile, ein Zusammenhang, eine typische Anwendung. - **fortgeschritten**: baut auf dem Fundament auf. Gängige Varianten, typische Anwendung, der nächste Schritt.
- **schwer**: komplexes Zusammenspiel mehrerer Konzepte. Setzt tieferes Verständnis voraus, ist gedanklich anspruchsvoll. - **experte**: Feinheiten, Sonderfälle, Edge-Cases, Nischen, seltene Details. Was man erst braucht, wenn man die Basis beherrscht — **auch wenn es inhaltlich einfach ist** (z. B. ein obskures Flag, ein Versions-Detail).
WICHTIG — was NICHT die Stufe bestimmt: WICHTIG — Schwierigkeit ist NICHT das Kriterium:
- Seltenheit ist KEINE Schwierigkeit. Ein Nischen-Feature kann trotzdem einfach sein. - Ein **einfaches Nischen-Detail** (selten gebraucht, z. B. „seit 6.4.12.0") ist `experte`, nicht `anfaenger`.
- Versions-Details (z.B. „seit 6.4.12.0"), Konstanten, Pfade, einzelne Fakten → meist **einfach**, nicht schwer. - Ein **fundamentales, aber komplexes Konzept** (man braucht es früh) ist `anfaenger`, nicht `experte`.
- Edge-Cases/Gotchas, die nur eine simple Regel sind (z.B. „Name muss dem Ordner entsprechen") → **einfach/mittel**, nicht schwer. - Frag: „Wann im Lernpfad braucht man das?" — nicht „Wie schwer ist es?".
- „schwer" NUR, wenn der Punkt selbst gedanklich schwer ist — nicht weil er obskur oder neu ist.
Regeln: Regeln:
- Stufe RELATIV im jeweiligen Baustein: vergleiche die Punkte eines Bausteins miteinander. - Stufe RELATIV im jeweiligen Baustein: was ist hier Fundament, was Aufbau, was Feinheit?
- Die meisten Punkte sind einfach/mittel. „schwer" nur für die echten Brocken. - Nicht jeder Baustein braucht einen `anfaenger`-Punkt. Manche Themen setzen Vorwissen voraus und sind ganz `fortgeschritten`/`experte` — das ist gewollt. Stufe ehrlich nach Lernpfad-Position, ohne künstlichen Einstieg zu erfinden.
- Jeder Baustein hat mindestens einen „einfach"-Punkt (den Einstieg). Im Zweifel die niedrigere Stufe.
- Urteile nur — erfinde nichts dazu, ändere keine Subbausteine. - Urteile nur — erfinde nichts dazu, ändere keine Subbausteine.
Schreibe NUR die JSON-Datei nach: {out_path} Schreibe NUR die JSON-Datei nach: {out_path}
Format (für JEDE Nummer genau eine Stufe; kein weiterer Text): Format (für JEDE Nummer genau eine Stufe; kein weiterer Text):
{{"stufen": {{"1": "einfach", "2": "mittel", "3": "schwer"}}}} {{"stufen": {{"1": "anfaenger", "2": "fortgeschritten", "3": "experte"}}}}
{extra} {extra}