From dce9156ac89e09a03e952ddc7a655973881f28df Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 17 Jun 2026 22:56:24 +0200 Subject: [PATCH] update --- backend/bausteine.py | 389 +++++++++++++++++++- backend/config.py | 4 + backend/guide.py | 58 ++- backend/paths.py | 5 + backend/pipeline.py | 24 ++ backend/textkit.py | 53 ++- frontend/src/App.vue | 7 + frontend/src/components/BausteinFokus.vue | 4 +- frontend/src/components/SubbausteinView.vue | 105 ++++++ frontend/src/components/TopicDetail.vue | 4 +- templates/Prompt/Guide-Inhalt.md | 4 +- templates/Prompt/Guide-Sections-Fix.md | 10 +- templates/Prompt/Guide-Writer.md | 32 +- templates/Prompt/Stufen-Mapping.md | 19 + templates/Prompt/Stufen-Recherche.md | 20 + templates/Prompt/Subbaustein-Mapping.md | 19 + templates/Prompt/Subbaustein-Recherche.md | 28 ++ 17 files changed, 743 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/SubbausteinView.vue create mode 100644 templates/Prompt/Stufen-Mapping.md create mode 100644 templates/Prompt/Stufen-Recherche.md create mode 100644 templates/Prompt/Subbaustein-Mapping.md create mode 100644 templates/Prompt/Subbaustein-Recherche.md diff --git a/backend/bausteine.py b/backend/bausteine.py index d70809e..f7e05fb 100644 --- a/backend/bausteine.py +++ b/backend/bausteine.py @@ -12,20 +12,31 @@ die Gesamtliste. import asyncio import logging +import math import shutil import subprocess from pathlib import Path from agents import kill_process from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER -from fsutil import atomic_write_text +from fsutil import atomic_write_text, atomic_write_json from jsonio import read_json_file as _json_datei -from paths import arbeit_dir, bausteine_path, project_dir +from paths import arbeit_dir, bausteine_path, project_dir, subbausteine_path from pipeline import ( CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _rest_schema, - _runde_schema, _semaphore, _str_liste, _timeout, run_single_slot, + _runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot, ) -from textkit import _eindeutige_titel, _parse_auswahl, _titel_aufloesen, _titel_index, _vormerge +from textkit import ( + _eindeutige_titel, _norm_titel, _parse_auswahl, _parse_subbausteine, _titel, + _titel_aufloesen, _titel_index, _vormerge, +) + +# Subbausteine + Stufen entstehen pro Baustein → wie die Writer chunken: +# 1 Agent je ~30 Bausteine, gedeckelt. +SUBBAUSTEIN_CHUNK = 30 +SUBBAUSTEIN_MAX = 20 +# Einstufen ist billig (kurzes Urteil, keine Websuche) → größere Pakete, weniger Dateien/Agenten. +STUFE_CHUNK = 100 log = logging.getLogger("creator.bausteine") @@ -34,14 +45,31 @@ _bausteine_errors: dict[str, str] = {} _bausteine_cancelled: set[str] = set() _bausteine_step: dict[str, int] = {} -BAUSTEINE_STEPS = ("Recherche", "Konsolidierung", "Klärung") +BAUSTEINE_STEPS = ( + "Recherche", "Konsolidierung", "Klärung", + "Subbausteine finden", "Subbausteine wählen", "Subbausteine klären", + "Stufen finden", "Stufen wählen", "Stufen klären", +) def _bausteine_steps(topic: str) -> tuple: - """Projekte haben einen zusätzlichen Schritt: Themenfeld-Ergänzung per Web-Recherche.""" + """Projekte haben einen zusätzlichen Schritt (Ergänzung), nach der Klärung eingefügt. + + Subbausteine + Stufen sind je drei Phasen (Finden, Wählen, Klären). Pro Phase + laufen alle Pakete parallel; der Schritt bleibt, bis das letzte Paket fertig ist. + """ + base = ("Recherche", "Konsolidierung", "Klärung") + rest = ( + "Subbausteine finden", "Subbausteine wählen", "Subbausteine klären", + "Stufen finden", "Stufen wählen", "Stufen klären", + ) if project_dir(topic).is_dir(): - return BAUSTEINE_STEPS + ("Ergänzung",) - return BAUSTEINE_STEPS + return base + ("Ergänzung",) + rest + return base + rest + + +def _step_idx(topic: str, name: str) -> int: + return _bausteine_steps(topic).index(name) def _bausteine_files(topic: str) -> dict: @@ -55,14 +83,20 @@ def _bausteine_files(topic: str) -> dict: "auswahl": {n: [arbeit / f"auswahl-r{n}-{i}.json" for i in (1, 2, 3)] for n in runden}, "mapping": {n: arbeit / f"auswahl-mapping-r{n}.json" for n in runden}, "ergaenzung": arbeit / "ergaenzung.json", + "sub_roh": arbeit / "subbausteine-roh.json", + "sidecar": subbausteine_path(topic), } def _alle_slot_dateien(files: dict) -> list[Path]: + arbeit = files["arbeit"] + # Subbaustein-/Stufen-Slots sind pro Chunk dynamisch — per Glob einsammeln. + dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("stufe-*"))) if arbeit.is_dir() else [] return [ *files["recherche"], files["recherche_mapping"], *(p for slots in files["auswahl"].values() for p in slots), *files["mapping"].values(), files["ergaenzung"], + files["sub_roh"], files["sidecar"], *dyn, ] @@ -89,8 +123,12 @@ def _resume_step(topic: str) -> int: if not geklaert: return 2 if project_dir(topic).is_dir() and not files["ergaenzung"].exists(): - return 3 - return len(_bausteine_steps(topic)) + return _step_idx(topic, "Ergänzung") + if _sidecar_schema(_json_datei(files["sidecar"])) is not None: + return len(_bausteine_steps(topic)) + if _sub_roh_schema(_json_datei(files["sub_roh"])) is None: + return _step_idx(topic, "Subbausteine finden") + return _step_idx(topic, "Stufen finden") def bausteine_status(topic: str) -> dict: @@ -127,6 +165,7 @@ def active_bausteine() -> list[dict]: def reset_bausteine(topic: str) -> None: files = _bausteine_files(topic) files["final"].unlink(missing_ok=True) + files["sidecar"].unlink(missing_ok=True) # liegt im Themen-Root, nicht in arbeit/ shutil.rmtree(files["arbeit"], ignore_errors=True) _bausteine_errors.pop(topic, None) @@ -200,6 +239,307 @@ def _mapping_schema(data): return bausteine, rest +def _sub_roh_schema(data): + """{Baustein-Titel: [Subbaustein, …]} → dict · sonst None (Zwischenstand Block B).""" + if not isinstance(data, dict) or not data: + return None + out: dict[str, list[str]] = {} + for k, v in data.items(): + subs = _str_liste(v) if isinstance(v, list) else None + if not isinstance(k, str) or not k.strip() or not subs: + return None + out[k] = subs + return out + + +def _sidecar_schema(data): + """{Baustein-Titel: [{titel, stufe}, …]} → dict · sonst None (finale Sidecar).""" + if not isinstance(data, dict) or not data: + return None + for v in data.values(): + if not isinstance(v, list) or not v: + return None + 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"): + return None + return data + + +def _read(p: Path) -> str: + return p.read_text(encoding="utf-8") if p.exists() else "" + + +def _chunk_nums(items: list, n: int) -> list[list]: + """Teilt eine flache Liste in n möglichst gleich große Chunks.""" + n = max(1, n) + size = max(1, math.ceil(len(items) / n)) + return [items[i:i + size] for i in range(0, len(items), size)] + + +def _n_chunks(count: int, size: int = SUBBAUSTEIN_CHUNK) -> int: + return min(SUBBAUSTEIN_MAX, max(1, math.ceil(count / size))) + + +def _merge_finder(num: int, idx: dict, finder: list[dict]) -> tuple[list[str], list[str]]: + """Subbausteine eines Bausteins über die Finder mergen → (konsens ≥2, rest ==1).""" + counts: dict[str, int] = {} + repr_text: dict[str, str] = {} + order: list[str] = [] + for d in finder: + # die Marker-Liste dieses Finders für genau diesen Baustein + subs = next((s for marker, s in d.items() if _titel_aufloesen(idx, marker) == num), []) + gesehen: set[str] = set() + for sub in subs: + key = _norm_titel(sub) + if not key or key in gesehen: + continue + gesehen.add(key) + if key not in counts: + counts[key], repr_text[key] = 0, sub + order.append(key) + counts[key] += 1 + konsens = [repr_text[k] for k in order if counts[k] >= 2] + rest = [repr_text[k] for k in order if counts[k] == 1] + return konsens, rest + + +def _final_text(chunk: list[int], entries: dict, daten: dict) -> str: + """Marker-Datei aus reinem Konsens (wenn kein Judge nötig).""" + teile = [] + for num in chunk: + konsens = daten[num][0] + if konsens: + teile.append(f"\n" + "\n".join(f"- {s}" for s in konsens)) + return "\n".join(teile) + "\n" + + +def _judge_block(chunk: list[int], entries: dict, daten: dict) -> str: + """Eingabe für den Subbaustein-Judge: pro Baustein Konsens + Strittiges.""" + lines = [] + for num in chunk: + konsens, rest = daten[num] + lines.append(f"BAUSTEIN: {_titel(entries[num])}") + lines.append("Konsens:") + lines.extend(f"- {s}" for s in konsens) + if not konsens: + lines.append("- (noch keiner)") + if rest: + lines.append("Strittig:") + lines.extend(f"- {s}" for s in rest) + lines.append("") + return "\n".join(lines) + + +async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str) -> dict | None: + """Block B: drei Phasen mit Barriere — Finden, Wählen (Code-Merge), Klären. + Pro Phase laufen alle Pakete parallel; der Schritt bleibt, bis das letzte fertig ist. + → {Baustein-Titel: [Subbaustein, …]} oder None bei Abbruch/Fehler.""" + topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled + arbeit = files["arbeit"] + idx = _titel_index(entries) + caps = "files" if project_dir(topic).is_dir() else "full" + nums = list(entries) + chunks = _chunk_nums(nums, _n_chunks(len(nums))) + n = len(chunks) + + def finder_paths(c): + return [arbeit / f"subbaustein-c{c}-{i}.md" for i in (1, 2, 3)] + + def final_path(c): + return arbeit / f"subbaustein-final-c{c}.md" + + # Phase „Subbausteine finden": pro Paket 3 Finder (min. 2), alle Pakete parallel. + async def _finde(c, chunk): + paths = finder_paths(c) + vorhanden = sum(1 for p in paths if _parse_subbausteine(_read(p))) + if vorhanden >= 2: + return True + zuteilung = "\n".join(f"- {entries[num]}" for num in chunk) + offen = [(i, p) for i, p in enumerate(paths, 1) if not _parse_subbausteine(_read(p))] + slots = [{ + "key": f"bausteine-{topic}-subbaustein-c{c}-{i}", + "prompt": _prompt("Subbaustein-Recherche", topic=topic, zuteilung=zuteilung, out_path=p, extra=_extra(instructions)), + "role": "quick", "capabilities": caps, + "payload": (lambda result, p=p: _parse_subbausteine(_read(p)) or None), + } for i, p in offen] + neu = await _race(topic, f"Subbausteine Paket {c}", slots, 2 - vorhanden, _timeout("subbaustein", len(chunk)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE) + return not is_cancelled() and neu is not None + + set_p(f"Subbausteine finden ({n} Pakete)…", step=_step_idx(topic, "Subbausteine finden")) + oks = await asyncio.gather(*[_finde(c, chunk) for c, chunk in enumerate(chunks, 1)], return_exceptions=True) + if is_cancelled(): + return None + if not all(ok is True for ok in oks): + _bausteine_errors[topic] = "Subbausteine fehlgeschlagen (Recherche)" + return None + + # Phase „Subbausteine wählen": Code-Merge je Paket (instant, kein Agent). + set_p(f"Subbausteine wählen ({n} Pakete)…", step=_step_idx(topic, "Subbausteine wählen")) + daten_by_c = {} + for c, chunk in enumerate(chunks, 1): + finder = [d for p in finder_paths(c) if (d := _parse_subbausteine(_read(p)))] + daten_by_c[c] = {num: _merge_finder(num, idx, finder) for num in chunk} + + # Phase „Subbausteine klären": Judge je Paket mit Strittigem, alle parallel. + async def _klaere(c, chunk): + daten = daten_by_c[c] + fp = final_path(c) + if _parse_subbausteine(_read(fp)): + return + if not any(daten[num][1] for num in chunk): + atomic_write_text(fp, _final_text(chunk, entries, daten)) + return + status, _ = await run_single_slot( + ctx, f"Subbaustein-Klärung {c}", + key=f"bausteine-{topic}-subbaustein-final-c{c}", + prompt=_prompt("Subbaustein-Mapping", topic=topic, bausteine=_judge_block(chunk, entries, daten), out_path=fp, extra=_extra(instructions)), + role="judge", capabilities="files", + payload=lambda result, p=fp: _parse_subbausteine(_read(p)) or None, + timeout=_timeout("subbaustein_check", len(chunk)), + ) + if status == FAILED: + _log(topic, f"Subbaustein-Klärung Paket {c} fehlgeschlagen — nur Konsens übernommen") + + set_p(f"Subbausteine klären ({n} Pakete)…", step=_step_idx(topic, "Subbausteine klären")) + await asyncio.gather(*[_klaere(c, chunk) for c, chunk in enumerate(chunks, 1)], return_exceptions=True) + if is_cancelled(): + return None + + # Fehlende finale Dateien → Konsens-Fallback; dann alle parsen. + roh: dict[str, list[str]] = {} + for c, chunk in enumerate(chunks, 1): + fp = final_path(c) + if not _parse_subbausteine(_read(fp)): + atomic_write_text(fp, _final_text(chunk, entries, daten_by_c[c])) + for marker, subs in (_parse_subbausteine(_read(fp)) or {}).items(): + num = _titel_aufloesen(idx, marker) + if num is not None: + roh[_titel(entries[num])] = subs + if not roh: + _bausteine_errors[topic] = "Keine Subbausteine ermittelt" + return None + return roh + + +async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instructions: str) -> dict | None: + """Block C: drei Phasen mit Barriere — Finden (einstufen), Wählen (Vote), Klären. + Lokale IDs 1..n pro Paket, hinterher auf globale gid gemappt. + → {Baustein-Titel: [{titel, stufe}, …]} oder None.""" + topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled + arbeit = files["arbeit"] + items = [(titel, sub) for titel, subs in roh.items() for sub in subs] # globale id = index+1 + if not items: + return {titel: [] for titel in roh} + chunks = _chunk_nums(list(range(len(items))), _n_chunks(len(items), STUFE_CHUNK)) + n = len(chunks) + + def rater_paths(c): + return [arbeit / f"stufe-c{c}-{i}.json" for i in (1, 2, 3)] + + def lset(item_idxs): + return set(range(1, len(item_idxs) + 1)) + + # Phase „Stufen finden": pro Paket 3 Rater (min. 2), lokale IDs. + async def _rate(c, item_idxs): + local_set = lset(item_idxs) + paths = rater_paths(c) + vorhanden = sum(1 for p in paths if _stufen_schema(_json_datei(p), local_set)) + if vorhanden >= 2: + return True + enum = "\n".join(f"{k}. [{items[j][0]}] {items[j][1]}" for k, j in enumerate(item_idxs, 1)) + offen = [(i, p) for i, p in enumerate(paths, 1) if not _stufen_schema(_json_datei(p), local_set)] + slots = [{ + "key": f"bausteine-{topic}-stufe-c{c}-{i}", + "prompt": _prompt("Stufen-Recherche", topic=topic, subbausteine=enum, out_path=p, extra=_extra(instructions)), + "role": "fast", "capabilities": "files", + "payload": (lambda result, p=p, ids=local_set: _stufen_schema(_json_datei(p), ids)), + } for i, p in offen] + neu = await _race(topic, f"Stufen Paket {c}", slots, 2 - vorhanden, _timeout("stufe", len(item_idxs)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE) + return not is_cancelled() and neu is not None + + set_p(f"Stufen finden ({n} Pakete)…", step=_step_idx(topic, "Stufen finden")) + oks = await asyncio.gather(*[_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], return_exceptions=True) + if is_cancelled(): + return None + if not all(ok is True for ok in oks): + _bausteine_errors[topic] = "Einstufung fehlgeschlagen (Recherche)" + return None + + # Phase „Stufen wählen": Code-Vote je Paket → (ergebnis, strittig). + set_p(f"Stufen wählen ({n} Pakete)…", step=_step_idx(topic, "Stufen wählen")) + vote_by_c = {} + for c, item_idxs in enumerate(chunks, 1): + local_set = lset(item_idxs) + rater = [d for p in rater_paths(c) if (d := _stufen_schema(_json_datei(p), local_set))] + ergebnis: dict[int, str] = {} + strittig: dict[int, list[str]] = {} + for k in range(1, len(item_idxs) + 1): + stimmen = [d[k] for d in rater if k in d] + zaehler: dict[str, int] = {} + for s in stimmen: + zaehler[s] = zaehler.get(s, 0) + 1 + best = max(zaehler.values(), default=0) + gewinner = [s for s, v in zaehler.items() if v == best] + if len(gewinner) == 1 and best >= 2: + ergebnis[k] = gewinner[0] + else: + strittig[k] = stimmen + vote_by_c[c] = (ergebnis, strittig) + + # Phase „Stufen klären": Judge je Paket mit Strittigem, alle parallel. + async def _klaere(c, item_idxs): + ergebnis, strittig = vote_by_c[c] + if strittig: + judge_path = arbeit / f"stufe-final-c{c}.json" + entsch = _stufen_schema(_json_datei(judge_path), set(strittig)) + if entsch is None: + strittig_block = "\n".join( + f"{k}. [{items[item_idxs[k - 1]][0]}] {items[item_idxs[k - 1]][1]} — Stimmen: {', '.join(stimmen) or 'keine'}" + for k, stimmen in strittig.items() + ) + status, entsch = await run_single_slot( + ctx, f"Stufen-Klärung {c}", + key=f"bausteine-{topic}-stufe-final-c{c}", + prompt=_prompt("Stufen-Mapping", topic=topic, strittig=strittig_block, out_path=judge_path, extra=_extra(instructions)), + role="judge", capabilities="files", + payload=lambda result, p=judge_path, ids=set(strittig): _stufen_schema(_json_datei(p), ids), + timeout=_timeout("stufe_check", len(strittig)), + ) + if status == FAILED: + _log(topic, f"Stufen-Klärung Paket {c} fehlgeschlagen — Default 'mittel'") + entsch = entsch if isinstance(entsch, dict) else {} + # Strittige ohne Entscheid → 'mittel'; Vote-Gewinner bleiben; Judge überschreibt. + ergebnis = {**{k: "mittel" for k in strittig}, **ergebnis, **entsch} + return {item_idxs[k - 1] + 1: stufe for k, stufe in ergebnis.items()} + + set_p(f"Stufen klären ({n} Pakete)…", step=_step_idx(topic, "Stufen klären")) + parts = await asyncio.gather(*[_klaere(c, idxs) for c, idxs in enumerate(chunks, 1)], return_exceptions=True) + if is_cancelled(): + return None + stufe_by_id: dict[int, str] = {} + for c, part in enumerate(parts, 1): + if not isinstance(part, dict): + # Klärung ist nicht fatal: Vote-Ergebnis + Default 'mittel' für Strittige. + if isinstance(part, BaseException): + _log(topic, f"Stufen-Klärung Paket {c}: {type(part).__name__}: {part}") + ergebnis, strittig = vote_by_c[c] + item_idxs = chunks[c - 1] + merged = {**{k: "mittel" for k in strittig}, **ergebnis} + part = {item_idxs[k - 1] + 1: s for k, s in merged.items()} + stufe_by_id.update(part) + + # Sidecar zusammensetzen — gleiche Reihenfolge wie items → gid stimmt + sidecar: dict[str, list[dict]] = {} + gid = 0 + for titel, subs in roh.items(): + lst = [] + for sub in subs: + gid += 1 + lst.append({"titel": sub, "stufe": stufe_by_id.get(gid, "mittel")}) + sidecar[titel] = lst + return sidecar + + async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None: if topic in _bausteine_progress: return @@ -228,9 +568,11 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = files["arbeit"].mkdir(parents=True, exist_ok=True) if project: await asyncio.to_thread(_pdfs_konvertieren, project) - # „Neu erstellen": fertige Bausteine → kompletter Frischstart. - # Sonst sind Slot-Dateien Reste eines Abbruchs/Fehlers → Resume. - if final_path.exists(): + # „Neu erstellen": NUR wenn wirklich alles fertig ist (bausteine.md UND + # Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor, + # ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen. + fertig = final_path.exists() and _sidecar_schema(_json_datei(files["sidecar"])) is not None + if fertig: for p_alt in _alle_slot_dateien(files): p_alt.unlink(missing_ok=True) @@ -425,6 +767,27 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = # Titel eindeutig machen und unsortiertes Inventar schreiben entries = _eindeutige_titel(entries) atomic_write_text(final_path, "\n".join(f"{i}. {t}" for i, t in entries.items()) + "\n") + + # Block B + C: Subbausteine je Baustein + Stufen → Sidecar subbausteine.json. + # Nicht-destruktiv: bausteine.md steht schon; fehlt die Sidecar, wird beim + # nächsten Lauf nur dieser Teil neu versucht. Guide fällt ohne Sidecar zurück. + if _sidecar_schema(_json_datei(files["sidecar"])) is None: + roh = _sub_roh_schema(_json_datei(files["sub_roh"])) + if roh is None: + roh = await _subbausteine_block(ctx, set_p, files, entries, instructions) + if is_cancelled(): + abgebrochen() + return + if roh is None: + return # Fehler ist gesetzt + atomic_write_json(files["sub_roh"], roh, indent=1) + sidecar = await _stufen_block(ctx, set_p, files, roh, instructions) + if is_cancelled(): + abgebrochen() + return + if sidecar is None: + return + atomic_write_json(files["sidecar"], sidecar, indent=1) except Exception as e: log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic) _bausteine_errors[topic] = str(e)[:2000] diff --git a/backend/config.py b/backend/config.py index f09be5e..30d6813 100644 --- a/backend/config.py +++ b/backend/config.py @@ -38,6 +38,10 @@ TIMEOUTS = { "plan_judge": (600, 5), # Judge liest bis zu 5 Gliederungen, n = Sections "inhalt": (600, 90), # Inhalte je Baustein im Chunk identifizieren (Websuche) "inhalt_check": (300, 10), # Inhalts-Prüfung je Baustein im Paket + "subbaustein": (900, 45), # Subbausteine je Baustein im Chunk finden (Websuche) + "subbaustein_check": (300, 15), # Judge entscheidet strittige Subbausteine im Chunk + "stufe": (300, 10), # Subbausteine einstufen je Chunk + "stufe_check": (300, 10), # Judge entscheidet strittige Stufen im Chunk "writer": (600, 120), # pro Section im Chunk "lese_check": (300, 10), # pro Section im Paket "onepager_recherche": (900, 0), diff --git a/backend/guide.py b/backend/guide.py index f238d74..6595834 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -24,7 +24,7 @@ from database import list_guides, update_guide from fsutil import atomic_write_json from jsonio import read_json_file as _json_datei from onepager import _generate_onepager -from paths import bausteine_path, guide_content_path, project_dir +from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path from pipeline import ( CANCELLED, FAILED, GenContext, _claude_error, _extra, _fail, _gather_error, _log, _prompt, _race, _rest_schema, _runde_schema, @@ -47,6 +47,41 @@ WRITER_SECTIONS = 30 WRITER_MAX = 20 +def _load_subbausteine(topic: str) -> dict[str, list[dict]]: + """Sidecar laden: {Baustein-Titel: [{titel, stufe}, …]}. Fehlt sie → {} (Fallback).""" + data = _json_datei(subbausteine_path(topic)) + if not isinstance(data, dict): + return {} + out: dict[str, list[dict]] = {} + for titel, subs in data.items(): + if not isinstance(subs, list): + continue + gut = [s for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip() + and s.get("stufe") in ("einfach", "mittel", "schwer")] + if gut: + out[titel] = gut + return out + + +def _section_markup(sec: dict) -> str: + """Section-Text mit Sub-Markern (für Lese-Check/Fix), damit die Struktur erhalten bleibt.""" + if not sec.get("subs"): + return sec["md"] + return "\n\n".join(f"\n{s['md']}" for s in sec["subs"]) + + +def _zuteilung_subs(chunk: list[dict], entries: dict[int, str], subs_by_titel: dict[str, list[dict]]) -> str: + """Wie _zuteilung_text, aber listet pro Baustein seine Subbausteine mit Stufe.""" + lines: list[str] = [] + for ch in chunk: + lines.append(f"KAPITEL: {ch['title']}") + for num in ch["nums"]: + lines.append(f"- {entries[num]}") + for s in subs_by_titel.get(_titel(entries[num]), []): + lines.append(f" [{s['stufe']}] {s['titel']}") + return "\n".join(lines) + + def _guide_files(content_path: Path) -> dict: d, stem = content_path.parent, content_path.stem runden = range(1, KONSENS_MAX_RUNDEN + 1) @@ -459,7 +494,10 @@ async def _generate_sections( # dieselbe Aufteilung: 1 Agent je ~30 Bausteine (gedeckelt). total_sections = sum(len(c["nums"]) for c in plan) chunks = _split_chunks(plan, min(WRITER_MAX, max(1, math.ceil(total_sections / WRITER_SECTIONS)))) - zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks] + # Subbausteine je Baustein (Sidecar). Fehlt sie → leer → Inhalte/Writer laufen + # auf Baustein-Granularität weiter (Fallback für Alt-Themen ohne Sidecar). + subs_by_titel = _load_subbausteine(topic) + 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] writer_count = len(chunks) idx = _titel_index(entries) @@ -616,11 +654,11 @@ async def _generate_sections( chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks] 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{_section_markup(by_num[num])}" for num in nums) def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str: return "\n\n".join( - f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}" + f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{_section_markup(by_num[num])}" for num in nums ) @@ -702,12 +740,16 @@ async def _generate_sections( scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums] await _set_progress(guide_id, "Setze zusammen…") + def _section(num: int) -> dict: + sec = by_num[num] + d = {"num": num, "title": _titel(entries[num]), "md": sec["md"]} + if sec.get("subs"): + d["subbausteine"] = [{"stufe": s["stufe"], "titel": s["titel"], "md": s["md"]} for s in sec["subs"]] + return d + chapters: list[dict] = [] for ch in plan: - sections = [ - {"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"]} - for num in ch["nums"] if num in by_num - ] + sections = [_section(num) for num in ch["nums"] if num in by_num] if sections: chapters.append({"title": ch["title"], "sections": sections}) geplant = {num for ch in plan for num in ch["nums"]} diff --git a/backend/paths.py b/backend/paths.py index e84a9c1..36668a1 100644 --- a/backend/paths.py +++ b/backend/paths.py @@ -21,6 +21,11 @@ def bausteine_path(topic: str) -> Path: return topic_dir(topic) / "bausteine.md" +def subbausteine_path(topic: str) -> Path: + """Sidecar: pro Baustein die Subbausteine mit Stufe (von allen Guides geteilt).""" + return topic_dir(topic) / "subbausteine.json" + + def guide_content_path(topic: str, format_name: str) -> Path: return topic_dir(topic) / "guides" / f"{format_name}.json" diff --git a/backend/pipeline.py b/backend/pipeline.py index 88cb05a..d3bc89b 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -16,6 +16,7 @@ from agents import run_agent, kill_process from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS from database import update_guide from jsonio import read_json_file as _json_datei +from textkit import _STUFEN log = logging.getLogger("creator.pipeline") @@ -134,6 +135,29 @@ def _runde_schema(data, final: bool = False): return aufnehmen, rest +def _stufen_schema(data, ids: set[int] | None = None): + """{"stufen": {"1": "einfach", …}} → {id: stufe} · sonst None. + + Stufe ∈ {einfach, mittel, schwer}. Sind `ids` gegeben, müssen mindestens + diese abgedeckt sein (Extras erlaubt); der Aufrufer filtert dann auf `ids`. + """ + if not isinstance(data, dict) or not isinstance(data.get("stufen"), dict) or not data["stufen"]: + return None + out: dict[int, str] = {} + for k, v in data["stufen"].items(): + try: + num = int(k) + except (ValueError, TypeError): + return None + stufe = str(v).strip().casefold() + if stufe not in _STUFEN: + return None + out[num] = stufe + if ids is not None and not ids <= set(out): + return None + return out + + _MAX_RESTARTS = 2 diff --git a/backend/textkit.py b/backend/textkit.py index 8943b74..4c84b8b 100644 --- a/backend/textkit.py +++ b/backend/textkit.py @@ -119,32 +119,81 @@ def _lade_bausteine(text: str) -> dict[int, str]: _FRAGMENT_KAPITEL_RE = re.compile(r"", re.IGNORECASE) _FRAGMENT_SECTION_RE = re.compile(r"", re.IGNORECASE) +_FRAGMENT_SUB_RE = re.compile(r"", re.IGNORECASE) +_FRAGMENT_BAUSTEIN_RE = re.compile(r"", re.IGNORECASE) +_STUFEN = ("einfach", "mittel", "schwer") def _parse_fragment(text: str) -> list[dict]: - """Parst eine Writer-Datei → [{"kapitel", "titel", "md"}] in Datei-Reihenfolge.""" + """Parst eine Writer-Datei → [{"kapitel", "titel", "md", "subs"}] in Datei-Reihenfolge. + + Optionale Subbaustein-Marker `` innerhalb einer + Section sammeln sich in `sec["subs"] = [{"stufe", "titel", "md"}]`. `sec["md"]` + bleibt der vollständige Body (alle Sub-Texte zusammengesetzt) — auch ohne Subs. + """ sections: list[dict] = [] kapitel = None current = None + cur_sub = None for line in text.splitlines(): s = line.strip() m = _FRAGMENT_KAPITEL_RE.match(s) if m: kapitel = m.group(1) current = None + cur_sub = None continue m = _FRAGMENT_SECTION_RE.match(s) if m: - current = {"kapitel": kapitel, "titel": m.group(1), "md": []} + current = {"kapitel": kapitel, "titel": m.group(1), "md": [], "subs": []} + cur_sub = None sections.append(current) continue + m = _FRAGMENT_SUB_RE.match(s) + if m and current is not None: + teil = m.group(1).split("|", 1) + stufe = teil[0].strip().casefold() + cur_sub = { + "stufe": stufe if stufe in _STUFEN else "einfach", + "titel": teil[1].strip() if len(teil) == 2 else "", + "md": [], + } + current["subs"].append(cur_sub) + continue if current is not None: current["md"].append(line) + if cur_sub is not None: + cur_sub["md"].append(line) for sec in sections: sec["md"] = "\n".join(sec["md"]).strip() + for sub in sec["subs"]: + sub["md"] = "\n".join(sub["md"]).strip() + sec["subs"] = [sub for sub in sec["subs"] if sub["md"]] return sections +def _parse_subbausteine(text: str) -> dict[str, list[str]]: + """Parst eine Subbaustein-Datei → {Baustein-Titel: [Subbaustein, …]} in Reihenfolge. + + Format: `` gefolgt von Listenzeilen `- Subbaustein`. + """ + out: dict[str, list[str]] = {} + current = None + for line in text.splitlines(): + s = line.strip() + m = _FRAGMENT_BAUSTEIN_RE.match(s) + if m: + current = m.group(1).strip() + out.setdefault(current, []) + continue + if current is None: + continue + m = re.match(r"[-*]\s+(.*\S)", s) + if m: + out[current].append(m.group(1).strip()) + return {k: v for k, v in out.items() if v} + + def _split_chunks(chapters: list[dict], n: int) -> list[list[dict]]: """Teilt Kapitel in bis zu n zusammenhängende Chunks, balanciert nach Section-Anzahl.""" n = max(1, min(n, len(chapters))) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5c6ad7e..bf266ac 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -440,6 +440,10 @@ onMounted(async () => { --warning-soft: #fef3c7; --warning-border: #fbbf24; --danger: #991b1b; + /* Subbaustein-Stufen als Ampel (Schwierigkeit), bewusst NICHT die Stand-Farben. */ + --stufe-einfach: #22c55e; + --stufe-mittel: #f59e0b; + --stufe-schwer: #ef4444; --code-bg: #1e2a3a; --code-fg: #e6e6e6; --shadow: rgba(0, 0, 0, 0.12); @@ -468,6 +472,9 @@ html.dark { --warning-soft: #3a2c0a; --warning-border: #a06a12; --danger: #f87171; + --stufe-einfach: #22c55e; + --stufe-mittel: #f59e0b; + --stufe-schwer: #ef4444; --shadow: rgba(0, 0, 0, 0.5); } diff --git a/frontend/src/components/BausteinFokus.vue b/frontend/src/components/BausteinFokus.vue index 7cf0e77..7488dfd 100644 --- a/frontend/src/components/BausteinFokus.vue +++ b/frontend/src/components/BausteinFokus.vue @@ -1,5 +1,6 @@ + + + + diff --git a/frontend/src/components/TopicDetail.vue b/frontend/src/components/TopicDetail.vue index 45a1da9..7ad926e 100644 --- a/frontend/src/components/TopicDetail.vue +++ b/frontend/src/components/TopicDetail.vue @@ -5,6 +5,7 @@ import { renderMarkdown } from '../markdown.js' import { useChat } from '../composables/useChat.js' import BausteinPanel from './BausteinPanel.vue' import BausteinFokus from './BausteinFokus.vue' +import SubbausteinView from './SubbausteinView.vue' const props = defineProps({ previewGuide: { type: Object, default: null }, @@ -199,7 +200,8 @@ function extractContext() { ✓✓ Verstanden ✓ Absolviert -
+
+ `-Marker (Subbausteine), BEHALTE diese Struktur bei: gib pro Subbaustein wieder den Marker und darunter den überarbeiteten Text. Stufe und Subbaustein-Titel exakt übernehmen, keine Subbausteine entfernen oder hinzufügen. + +Schreibe NUR die Datei {out_path} in GENAU diesem Format — für JEDE beanstandete Section ein section-Marker (Titel EXAKT wie oben), darunter der vollständige neue Markdown-Body (mit den ``-Markern, falls die Section welche hatte): -Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION… - -### Beispiel -(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario) + +Überarbeiteter Text zu diesem Teilpunkt… Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections. {extra} diff --git a/templates/Prompt/Guide-Writer.md b/templates/Prompt/Guide-Writer.md index 8d756bf..01e47e3 100644 --- a/templates/Prompt/Guide-Writer.md +++ b/templates/Prompt/Guide-Writer.md @@ -1,28 +1,40 @@ 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: +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]`): {zuteilung} {facts} -Das Format ({format_name}) beeinflusst NICHT, wie ein einzelner Baustein geschrieben wird — nur, wie viele Bausteine das Thema abdeckt. Derselbe Baustein bekommt in MiniGuide, Guide und FullGuide denselben Text. Wie lang und tief ein Baustein ist, richtet sich allein nach SEINER Schwierigkeit (laut Spezifikation): ein einfacher Baustein bleibt kurz, ein kniffliger darf länger sein. Bausteine untereinander sind also verschieden lang — derselbe Baustein über die Formate hinweg aber gleich. +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. + +SUBBAUSTEINE — so schreibst du: +- Pro zugeteiltem Subbaustein schreibst du EINEN kurzen, eigenständigen Abschnitt. Ein Subbaustein = ein Teilpunkt. +- Jeder Subbaustein-Abschnitt steht für sich: allein verständlich, ohne die anderen gelesen zu haben. Ein Anfänger darf später nur die `[einfach]`-Abschnitte sehen — die müssen für sich Sinn ergeben. +- Die Stufe steuert die Tiefe: `[einfach]` = Grundlage, knapp und sofort verständlich. `[mittel]` = ein Detail/eine Variante. `[schwer]` = Sonderfall/Tücke. +- So lang wie nötig, so kurz wie möglich: 1–4 Sätze je Subbaustein. Ein Beispiel NUR, wo es wirklich hilft (Format laut Spezifikation). +- Ein Baustein mit vielen Subbausteinen wird dadurch lang, einer mit wenigen kurz — das ist gewollt. GEPRÜFTE INHALTE je Baustein — das ist verbindlich, was gelehrt werden muss: {inhalte} -Nutze diese Inhalte. Erfinde nichts dazu, lass nichts Wesentliches weg. Formuliere sie als lesbaren Lern-Text laut SECTION-SPEZIFIKATION. Keine feste Länge — die Länge folgt den Inhalten. Lehre simpel: kurze Sätze, Listen für Aufzählungen, ein Anfänger versteht es sofort. +Nutze diese Inhalte. Erfinde nichts dazu, lass nichts Wesentliches weg. Verteile sie auf die Subbausteine. Lehre simpel: kurze Sätze, Listen für Aufzählungen, ein Anfänger versteht es sofort. -SECTION-SPEZIFIKATION: +SECTION-SPEZIFIKATION (gilt je Subbaustein-Abschnitt): {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), darunter der Markdown-Body: +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), darunter PRO Subbaustein ein sub-Marker `` und dann der Markdown-Body dieses Subbausteins: -Erklärung (Einordnung → Wie/Warum) laut SECTION-SPEZIFIKATION… + +Kurzer, eigenständiger Text zu diesem Teilpunkt… -### Beispiel -(Beispiel im themengerechten Format laut SECTION-SPEZIFIKATION: Codeblock NUR bei Code-Themen, sonst Beispielsätze oder Mini-Szenario) + +Kurzer Text… -Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections, kein Dokument-Titel, kein Inhaltsverzeichnis. -{extra} \ No newline at end of file +Regeln für die Marker: +- `stufe` ist genau eines von: einfach, mittel, schwer (aus der Zuteilung übernehmen). +- Der Subbaustein-Titel nach `|` EXAKT aus der Zuteilung. +- Hat ein Baustein in der Zuteilung KEINE Subbausteine, schreibe direkt unter den section-Marker einen normalen Section-Body (ohne sub-Marker). +- Kein Text außerhalb der Marker, kein Dokument-Titel, kein Inhaltsverzeichnis. +{extra} diff --git a/templates/Prompt/Stufen-Mapping.md b/templates/Prompt/Stufen-Mapping.md new file mode 100644 index 0000000..a839c2a --- /dev/null +++ b/templates/Prompt/Stufen-Mapping.md @@ -0,0 +1,19 @@ +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. + +STRITTIGE SUBBAUSTEINE (mit den abgegebenen Stimmen): +{strittig} + +Stufen: +- **einfach**: Grundlegend, Anfänger versteht es sofort, Kernfunktion. +- **mittel**: Braucht etwas Erklärung, gängige Variante oder wichtiges Detail. +- **schwer**: Sonderfall, Tücke, fortgeschritten, leicht falsch zu machen. + +Regeln: +- Wäge die Stimmen ab, entscheide fachlich. Im Zweifel die niedrigere Stufe. +- Für JEDE strittige Nummer genau eine Stufe. + +Schreibe NUR die JSON-Datei nach: {out_path} + +Format (kein weiterer Text): +{{"stufen": {{"1": "einfach", "4": "schwer"}}}} +{extra} diff --git a/templates/Prompt/Stufen-Recherche.md b/templates/Prompt/Stufen-Recherche.md new file mode 100644 index 0000000..4e6d172 --- /dev/null +++ b/templates/Prompt/Stufen-Recherche.md @@ -0,0 +1,20 @@ +Stufe jeden Subbaustein des Themas "{topic}" nach Schwierigkeit ein: einfach, mittel oder schwer. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „einfach", Fortgeschrittene alles). + +SUBBAUSTEINE (Nummer in Klammern der zugehörige Baustein): +{subbausteine} + +Stufen: +- **einfach**: Grundlegend. Ein Anfänger versteht es sofort. Die Kernfunktion, ohne die der Baustein nicht nutzbar ist. +- **mittel**: Braucht etwas Erklärung. Eine gängige Variante, ein wichtiges Detail, ein zweiter Schritt. +- **schwer**: Sonderfall, Tücke, fortgeschritten. Leicht falsch zu machen, selten gebraucht, oder setzt mehr Verständnis voraus. + +Regeln: +- Stufe jeden Punkt einzeln, gemessen an SEINEM Baustein. Im Zweifel die niedrigere Stufe. +- Jeder Baustein sollte mindestens einen „einfach"-Punkt haben (den Einstieg). +- Urteile nur — erfinde nichts dazu, ändere keine Subbausteine. + +Schreibe NUR die JSON-Datei nach: {out_path} + +Format (für JEDE Nummer genau eine Stufe; kein weiterer Text): +{{"stufen": {{"1": "einfach", "2": "mittel", "3": "schwer"}}}} +{extra} diff --git a/templates/Prompt/Subbaustein-Mapping.md b/templates/Prompt/Subbaustein-Mapping.md new file mode 100644 index 0000000..0a83899 --- /dev/null +++ b/templates/Prompt/Subbaustein-Mapping.md @@ -0,0 +1,19 @@ +Mehrere Agenten haben unabhängig die Subbausteine je Baustein des Themas "{topic}" ermittelt. Pro Baustein ist der KONSENS (von mindestens zwei Agenten genannt) und der strittige REST (nur einmal genannt) gelistet. Entscheide je Baustein die finale Subbaustein-Liste. + +{bausteine} + +Regeln: +- Übernimm alle Konsens-Subbausteine. +- Einen strittigen Rest-Punkt AUFNEHMEN, wenn er ein eigener, korrekter Teilpunkt des Bausteins ist, den der Konsens nicht abdeckt. +- VERWERFEN, wenn er eine Dublette/Variante eines vorhandenen Punkts ist, zu fein granular, am Rand des Themas oder fachlich zweifelhaft. +- Die Anzahl folgt der Schwierigkeit: trivialer Baustein wenige Punkte, komplexer viele. Blähe nichts auf, lass nichts Wesentliches weg. +- Subbausteine wörtlich übernehmen, nicht umformulieren. Jeder Punkt atomar (eine Aussage). + +Schreibe NUR die Datei {out_path} — pro Baustein ein baustein-Marker (Titel EXAKT wie oben), darunter die finale Subbaustein-Liste: + + +- Subbaustein +- Subbaustein + +Die Marker-Zeile exakt so schreiben. Jeder Baustein muss vorkommen. Kein Text außerhalb der Bausteine. +{extra} diff --git a/templates/Prompt/Subbaustein-Recherche.md b/templates/Prompt/Subbaustein-Recherche.md new file mode 100644 index 0000000..c0e169c --- /dev/null +++ b/templates/Prompt/Subbaustein-Recherche.md @@ -0,0 +1,28 @@ +Zerlege jeden zugeteilten Baustein des Themas "{topic}" in seine SUBBAUSTEINE — die einzelnen lernbaren Teilpunkte, aus denen das Konzept besteht. Ein späterer Guide wird PRO Subbaustein einen kurzen Text schreiben. + +Dir zugeteilt — verbindlich: jeder Baustein muss vorkommen, keine zusätzlichen erfinden: +{zuteilung} + +Was ein Subbaustein ist: +- Ein einzelner Teilpunkt, den man am Baustein lernen muss. Eine Aussage, ein Aspekt, eine Tücke. +- Beispiele: Baustein `` → `src` (Bildquelle), `alt` (Alternativtext), leeres `alt` für Deko-Bilder, `width`/`height` gegen Layout-Sprünge, Void-Element ohne Endtag. Baustein `

` → Textabsatz als Block, erlaubte Inline-Kinder, keine Verschachtelung. + +ENTSCHEIDEND — die Anzahl folgt der Schwierigkeit: +- Ein einfacher, trivialer Baustein hat WENIGE Subbausteine (1–2). Blähe ihn NICHT auf. +- Ein komplexer, reichhaltiger Baustein hat VIELE Subbausteine. Lass nichts Wesentliches weg. +- Es gibt KEINE Zielzahl. `` muss deutlich mehr Subbausteine haben als `

`. + +Regeln: +- Jeder Subbaustein ist atomar: genau ein Teilpunkt. Keine zwei Aspekte in einen Punkt. +- Nur was DIESER Baustein hergibt (Scope). Nichts am Rand Erwähntes aufblasen. +- Belegt, nicht erfunden. Unsicheres per Websuche prüfen. +- Subbaustein-Titel auf DEUTSCH (Code-Bezeichner bleiben original), max. ~10 Wörter, eine Aussage. + +Schreibe NUR die Datei {out_path} — pro Baustein ein baustein-Marker (Titel EXAKT aus der Zuteilung), darunter die Subbausteine als Liste: + + +- Erster Subbaustein +- Zweiter Subbaustein + +Die Marker-Zeile exakt so schreiben. Kein Text außerhalb der Bausteine. +{extra}