diff --git a/Makefile b/Makefile index df08abf..e92683b 100644 --- a/Makefile +++ b/Makefile @@ -18,10 +18,10 @@ stop: @echo "gestoppt." test: - cd backend && python -m pytest ../tests -x -q + cd backend && python3 -m pytest ../tests -x -q test-e2e: - cd backend && CREATOR_FAKE_AGENTS=1 python -m pytest ../tests/test_e2e.py -x -q + cd backend && CREATOR_FAKE_AGENTS=1 python3 -m pytest ../tests/test_e2e.py -x -q build: cd frontend && npm run build diff --git a/backend/agents.py b/backend/agents.py index 7660fbc..7bf9acd 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -82,11 +82,12 @@ async def _api_slot(key: str) -> None: class AgentErgebnis: - __slots__ = ("rc", "text", "err", "tokens", "wait_s") + __slots__ = ("rc", "text", "err", "tokens", "wait_s", "model") def __init__(self, rc: int, text: str, err: str, tokens: dict | None = None): self.rc, self.text, self.err, self.tokens = rc, text, err, tokens or {} self.wait_s = 0.0 # Queue-Zeit (Semaphore/Drossel) — run_agent füllt sie + self.model = "" # aufgelöstes Modell — run_agent füllt es (Ledger) @property def ok(self) -> bool: @@ -161,12 +162,13 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str, await _api_slot(key) wait_s = time.time() - warte_start try: - res = await _text_api(key, prompt, timeout, model) + res = await _text_api(key, prompt, timeout, model, role) finally: _api_inflight -= 1 if res.rc == 0: _erfolg_melden() res.wait_s = wait_s + res.model = model return res await _drossel_warten(key) wait_s = time.time() - warte_start @@ -175,6 +177,7 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str, else: res = await _claude_cli(key, prompt, timeout, model, capabilities) res.wait_s = wait_s + res.model = model return res finally: _active.pop(key, None) @@ -335,12 +338,20 @@ _API_MODEL_OPTS = { # Antwort nach bis zu 44 min — Extraktion war 53 von 63 min der Ebene). "minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3, "thinking": {"type": "disabled"}}, + # native Route (judge/guide): ungedrosselt liefen Merge-Judges ins Output-Cap + # (58–71 % Parse-Fehler). role=guide (Writer) behält Thinking — _text_api + # nimmt die thinking-Option dort wieder raus. + "minimax/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}}, } -async def _text_api(key: str, prompt: str, timeout: int, model: str) -> AgentErgebnis: +async def _text_api(key: str, prompt: str, timeout: int, model: str, + role: str = "judge") -> AgentErgebnis: + opts = dict(_API_MODEL_OPTS.get(model, {})) + if role == "guide": + opts.pop("thinking", None) # Writer braucht Denkraum — nur Judges kalt body = {"model": model.split("/", 1)[1], "max_tokens": _API_MAX_TOKENS, - "messages": [{"role": "user", "content": prompt}], **_API_MODEL_OPTS.get(model, {})} + "messages": [{"role": "user", "content": prompt}], **opts} headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": "2023-06-01"} try: async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client: diff --git a/backend/config.py b/backend/config.py index 6fd7f12..ea92bd9 100644 --- a/backend/config.py +++ b/backend/config.py @@ -74,6 +74,13 @@ ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestück VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert # ── Ebene 3: Struktur ───────────────────────────────────────────────────────── +LEVEL_RANG = {"E": 0, "M": 1, "S": 2} # Durchgänge: Grundgerüst / Standard / Feinheiten +DURCHGANG = { # Leser-Kontext je Durchgang (Templates: {durchgang}) + "E": "Grundgerüst, erster Durchgang — der Leser kennt noch nichts," + " alles voraussetzungsfrei erklären", + "M": "Standardstoff, zweiter Durchgang — der Leser kennt das Grundgerüst", + "S": "Feinheiten, dritter Durchgang — der Leser kennt Grundgerüst und Standardstoff", +} BAUSTEIN_MIN_ATOME = 4 BAUSTEIN_MAX_ATOME = 8 ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call diff --git a/backend/db.py b/backend/db.py index 964d0c2..77c21f2 100644 --- a/backend/db.py +++ b/backend/db.py @@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS quellen( CREATE TABLE IF NOT EXISTS soll( id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]', - intro TEXT NOT NULL DEFAULT ''); + intro TEXT NOT NULL DEFAULT '', geprueft TEXT NOT NULL DEFAULT '[]'); CREATE TABLE IF NOT EXISTS atome( id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '', definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M', @@ -90,11 +90,12 @@ CREATE TABLE IF NOT EXISTS lernziele( titel TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'neu'); CREATE TABLE IF NOT EXISTS bausteine( id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL, - ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER); + ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER, + level TEXT NOT NULL DEFAULT 'M'); CREATE TABLE IF NOT EXISTS kapitel( id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0, - art TEXT NOT NULL DEFAULT 'judge'); + art TEXT NOT NULL DEFAULT 'judge', level TEXT NOT NULL DEFAULT 'M'); CREATE TABLE IF NOT EXISTS sections( baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer', text_kompakt TEXT DEFAULT '', text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]', @@ -122,7 +123,10 @@ def _init_schema(con: sqlite3.Connection) -> None: "ALTER TABLE topics ADD COLUMN auto TEXT NOT NULL DEFAULT '{}'", "ALTER TABLE topics ADD COLUMN resets TEXT NOT NULL DEFAULT '{}'", "ALTER TABLE bausteine ADD COLUMN kapitel_id INTEGER", - "ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'"): + "ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'", + "ALTER TABLE bausteine ADD COLUMN level TEXT NOT NULL DEFAULT 'M'", + "ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'", + "ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'"): try: con.execute(zusatz) except sqlite3.OperationalError: diff --git a/backend/fake_agents.py b/backend/fake_agents.py index c5f84ca..0b5765f 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -75,10 +75,13 @@ def _soll_konsens(prompt: str): def _soll_beleg(prompt: str): text = _text_nach(prompt, "QUELLTEXT:") - for _, saetze in PUNKTE: - if saetze[0] in text and saetze[0] in prompt: - return {"zitat": saetze[0]} - return {"zitat": ""} + zeilen = re.findall(r"^(\d+): (.+)$", _text_nach(prompt, "SOLL-PUNKTE:"), re.MULTILINE) + out = [] + for i, punkt in zeilen: + for p, saetze in PUNKTE: + if p == punkt.strip() and saetze[0] in text: + out.append({"soll": int(i), "zitat": saetze[0]}) + return out def _extraktion(prompt: str): @@ -116,14 +119,6 @@ def _soll_stich(prompt: str): return {"soll": next(iter(punkte.values()), "fremd")} -def _anker_fix(prompt: str): - text = _text_nach(prompt, "QUELLTEXT:") - for a in ATOME: - if a["titel"] in prompt and a["zitat"] in text: - return {"zitat": a["zitat"]} - return {"zitat": ""} - - def _anker_fix_batch(prompt: str): text = _text_nach(prompt, "QUELLTEXT:") zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE) @@ -176,9 +171,16 @@ def _kapitel_intro(prompt: str): def _level_kalibrierung(prompt: str): - ids = _ids(_text_nach(prompt, "ATOME:")) - # deterministisch: erstes Atom wird Grundgerüst, Rest Standardstoff - return [{"atom": i, "level": "E" if n == 0 else "M"} for n, i in enumerate(ids)] + # deklariertes Level der Fake-Welt zurückgeben (Titel-Match) — der E2E-Pfad + # erzeugt so echte E/M/S-Durchgänge; Unbekanntes wird Standardstoff + zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE) + je_titel = {a["titel"]: a["level"] for a in ATOME} + return [{"atom": int(i), "level": je_titel.get(t.strip(), "M")} for i, t in zeilen] + + +def _titel_fix(prompt: str): + zeilen = re.findall(r"^(\d+): ", _text_nach(prompt, "ATOME:"), re.MULTILINE) + return [{"atom": int(i), "titel": f"Konzept {i}"} for i in zeilen] def _kapitel_schnitt(prompt: str): @@ -196,7 +198,7 @@ def _zyklus(prompt: str): def _writer(prompt: str): ziel = re.search(r"Lernziel: (.+)", prompt) - marker = re.findall(r"MARKER \(exakt so übernehmen\): ()", + marker = re.findall(r"MARKER \(exakt so übernehmen\): ()", prompt) defs = re.findall(r"Definition: (.+)", prompt) fragen = re.findall(r"^- (.+\?)$", prompt, re.MULTILINE)[:2] @@ -240,8 +242,9 @@ _HANDLER = { "Atom-Extraktion": _extraktion, "Atom-Extraktion-Aufgaben": _extraktion, "Atom-Merge": _merge, "Atom-Soll-Zuordnung": _soll_zuordnung, "Atom-Soll-Stich": _soll_stich, - "Atom-Anker-Fix": _anker_fix, "Atom-Anker-Fix-Batch": _anker_fix_batch, - "Atom-Luecke": _luecke, "Soll-Abgedeckt": lambda p: {"abgedeckt": True}, + "Atom-Anker-Fix-Batch": _anker_fix_batch, + "Atom-Luecke": _luecke, "Atom-Titel-Fix": _titel_fix, + "Soll-Abgedeckt": lambda p: {"abgedeckt": True}, "Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify, "Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro, "Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung, diff --git a/backend/guide.py b/backend/guide.py index 05c2fc2..0e7aa63 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -17,7 +17,7 @@ from pathlib import Path import db import llm import textkit -from config import SECTION_WOERTER_PRO_ATOM +from config import DURCHGANG, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM log = logging.getLogger("creator2.guide") @@ -50,10 +50,11 @@ def _katex_fehler(formeln: list[dict]) -> list[str]: EBENE = "guide" _MARKER = re.compile(r"") # ganze Marker-Zeile (für Text-Checks) # Kritische Befund-Arten routen zurück auf den Prüfer, Stil-Arten nur in den Fix # (vorwaerts ist Stil: ein Halbsatz-Fix, kein Fakten-Problem) -KRITISCH = ("marker_fehlend", "ziel_ohne_anker", "fachlich_falsch") +KRITISCH = ("marker_fehlend", "marker_fremd", "ziel_ohne_anker", "fachlich_falsch") def _bausteine(topic: str) -> list[dict]: @@ -86,7 +87,7 @@ def _fragen_pool(atom_id: int, max_n: int = 2) -> list[str]: def _atom_paket(a: dict) -> str: zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)" beispiel = _beispiel(a["id"]) - teil = (f"MARKER (exakt so übernehmen): \n" + teil = (f"MARKER (exakt so übernehmen): \n" f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}") if beispiel: teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}" @@ -101,16 +102,40 @@ def _laenge_band(n_atome: int) -> tuple[int, int]: return lo * n_atome, hi * n_atome +def _anknuepf_kontext(b: dict) -> str: + """Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome + desselben Ziels plus direkte braucht-Voraussetzungen. Der Writer knüpft an, + statt neu zu erklären (Dopplungs-Schutz zwischen den Durchgängen).""" + rang = LEVEL_RANG.get(b["level"], 1) + atome = _atome_von(b["id"]) + ids = {a["id"] for a in atome} + kontext: dict[int, dict] = {} + for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN" + " ('gemerged','verworfen')", (b["ziel_id"],)): + if LEVEL_RANG.get(a["level"], 1) < rang: + kontext[a["id"]] = a + if ids: + marks = ",".join("?" * len(ids)) + for a in db.query(f"SELECT a.* FROM kanten k JOIN atome a ON a.id=k.zu_atom" + f" WHERE k.art='braucht' AND k.status='aktiv'" + f" AND k.von_atom IN ({marks})", tuple(ids)): + if a["id"] not in ids and LEVEL_RANG.get(a["level"], 1) < rang: + kontext[a["id"]] = a + return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values()) + + # ── Stages ──────────────────────────────────────────────────────────────────── async def _stage_writer(ctx: llm.Kontext, b: dict) -> str: atome = _atome_von(b["id"]) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]} lo, hi = _laenge_band(len(atome)) - res = await llm.call(ctx, stage="writer", template="Guide-Writer", - werte={"titel": b["titel"], "ziel": ziel["text"], - "atome": "\n\n".join(_atom_paket(a) for a in atome), - "min_woerter": lo, "max_woerter": hi}, + werte = {"titel": b["titel"], "ziel": ziel["text"], + "atome": "\n\n".join(_atom_paket(a) for a in atome), + "min_woerter": lo, "max_woerter": hi, + "durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]), + "kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)"} + res = await llm.call(ctx, stage="writer", template="Guide-Writer", werte=werte, role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict) if res is None: return "writer" # Stage bleibt, nächster Lauf versucht erneut @@ -119,11 +144,7 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str: if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer", schritt="writer", role="guide", n=len(atome), - item=f"b{b['id']}-2", - werte={"titel": b["titel"], "ziel": ziel["text"], - "atome": "\n\n".join(_atom_paket(a) for a in atome), - "min_woerter": lo, "max_woerter": hi}, - erwartet=dict) + item=f"b{b['id']}-2", werte=werte, erwartet=dict) if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome): kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", "")) db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang) @@ -135,6 +156,46 @@ def _marker_fehlend(text: str, atome: list[dict]) -> list[int]: return [a["id"] for a in atome if a["id"] not in da] +def _marker_fremd_ids(text: str, atome: list[dict]) -> list[int]: + """Marker fremder Atome (z. B. aus dem Anknüpf-Kontext übernommen).""" + eigene = {a["id"] for a in atome} + return sorted({int(m) for m in _MARKER.findall(text)} - eigene) + + +_FLOSKEL = re.compile(r"später (mehr|noch (ausführlicher|genauer|vertieft))" + r"|späteren (Kapitel|Abschnitt|Durchgang)", re.IGNORECASE) +_LEAK = re.compile(r"\b(Satz|Lemma|Korollar|Bemerkung|Transformation|Definition)" + r"\s+[IVX]*\d+\.\d+|\bZeigen Sie\b") +_EN_STOP = frozenset("the and of is that this are with for as by be from which were" + " has have not can will or an it its when each such".split()) + + +def _stil_auftraege(text: str, wo: str) -> list[str]: + """Deterministische Lesbarkeits-Checks je Fassung: Vertröstungs-Floskeln + (der alte Vorwärts-Fix klebte „dazu später mehr“ ein statt zu erklären), + Quellen-Referenzen (Satz 6.25 — der Leser hat kein nummeriertes Skript) + und unübersetzte englische Passagen (Lernen: 13 Passagen aus EN-Quellen). + Marker-Zeilen sind Schnittstelle, kein Text — vorher strippen, sonst + erzeugen Atom-TITEL wie „Satz 6.26“ unfixierbare Aufträge (Token-Loop).""" + text = _MARKER_VOLL.sub("", text) + auftraege = [] + for m in sorted({m.group(0) for m in _FLOSKEL.finditer(text)}): + auftraege.append(f"{wo}: Vertröstungs-Floskel („{m}“) streichen — den Begriff" + f" stattdessen kurz mit Bekanntem einordnen oder die Stelle" + f" entfernen.") + for m in sorted({m.group(0) for m in _LEAK.finditer(text)}): + auftraege.append(f"{wo}: Quellen-Referenz „{m}“ — der Leser kennt kein" + f" nummeriertes Skript und keine Aufgabenstellung; Inhalt" + f" eigenständig formulieren.") + for absatz in text.split("\n\n"): + toks = re.findall(r"[a-zA-Z']+", absatz.lower()) + if len(toks) >= 12 and sum(t in _EN_STOP for t in toks) / len(toks) > 0.15: + kurz = " ".join(absatz.split())[:60] + auftraege.append(f"{wo}: englische Passage („{kurz}…“) ins Deutsche" + f" übersetzen — etablierte Fachtermini dürfen bleiben.") + return auftraege + + def _mathe_auftraege(text: str, wo: str) -> list[str]: """Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle, Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6 @@ -182,21 +243,30 @@ def _mathe_auftraege(text: str, wo: str) -> list[str]: return auftraege -def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[str]: - """Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise.""" +def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "", + qa: bool = False) -> list[str]: + """Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise, + Lesbarkeit. qa=True (Ebenen-QA): Marker/Länge/Vorwärts haben dort eigene + Befund-Arten und ein weiteres Band (Lektion 75) — nur die Stil-Checks.""" atome = _atome_von(b["id"]) - auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen." - for i in _marker_fehlend(lang, atome)] - lo, hi = _laenge_band(len(atome)) - woerter = len(lang.split()) - if woerter > hi: - auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).") - elif woerter < lo: - auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).") - auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel): beim" - f" ersten Auftreten mit einem Halbsatz einordnen („… dazu später mehr“)" - f" oder entfernen — nie unerklärt verwenden." - for t in _vorwaertsverweise(topic, b, lang)] + auftraege = [] + if not qa: + auftraege += [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen." + for i in _marker_fehlend(lang, atome)] + auftraege += [f"KRITISCH: Marker {i} gehört nicht zu diesem Baustein" + f" (Kontext-Atom?) — Marker-Zeile ersatzlos entfernen." + for i in _marker_fremd_ids(lang, atome)] + lo, hi = _laenge_band(len(atome)) + woerter = len(lang.split()) + if woerter > hi: + auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).") + elif woerter < lo: + auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).") + auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel):" + f" beim ersten Auftreten mit bereits eingeführten Begriffen kurz" + f" erklären oder die Verwendung entfernen — nie unerklärt lassen," + f" NICHT vertrösten." + for t in _vorwaertsverweise(topic, b, lang)] zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">")) if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext auftraege.append(f"{zitate} Blockquote-Zeile(n) („>“) im Text: Inhalt in eigenen" @@ -204,21 +274,49 @@ def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[st if "6=" in lang: auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —" " durch $\\neq$ ersetzen.") + for nr, absatz in enumerate(lang.split("\n\n"), 1): + # Marker-Zeilen zählen nicht — sie stehen im selben Block wie der Absatz + zeilen = [z for z in absatz.splitlines() + if z.strip() and not z.strip().startswith("/g +// Atom-Marker sind eine unsichtbare Backend-Schnittstelle — vor der Anzeige +// ausblenden. Die Stufen-Trennung passiert seit dem Umbau über getrennte +// Bausteine je Level, nicht mehr über Marker-Filterung im Text. +const MARKER = //g // Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung) export function lesestat(text) { @@ -61,47 +63,3 @@ export function lesestat(text) { const woerter = t.split(/\s+/).filter(Boolean).length return { woerter, displayFormeln } } - -// Text an Atom-Markern in Segmente schneiden; Filter zeigt Atome bis zum gewählten Level. -export function segmente(lang) { - const out = [] - let letzt = 0 - let aktuell = { level: null, titel: '', text: '' } - for (const m of lang.matchAll(MARKER)) { - aktuell.text = lang.slice(letzt, m.index) - out.push(aktuell) - aktuell = { level: m[3], titel: m[2].trim(), text: '' } - letzt = m.index + m[0].length - } - aktuell.text = lang.slice(letzt) - out.push(aktuell) - return out -} - -export function gefiltert(lang, maxLevel) { - if (!maxLevel || maxLevel === 'S') return lang - return segmente(lang) - .filter((s) => s.level === null || RANG[s.level] <= RANG[maxLevel]) - .map((s) => s.text) - .join('\n') -} - -// Gestuftes Rendern: Segmente über dem Level fallen weg, Segmente DARUNTER -// werden gedimmt (schon gelernt) — der Blick geht auf die neuen Inhalte. -// Kernpunkte/Prüfe-dich (nach dem letzten Atom) bleiben immer ungedimmt. -export function renderGestuft(lang, maxLevel) { - if (!maxLevel || maxLevel === 'E') return render(gefiltert(lang, maxLevel)) - const seg = segmente(lang) - const letzt = seg[seg.length - 1] - const i = letzt.text.indexOf('**Kernpunkte:**') - if (i >= 0) { - seg.push({ level: null, titel: '', text: letzt.text.slice(i) }) - letzt.text = letzt.text.slice(0, i) - } - return seg.map((s) => { - if (s.level !== null && RANG[s.level] > RANG[maxLevel]) return '' - const html = render(s.text) - const gelernt = s.level !== null && RANG[s.level] < RANG[maxLevel] - return gelernt ? `