This commit is contained in:
Team3
2026-06-07 09:00:20 +02:00
parent fce82fbd16
commit 8d6d1bf089
22 changed files with 876 additions and 274 deletions

View File

@@ -15,8 +15,11 @@ TIMEOUTS = {
"recherche": (1800, 0), # fix 30 min "recherche": (1800, 0), # fix 30 min
"auswahl": (600, 10), "auswahl": (600, 10),
"auswahl_check": (300, 2), "auswahl_check": (300, 2),
"guide_auswahl": (300, 5), # pro Baustein im Inventar
"guide_check": (300, 2), # Auswahl-/Gliederungs-Prüfung (nur Titellisten)
"plan": (300, 5), "plan": (300, 5),
"writer": (600, 120), # pro Section im Chunk "writer": (600, 120), # pro Section im Chunk
"lese_check": (300, 10), # pro Section im Paket
"onepager_recherche": (900, 0), "onepager_recherche": (900, 0),
"onepager_bauen": (300, 0), "onepager_bauen": (300, 0),
"onepager_verify": (300, 0), "onepager_verify": (300, 0),

View File

@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS guides (
instructions TEXT NOT NULL DEFAULT '', instructions TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'queued', status TEXT NOT NULL DEFAULT 'queued',
progress TEXT, progress TEXT,
step INTEGER,
error_msg TEXT, error_msg TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
@@ -47,6 +48,10 @@ async def init_db():
await db.execute(CREATE_GUIDES) await db.execute(CREATE_GUIDES)
await db.execute(CREATE_PROGRESS) await db.execute(CREATE_PROGRESS)
await db.execute(CREATE_TOPICS) await db.execute(CREATE_TOPICS)
try: # Migration für Bestands-DBs ohne step-Spalte
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
except aiosqlite.OperationalError:
pass
await db.execute( await db.execute(
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' " "UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
"WHERE status IN ('queued', 'generating')" "WHERE status IN ('queued', 'generating')"

View File

@@ -501,9 +501,12 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
_bausteine_cancelled.discard(topic) _bausteine_cancelled.discard(topic)
# --- Guide-Generierung: Bausteine → (Plan) → Writer → JSON --- # --- Guide-Generierung: 6 Schritte mit Prüfung nach jeder Phase (OnePager hat einen eigenen Weg) ---
# Prüf-Agenten notieren nur Probleme; das Anpassen übernimmt der jeweilige Erzeuger-Typ.
# Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
GUIDE_STEPS = ("Auswahl", "Auswahl-Prüfung", "Gliederung", "Gliederungs-Prüfung", "Schreiben", "Lese-Prüfung")
# Parallele Writer pro Format (OnePager hat einen eigenen Weg).
# Writer skalieren mit der Section-Zahl: 1 Writer je ~30 Sections (gedeckelt). # Writer skalieren mit der Section-Zahl: 1 Writer je ~30 Sections (gedeckelt).
# Kleine Pakete vermeiden Lazy-Output bei langen Listen und begrenzen den Schaden # Kleine Pakete vermeiden Lazy-Output bei langen Listen und begrenzen den Schaden
# eines fehlgeschlagenen Writers. # eines fehlgeschlagenen Writers.
@@ -511,6 +514,80 @@ WRITER_SECTIONS = 30
WRITER_MAX = 20 WRITER_MAX = 20
def _guide_files(content_path: Path) -> dict:
d, stem = content_path.parent, content_path.stem
return {
"auswahl": d / f"{stem}.auswahl.json",
"auswahl_check": d / f"{stem}.auswahl-check.json",
"gliederung": d / f"{stem}.gliederung.json",
"gliederung_check": d / f"{stem}.gliederung-check.json",
# chunk-/lese-check-/fix-Dateien sind dynamisch: {stem}.chunk-i.md usw.
}
def guide_slot_dateien(content_path: Path) -> list[Path]:
"""Alle Schritt-Dateien eines Guides (für den Frischstart)."""
return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path]
async def _set_step(guide_id: str, step: int, progress: str) -> None:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, step=step, progress=progress, updated_at=now)
def _resolve_auswahl(data, entries: dict[int, str], k_min: int, k_max: int) -> list[int] | None:
"""{"bausteine": [Titel]} → Nummern; None bei Schema-Verstoß/Drift/falschem Umfang."""
if not isinstance(data, dict) or not isinstance(data.get("bausteine"), list):
return None
idx = _titel_index(entries)
nums: list[int] = []
seen: set[int] = set()
total = unknown = 0
for t in data["bausteine"]:
total += 1
num = _titel_aufloesen(idx, t) if isinstance(t, str) else None
if num is None:
unknown += 1
elif num not in seen:
seen.add(num)
nums.append(num)
if total == 0 or (total - unknown) / total < 0.85:
return None
if len(nums) < 0.9 * k_min or len(nums) > 1.1 * k_max:
return None
return nums
def _probleme_schema(data):
"""{"ok": true} → [] · {"probleme": [str]} → Liste · sonst None."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return []
p = data.get("probleme")
if not isinstance(p, list) or not p:
return None
out = [str(x).strip() for x in p if str(x).strip()]
return out or None
def _lese_probleme_schema(data):
"""{"ok": true} → [] · {"probleme": [{"section", "problem"}]} → Liste · sonst None."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return []
p = data.get("probleme")
if not isinstance(p, list) or not p:
return None
out = []
for x in p:
if not isinstance(x, dict) or not isinstance(x.get("section"), str) or not isinstance(x.get("problem"), str):
return None
out.append({"section": x["section"].strip(), "problem": x["problem"].strip()})
return out or None
def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None: def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None:
"""{"kapitel": [{"titel", "bausteine": [Titel]}]} → [{"title", "nums"}]. """{"kapitel": [{"titel", "bausteine": [Titel]}]} → [{"title", "nums"}].
@@ -605,40 +682,38 @@ def _parse_fragment(text: str) -> list[dict]:
async def _generate_onepager( async def _generate_onepager(
guide_id: str, topic: str, instructions: str, provider: str, guide_id: str, topic: str, instructions: str, provider: str,
project: Path | None, content_path: Path, fragment_paths: list[Path], project: Path | None, content_path: Path,
) -> list[dict] | None: ) -> list[dict] | None:
def is_cancelled() -> bool: def is_cancelled() -> bool:
return guide_id in _cancelled return guide_id in _cancelled
PFLICHT_KARTEN = ("was ist", "welches problem", "wann nehmen", "einordnung", "so sieht", "fakten", "erste schritte") # 3×3-Raster: 7 Karten mit festen Schlüsseln (Reihenfolge = Lesereihenfolge mobil)
KARTEN_KEYS = ("info", "eigenschaften", "beispiel", "zusammenhaenge", "voraussetzungen", "modern", "veraltet")
def karten_schema(data): def karten_schema(data):
"""{"karten": {key: {titel, md}}} → Liste · sonst None."""
if not isinstance(data, dict): if not isinstance(data, dict):
return None return None
if data.get("ok") is True:
return "ok"
karten = data.get("karten") karten = data.get("karten")
if not isinstance(karten, list) or not karten: if not isinstance(karten, dict):
return None return None
out = [] out = []
for k in karten: for key in KARTEN_KEYS:
if not isinstance(k, dict) or not isinstance(k.get("titel"), str) or not isinstance(k.get("merksatz"), str): k = karten.get(key)
if not isinstance(k, dict) or not isinstance(k.get("titel"), str) or not isinstance(k.get("md"), str):
return None return None
titel, merksatz = k["titel"].strip(), k["merksatz"].strip() titel, md = k["titel"].strip(), k["md"].strip()
if len(merksatz) < 5: # abgebrochene/leere Karten ("Per") sind ungültig if not titel or len(md) < 5: # abgebrochene/leere Karten sind ungültig
return None
out.append({"titel": titel, "merksatz": merksatz})
vorhanden = [k["titel"].lower() for k in out]
for pflicht in PFLICHT_KARTEN:
if not any(t.startswith(pflicht) for t in vorhanden):
return None return None
out.append({"key": key, "titel": titel, "md": md})
return out return out
# Schritt 1: Recherche — eigene Faktenbasis, unabhängig von den Bausteinen d, stem = content_path.parent, content_path.stem
await _set_progress(guide_id, "Recherchiere…") recherche_path = d / f"{stem}.recherche.md"
recherche_path = content_path.parent / f"{content_path.stem}.recherche.md" recherche_check_path = d / f"{stem}.recherche-check.json"
fragment_paths.append(recherche_path) karten_path = d / f"{stem}.karten.json"
recherche_path.unlink(missing_ok=True) check_path = d / f"{stem}.onepager-check.json"
# Projekte bekommen eigene Recherche-Dimensionen — Produkt-Fragen # Projekte bekommen eigene Recherche-Dimensionen — Produkt-Fragen
# (Version, Lizenz, Alternativen) laufen dort ins Leere. # (Version, Lizenz, Alternativen) laufen dort ins Leere.
if project: if project:
@@ -647,11 +722,22 @@ async def _generate_onepager(
else: else:
source = _prompt("OnePager-Quelle-Thema", topic=topic) source = _prompt("OnePager-Quelle-Thema", topic=topic)
recherche_template = "OnePager-Recherche" recherche_template = "OnePager-Recherche"
def recherche_payload(result=None):
if not recherche_path.exists():
return None
text = recherche_path.read_text(encoding="utf-8").strip()
return text or None
# Schritt 1: Recherche — vorhandene Datei wird übernommen (Resume)
recherche = recherche_payload()
if recherche is None:
await _set_step(guide_id, 0, "Recherchiere…")
slots = [{ slots = [{
"key": f"{guide_id}-recherche", "key": f"{guide_id}-recherche",
"prompt": _prompt(recherche_template, topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)), "prompt": _prompt(recherche_template, topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)),
"role": "quick", "capabilities": "files" if project else "full", "role": "quick", "capabilities": "files" if project else "full",
"payload": (lambda result: recherche_path.read_text(encoding="utf-8") if recherche_path.exists() else None), "payload": recherche_payload,
}] }]
res = await _race(topic, "OnePager-Recherche", slots, 1, _timeout("onepager_recherche"), provider, cancelled=is_cancelled) res = await _race(topic, "OnePager-Recherche", slots, 1, _timeout("onepager_recherche"), provider, cancelled=is_cancelled)
if is_cancelled(): if is_cancelled():
@@ -661,16 +747,54 @@ async def _generate_onepager(
return None return None
recherche = res[0] recherche = res[0]
# Schritt 2: Bauen — Karten nur aus der Faktenbasis (JSON) # Schritt 2: Recherche-Prüfung — notiert Probleme; Anpassung macht ein Recherche-Agent
await _set_progress(guide_id, "Baue OnePager…") if not recherche_check_path.exists():
karten_path = content_path.parent / f"{content_path.stem}.karten.json" await _set_step(guide_id, 1, "Prüfe Recherche…")
fragment_paths.append(karten_path) slots = [{
"key": f"{guide_id}-recherche-check",
"prompt": _prompt("OnePager-Recherche-Check", topic=topic, recherche=recherche, out_path=recherche_check_path),
"role": "fast", "capabilities": "files",
"payload": (lambda result: _probleme_schema(_json_datei(recherche_check_path))),
}]
res = await _race(topic, "Recherche-Prüfung", slots, 1, _timeout("onepager_verify"), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
await _fail(guide_id, "Recherche-Prüfung fehlgeschlagen")
return None
probleme = res[0]
if probleme:
_log(topic, f"Recherche-Prüfung: {len(probleme)} Problem(e) notiert")
await _set_step(guide_id, 1, "Passe Recherche an…")
slots = [{
"key": f"{guide_id}-recherche-fix",
"prompt": _prompt(
"OnePager-Recherche-Fix",
topic=topic, source=source, recherche=recherche,
probleme="\n".join(f"- {p}" for p in probleme),
out_path=recherche_path, extra=_extra(instructions),
),
"role": "quick", "capabilities": "files" if project else "full",
"payload": recherche_payload,
}]
res = await _race(topic, "Recherche-Fix", slots, 1, _timeout("onepager_recherche"), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
_log(topic, "Recherche-Fix ungültig — ursprüngliche Recherche bleibt")
else:
recherche = res[0]
# Schritt 3: Bauen — Karten nur aus der Faktenbasis (Resume: gültige Datei wird übernommen)
karten = karten_schema(_json_datei(karten_path))
if karten is None:
await _set_step(guide_id, 2, "Baue OnePager…")
karten_path.unlink(missing_ok=True) karten_path.unlink(missing_ok=True)
slots = [{ slots = [{
"key": f"{guide_id}-bauen", "key": f"{guide_id}-bauen",
"prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=karten_path, extra=_extra(instructions)), "prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=karten_path, extra=_extra(instructions)),
"role": "fast", "capabilities": "files", "role": "fast", "capabilities": "files",
"payload": (lambda result: (k if isinstance(k := karten_schema(_json_datei(karten_path)), list) else None)), "payload": (lambda result: karten_schema(_json_datei(karten_path))),
}] }]
res = await _race(topic, "OnePager-Bauen", slots, 1, _timeout("onepager_bauen"), provider, cancelled=is_cancelled) res = await _race(topic, "OnePager-Bauen", slots, 1, _timeout("onepager_bauen"), provider, cancelled=is_cancelled)
if is_cancelled(): if is_cancelled():
@@ -680,29 +804,53 @@ async def _generate_onepager(
return None return None
karten = res[0] karten = res[0]
# Schritt 3: Verifizieren — {"ok": true} oder vollständig korrigierte Liste (nicht fatal) def karten_block() -> str:
await _set_progress(guide_id, "Verifiziere OnePager…") return "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in karten)
check_path = content_path.parent / f"{content_path.stem}.onepager-check.json"
fragment_paths.append(check_path) # Schritt 4: Prüfung — notiert Probleme; Anpassung macht ein Bauen-Agent
check_path.unlink(missing_ok=True) if not check_path.exists():
karten_block = "\n".join(f"- {k['titel']}{k['merksatz']}" for k in karten) await _set_step(guide_id, 3, "Prüfe OnePager…")
slots = [{ slots = [{
"key": f"{guide_id}-verify", "key": f"{guide_id}-verify",
"prompt": _prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block, out_path=check_path), "prompt": _prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=check_path),
"role": "fast", "capabilities": "files", "role": "fast", "capabilities": "files",
"payload": (lambda result: karten_schema(_json_datei(check_path))), "payload": (lambda result: _probleme_schema(_json_datei(check_path))),
}] }]
res = await _race(topic, "OnePager-Verifikation", slots, 1, _timeout("onepager_verify"), provider, cancelled=is_cancelled) res = await _race(topic, "OnePager-Prüfung", slots, 1, _timeout("onepager_verify"), provider, cancelled=is_cancelled)
if is_cancelled(): if is_cancelled():
return None return None
if res is None: if res is None:
_log(topic, "OnePager-Verifikation fehlgeschlagen — ungeprüfte Version wird verwendet") await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen")
elif isinstance(res[0], list): return None
_log(topic, "OnePager-Verifikation hat Korrekturen geliefert") probleme = res[0]
if probleme:
_log(topic, f"OnePager-Prüfung: {len(probleme)} Problem(e) notiert")
await _set_step(guide_id, 3, "Passe OnePager an…")
slots = [{
"key": f"{guide_id}-karten-fix",
"prompt": _prompt(
"OnePager-Fix",
topic=topic, recherche=recherche, karten=karten_block(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=karten_path, extra=_extra(instructions),
),
"role": "fast", "capabilities": "files",
"payload": (lambda result: karten_schema(_json_datei(karten_path))),
}]
res = await _race(topic, "OnePager-Fix", slots, 1, _timeout("onepager_bauen"), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
_log(topic, "OnePager-Fix ungültig — ursprüngliche Karten bleiben")
karten_path.write_text(
json.dumps({"karten": {k["key"]: {"titel": k["titel"], "md": k["md"]} for k in karten}}, ensure_ascii=False),
encoding="utf-8",
)
else:
karten = res[0] karten = res[0]
sections = [ sections = [
{"num": i, "title": k["titel"], "md": k["merksatz"]} {"num": i, "title": k["titel"], "md": k["md"], "key": k["key"]}
for i, k in enumerate(karten, 1) for i, k in enumerate(karten, 1)
] ]
return [{"title": topic, "sections": sections}] return [{"title": topic, "sections": sections}]
@@ -711,12 +859,13 @@ async def _generate_onepager(
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,
content_path: Path, fragment_paths: list[Path], content_path: Path,
) -> list[dict] | None: ) -> list[dict] | None:
def is_cancelled() -> bool: def is_cancelled() -> bool:
return guide_id in _cancelled return guide_id in _cancelled
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8") spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
files = _guide_files(content_path)
bausteine_liste = "\n".join(f"- {t}" for t in entries.values()) bausteine_liste = "\n".join(f"- {t}" for t in entries.values())
n = len(entries) n = len(entries)
anteil_min, anteil_max, minimum, zweck = FORMAT_ANTEIL[format_name] anteil_min, anteil_max, minimum, zweck = FORMAT_ANTEIL[format_name]
@@ -727,76 +876,289 @@ async def _generate_sections(
"Wähle, was diesem Zweck dient — lass weg, was dafür nicht nötig ist." "Wähle, was diesem Zweck dient — lass weg, was dafür nicht nötig ist."
) )
await _set_progress(guide_id, "Wähle Bausteine & plane Gliederung…") # Schritt 1: Auswahl — vorhandene gültige Datei wird übernommen (Resume)
plan_path = content_path.parent / f"{content_path.stem}.gliederung.json" auswahl = _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max)
fragment_paths.append(plan_path) if auswahl is None:
plan_path.unlink(missing_ok=True) await _set_step(guide_id, 0, "Wähle Bausteine…")
files["auswahl"].unlink(missing_ok=True)
slots = [{ slots = [{
"key": f"{guide_id}-plan", "key": f"{guide_id}-auswahl",
"prompt": _prompt( "prompt": _prompt(
"Guide-Plan", "Guide-Auswahl",
topic=topic, format_name=format_name, bausteine=bausteine_liste, topic=topic, format_name=format_name, bausteine=bausteine_liste,
auswahl_auftrag=auswahl_auftrag, out_path=plan_path, extra=_extra(instructions), auswahl_auftrag=auswahl_auftrag, out_path=files["auswahl"], extra=_extra(instructions),
), ),
"role": "guide", "capabilities": "files", "role": "guide", "capabilities": "files",
"payload": (lambda result: _resolve_gliederung(_json_datei(plan_path), entries, k_min, k_max)), "payload": (lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max)),
}] }]
res = await _race(topic, "Gliederung", slots, 1, _timeout("plan", n), provider, cancelled=is_cancelled) res = await _race(topic, "Guide-Auswahl", slots, 1, _timeout("guide_auswahl", n), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
await _fail(guide_id, "Auswahl fehlgeschlagen")
return None
auswahl = res[0]
def auswahl_titel() -> str:
return "\n".join(f"- {_titel(entries[num])}" for num in auswahl)
def auswahl_json() -> str:
return json.dumps({"bausteine": [_titel(entries[num]) for num in auswahl]}, ensure_ascii=False)
# Schritt 2: Auswahl-Prüfung — notiert Probleme; Anpassung macht ein Auswahl-Agent
if not files["auswahl_check"].exists():
await _set_step(guide_id, 1, "Prüfe Auswahl…")
slots = [{
"key": f"{guide_id}-auswahl-check",
"prompt": _prompt(
"Guide-Auswahl-Check",
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
bausteine=bausteine_liste, auswahl=auswahl_titel(),
out_path=files["auswahl_check"], extra=_extra(instructions),
),
"role": "fast", "capabilities": "files",
"payload": (lambda result: _probleme_schema(_json_datei(files["auswahl_check"]))),
}]
res = await _race(topic, "Auswahl-Prüfung", slots, 1, _timeout("guide_check", len(auswahl)), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
await _fail(guide_id, "Auswahl-Prüfung fehlgeschlagen")
return None
probleme = res[0]
if probleme:
_log(topic, f"Auswahl-Prüfung: {len(probleme)} Problem(e) notiert")
await _set_step(guide_id, 1, "Passe Auswahl an…")
slots = [{
"key": f"{guide_id}-auswahl-fix",
"prompt": _prompt(
"Guide-Auswahl-Fix",
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
bausteine=bausteine_liste, auswahl=auswahl_titel(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=files["auswahl"], extra=_extra(instructions),
),
"role": "guide", "capabilities": "files",
"payload": (lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max)),
}]
res = await _race(topic, "Auswahl-Fix", slots, 1, _timeout("guide_auswahl", n), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
_log(topic, "Auswahl-Fix ungültig — ursprüngliche Auswahl bleibt")
files["auswahl"].write_text(auswahl_json(), encoding="utf-8")
else:
auswahl = res[0]
sel_entries = {num: entries[num] for num in auswahl}
soll = len(sel_entries)
sel_liste = "\n".join(f"- {t}" for t in sel_entries.values())
# Schritt 3: Gliederung der festen Auswahl
plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
if plan is None:
await _set_step(guide_id, 2, "Plane Gliederung…")
files["gliederung"].unlink(missing_ok=True)
slots = [{
"key": f"{guide_id}-gliederung",
"prompt": _prompt(
"Guide-Gliederung",
topic=topic, format_name=format_name, bausteine=sel_liste,
out_path=files["gliederung"], extra=_extra(instructions),
),
"role": "guide", "capabilities": "files",
"payload": (lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)),
}]
res = await _race(topic, "Gliederung", slots, 1, _timeout("plan", soll), provider, cancelled=is_cancelled)
if is_cancelled(): if is_cancelled():
return None return None
if res is None: if res is None:
await _fail(guide_id, "Gliederung fehlgeschlagen") await _fail(guide_id, "Gliederung fehlgeschlagen")
return None return None
plan = res[0] plan = res[0]
def gliederung_text() -> str:
return "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in plan)
def gliederung_json() -> str:
return json.dumps(
{"kapitel": [{"titel": ch["title"], "bausteine": [_titel(entries[num]) for num in ch["nums"]]} for ch in plan]},
ensure_ascii=False,
)
# Schritt 4: Gliederungs-Prüfung
if not files["gliederung_check"].exists():
await _set_step(guide_id, 3, "Prüfe Gliederung…")
slots = [{
"key": f"{guide_id}-gliederung-check",
"prompt": _prompt(
"Guide-Gliederung-Check",
topic=topic, format_name=format_name, zweck=zweck,
auswahl=auswahl_titel(), gliederung=gliederung_text(),
out_path=files["gliederung_check"], extra=_extra(instructions),
),
"role": "fast", "capabilities": "files",
"payload": (lambda result: _probleme_schema(_json_datei(files["gliederung_check"]))),
}]
res = await _race(topic, "Gliederungs-Prüfung", slots, 1, _timeout("guide_check", soll), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
await _fail(guide_id, "Gliederungs-Prüfung fehlgeschlagen")
return None
probleme = res[0]
if probleme:
_log(topic, f"Gliederungs-Prüfung: {len(probleme)} Problem(e) notiert")
await _set_step(guide_id, 3, "Passe Gliederung an…")
slots = [{
"key": f"{guide_id}-gliederung-fix",
"prompt": _prompt(
"Guide-Gliederung-Fix",
topic=topic, format_name=format_name,
auswahl=auswahl_titel(), gliederung=gliederung_text(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=files["gliederung"], extra=_extra(instructions),
),
"role": "guide", "capabilities": "files",
"payload": (lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)),
}]
res = await _race(topic, "Gliederungs-Fix", slots, 1, _timeout("plan", soll), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
_log(topic, "Gliederungs-Fix ungültig — ursprüngliche Gliederung bleibt")
files["gliederung"].write_text(gliederung_json(), encoding="utf-8")
else:
plan = res[0]
# Schritt 5: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume)
total_sections = sum(len(c["nums"]) for c in plan) 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)))) chunks = _split_chunks(plan, min(WRITER_MAX, max(1, math.ceil(total_sections / WRITER_SECTIONS))))
zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks] zuteilungen = [_zuteilung_text(chunk, entries) 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]
writer_count = len(zuteilungen) writer_count = len(zuteilungen)
await _set_progress(guide_id, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, writer_count + 1)] paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, writer_count + 1)]
fragment_paths.extend(paths) offen = [i for i, p in enumerate(paths) if not p.exists()]
if offen:
await _set_step(guide_id, 4, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
results = await asyncio.gather(*[ results = await asyncio.gather(*[
run_agent( run_agent(
f"{guide_id}-w{i}", f"{guide_id}-w{i + 1}",
_prompt( _prompt(
"Guide-Writer", "Guide-Writer",
topic=topic, format_name=format_name, zuteilung=zuteilung, topic=topic, format_name=format_name, zuteilung=zuteilungen[i],
facts=facts, spec=spec, out_path=path, extra=_extra(instructions), facts=facts, spec=spec, out_path=paths[i], extra=_extra(instructions),
), ),
_timeout("writer", size), provider=provider, role="guide", capabilities="full", _timeout("writer", chunk_sizes[i]), provider=provider, role="guide", capabilities="full",
) )
for i, (zuteilung, path, size) in enumerate(zip(zuteilungen, paths, chunk_sizes), 1) for i in offen
], return_exceptions=True) ], return_exceptions=True)
if is_cancelled(): if is_cancelled():
return None return None
for i, (r, p) in enumerate(zip(results, paths), 1): for i, r in zip(offen, results):
if isinstance(r, BaseException): if isinstance(r, BaseException):
_log(topic, f"Writer {i}: {type(r).__name__}: {r}") _log(topic, f"Writer {i + 1}: {type(r).__name__}: {r}")
elif r[0] != 0: elif r[0] != 0:
_log(topic, f"Writer {i}: {_claude_error('Fehler', *r)}") _log(topic, f"Writer {i + 1}: {_claude_error('Fehler', *r)}")
elif not p.exists(): elif not paths[i].exists():
_log(topic, f"Writer {i}: keine Ausgabedatei erstellt") _log(topic, f"Writer {i + 1}: keine Ausgabedatei erstellt")
fragments: list[dict] = [] if not any(p.exists() for p in paths):
for p in paths:
if p.exists():
fragments.extend(_parse_fragment(p.read_text(encoding="utf-8")))
if not fragments:
await _fail(guide_id, _gather_error("Writer-Fehler", list(results))) await _fail(guide_id, _gather_error("Writer-Fehler", list(results)))
return None return None
await _set_progress(guide_id, "Setze zusammen…")
idx = _titel_index(entries) idx = _titel_index(entries)
by_num: dict[int, dict] = {} by_num: dict[int, dict] = {}
for sec in fragments: for p in paths:
if not p.exists():
continue
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 is None: if num is None:
_log(topic, f"Writer lieferte unbekannte Section '{sec['titel'][:40]}' (ignoriert)") _log(topic, f"Writer lieferte unbekannte Section '{sec['titel'][:40]}' (ignoriert)")
continue elif num not in by_num:
if num not in by_num:
by_num[num] = sec by_num[num] = sec
if not by_num:
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
return None
# Schritt 6: Lese-Prüfung pro Writer-Paket — Fix beauftragt Writer nur mit beanstandeten Sections
chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks]
check_paths = [content_path.parent / f"{content_path.stem}.lese-check-{i}.json" for i in range(1, writer_count + 1)]
offen_checks = [i for i, p in enumerate(check_paths) if _lese_probleme_schema(_json_datei(p)) is None and chunk_nums[i]]
if offen_checks:
await _set_step(guide_id, 5, f"Prüfe Lesbarkeit ({len(offen_checks)} Prüfer)…" if len(offen_checks) > 1 else "Prüfe Lesbarkeit…")
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)
slots = [{
"key": f"{guide_id}-lese-check-{i + 1}",
"prompt": _prompt(
"Guide-Lese-Check",
topic=topic, format_name=format_name, spec=spec,
sections=sections_text(chunk_nums[i]),
out_path=check_paths[i], extra=_extra(instructions),
),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))),
} for i in offen_checks]
res = await _race(topic, "Lese-Prüfung", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
await _fail(guide_id, "Lese-Prüfung fehlgeschlagen")
return None
probleme_by_num: dict[int, str] = {}
for p in check_paths:
for item in (_lese_probleme_schema(_json_datei(p)) or []):
num = _titel_aufloesen(idx, item["section"])
if num in by_num and num not in probleme_by_num:
probleme_by_num[num] = item["problem"]
if probleme_by_num:
_log(topic, f"Lese-Prüfung: {len(probleme_by_num)} Section(s) beanstandet")
await _set_step(guide_id, 5, f"Überarbeite {len(probleme_by_num)} Section(s)…")
fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in chunk_nums]
fix_offen = [i for i, nums in enumerate(fix_chunks) if nums]
fix_paths = [content_path.parent / f"{content_path.stem}.fix-{i + 1}.md" for i in range(writer_count)]
def auftraege_text(nums: list[int]) -> str:
return "\n\n".join(
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
for num in nums
)
results = await asyncio.gather(*[
run_agent(
f"{guide_id}-fix-w{i + 1}",
_prompt(
"Guide-Sections-Fix",
topic=topic, format_name=format_name, facts=facts, spec=spec,
auftraege=auftraege_text(fix_chunks[i]),
out_path=fix_paths[i], extra=_extra(instructions),
),
_timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
)
for i in fix_offen
], return_exceptions=True)
if is_cancelled():
return None
for i, r in zip(fix_offen, results):
if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0):
_log(topic, f"Sections-Fix {i + 1} fehlgeschlagen — Original bleibt")
ersetzt = 0
for i in fix_offen:
if not fix_paths[i].exists():
continue
for sec in _parse_fragment(fix_paths[i].read_text(encoding="utf-8")):
num = _titel_aufloesen(idx, sec["titel"])
if num in probleme_by_num and sec["md"].strip():
by_num[num] = sec
ersetzt += 1
_log(topic, f"Lese-Prüfung: {ersetzt} Section(s) überarbeitet")
await _set_progress(guide_id, "Setze zusammen…")
chapters: list[dict] = [] chapters: list[dict] = []
for ch in plan: for ch in plan:
sections = [ sections = [
@@ -823,14 +1185,19 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
content_path = guide_content_path(topic, format_name) content_path = guide_content_path(topic, format_name)
content_path.parent.mkdir(parents=True, exist_ok=True) content_path.parent.mkdir(parents=True, exist_ok=True)
project = project_dir(topic) if project_dir(topic).is_dir() else None project = project_dir(topic) if project_dir(topic).is_dir() else None
fragment_paths: list[Path] = []
try: try:
if guide_id in _cancelled: if guide_id in _cancelled:
return return
# „Neu erstellen": fertiger Guide → kompletter Frischstart.
# Sonst sind Schritt-Dateien Reste eines Abbruchs/Fehlers → Resume.
if content_path.exists():
for p_alt in guide_slot_dateien(content_path):
p_alt.unlink(missing_ok=True)
if format_name == "OnePager": if format_name == "OnePager":
chapters = await _generate_onepager(guide_id, topic, instructions, provider, project, content_path, fragment_paths) chapters = await _generate_onepager(guide_id, topic, instructions, provider, project, content_path)
else: else:
alle = _lade_bausteine(bausteine_path(topic).read_text(encoding="utf-8")) alle = _lade_bausteine(bausteine_path(topic).read_text(encoding="utf-8"))
if not alle: if not alle:
@@ -840,7 +1207,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema") facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
chapters = await _generate_sections( chapters = await _generate_sections(
guide_id, topic, format_name, entries, guide_id, topic, format_name, entries,
facts, instructions, provider, content_path, fragment_paths, facts, instructions, provider, content_path,
) )
if chapters is None or guide_id in _cancelled: if chapters is None or guide_id in _cancelled:
return return
@@ -851,7 +1218,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
) )
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="done", progress=None, updated_at=now) await update_guide(guide_id, status="done", progress=None, step=None, updated_at=now)
except asyncio.TimeoutError: except asyncio.TimeoutError:
await _fail(guide_id, "Timeout bei der Generierung") await _fail(guide_id, "Timeout bei der Generierung")
@@ -861,8 +1228,6 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
await _fail(guide_id, str(e)[:2000]) await _fail(guide_id, str(e)[:2000])
finally: finally:
_cancelled.discard(guide_id) _cancelled.discard(guide_id)
for p in fragment_paths:
p.unlink(missing_ok=True)
# --- Tutor-Chat --- # --- Tutor-Chat ---

View File

@@ -57,6 +57,7 @@ class GuideResponse(BaseModel):
format: str format: str
status: str status: str
progress: str | None = None progress: str | None = None
step: int | None = None
error_msg: str | None = None error_msg: str | None = None
created_at: str created_at: str
updated_at: str updated_at: str

View File

@@ -15,7 +15,7 @@ from database import (
list_progress, set_progress, delete_progress, list_progress, set_progress, delete_progress,
) )
from generator import ( from generator import (
generate_guide, cancel_guide, chat_with_guide, generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien,
generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
) )
from models import ( from models import (
@@ -228,7 +228,10 @@ async def remove(guide_id: str):
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")
guide_content_path(guide["topic"], guide["format"]).unlink(missing_ok=True) content = guide_content_path(guide["topic"], guide["format"])
for p in guide_slot_dateien(content):
p.unlink(missing_ok=True)
content.unlink(missing_ok=True)
await delete_progress(guide_id) await delete_progress(guide_id)
await delete_guide(guide_id) await delete_guide(guide_id)
return {"ok": True} return {"ok": True}

View File

@@ -200,7 +200,12 @@ async function send() {
> >
<h2 class="chapter-title">{{ ch.title }}</h2> <h2 class="chapter-title">{{ ch.title }}</h2>
<div class="sections"> <div class="sections">
<article v-for="s in ch.sections" :key="s.num" class="section-card"> <article
v-for="s in ch.sections"
:key="s.num"
class="section-card"
:style="isOnePager && s.key ? { gridArea: s.key } : null"
>
<h3>{{ s.title }}</h3> <h3>{{ s.title }}</h3>
<div class="section-body markdown" v-html="renderMarkdown(s.md)"></div> <div class="section-body markdown" v-html="renderMarkdown(s.md)"></div>
</article> </article>
@@ -326,16 +331,43 @@ async function send() {
} }
} }
/* OnePager: dichtes Karten-Grid */ /* OnePager: festes 3×3-Raster über volle Breite und Höhe */
.guide-content.onepager {
max-width: none;
height: 100%;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
}
.guide-content.onepager .guide-head,
.guide-content.onepager .chapter-title {
display: none; /* Thema steht in der Info-Karte — Platz fürs Raster */
}
.guide-content.onepager .chapter {
flex: 1;
min-height: 0;
margin-bottom: 0;
}
.guide-content.onepager .sections { .guide-content.onepager .sections {
height: 100%;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
grid-template-areas:
"info beispiel voraussetzungen"
"eigenschaften beispiel modern"
"eigenschaften zusammenhaenge veraltet";
gap: 0.6rem; gap: 0.6rem;
} }
.guide-content.onepager .section-card { .guide-content.onepager .section-card {
margin-bottom: 0; margin-bottom: 0;
padding: 0.7rem 0.9rem; padding: 0.7rem 0.9rem;
min-height: 0;
overflow-y: auto;
h3 { h3 {
font-size: 0.88rem; font-size: 0.88rem;
@@ -347,6 +379,23 @@ async function send() {
} }
} }
/* Mobil: eine Spalte in Quellreihenfolge (info → … → veraltet) */
@media (max-width: 900px) {
.guide-content.onepager {
height: auto;
}
.guide-content.onepager .sections {
height: auto;
display: flex;
flex-direction: column;
}
.guide-content.onepager .section-card {
overflow-y: visible;
}
}
.ch-toggle { .ch-toggle {
display: block; display: block;
width: 100%; width: 100%;
@@ -451,9 +500,23 @@ async function send() {
padding: 2px 6px; padding: 2px 6px;
} }
/* Lesbarkeit: ~17px Fließtext, Zeilenhöhe 1.6, Textspalte max. ~70 Zeichen —
Code-Blöcke dürfen die volle Kartenbreite nutzen */
.section-body { .section-body {
font-size: 0.92rem; font-size: 1.0625rem;
line-height: 1.55; line-height: 1.6;
}
.section-card .markdown :deep(p),
.section-card .markdown :deep(ul),
.section-card .markdown :deep(ol) {
max-width: 70ch;
}
.onepager .section-card .markdown :deep(p),
.onepager .section-card .markdown :deep(ul),
.onepager .section-card .markdown :deep(ol) {
max-width: none; /* OnePager-Zellen sind selbst schmal genug */
} }
/* --- Chat --- */ /* --- Chat --- */

View File

@@ -46,8 +46,6 @@ const formats = [
{ key: 'FullGuide', label: 'FullGuide' }, { key: 'FullGuide', label: 'FullGuide' },
] ]
const BAUSTEINE_KEY = '__bausteine__'
const bausteineState = computed(() => { const bausteineState = computed(() => {
if (props.bausteine.generating) return 'generating' if (props.bausteine.generating) return 'generating'
return props.bausteine.ready ? 'done' : 'none' return props.bausteine.ready ? 'done' : 'none'
@@ -89,10 +87,7 @@ function confirmResetBausteine() {
function handleBausteinePlay() { function handleBausteinePlay() {
if (bausteineState.value === 'generating') return if (bausteineState.value === 'generating') return
const text = activeInput.value === BAUSTEINE_KEY ? inputText.value.trim() : '' emit('bausteineClick', { instructions: '' })
emit('bausteineClick', { instructions: text })
activeInput.value = null
inputText.value = ''
} }
function guideStatus(format) { function guideStatus(format) {
@@ -105,6 +100,21 @@ function guideStatus(format) {
return latest.status return latest.status
} }
// Schritt-Kugeln der Guide-Pipelines
const GUIDE_STEPS = ['Auswahl', 'Auswahl-Prüfung', 'Gliederung', 'Gliederungs-Prüfung', 'Schreiben', 'Lese-Prüfung']
const ONEPAGER_STEPS = ['Recherche', 'Recherche-Prüfung', 'Bauen', 'Prüfung']
function guideSteps(format) {
const st = guideStatus(format)
if (st !== 'generating' && st !== 'queued') return []
const labels = format === 'OnePager' ? ONEPAGER_STEPS : GUIDE_STEPS
const step = props.latestByFormat[format]?.step ?? -1
return labels.map((label, i) => ({
label,
state: i < step ? 'done' : i === step ? 'active' : 'pending',
}))
}
function errorMsg(format) { function errorMsg(format) {
const latest = props.latestByFormat[format] const latest = props.latestByFormat[format]
if (latest?.status === 'error') return latest.error_msg || 'Fehler bei der Generierung' if (latest?.status === 'error') return latest.error_msg || 'Fehler bei der Generierung'
@@ -118,24 +128,8 @@ function handleFormatClick(format) {
} }
} }
const activeInput = ref(null)
const inputText = ref('')
function toggleInput(format) {
if (activeInput.value === format) {
activeInput.value = null
inputText.value = ''
} else {
activeInput.value = format
inputText.value = ''
}
}
function handlePlay(format) { function handlePlay(format) {
const text = activeInput.value === format ? inputText.value.trim() : '' emit('formatClick', { format, instructions: '' })
emit('formatClick', { format, instructions: text })
activeInput.value = null
inputText.value = ''
} }
function dismissError(format) { function dismissError(format) {
@@ -219,7 +213,7 @@ function confirmDeleteProject(name) {
<div class="progress-info" v-if="activeGenerations.length"> <div class="progress-info" v-if="activeGenerations.length">
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div> <div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
</div> </div>
<div class="format-row bausteine-row"> <div class="format-row bausteine-row ord-bausteine">
<div class="format-name bausteine-name"> <div class="format-name bausteine-name">
<span class="format-label">Bausteine</span> <span class="format-label">Bausteine</span>
<span class="step-dots"> <span class="step-dots">
@@ -253,29 +247,26 @@ function confirmDeleteProject(name) {
:title="bausteine.partial ? 'Fortsetzen' : bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'" :title="bausteine.partial ? 'Fortsetzen' : bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'"
@click="handleBausteinePlay" @click="handleBausteinePlay"
></button> ></button>
<button
class="action-btn pencil"
:class="{ active: activeInput === BAUSTEINE_KEY }"
title="Anweisungen"
@click="toggleInput(BAUSTEINE_KEY)"
></button>
</template> </template>
</div> </div>
</div> </div>
<div v-if="bausteine.error" class="format-error"> <div v-if="bausteine.error" class="format-error ord-bausteine">
<span class="format-error-text">{{ bausteine.error }}</span> <span class="format-error-text">{{ bausteine.error }}</span>
</div> </div>
<div v-if="activeInput === BAUSTEINE_KEY" class="format-input"> <!-- OnePager (unabhängig von Bausteinen) steht per CSS-order vor der Bausteine-Zeile -->
<input <div v-for="f in formats" :key="f.key" :style="{ order: f.key === 'OnePager' ? 1 : 3 }">
v-model="inputText"
placeholder="Anweisungen (optional)…"
@keyup.enter="handleBausteinePlay"
/>
</div>
<div v-for="f in formats" :key="f.key">
<div :class="['format-row', 'fmt-' + guideStatus(f.key)]"> <div :class="['format-row', 'fmt-' + guideStatus(f.key)]">
<button class="format-name" @click="handleFormatClick(f.key)"> <button class="format-name" @click="handleFormatClick(f.key)">
<span class="format-label">{{ f.label }}</span> <span class="format-label">{{ f.label }}</span>
<span class="step-dots" v-if="guideSteps(f.key).length">
<span
v-for="s in guideSteps(f.key)"
:key="s.label"
class="step-dot"
:class="s.state"
:title="s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label"
></span>
</span>
<span <span
v-if="guideStatus(f.key) !== 'none'" v-if="guideStatus(f.key) !== 'none'"
class="format-x" class="format-x"
@@ -292,12 +283,6 @@ function confirmDeleteProject(name) {
:disabled="f.key !== 'OnePager' && !bausteine.ready" :disabled="f.key !== 'OnePager' && !bausteine.ready"
@click="handlePlay(f.key)" @click="handlePlay(f.key)"
></button> ></button>
<button
class="action-btn pencil"
:class="{ active: activeInput === f.key }"
title="Anweisungen"
@click="toggleInput(f.key)"
></button>
</template> </template>
</div> </div>
</div> </div>
@@ -305,13 +290,6 @@ function confirmDeleteProject(name) {
<span class="format-error-text">{{ errorMsg(f.key) }}</span> <span class="format-error-text">{{ errorMsg(f.key) }}</span>
<button class="format-error-x" title="Fehler entfernen" @click="dismissError(f.key)">&times;</button> <button class="format-error-x" title="Fehler entfernen" @click="dismissError(f.key)">&times;</button>
</div> </div>
<div v-if="activeInput === f.key" class="format-input">
<input
v-model="inputText"
placeholder="Anweisungen (optional)…"
@keyup.enter="handlePlay(f.key)"
/>
</div>
</div> </div>
</div> </div>
@@ -564,10 +542,6 @@ function confirmDeleteProject(name) {
} }
/* Format section */ /* Format section */
.format-row.bausteine-row {
margin-bottom: 0.5rem;
}
.bausteine-name { .bausteine-name {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -613,6 +587,13 @@ function confirmDeleteProject(name) {
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
padding: 0.5rem 0; padding: 0.5rem 0;
/* flex + order: OnePager (order 1) vor Bausteine (order 2) vor den restlichen Formaten (order 3) */
display: flex;
flex-direction: column;
}
.ord-bausteine {
order: 2;
} }
.progress-info { .progress-info {
@@ -648,6 +629,7 @@ function confirmDeleteProject(name) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px;
} }
.format-x { .format-x {
@@ -754,36 +736,6 @@ function confirmDeleteProject(name) {
border-color: var(--success-border); border-color: var(--success-border);
} }
.action-btn.pencil {
color: var(--accent);
}
.action-btn.pencil:hover,
.action-btn.pencil.active {
background: var(--accent-soft);
border-color: var(--accent-border);
}
.format-input {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0.75rem 8px;
}
.format-input input {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--border-strong);
border-radius: 4px;
font-size: 0.8rem;
outline: none;
}
.format-input input:focus {
border-color: var(--accent);
}
.action-btn:disabled { .action-btn:disabled {
opacity: 0.35; opacity: 0.35;
cursor: not-allowed; cursor: not-allowed;

View File

@@ -0,0 +1,24 @@
Prüfe die Baustein-Auswahl für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}).
Der Auftrag an die Auswahl war: {auswahl_auftrag}
INVENTAR (alle verfügbaren Bausteine):
{bausteine}
GETROFFENE AUSWAHL:
{auswahl}
Prüfe:
1. Fehlt etwas, das der Leser für diesen Zweck zwingend braucht?
2. Ist etwas drin, das dem Zweck nicht dient — Interna, Nischenfälle, Doppelungen (mehrere Lösungen fürs selbe Problem)?
3. Passt der Umfang zum Auftrag?
Du PRÜFST nur und notierst Probleme — du änderst die Auswahl nicht.
Schreibe NUR die JSON-Datei nach: {out_path}
Format — Auswahl in Ordnung:
{{"ok": true}}
Sonst (kurz und konkret, maximal 10 Punkte, Baustein-Titel exakt nennen):
{{"probleme": ["…", "…"]}}
{extra}

View File

@@ -0,0 +1,21 @@
Korrigiere die Baustein-Auswahl für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}).
Der Auftrag an die Auswahl war: {auswahl_auftrag}
INVENTAR (alle verfügbaren Bausteine):
{bausteine}
BISHERIGE AUSWAHL:
{auswahl}
NOTIERTE PROBLEME (von der Prüfung):
{probleme}
Behebe NUR die notierten Probleme — alles andere bleibt unverändert.
Verwende die Titel EXAKT so, wie sie im Inventar stehen. Keine neuen erfinden.
Schreibe NUR die vollständige, korrigierte JSON-Datei nach: {out_path}
Format:
{{"bausteine": ["Exakter Titel", "Exakter Titel"]}}
{extra}

View File

@@ -0,0 +1,18 @@
Wähle die Bausteine für einen Lern-Guide zum Thema "{topic}" (Format: {format_name}).
BAUSTEINE (unsortiertes Inventar):
{bausteine}
{auswahl_auftrag}
Denke vom Ziel her: Was soll der Leser am Ende KÖNNEN?
- Wähle, was der Leser dafür praktisch braucht und wirklich benutzt.
- Lass weg: Interna (was das Werkzeug intern tut, ohne dass man es anfasst), Spezialfälle und Alternativen zum selben Problem — ein Weg reicht.
- "Klingt fundamental" ist kein Kriterium. Frage stattdessen: Fasst der Leser das selbst an?
- Verwende die Titel EXAKT so, wie sie in der Liste stehen. Keine neuen erfinden.
Schreibe NUR die JSON-Datei nach: {out_path}
Format:
{{"bausteine": ["Exakter Titel", "Exakter Titel"]}}
{extra}

View File

@@ -0,0 +1,24 @@
Prüfe die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}).
Zielgruppe: Anfänger. Zweck: {zweck}.
GEWÄHLTE BAUSTEINE (müssen alle vorkommen):
{auswahl}
GLIEDERUNG:
{gliederung}
Prüfe:
1. Kommt jeder gewählte Baustein in GENAU einem Kapitel vor (nichts fehlt, nichts doppelt, nichts erfunden)?
2. Führt Kapitel 1 zum schnellsten sichtbaren Ergebnis — oder beginnt es mit Theorie/Interna?
3. Stehen Voraussetzungen vor dem, was auf ihnen aufbaut? Konkretes vor Abstraktem?
4. Kapitelgrößen 37, Kapiteltitel kurz und konkret?
Du PRÜFST nur und notierst Probleme — du änderst die Gliederung nicht.
Schreibe NUR die JSON-Datei nach: {out_path}
Format — Gliederung in Ordnung:
{{"ok": true}}
Sonst (kurz und konkret, maximal 10 Punkte):
{{"probleme": ["…", "…"]}}
{extra}

View File

@@ -0,0 +1,20 @@
Korrigiere die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}).
GEWÄHLTE BAUSTEINE (müssen alle vorkommen):
{auswahl}
BISHERIGE GLIEDERUNG:
{gliederung}
NOTIERTE PROBLEME (von der Prüfung):
{probleme}
Behebe NUR die notierten Probleme — alles andere bleibt unverändert.
- JEDER gewählte Baustein landet in GENAU einem Kapitel.
- Verwende die Titel EXAKT so, wie sie in der Liste stehen.
Schreibe NUR die vollständige, korrigierte JSON-Datei nach: {out_path}
Format:
{{"kapitel": [{{"titel": "Grundlagen", "bausteine": ["Exakter Titel", "Exakter Titel"]}}]}}
{extra}

View File

@@ -1,22 +1,15 @@
Plane die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}). Plane die Gliederung eines Lern-Guides zum Thema "{topic}" (Format: {format_name}).
BAUSTEINE (unsortiertes Inventar): GEWÄHLTE BAUSTEINE (unsortiert — die Auswahl steht fest, du ordnest nur):
{bausteine} {bausteine}
{auswahl_auftrag}
AUSWAHL — denke vom Ziel her: Was soll der Leser am Ende KÖNNEN?
- Wähle, was der Leser dafür praktisch braucht und wirklich benutzt.
- Lass weg: Interna (was das Werkzeug intern tut, ohne dass man es anfasst), Spezialfälle und Alternativen zum selben Problem — ein Weg reicht.
- "Klingt fundamental" ist kein Kriterium. Frage stattdessen: Fasst der Leser das selbst an?
REIHENFOLGE — vom Bekannten zum Unbekannten: REIHENFOLGE — vom Bekannten zum Unbekannten:
- Kapitel 1 führt zum schnellsten sichtbaren Ergebnis (erster Erfolg), nicht zur Theorie. - Kapitel 1 führt zum schnellsten sichtbaren Ergebnis (erster Erfolg), nicht zur Theorie.
- Konkretes vor Abstraktem, Einfaches vor Komplexem, Voraussetzungen vor dem, was auf ihnen aufbaut. - Konkretes vor Abstraktem, Einfaches vor Komplexem, Voraussetzungen vor dem, was auf ihnen aufbaut.
- Jedes Kapitel baut auf den vorigen auf — ein roter Faden, keine Themensammlung. - Jedes Kapitel baut auf den vorigen auf — ein roter Faden, keine Themensammlung.
Regeln: Regeln:
- Jeder gewählte Baustein landet in GENAU einem Kapitel. Keine neuen erfinden. - JEDER gewählte Baustein landet in GENAU einem Kapitel. Keinen weglassen, keine neuen erfinden.
- Verwende die Titel EXAKT so, wie sie in der Liste stehen. - Verwende die Titel EXAKT so, wie sie in der Liste stehen.
- 37 Bausteine pro Kapitel; die Kapitelzahl folgt aus dem Thema. - 37 Bausteine pro Kapitel; die Kapitelzahl folgt aus dem Thema.
- Kapiteltitel kurz und konkret. - Kapiteltitel kurz und konkret.

View File

@@ -0,0 +1,23 @@
Prüfe geschriebene Sections eines Lern-Guides zum Thema "{topic}" (Format: {format_name}) auf Lesbarkeit.
Zielgruppe: Anfänger.
SECTION-SPEZIFIKATION (Soll-Zustand):
{spec}
SECTIONS:
{sections}
Prüfe jede Section:
1. Ist die Beschreibung für Anfänger verständlich und maximal 12 Sätze?
2. Sind die Beispiele kurz, simpel und plausibel korrekt?
3. 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.
Schreibe NUR die JSON-Datei nach: {out_path}
Format — alles in Ordnung:
{{"ok": true}}
Sonst (Section-Titel EXAKT wie oben):
{{"probleme": [{{"section": "Exakter Section-Titel", "problem": "…"}}]}}
{extra}

View File

@@ -0,0 +1,24 @@
Überarbeite einzelne Sections eines Lern-Guides zum Thema "{topic}" (Format: {format_name}).
{facts}
SECTION-SPEZIFIKATION:
{spec}
ZU ÜBERARBEITEN — je Section der aktuelle Inhalt und das notierte Problem:
{auftraege}
Behebe pro Section NUR das notierte Problem; was in Ordnung ist, bleibt inhaltlich erhalten.
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:
<!-- section: Exakter Section-Titel -->
Beschreibung…
### Beispiel
```sprache
```
Die Marker-Zeilen exakt so schreiben. Kein Text außerhalb der Sections.
{extra}

View File

@@ -1,21 +1,21 @@
Baue aus der Faktenbasis einen OnePager zum Thema "{topic}" — ein Einordnungs- und Entscheidungsdokument auf einer Seite. Danach muss der Leser verstehen, was das Thema ist, wo es hingehört und ob es das ist, was er sucht. Baue aus der Faktenbasis einen OnePager zum Thema "{topic}" — ein Übersichtsblatt im 3×3-Raster auf einer Seite.
FAKTENBASIS (alleinige Quelle, nichts hinzuerfinden): FAKTENBASIS (alleinige Quelle, nichts hinzuerfinden):
{recherche} {recherche}
Erstelle GENAU diese 7 Karten (Titel exakt so): Erstelle GENAU diese 7 Karten (JSON-Schlüssel exakt so):
1. "Was ist {topic}?" — Definition in 12 Sätzen - "info" — Titel: "{topic}". Kurzbeschreibung in 12 Sätzen, darunter technische Daten als Stichpunkte (Art/Typ, Version/Stand, Lizenz/Kosten).
2. "Welches Problem löst es?" — der Schmerzpunkt, für den es gebaut wurde - "eigenschaften" — Titel: "Kerneigenschaften". Die 47 prägenden Eigenschaften des Systems als Stichpunkte.
3. "Wann nehmen — wann nicht?" — konkrete Entscheidungshilfe in 24 Stichpunkten - "beispiel" — Titel: "Beispiel". EIN anschauliches, typisches Codebeispiel (Markdown-Codeblock) mit einem Satz Erklärung.
4. "Einordnung & Alternativen" — die wichtigsten Nachbarn und der Unterschied - "zusammenhaenge" — Titel: "Zusammenhänge". Mit welchen Systemen/Themen es zusammenhängt — Stichpunkte mit je einem halben Satz.
5. "So sieht es aus" — EIN minimales, typisches Codebeispiel (Markdown-Codeblock) - "voraussetzungen" — Titel: "Voraussetzungen". Was man vorher können oder haben muss.
6. "Fakten" — Version, Reife, Lizenz/Kosten, Verbreitung - "modern" — Titel: "Moderne Features". Was aktuell ist und heute verwendet wird.
7. "Erste Schritte" — wie man anfängt, in 12 Zeilen - "veraltet" — Titel: "Veraltete Features". Was es noch gibt, aber nicht mehr verwendet werden sollte. Gibt es nichts Veraltetes: ehrlich "Keine." mit einem Satz Begründung — nichts erfinden.
Inhalt pro Karte kompakt (14 Sätze bzw. Stichpunkte, Markdown erlaubt), auf DEUTSCH, alles aus der Faktenbasis belegbar. Inhalt pro Karte kompakt (Markdown erlaubt), auf DEUTSCH, alles aus der Faktenbasis belegbar.
Schreibe NUR die JSON-Datei nach: {out_path} Schreibe NUR die JSON-Datei nach: {out_path}
Format: Format:
{{"karten": [{{"titel": "Was ist {topic}?", "merksatz": "…"}}]}} {{"karten": {{"info": {{"titel": "{topic}", "md": "…"}}, "eigenschaften": {{"titel": "Kerneigenschaften", "md": "…"}}, "beispiel": {{"titel": "Beispiel", "md": "…"}}, "zusammenhaenge": {{"titel": "Zusammenhänge", "md": "…"}}, "voraussetzungen": {{"titel": "Voraussetzungen", "md": "…"}}, "modern": {{"titel": "Moderne Features", "md": "…"}}, "veraltet": {{"titel": "Veraltete Features", "md": "…"}}}}}}
{extra} {extra}

View File

@@ -0,0 +1,18 @@
Korrigiere die Karten eines OnePagers zum Thema "{topic}".
FAKTENBASIS (alleinige Quelle, nichts hinzuerfinden):
{recherche}
BISHERIGE KARTEN:
{karten}
NOTIERTE PROBLEME (von der Prüfung):
{probleme}
Behebe NUR die notierten Probleme — alle anderen Karten bleiben unverändert.
Schreibe NUR die vollständige, korrigierte JSON-Datei (alle 7 Karten) nach: {out_path}
Format:
{{"karten": {{"info": {{"titel": "…", "md": "…"}}, "eigenschaften": {{"titel": "…", "md": "…"}}, "beispiel": {{"titel": "…", "md": "…"}}, "zusammenhaenge": {{"titel": "…", "md": "…"}}, "voraussetzungen": {{"titel": "…", "md": "…"}}, "modern": {{"titel": "…", "md": "…"}}, "veraltet": {{"titel": "…", "md": "…"}}}}}}
{extra}

View File

@@ -0,0 +1,27 @@
Prüfe die Faktenbasis für einen OnePager zum Thema "{topic}".
FAKTENBASIS:
{recherche}
Sie muss diese Dimensionen abdecken:
1. Kurzbeschreibung (12 Sätze)
2. Technische Daten (Art/Typ, Version/Stand, Lizenz/Kosten)
3. Kerneigenschaften des Systems
4. Ein typisches Beispiel
5. Zusammenhänge mit anderen Systemen/Themen
6. Voraussetzungen
7. Moderne vs. veraltete Features (oder die ausdrückliche Feststellung, dass nichts veraltet ist)
Prüfe:
1. Ist jede Dimension mit konkreten Fakten belegt (Namen, Versionen, Zahlen — nicht vage)?
2. Hat jeder Punkt eine Quelle?
3. Wirkt etwas erfunden oder widersprüchlich?
Du PRÜFST nur und notierst Probleme — du änderst nichts.
Schreibe NUR die JSON-Datei nach: {out_path}
Format — alles in Ordnung:
{{"ok": true}}
Sonst (kurz und konkret, maximal 10 Punkte):
{{"probleme": ["…", "…"]}}

View File

@@ -0,0 +1,16 @@
Überarbeite die Faktenbasis für einen OnePager zum Thema "{topic}".
{source}
BISHERIGE FAKTENBASIS:
{recherche}
NOTIERTE PROBLEME (von der Prüfung):
{probleme}
Behebe NUR die notierten Probleme — fehlende Dimensionen nachrecherchieren, Vages konkretisieren, Unbelegtes belegen oder streichen. Alles andere bleibt erhalten.
Schreibe die VOLLSTÄNDIGE, überarbeitete Markdown-Datei nach: {out_path}
Kompakt, faktenorientiert, mit Quelle pro Punkt.
{extra}

View File

@@ -1,15 +1,15 @@
Sammle die Faktenbasis für einen OnePager — ein Einordnungs- und Entscheidungsdokument — zum Projekt "{topic}". Sammle die Faktenbasis für einen OnePager — ein Übersichtsblatt auf einer Seite — zum Projekt "{topic}".
{source} {source}
Erfasse gezielt diese Dimensionen: Erfasse gezielt diese Dimensionen:
1. Definition: Was ist "{topic}" in 12 Sätzen (Art des Projekts, Gegenstand)? 1. Kurzbeschreibung: Was ist "{topic}" in 12 Sätzen (Art des Projekts, Gegenstand)?
2. Problem: Welches Problem löst es, für wen ist es gedacht? 2. Technische Daten: Technologie/Format, Umfang (Dateien/Seiten/Module), Stand/Aktualität.
3. Abgrenzung: Was deckt das Projekt ab, was ausdrücklich nicht? 3. Kerneigenschaften: die prägenden Konzepte, Komponenten oder Inhalte des Projekts.
4. Einordnung: In welchem Kontext steht es (Umfeld, Abhängigkeiten, angrenzende Systeme/Themen)? 4. Beispiel: ein typisches, konkretes Beispiel aus dem Projekt (zentraler Code-Flow bzw. Kerninhalt).
5. Anschauung: Ein typisches, konkretes Beispiel aus dem Projekt (zentraler Code-Flow bzw. Kerninhalt). 5. Zusammenhänge: in welchem Umfeld es steht (Abhängigkeiten, angrenzende Systeme/Themen).
6. Fakten: Technologie/Format, Umfang (Dateien/Seiten/Module), Stand/Aktualität. 6. Voraussetzungen: was man können oder haben muss, um es zu nutzen bzw. zu verstehen.
7. Einstieg: Wo fängt man an — wie startet man es bzw. was liest man zuerst? 7. Moderne vs. veraltete Teile: was aktueller Stand ist — und was als Altlast gilt (falls nichts veraltet ist, ausdrücklich notieren).
Schreibe NUR die Markdown-Datei nach: {out_path} Schreibe NUR die Markdown-Datei nach: {out_path}

View File

@@ -1,15 +1,15 @@
Sammle die Faktenbasis für einen OnePager — ein Einordnungs- und Entscheidungsdokument — zum Thema "{topic}". Sammle die Faktenbasis für einen OnePager — ein Übersichtsblatt auf einer Seite — zum Thema "{topic}".
{source} {source}
Recherchiere gezielt diese Dimensionen: Recherchiere gezielt diese Dimensionen:
1. Definition: Was ist "{topic}" in 12 Sätzen? 1. Kurzbeschreibung: Was ist "{topic}" in 12 Sätzen?
2. Problem: Welches Problem löst es, wer braucht es? 2. Technische Daten: Art/Typ, aktuelle Version/Stand, Lizenz/Kosten, Verbreitung.
3. Abgrenzung: Wofür ist es geeignet, wofür ausdrücklich nicht? 3. Kerneigenschaften: die prägenden Eigenschaften und Merkmale des Systems.
4. Einordnung: Die wichtigsten Alternativen/Nachbarn und wie sich "{topic}" davon unterscheidet. 4. Beispiel: ein minimales, typisches Code-/Anwendungsbeispiel.
5. Anschauung: Ein minimales, typisches Code-/Anwendungsbeispiel. 5. Zusammenhänge: mit welchen Systemen/Themen es zusammenhängt (Ökosystem, Nachbarn, typische Kombinationen).
6. Fakten: Aktuelle Version, Reife/Alter, Lizenz/Kosten, Verbreitung. 6. Voraussetzungen: was man vorher können oder haben muss.
7. Einstieg: Wie fängt man an (Installation/erster Schritt)? 7. Moderne vs. veraltete Features: was heute verwendet wird — und was es noch gibt, aber nicht mehr verwendet werden sollte (falls nichts veraltet ist, ausdrücklich notieren).
Schreibe NUR die Markdown-Datei nach: {out_path} Schreibe NUR die Markdown-Datei nach: {out_path}

View File

@@ -1,4 +1,4 @@
Verifiziere einen OnePager zum Thema "{topic}" gegen seine Faktenbasis. Prüfe einen OnePager zum Thema "{topic}" gegen seine Faktenbasis.
FAKTENBASIS: FAKTENBASIS:
{recherche} {recherche}
@@ -7,13 +7,15 @@ ONEPAGER-KARTEN:
{karten} {karten}
Prüfe: Prüfe:
1. Sind alle 7 Pflicht-Karten vorhanden und vollständig ausgefüllt (keine abgebrochenen oder leeren Inhalte)? — "Was ist {topic}?", "Welches Problem löst es?", "Wann nehmen — wann nicht?", "Einordnung & Alternativen", "So sieht es aus", "Fakten", "Erste Schritte" 1. Sind alle 7 Karten vollständig ausgefüllt (keine abgebrochenen oder leeren Inhalte, keine Platzhalter)?
2. Stimmen alle Aussagen mit der Faktenbasis überein? Nichts Erfundenes? 2. Stimmen alle Aussagen mit der Faktenbasis überein? Nichts Erfundenes?
3. Beantwortet der OnePager die Leserfrage „Ist das das, was ich suche?" — ist die Abgrenzung konkret genug? 3. Ist jede Karte kompakt und für sich verständlich? Ist das Beispiel ein lauffähig plausibler Codeblock?
Du PRÜFST nur und notierst Probleme — du änderst nichts. Nenne die betroffene Karte über ihren Schlüssel (info, eigenschaften, beispiel, zusammenhaenge, voraussetzungen, modern, veraltet).
Schreibe NUR die JSON-Datei nach: {out_path} Schreibe NUR die JSON-Datei nach: {out_path}
Format — alles in Ordnung: Format — alles in Ordnung:
{{"ok": true}} {{"ok": true}}
Sonst die vollständige, korrigierte Karten-Liste: Sonst (kurz und konkret, maximal 10 Punkte):
{{"karten": [{{"titel": "…", "merksatz": "…"}}]}} {{"probleme": ["beispiel: …", "fakten in info veraltet: …"]}}