This commit is contained in:
Team3
2026-06-06 16:07:04 +02:00
parent 18bb18bf4a
commit 4aa3130807
19 changed files with 861 additions and 206 deletions

View File

@@ -63,7 +63,6 @@ async def run_agent(
return 1, "", f"Unbekannter Provider: {provider}" return 1, "", f"Unbekannter Provider: {provider}"
if shutil.which(PROVIDERS[provider]["cli"]) is None: if shutil.which(PROVIDERS[provider]["cli"]) is None:
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})" return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
timeout = int(timeout * PROVIDERS[provider].get("timeout_factor", 1))
if provider == "minimax": if provider == "minimax":
return await _run_opencode(agent_key, prompt, timeout, role, capabilities) return await _run_opencode(agent_key, prompt, timeout, role, capabilities)
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities) return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)

View File

@@ -7,26 +7,42 @@ FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
DB_PATH = STORAGE_DIR / "creator.db" DB_PATH = STORAGE_DIR / "creator.db"
PROJECTS_DIR = PROJECT_ROOT / "projects" PROJECTS_DIR = PROJECT_ROOT / "projects"
AGENT_TIMEOUT = 3600
MAX_CONCURRENT_GENERATIONS = 10 MAX_CONCURRENT_GENERATIONS = 10
# Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section).
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
TIMEOUTS = {
"recherche": (1800, 0), # fix 30 min
"auswahl": (600, 10),
"auswahl_check": (300, 2),
"einordnung": (300, 5),
"final": (300, 2), # verifiziert nur noch, kleiner Output
"sortierung": (300, 2),
"plan": (300, 5),
"writer": (600, 120), # pro Section im Chunk
"onepager_recherche": (900, 0),
"onepager_bauen": (300, 0),
"onepager_verify": (300, 0),
}
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden. # Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
# Rollen: "guide" = große Generierung, "fast" = Baustein-Recherche/Chat. # Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
# "fast" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat),
# "guide" = große Generierung (Plan, Writer).
DEFAULT_PROVIDER = "claude" DEFAULT_PROVIDER = "claude"
PROVIDERS = { PROVIDERS = {
"claude": { "claude": {
"cli": "claude", "cli": "claude",
"guide": "claude-opus-4-8[1m]", "guide": "claude-opus-4-8[1m]",
"fast": "claude-sonnet-4-6", "fast": "claude-sonnet-4-6",
"quick": "claude-haiku-4-5",
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude "env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
"timeout_factor": 1,
}, },
"minimax": { "minimax": {
"cli": "opencode", "cli": "opencode",
"guide": "minimax/MiniMax-M3", "guide": "minimax/MiniMax-M3",
"fast": "minimax/MiniMax-M3", "fast": "minimax/MiniMax-M2.7-highspeed",
"quick": "minimax/MiniMax-M2.7-highspeed",
"env_key": "MINIMAX_API_KEY", "env_key": "MINIMAX_API_KEY",
"timeout_factor": 3, # M3 ist bei großen Dokumenten deutlich langsamer
}, },
} }

View File

@@ -24,6 +24,13 @@ CREATE TABLE IF NOT EXISTS guide_progress (
) )
""" """
CREATE_TOPICS = """
CREATE TABLE IF NOT EXISTS topics (
name TEXT PRIMARY KEY,
created_at TEXT NOT NULL
)
"""
_db: aiosqlite.Connection | None = None _db: aiosqlite.Connection | None = None
@@ -39,6 +46,7 @@ async def init_db():
db = await get_db() db = await get_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( 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')"
@@ -100,6 +108,31 @@ async def delete_guide(guide_id: str) -> bool:
return cursor.rowcount > 0 return cursor.rowcount > 0
# --- Themen ---
async def create_topic(name: str) -> None:
from datetime import datetime, timezone
db = await get_db()
await db.execute(
"INSERT OR IGNORE INTO topics (name, created_at) VALUES (?, ?)",
(name, datetime.now(timezone.utc).isoformat()),
)
await db.commit()
async def list_topics() -> list[str]:
db = await get_db()
cursor = await db.execute("SELECT name FROM topics ORDER BY created_at DESC")
rows = await cursor.fetchall()
return [row[0] for row in rows]
async def delete_topic(name: str) -> None:
db = await get_db()
await db.execute("DELETE FROM topics WHERE name = ?", (name,))
await db.commit()
# --- Kapitel-Fortschritt --- # --- Kapitel-Fortschritt ---
async def list_progress(guide_id: str) -> list[str]: async def list_progress(guide_id: str) -> list[str]:

View File

@@ -2,14 +2,15 @@ import asyncio
import json import json
import re import re
import uuid import uuid
from collections import Counter
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from agents import run_agent, kill_process from agents import run_agent, kill_process
from config import ( from config import (
AGENT_TIMEOUT,
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
TEMPLATES_DIR, TEMPLATES_DIR,
TIMEOUTS,
MAX_CONCURRENT_GENERATIONS, MAX_CONCURRENT_GENERATIONS,
) )
from database import update_guide from database import update_guide
@@ -70,20 +71,148 @@ async def _fail(guide_id: str, msg: str) -> None:
await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now) await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now)
# --- Bausteine-Pipeline: 3x Recherche → Auswahl → 2x Einordnung → finale Einordnung --- def _timeout(step: str, n: int = 0) -> int:
base, per = TIMEOUTS[step]
return base + per * n
_MAX_RESTARTS = 2
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None) -> list | None:
"""Startet alle Slots parallel und sammelt `quorum` gültige Ergebnisse.
Slot-Spec: {key, prompt, role, capabilities, payload}. `payload(result)`
prüft die Gültigkeit und liefert das Slot-Ergebnis oder None.
Fehler/Timeout/ungültig → Slot-Neustart (max. _MAX_RESTARTS). Sobald das
Quorum steht, werden die übrigen Agenten gekillt. None = Quorum verfehlt.
`cancelled()` → True bricht ab (keine Restarts, Rückgabe None).
"""
attempts = {i: 0 for i in range(len(slots))}
tasks: dict[asyncio.Task, int] = {}
def spawn(i: int) -> None:
slot = slots[i]
task = asyncio.create_task(run_agent(
slot["key"], slot["prompt"], timeout,
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
))
tasks[task] = i
for i in range(len(slots)):
spawn(i)
results: list = []
try:
while tasks:
if cancelled and cancelled():
return None
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED)
for task in done:
i = tasks.pop(task)
payload, err = None, None
try:
result = task.result()
if result[0] != 0:
err = _claude_error("Fehler", *result)
else:
payload = slots[i]["payload"](result)
if payload is None:
err = "Ergebnis ungültig/nicht parsebar"
except asyncio.TimeoutError:
err = f"Timeout nach {timeout}s"
except Exception as e:
err = f"{type(e).__name__}: {e}"
if payload is not None:
results.append(payload)
if on_update:
on_update(len(results))
if len(results) >= quorum:
return results
continue
_log(topic, f"{label} {i + 1} (Versuch {attempts[i] + 1}): {err}")
attempts[i] += 1
if attempts[i] <= _MAX_RESTARTS and not (cancelled and cancelled()):
spawn(i)
_log(topic, f"{label}: Quorum {quorum} nicht erreicht ({len(results)} gültig)")
return None
finally:
for task, i in tasks.items():
kill_process(slots[i]["key"])
task.cancel()
if tasks:
await asyncio.gather(*tasks.keys(), return_exceptions=True)
# --- Bausteine-Pipeline: 4x Recherche (3 nötig) → 2x Auswahl (1) → 4x Einordnung (3) → 2x Final (1) ---
_bausteine_progress: dict[str, str] = {} _bausteine_progress: dict[str, str] = {}
_bausteine_errors: dict[str, str] = {} _bausteine_errors: dict[str, str] = {}
_bausteine_cancelled: set[str] = set()
_bausteine_step: dict[str, int] = {}
BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung", "Einordnung", "Verifikation", "Sortierung")
def cancel_bausteine(topic: str) -> bool:
if topic not in _bausteine_progress:
return False
_bausteine_cancelled.add(topic)
kill_process(f"bausteine-{topic}-")
return True
_CATEGORIES = ("KERN", "WICHTIG", "REST") _CATEGORIES = ("KERN", "WICHTIG", "REST")
def _resume_step(topic: str) -> int:
"""Erster noch offener Schritt anhand der persistierten Zwischendateien."""
final_path = bausteine_path(topic)
stem, parent = final_path.stem, final_path.parent
if sum((parent / f"{stem}.recherche-{i}.md").exists() for i in (1, 2, 3, 4)) < 3:
return 0
if not any((parent / f"{stem}.auswahl-{i}.md").exists() for i in (1, 2)):
return 1
if not (parent / f"{stem}.auswahl-check.md").exists():
return 2
if sum((parent / f"{stem}.einordnung-{i}.md").exists() for i in (1, 2, 3)) < 3:
return 3
if not (parent / f"{stem}.final-check.md").exists():
return 4
return 5
def _sortierung_path(topic: str):
final_path = bausteine_path(topic)
return final_path.parent / f"{final_path.stem}.sortierung.md"
def bausteine_status(topic: str) -> dict: def bausteine_status(topic: str) -> dict:
ready = bausteine_path(topic).exists()
generating = topic in _bausteine_progress
partial = False
if generating:
current = _bausteine_step.get(topic)
states = [
"pending" if current is None else "done" if i < current else "active" if i == current else "pending"
for i in range(len(BAUSTEINE_STEPS))
]
elif ready:
states = ["done"] * len(BAUSTEINE_STEPS)
if not _sortierung_path(topic).exists():
states[-1] = "pending"
else:
nxt = _resume_step(topic)
partial = nxt > 0
states = ["done" if i < nxt else "pending" for i in range(len(BAUSTEINE_STEPS))]
return { return {
"ready": bausteine_path(topic).exists(), "ready": ready,
"generating": topic in _bausteine_progress, "generating": generating,
"progress": _bausteine_progress.get(topic), "progress": _bausteine_progress.get(topic),
"error": _bausteine_errors.get(topic), "error": _bausteine_errors.get(topic),
"partial": partial,
"steps": [{"label": label, "state": s} for label, s in zip(BAUSTEINE_STEPS, states)],
} }
@@ -92,7 +221,16 @@ def active_bausteine() -> list[dict]:
def reset_bausteine(topic: str) -> None: def reset_bausteine(topic: str) -> None:
bausteine_path(topic).unlink(missing_ok=True) final_path = bausteine_path(topic)
final_path.unlink(missing_ok=True)
for i in (1, 2, 3, 4):
(final_path.parent / f"{final_path.stem}.recherche-{i}.md").unlink(missing_ok=True)
(final_path.parent / f"{final_path.stem}.einordnung-{i}.md").unlink(missing_ok=True)
for i in (1, 2):
(final_path.parent / f"{final_path.stem}.auswahl-{i}.md").unlink(missing_ok=True)
(final_path.parent / f"{final_path.stem}.auswahl-check.md").unlink(missing_ok=True)
(final_path.parent / f"{final_path.stem}.final-check.md").unlink(missing_ok=True)
(final_path.parent / f"{final_path.stem}.sortierung.md").unlink(missing_ok=True)
_bausteine_errors.pop(topic, None) _bausteine_errors.pop(topic, None)
@@ -142,25 +280,125 @@ def _parse_einordnung(text: str) -> dict[int, str]:
return mapping return mapping
def _build_final_bausteine(topic: str, entries: dict[int, str], mapping: dict[int, str]) -> str: def _build_final_bausteine(topic: str, entries: dict[int, str], mapping: dict[int, str], order: dict[str, list[int]] | None = None) -> str:
"""Baut die finale Baustein-Datei aus konsolidierter Liste + finaler Zuordnung.""" """Baut die finale Baustein-Datei aus konsolidierter Liste + finaler Zuordnung.
grouped: dict[str, list[str]] = {c: [] for c in _CATEGORIES}
`order` (Kategorie → Nummern in Lernreihenfolge) sortiert innerhalb der
Kategorien; nicht gelistete Nummern hängen in Originalreihenfolge hinten an.
"""
grouped: dict[str, list[int]] = {c: [] for c in _CATEGORIES}
for num in sorted(entries): for num in sorted(entries):
cat = mapping.get(num) cat = mapping.get(num)
if cat is None: if cat is None:
_log(topic, f"Baustein {num} fehlt in finaler Einordnung → REST") _log(topic, f"Baustein {num} fehlt in finaler Einordnung → REST")
cat = "REST" cat = "REST"
grouped[cat].append(entries[num]) grouped[cat].append(num)
unknown = sorted(set(mapping) - set(entries)) unknown = sorted(set(mapping) - set(entries))
if unknown: if unknown:
_log(topic, f"finale Einordnung enthält unbekannte Nummern (ignoriert): {unknown}") _log(topic, f"finale Einordnung enthält unbekannte Nummern (ignoriert): {unknown}")
if order:
for cat in _CATEGORIES:
wanted = set(grouped[cat])
seq = [n for n in order.get(cat, []) if n in wanted]
grouped[cat] = seq + [n for n in grouped[cat] if n not in seq]
parts = [] parts = []
for cat in _CATEGORIES: for cat in _CATEGORIES:
lines = "\n".join(f"{i}. {text}" for i, text in enumerate(grouped[cat], 1)) lines = "\n".join(f"{i}. {entries[num]}" for i, num in enumerate(grouped[cat], 1))
parts.append(f"## {cat}\n{lines}") parts.append(f"## {cat}\n{lines}")
return "\n\n".join(parts) + "\n" return "\n\n".join(parts) + "\n"
def _file_payload(path: Path):
"""Gültig, wenn die Slot-Datei existiert und nummerierte Einträge enthält."""
if not path.exists():
return None
text = path.read_text(encoding="utf-8")
return text if _parse_auswahl(text) else None
def _auswahl_payload(path: Path):
if not path.exists():
return None
text = path.read_text(encoding="utf-8")
entries = _parse_auswahl(text)
return (text, entries) if entries else None
def _parse_auswahl_check(text: str):
"""Parst die Auswahl-Prüfung: NACHTRÄGE (neue Einträge) + STREICHEN (Nummern)."""
additions: list[str] = []
removals: set[int] = set()
mode = None
seen_marker = False
for line in text.splitlines():
s = line.strip().lstrip("-*# ").strip()
if not s:
continue
u = s.upper().rstrip(":")
if u.startswith("NACHTR"):
mode = "add"
seen_marker = True
continue
if u.startswith("STREICH"):
mode = "del"
seen_marker = True
continue
if u == "OK":
seen_marker = True
continue
if mode == "add":
additions.append(s)
elif mode == "del":
m = re.match(r"(\d+)\b", s)
if m:
removals.add(int(m.group(1)))
if not seen_marker:
return None # Antwort hat das Format nicht getroffen
return {"add": additions, "remove": removals}
def _majority(mappings: list[dict[int, str]], entries: dict[int, str]) -> tuple[dict[int, str], list[int]]:
"""Mehrheitsentscheid über die Einordnungen; ohne Mehrheit → Streitfall."""
mapping: dict[int, str] = {}
disputes: list[int] = []
for num in entries:
votes = [m[num] for m in mappings if num in m]
if not votes:
disputes.append(num)
continue
cat, count = Counter(votes).most_common(1)[0]
if count >= 2:
mapping[num] = cat
else:
disputes.append(num)
return mapping, disputes
def _einordnung_block(mapping: dict[int, str], entries: dict[int, str]) -> str:
parts = []
for cat in _CATEGORIES:
nums = [n for n in sorted(entries) if mapping.get(n) == cat]
lines = "\n".join(f"{n} {_titel(entries[n])}" for n in nums)
parts.append(f"{cat}:\n{lines}" if lines else f"{cat}:")
return "\n".join(parts)
async def _run_sortierung(topic: str, entries: dict[int, str], mapping: dict[int, str], provider: str, cancelled) -> dict[str, list[int]] | None:
"""Sortiert innerhalb der Kategorien; schreibt bei Erfolg den Marker und liefert die Reihenfolge."""
slots = [{
"key": f"bausteine-{topic}-sortierung-1",
"prompt": _prompt("Bausteine-Sortierung", topic=topic, einordnung=_einordnung_block(mapping, entries)),
"role": "quick", "capabilities": "none",
"payload": (lambda result: (result[1].strip(), _parse_einordnung(result[1])) if _parse_einordnung(result[1]) else None),
}]
res = await _race(topic, "Sortierung", slots, 1, _timeout("sortierung", len(entries)), provider, cancelled=cancelled)
if res is None:
return None
raw, sort_mapping = res[0]
_sortierung_path(topic).write_text(raw, encoding="utf-8")
return {cat: [num for num, c in sort_mapping.items() if c == cat] for cat in _CATEGORIES}
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None: async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
if topic in _bausteine_progress: if topic in _bausteine_progress:
return return
@@ -170,107 +408,252 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
final_path = bausteine_path(topic) final_path = bausteine_path(topic)
project = project_dir(topic) if project_dir(topic).is_dir() else None project = project_dir(topic) if project_dir(topic).is_dir() else None
stem = final_path.stem stem = final_path.stem
recherche_paths = [final_path.parent / f"{stem}.recherche-{i}.md" for i in (1, 2, 3)] recherche_paths = [final_path.parent / f"{stem}.recherche-{i}.md" for i in (1, 2, 3, 4)]
auswahl_path = final_path.parent / f"{stem}.auswahl.md" auswahl_paths = [final_path.parent / f"{stem}.auswahl-{i}.md" for i in (1, 2)]
einordnung_paths = [final_path.parent / f"{stem}.einordnung-{i}.md" for i in (1, 2, 3)]
auswahl_check_path = final_path.parent / f"{stem}.auswahl-check.md"
final_check_path = final_path.parent / f"{stem}.final-check.md"
sortierung_path = _sortierung_path(topic)
slot_files = [*recherche_paths, *auswahl_paths, *einordnung_paths, auswahl_check_path, final_check_path, sortierung_path]
def set_p(msg: str, step: int | None = None) -> None:
_bausteine_progress[topic] = msg
if step is not None:
_bausteine_step[topic] = step
def is_cancelled() -> bool:
return topic in _bausteine_cancelled
def abgebrochen() -> None:
_bausteine_errors[topic] = "Abgebrochen — Fortschritt bleibt erhalten"
try: try:
async with _semaphore: async with _semaphore:
# Schritt 1: 3 Recherche-Agenten parallel (Thema: Websuche, Projekt: Dateien lesen) # Fertig, aber ohne Sortier-Marker (ältere Pipeline-Version): nur die Sortierung nachholen.
_bausteine_progress[topic] = "Recherche läuft (3 Agenten)…" if final_path.exists() and not sortierung_path.exists():
caps = "files" if project else "full" cats = _parse_kategorien(final_path.read_text(encoding="utf-8"))
results = await asyncio.gather(*[ entries, mapping = {}, {}
run_agent( i = 0
f"bausteine-{topic}-recherche-{i}", for cat in _CATEGORIES:
_build_recherche_prompt(topic, path, instructions, project), for text in cats.get(cat, []):
AGENT_TIMEOUT, provider=provider, role="fast", capabilities=caps, i += 1
) entries[i] = text
for i, path in enumerate(recherche_paths, 1) mapping[i] = cat
], return_exceptions=True) if entries:
for i, (r, p) in enumerate(zip(results, recherche_paths), 1): set_p("Sortiere Bausteine…", step=5)
if isinstance(r, BaseException): order = await _run_sortierung(topic, entries, mapping, provider, is_cancelled)
_log(topic, f"Recherche {i}: {type(r).__name__}: {r}") if is_cancelled():
elif r[0] != 0: abgebrochen()
_log(topic, f"Recherche {i}: {_claude_error('Fehler', *r)}") return
elif not p.exists(): if order is None:
_log(topic, f"Recherche {i}: keine Ausgabedatei erstellt") _bausteine_errors[topic] = "Sortierung fehlgeschlagen"
recherchen = [p.read_text(encoding="utf-8") for p in recherche_paths if p.exists()] return
if not recherchen: final_path.write_text(_build_final_bausteine(topic, entries, mapping, order), encoding="utf-8")
_bausteine_errors[topic] = _gather_error("Recherche-Fehler", results)
return return
# Schritt 2: Auswahl-Agent konsolidiert die Ergebnisse (ohne Quellen) # „Neu erstellen": fertige (sortierte) Bausteine → kompletter Frischstart.
_bausteine_progress[topic] = f"Konsolidiere Recherche ({len(recherchen)}/3 erfolgreich)…" # Sonst sind Slot-Dateien Reste eines Abbruchs/Fehlers → Resume, fertige Schritte überspringen.
results_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1)) if final_path.exists():
returncode, stdout, stderr = await run_agent( for p_alt in slot_files:
f"bausteine-{topic}-auswahl", p_alt.unlink(missing_ok=True)
_prompt("Bausteine-Auswahl", topic=topic, results=results_block, out_path=auswahl_path),
AGENT_TIMEOUT, provider=provider, role="fast", capabilities="files",
)
if returncode != 0 or not auswahl_path.exists():
_bausteine_errors[topic] = _claude_error("Auswahl-Fehler", returncode, stdout, stderr)
return
flat = auswahl_path.read_text(encoding="utf-8")
entries = _parse_auswahl(flat)
if not entries:
_bausteine_errors[topic] = "Auswahl-Liste nicht parsebar"
return
# Schritt 3: 2 Einordnungs-Agenten parallel (antworten nur mit Nummer+Titel je Kategorie) # Schritt 1: 4 Recherche-Agenten, 3 gültige nötig — vorhandene Slot-Dateien zählen
_bausteine_progress[topic] = "Einordnung läuft (2 Agenten)…" recherchen = []
results = await asyncio.gather(*[ offen = []
run_agent( for i, path in enumerate(recherche_paths, 1):
f"bausteine-{topic}-einordnung-{i}", text = _file_payload(path)
_prompt("Bausteine-Einordnung", topic=topic, bausteine=flat), if text is not None and len(recherchen) < 3:
AGENT_TIMEOUT, provider=provider, role="fast", capabilities="none", recherchen.append(text)
)
for i in (1, 2)
], return_exceptions=True)
einordnungen = []
for i, r in enumerate(results, 1):
if isinstance(r, BaseException):
_log(topic, f"Einordnung {i}: {type(r).__name__}: {r}")
elif r[0] != 0:
_log(topic, f"Einordnung {i}: {_claude_error('Fehler', *r)}")
elif not _parse_einordnung(r[1]):
_log(topic, f"Einordnung {i}: Antwort nicht parsebar")
else: else:
einordnungen.append(r[1].strip()) offen.append((i, path))
if not einordnungen: vorhanden = len(recherchen)
_bausteine_errors[topic] = _gather_error("Einordnungs-Fehler", results) set_p(f"Recherche läuft ({vorhanden}/3 gültig)…", step=0)
return if vorhanden < 3:
caps = "files" if project else "full"
slots = [
{
"key": f"bausteine-{topic}-recherche-{i}",
"prompt": _build_recherche_prompt(topic, path, instructions, project),
"role": "quick", "capabilities": caps,
"payload": (lambda result, p=path: _file_payload(p)),
}
for i, path in offen
]
neue = await _race(
topic, "Recherche", slots, 3 - vorhanden, _timeout("recherche"), provider,
on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c}/3 gültig)…"),
cancelled=is_cancelled,
)
if is_cancelled():
abgebrochen()
return
if neue is None:
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Quorum nicht erreicht)"
return
recherchen += neue
# Schritt 4: finale Einordnung — Python validiert und baut die Datei # Schritt 2: 2 Auswahl-Agenten, der erste gewinnt — vorhandene gültige Datei wird übernommen
_bausteine_progress[topic] = f"Finale Einordnung ({len(einordnungen)}/2 erfolgreich)…" n_est = max(len(_parse_auswahl(t)) for t in recherchen)
returncode, stdout, stderr = await run_agent( results_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1))
f"bausteine-{topic}-final", bestehende = next((res for p in auswahl_paths if (res := _auswahl_payload(p)) is not None), None)
_prompt( if bestehende is not None:
flat, entries = bestehende
else:
set_p("Konsolidiere Recherche…", step=1)
slots = [
{
"key": f"bausteine-{topic}-auswahl-{i}",
"prompt": _prompt("Bausteine-Auswahl", topic=topic, results=results_block, out_path=path),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=path: _auswahl_payload(p)),
}
for i, path in enumerate(auswahl_paths, 1)
]
auswahl = await _race(topic, "Auswahl", slots, 1, _timeout("auswahl", n_est), provider, cancelled=is_cancelled)
if is_cancelled():
abgebrochen()
return
if auswahl is None:
_bausteine_errors[topic] = "Auswahl fehlgeschlagen (kein gültiges Ergebnis)"
return
flat, entries = auswahl[0]
# Schritt 2b: Auswahl-Prüfung (nicht fatal) — gespeicherte Antwort wird erneut angewendet
set_p("Prüfe Auswahl…", step=2)
raw_check = auswahl_check_path.read_text(encoding="utf-8") if auswahl_check_path.exists() else None
patch = _parse_auswahl_check(raw_check) if raw_check is not None else None
if patch is None:
slots = [{
"key": f"bausteine-{topic}-auswahlcheck-1",
"prompt": _prompt("Bausteine-Auswahl-Check", topic=topic, results=results_block, auswahl=flat),
"role": "fast", "capabilities": "none",
"payload": (lambda result: (result[1].strip(), _parse_auswahl_check(result[1])) if _parse_auswahl_check(result[1]) is not None else None),
}]
checks = await _race(topic, "Auswahl-Check", slots, 1, _timeout("auswahl_check", len(entries)), provider, cancelled=is_cancelled)
if is_cancelled():
abgebrochen()
return
if checks is None:
_log(topic, "Auswahl-Check fehlgeschlagen — fahre ohne Korrekturen fort")
else:
raw_check, patch = checks[0]
auswahl_check_path.write_text(raw_check, encoding="utf-8")
if patch is not None:
if patch["remove"]:
_log(topic, f"Auswahl-Check streicht Duplikate: {sorted(patch['remove'])}")
entries = {n: t for n, t in entries.items() if n not in patch["remove"]}
if patch["add"]:
_log(topic, f"Auswahl-Check ergänzt {len(patch['add'])} Bausteine")
if patch["remove"] or patch["add"]:
texts = [t for _, t in sorted(entries.items())] + patch["add"]
entries = {i: t for i, t in enumerate(texts, 1)}
flat = "\n".join(f"{i}. {t}" for i, t in entries.items())
# Schritt 3: 4 Einordnungs-Agenten, 3 gültige nötig — gespeicherte Stimmen einlesen
n = len(entries)
einordnungen = []
for path in einordnung_paths:
if path.exists():
text = path.read_text(encoding="utf-8")
parsed = _parse_einordnung(text)
if parsed:
einordnungen.append((text, parsed))
einordnungen = einordnungen[:3]
vorhanden = len(einordnungen)
set_p(f"Einordnung läuft ({vorhanden}/3 gültig)…", step=3)
if vorhanden < 3:
slots = [
{
"key": f"bausteine-{topic}-einordnung-{i}",
"prompt": _prompt("Bausteine-Einordnung", topic=topic, bausteine=flat),
"role": "quick", "capabilities": "none",
"payload": (lambda result: (result[1].strip(), _parse_einordnung(result[1])) if _parse_einordnung(result[1]) else None),
}
for i in range(vorhanden + 1, 5)
]
neue = await _race(
topic, "Einordnung", slots, 3 - vorhanden, _timeout("einordnung", n), provider,
on_update=lambda c: set_p(f"Einordnung läuft ({vorhanden + c}/3 gültig)…"),
cancelled=is_cancelled,
)
if is_cancelled():
abgebrochen()
return
if neue is None:
_bausteine_errors[topic] = "Einordnung fehlgeschlagen (Quorum nicht erreicht)"
return
for path, (text, _) in zip(einordnung_paths[vorhanden:], neue):
path.write_text(text, encoding="utf-8")
einordnungen += neue
# Schritt 4: Python-Mehrheitsentscheid + Verifikations-Agent — gespeicherte Antwort wird erneut angewendet
set_p("Verifiziere Einordnung…", step=4)
mapping, disputes = _majority([m for _, m in einordnungen], entries)
if disputes:
_log(topic, f"Keine Mehrheit bei: {disputes}")
raw_final = final_check_path.read_text(encoding="utf-8") if final_check_path.exists() else None
if raw_final is not None and not (_parse_einordnung(raw_final) or "OK" in raw_final.upper()):
raw_final = None
if raw_final is None:
streit_block = "\n".join(f"{num} {entries[num]}" for num in disputes) or "(keine)"
final_prompt = _prompt(
"Bausteine-Einordnung-Final", "Bausteine-Einordnung-Final",
topic=topic, bausteine=flat, topic=topic,
einordnung_1=einordnungen[0], einordnung_2=einordnungen[-1], einordnung=_einordnung_block(mapping, entries),
), streitfaelle=streit_block,
AGENT_TIMEOUT, provider=provider, role="fast", capabilities="none", )
) slots = [
if returncode != 0: {
_bausteine_errors[topic] = _claude_error("Finale-Einordnungs-Fehler", returncode, stdout, stderr) "key": f"bausteine-{topic}-final-{i}",
"prompt": final_prompt,
"role": "fast", "capabilities": "none",
"payload": (lambda result: result[1].strip() if (_parse_einordnung(result[1]) or "OK" in result[1].upper()) else None),
}
for i in (1, 2)
]
finals = await _race(topic, "Final", slots, 1, _timeout("final", n), provider, cancelled=is_cancelled)
if is_cancelled():
abgebrochen()
return
if finals is None:
_log(topic, "Final-Verifikation fehlgeschlagen — Mehrheitsentscheid bleibt unverändert")
else:
raw_final = finals[0]
final_check_path.write_text(raw_final, encoding="utf-8")
if raw_final is not None:
overrides = {num: cat for num, cat in _parse_einordnung(raw_final).items() if num in entries}
korrekturen = {num: cat for num, cat in overrides.items() if mapping.get(num) != cat and num not in disputes}
if korrekturen:
_log(topic, f"Final-Verifikation korrigiert: {korrekturen}")
mapping.update(overrides)
for num in disputes:
if num not in mapping:
_log(topic, f"Streitfall {num} unentschieden → WICHTIG")
mapping[num] = "WICHTIG"
# Schritt 5: Sortierung innerhalb der Kategorien (einfach → komplex, nicht fatal)
set_p("Sortiere Bausteine…", step=5)
order = await _run_sortierung(topic, entries, mapping, provider, is_cancelled)
if is_cancelled():
abgebrochen()
return return
mapping = _parse_einordnung(stdout) if order is None:
if not mapping: _log(topic, "Sortierung fehlgeschlagen — Originalreihenfolge bleibt (Nachholen über ▶)")
_bausteine_errors[topic] = "Finale Einordnung nicht parsebar" final_path.write_text(_build_final_bausteine(topic, entries, mapping, order), encoding="utf-8")
return
final_path.write_text(_build_final_bausteine(topic, entries, mapping), encoding="utf-8")
except Exception as e: except Exception as e:
_bausteine_errors[topic] = str(e)[:2000] _bausteine_errors[topic] = str(e)[:2000]
finally: finally:
# Kein Datei-Cleanup: Zwischendateien bleiben für Resume bzw. Nachvollziehbarkeit.
# Aufräumen passiert nur explizit über reset_bausteine().
_bausteine_progress.pop(topic, None) _bausteine_progress.pop(topic, None)
for p in [*recherche_paths, auswahl_path]: _bausteine_step.pop(topic, None)
p.unlink(missing_ok=True) _bausteine_cancelled.discard(topic)
# --- Guide-Generierung: Bausteine → (Plan) → Writer → JSON --- # --- Guide-Generierung: Bausteine → (Plan) → Writer → JSON ---
# Welche Baustein-Kategorien jedes Format abdeckt. # Welche Baustein-Kategorien jedes Format abdeckt.
FORMAT_COVERAGE = { FORMAT_COVERAGE = {
"OnePager": ("KERN",),
"MiniGuide": ("KERN",), "MiniGuide": ("KERN",),
"Guide": ("KERN", "WICHTIG"), "Guide": ("KERN", "WICHTIG"),
"FullGuide": ("KERN", "WICHTIG", "REST"), "FullGuide": ("KERN", "WICHTIG", "REST"),
@@ -388,34 +771,76 @@ def _section_json(sec: dict, entries: dict[int, str]) -> dict:
return {"num": sec["num"], "title": sec["title"] or _titel(entries[sec["num"]]), "md": sec["md"]} return {"num": sec["num"], "title": sec["title"] or _titel(entries[sec["num"]]), "md": sec["md"]}
async def _generate_onepager(guide_id: str, topic: str, entries: dict[int, str], instructions: str, provider: str) -> list[dict] | None: async def _generate_onepager(
await _set_progress(guide_id, "Generiere OnePager…") guide_id: str, topic: str, instructions: str, provider: str,
bausteine_block = "\n".join(f"{i}. {t}" for i, t in entries.items()) project: Path | None, content_path: Path, fragment_paths: list[Path],
returncode, stdout, stderr = await run_agent( ) -> list[dict] | None:
f"{guide_id}-onepager", def is_cancelled() -> bool:
_prompt("OnePager", topic=topic, bausteine=bausteine_block, extra=_extra(instructions)), return guide_id in _cancelled
AGENT_TIMEOUT, provider=provider, role="fast", capabilities="none",
) # Schritt 1: Recherche — eigene Faktenbasis, unabhängig von den Bausteinen
if guide_id in _cancelled: await _set_progress(guide_id, "Recherchiere…")
recherche_path = content_path.parent / f"{content_path.stem}.recherche.md"
fragment_paths.append(recherche_path)
recherche_path.unlink(missing_ok=True)
if project:
source = _prompt("OnePager-Quelle-Projekt", project=project)
else:
source = _prompt("OnePager-Quelle-Thema", topic=topic)
slots = [{
"key": f"{guide_id}-recherche",
"prompt": _prompt("OnePager-Recherche", topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)),
"role": "quick", "capabilities": "files" if project else "full",
"payload": (lambda result: recherche_path.read_text(encoding="utf-8") if recherche_path.exists() else None),
}]
res = await _race(topic, "OnePager-Recherche", slots, 1, _timeout("onepager_recherche"), provider, cancelled=is_cancelled)
if is_cancelled():
return None return None
if returncode != 0: if res is None:
await _fail(guide_id, _claude_error("OnePager-Fehler", returncode, stdout, stderr)) await _fail(guide_id, "OnePager-Recherche fehlgeschlagen")
return None return None
merksaetze: dict[int, str] = {} recherche = res[0]
for line in stdout.splitlines():
m = re.match(r"\s*(\d+)\s*[:.\-–—]\s*(.*\S)", line) # Schritt 2: Bauen — Karten nur aus der Faktenbasis
if m: await _set_progress(guide_id, "Baue OnePager…")
merksaetze.setdefault(int(m.group(1)), m.group(2)) slots = [{
sections = [] "key": f"{guide_id}-bauen",
for num, entry in entries.items(): "prompt": _prompt("OnePager-Bauen", topic=topic, recherche=recherche, extra=_extra(instructions)),
md = merksaetze.get(num) "role": "fast", "capabilities": "none",
if md is None: "payload": (lambda result: _parse_auswahl(result[1]) or None),
_log(topic, f"OnePager: Merksatz für Baustein {num} fehlt") }]
continue res = await _race(topic, "OnePager-Bauen", slots, 1, _timeout("onepager_bauen"), provider, cancelled=is_cancelled)
sections.append({"num": num, "title": _titel(entry), "md": md}) if is_cancelled():
if not sections:
await _fail(guide_id, "OnePager-Antwort nicht parsebar")
return None return None
if res is None:
await _fail(guide_id, "OnePager-Bau fehlgeschlagen")
return None
cards = res[0]
# Schritt 3: Verifizieren — OK oder vollständig korrigierte Liste (nicht fatal)
await _set_progress(guide_id, "Verifiziere OnePager…")
karten_block = "\n".join(f"{i}. {t}" for i, t in cards.items())
slots = [{
"key": f"{guide_id}-verify",
"prompt": _prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block),
"role": "fast", "capabilities": "none",
"payload": (lambda result: result[1].strip() if (_parse_auswahl(result[1]) or "OK" in result[1].upper()) else None),
}]
res = await _race(topic, "OnePager-Verifikation", slots, 1, _timeout("onepager_verify"), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
_log(topic, "OnePager-Verifikation fehlgeschlagen — ungeprüfte Version wird verwendet")
else:
corrected = _parse_auswahl(res[0])
if corrected:
_log(topic, "OnePager-Verifikation hat Korrekturen geliefert")
cards = corrected
sections = [
{"num": i, "title": _titel(text), "md": text.split("", 1)[1].strip() if "" in text else text}
for i, text in cards.items()
]
return [{"title": topic, "sections": sections}] return [{"title": topic, "sections": sections}]
@@ -431,12 +856,13 @@ async def _generate_sections(
# Ein Writer, gliedert selbst in Kapitel # Ein Writer, gliedert selbst in Kapitel
plan = None plan = None
zuteilungen = [bausteine_block] zuteilungen = [bausteine_block]
chunk_sizes = [len(entries)]
else: else:
await _set_progress(guide_id, "Plane Gliederung…") await _set_progress(guide_id, "Plane Gliederung…")
returncode, stdout, stderr = await run_agent( returncode, stdout, stderr = await run_agent(
f"{guide_id}-plan", f"{guide_id}-plan",
_prompt("Guide-Plan", topic=topic, format_name=format_name, bausteine=bausteine_block, extra=_extra(instructions)), _prompt("Guide-Plan", topic=topic, format_name=format_name, bausteine=bausteine_block, extra=_extra(instructions)),
AGENT_TIMEOUT, provider=provider, role="fast", capabilities="none", _timeout("plan", len(entries)), provider=provider, role="guide", capabilities="none",
) )
if guide_id in _cancelled: if guide_id in _cancelled:
return None return None
@@ -449,6 +875,7 @@ async def _generate_sections(
return None return None
chunks = _split_chunks(plan, WRITER_COUNT[format_name]) chunks = _split_chunks(plan, WRITER_COUNT[format_name])
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]
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…") await _set_progress(guide_id, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
@@ -462,9 +889,9 @@ async def _generate_sections(
topic=topic, format_name=format_name, zuteilung=zuteilung, topic=topic, format_name=format_name, zuteilung=zuteilung,
facts=facts, spec=spec, out_path=path, extra=_extra(instructions), facts=facts, spec=spec, out_path=path, extra=_extra(instructions),
), ),
AGENT_TIMEOUT, provider=provider, role="guide", capabilities="full", _timeout("writer", size), provider=provider, role="guide", capabilities="full",
) )
for i, (zuteilung, path) in enumerate(zip(zuteilungen, paths), 1) for i, (zuteilung, path, size) in enumerate(zip(zuteilungen, paths, chunk_sizes), 1)
], return_exceptions=True) ], return_exceptions=True)
if guide_id in _cancelled: if guide_id in _cancelled:
return None return None
@@ -515,7 +942,7 @@ async def _generate_sections(
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None: async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
async with _semaphore: async with _semaphore:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Lese Bausteine…", updated_at=now) await update_guide(guide_id, status="generating", progress="Starte…", updated_at=now)
content_path = guide_content_path(topic, format_name) content_path = guide_content_path(topic, format_name)
project = project_dir(topic) if project_dir(topic).is_dir() else None project = project_dir(topic) if project_dir(topic).is_dir() else None
@@ -525,18 +952,17 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
if guide_id in _cancelled: if guide_id in _cancelled:
return return
cats = _parse_kategorien(bausteine_path(topic).read_text(encoding="utf-8"))
selected: list[str] = []
for cat in FORMAT_COVERAGE[format_name]:
selected.extend(cats.get(cat, []))
if not selected:
await _fail(guide_id, "Keine passenden Bausteine gefunden")
return
entries = {i: text for i, text in enumerate(selected, 1)}
if format_name == "OnePager": if format_name == "OnePager":
chapters = await _generate_onepager(guide_id, topic, entries, instructions, provider) chapters = await _generate_onepager(guide_id, topic, instructions, provider, project, content_path, fragment_paths)
else: else:
cats = _parse_kategorien(bausteine_path(topic).read_text(encoding="utf-8"))
selected: list[str] = []
for cat in FORMAT_COVERAGE[format_name]:
selected.extend(cats.get(cat, []))
if not selected:
await _fail(guide_id, "Keine passenden Bausteine gefunden")
return
entries = {i: text for i, text in enumerate(selected, 1)}
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,
@@ -554,7 +980,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
await update_guide(guide_id, status="done", progress=None, updated_at=now) await update_guide(guide_id, status="done", progress=None, updated_at=now)
except asyncio.TimeoutError: except asyncio.TimeoutError:
await _fail(guide_id, f"Timeout bei Generierung nach {AGENT_TIMEOUT}s") await _fail(guide_id, "Timeout bei der Generierung")
except FileNotFoundError: except FileNotFoundError:
await _fail(guide_id, "Bausteine fehlen") await _fail(guide_id, "Bausteine fehlen")
except Exception as e: except Exception as e:

View File

@@ -18,17 +18,28 @@ class GuideCreateRequest(BaseModel):
provider: ProviderType = "claude" provider: ProviderType = "claude"
class TopicCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
class BausteineCreateRequest(BaseModel): class BausteineCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100) topic: str = Field(min_length=1, max_length=100)
instructions: str = Field(default="", max_length=2000) instructions: str = Field(default="", max_length=2000)
provider: ProviderType = "claude" provider: ProviderType = "claude"
class BausteineStep(BaseModel):
label: str
state: Literal["done", "active", "pending"]
class BausteineStatusResponse(BaseModel): class BausteineStatusResponse(BaseModel):
ready: bool ready: bool
generating: bool generating: bool
progress: str | None = None progress: str | None = None
error: str | None = None error: str | None = None
partial: bool = False
steps: list[BausteineStep] = []
class ProjectResponse(BaseModel): class ProjectResponse(BaseModel):

View File

@@ -23,7 +23,7 @@ def bausteine_topics() -> list[str]:
return [] return []
return [ return [
p.stem for p in bdir.glob("*.md") p.stem for p in bdir.glob("*.md")
if not re.search(r"\.(recherche-\d+|auswahl)$", p.stem) if not re.search(r"\.(recherche-\d+|auswahl(-\d+|-check)?|einordnung-\d+|final-check|sortierung)$", p.stem)
] ]

View File

@@ -10,14 +10,16 @@ from agents import provider_available
from config import PROJECTS_DIR, PROVIDERS from config import PROJECTS_DIR, PROVIDERS
from database import ( from database import (
create_guide, delete_guide, get_guide, list_guides, create_guide, delete_guide, get_guide, list_guides,
create_topic, list_topics as db_list_topics, delete_topic,
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,
generate_bausteine, bausteine_status, active_bausteine, reset_bausteine, generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
) )
from models import ( from models import (
GuideCreateRequest, GuideResponse, GuideCreateRequest, GuideResponse,
TopicCreateRequest,
BausteineCreateRequest, BausteineStatusResponse, BausteineCreateRequest, BausteineStatusResponse,
GuideChatRequest, GuideChatResponse, GuideChatRequest, GuideChatResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
@@ -33,12 +35,26 @@ async def get_providers():
@router.get("/topics") @router.get("/topics")
async def list_topics(): async def get_topics():
db_topics = await db_list_topics()
guides = await list_guides() guides = await list_guides()
topics = {g["topic"] for g in guides} derived = {g["topic"] for g in guides}
topics.update(bausteine_topics()) derived.update(bausteine_topics())
topics.update(job["topic"] for job in active_bausteine()) derived.update(job["topic"] for job in active_bausteine())
return sorted(topics) # DB ist führend (Reihenfolge: neueste zuerst); Abgeleitetes ohne DB-Eintrag hinten anhängen
return db_topics + sorted(derived - set(db_topics))
@router.post("/topics")
async def add_topic(req: TopicCreateRequest):
await create_topic(req.name.strip())
return {"ok": True}
@router.delete("/topics")
async def remove_topic(topic: str):
await delete_topic(topic)
return {"ok": True}
def _safe_project_name(name: str) -> str: def _safe_project_name(name: str) -> str:
@@ -81,10 +97,18 @@ async def create_bausteine(req: BausteineCreateRequest):
topic = req.topic.strip() topic = req.topic.strip()
if bausteine_status(topic)["generating"]: if bausteine_status(topic)["generating"]:
return {"ok": True, "status": "already_generating"} return {"ok": True, "status": "already_generating"}
await create_topic(topic)
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider)) asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider))
return {"ok": True} return {"ok": True}
@router.post("/bausteine/cancel")
async def cancel_bausteine_route(topic: str):
if not cancel_bausteine(topic):
raise HTTPException(404, "Keine laufende Generierung")
return {"ok": True}
@router.delete("/bausteine") @router.delete("/bausteine")
async def remove_bausteine(topic: str): async def remove_bausteine(topic: str):
reset_bausteine(topic) reset_bausteine(topic)
@@ -95,8 +119,9 @@ async def remove_bausteine(topic: str):
@router.post("/guides", response_model=GuideResponse) @router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest): async def create(req: GuideCreateRequest):
if not bausteine_path(req.topic.strip()).exists(): if req.format != "OnePager" and not bausteine_path(req.topic.strip()).exists():
raise HTTPException(400, "Erst Bausteine erstellen") raise HTTPException(400, "Erst Bausteine erstellen")
await create_topic(req.topic.strip())
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
guide = { guide = {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),

View File

@@ -1,17 +1,12 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { fetchGuides, fetchTopics, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders } from './api.js' import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders } from './api.js'
import TopicSidebar from './components/TopicSidebar.vue' import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue' import TopicDetail from './components/TopicDetail.vue'
const guides = ref([]) const guides = ref([])
const projects = ref([]) const projects = ref([])
const manualTopics = ref(JSON.parse(localStorage.getItem('manualTopics') || '[]'))
const backendTopics = ref([]) const backendTopics = ref([])
function persistManualTopics() {
localStorage.setItem('manualTopics', JSON.stringify(manualTopics.value))
}
const selectedTopic = ref(null) const selectedTopic = ref(null)
const previewGuide = ref(null) const previewGuide = ref(null)
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false') const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
@@ -21,7 +16,7 @@ const darkMode = ref(
? window.matchMedia('(prefers-color-scheme: dark)').matches ? window.matchMedia('(prefers-color-scheme: dark)').matches
: localStorage.getItem('darkMode') === 'true', : localStorage.getItem('darkMode') === 'true',
) )
const EMPTY_BAUSTEINE = { ready: false, generating: false, progress: null, error: null } const EMPTY_BAUSTEINE = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [] }
const bausteine = ref({ ...EMPTY_BAUSTEINE }) const bausteine = ref({ ...EMPTY_BAUSTEINE })
const activeBausteine = ref([]) const activeBausteine = ref([])
const provider = ref(localStorage.getItem('provider') || 'claude') const provider = ref(localStorage.getItem('provider') || 'claude')
@@ -76,18 +71,7 @@ const projectNames = computed(() => projects.value.map((p) => p.name))
const topics = computed(() => { const topics = computed(() => {
const isProject = new Set(projectNames.value) const isProject = new Set(projectNames.value)
const topicDates = {} return backendTopics.value.filter((t) => !isProject.has(t))
for (const g of guides.value) {
if (isProject.has(g.topic)) continue
if (!topicDates[g.topic] || g.created_at > topicDates[g.topic]) {
topicDates[g.topic] = g.created_at
}
}
for (const t of [...backendTopics.value, ...manualTopics.value]) {
if (isProject.has(t)) continue
if (!topicDates[t]) topicDates[t] = ''
}
return Object.keys(topicDates).sort((a, b) => topicDates[b].localeCompare(topicDates[a]))
}) })
const doneByFormat = computed(() => { const doneByFormat = computed(() => {
@@ -176,16 +160,26 @@ function selectTopic(topic) {
nextTick(autoPreview) nextTick(autoPreview)
} }
function createTopic(topic) { async function createTopic(topic) {
if (!manualTopics.value.includes(topic)) { await apiCreateTopic(topic)
manualTopics.value.push(topic) await loadTopics()
persistManualTopics()
}
selectedTopic.value = topic selectedTopic.value = topic
previewGuide.value = null previewGuide.value = null
loadBausteine() loadBausteine()
} }
async function handleCancelBausteine() {
if (!selectedTopic.value) return
await apiCancelBausteine(selectedTopic.value)
await loadBausteine()
}
async function handleResetBausteine() {
if (!selectedTopic.value) return
await apiDeleteBausteine(selectedTopic.value)
await loadBausteine()
}
async function handleBausteineClick({ instructions }) { async function handleBausteineClick({ instructions }) {
if (!selectedTopic.value) return if (!selectedTopic.value) return
await apiCreateBausteine(selectedTopic.value, instructions, provider.value) await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
@@ -247,8 +241,7 @@ async function handleDeleteTopic(topic) {
await deleteGuide(g.id) await deleteGuide(g.id)
} }
await apiDeleteBausteine(topic) await apiDeleteBausteine(topic)
manualTopics.value = manualTopics.value.filter((t) => t !== topic) await apiDeleteTopic(topic)
persistManualTopics()
await loadTopics() await loadTopics()
if (selectedTopic.value === topic) { if (selectedTopic.value === topic) {
selectedTopic.value = null selectedTopic.value = null
@@ -304,6 +297,8 @@ onUnmounted(() => {
@create="createTopic" @create="createTopic"
@formatClick="handleFormatClick" @formatClick="handleFormatClick"
@bausteineClick="handleBausteineClick" @bausteineClick="handleBausteineClick"
@cancelBausteine="handleCancelBausteine"
@resetBausteine="handleResetBausteine"
@deleteTopic="handleDeleteTopic" @deleteTopic="handleDeleteTopic"
@deleteProject="handleDeleteProject" @deleteProject="handleDeleteProject"
@cancelGuide="handleCancel" @cancelGuide="handleCancel"

View File

@@ -33,6 +33,10 @@ export async function createBausteine(topic, instructions = '', provider = 'clau
return res.json() return res.json()
} }
export async function cancelBausteine(topic) {
await fetch(`${BASE}/bausteine/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
}
export async function deleteBausteine(topic) { export async function deleteBausteine(topic) {
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' }) await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
} }
@@ -70,6 +74,18 @@ export async function fetchTopics() {
return res.json() return res.json()
} }
export async function createTopic(name) {
await fetch(`${BASE}/topics`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
}
export async function deleteTopic(name) {
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
}
export async function fetchProgress(id) { export async function fetchProgress(id) {
const res = await fetch(`${BASE}/guides/${id}/progress`) const res = await fetch(`${BASE}/guides/${id}/progress`)
return res.json() return res.json()

View File

@@ -16,7 +16,7 @@ const props = defineProps({
providers: { type: Array, default: () => [] }, providers: { type: Array, default: () => [] },
}) })
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'preview', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider']) const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'preview', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
function providerAvailable(id) { function providerAvailable(id) {
const p = props.providers.find((x) => x.id === id) const p = props.providers.find((x) => x.id === id)
@@ -34,6 +34,10 @@ const formats = [
const BAUSTEINE_KEY = '__bausteine__' const BAUSTEINE_KEY = '__bausteine__'
const bausteineUnsortiert = computed(
() => props.bausteine.ready && props.bausteine.steps?.at(-1)?.state === 'pending',
)
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'
@@ -49,6 +53,16 @@ const activeGenerations = computed(() => {
return [...bausteinLines, ...guideLines] return [...bausteinLines, ...guideLines]
}) })
function confirmCancelBausteine() {
if (!confirm('Aktuellen Schritt abbrechen? Bisheriger Fortschritt bleibt erhalten.')) return
emit('cancelBausteine')
}
function confirmResetBausteine() {
if (!confirm('Gespeicherten Bausteine-Fortschritt löschen?')) return
emit('resetBausteine')
}
function handleBausteinePlay() { function handleBausteinePlay() {
if (bausteineState.value === 'generating') return if (bausteineState.value === 'generating') return
const text = activeInput.value === BAUSTEINE_KEY ? inputText.value.trim() : '' const text = activeInput.value === BAUSTEINE_KEY ? inputText.value.trim() : ''
@@ -171,15 +185,36 @@ 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', 'fmt-' + bausteineState]"> <div class="format-row bausteine-row">
<button class="format-name"> <div class="format-name bausteine-name">
<span class="format-label">Bausteine</span> <span class="format-label">Bausteine</span>
</button> <span class="step-dots">
<span
v-for="s in (bausteine.steps || [])"
:key="s.label"
class="step-dot"
:class="s.state"
:title="s.state === 'active' ? (bausteine.progress || s.label) : s.label"
></span>
</span>
<span
v-if="bausteineState === 'generating'"
class="format-x"
title="Aktuellen Schritt abbrechen (Fortschritt bleibt)"
@click.stop="confirmCancelBausteine"
>&times;</span>
<span
v-else-if="bausteine.partial"
class="format-x"
title="Fortschritt löschen"
@click.stop="confirmResetBausteine"
>&times;</span>
</div>
<div class="format-actions"> <div class="format-actions">
<template v-if="bausteineState !== 'generating'"> <template v-if="bausteineState !== 'generating'">
<button <button
class="action-btn play" class="action-btn play"
:title="bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'" :title="bausteine.partial ? 'Fortsetzen' : bausteineUnsortiert ? 'Sortierung nachholen' : bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'"
@click="handleBausteinePlay" @click="handleBausteinePlay"
></button> ></button>
<button <button
@@ -216,8 +251,8 @@ function confirmDeleteProject(name) {
<template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'"> <template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
<button <button
class="action-btn play" class="action-btn play"
:title="bausteine.ready ? 'Generieren' : 'Erst Bausteine erstellen'" :title="f.key === 'OnePager' || bausteine.ready ? 'Generieren' : 'Erst Bausteine erstellen'"
:disabled="!bausteine.ready" :disabled="f.key !== 'OnePager' && !bausteine.ready"
@click="handlePlay(f.key)" @click="handlePlay(f.key)"
></button> ></button>
<button <button
@@ -452,6 +487,41 @@ function confirmDeleteProject(name) {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.bausteine-name {
display: flex;
align-items: center;
gap: 8px;
cursor: default;
}
.step-dots {
display: inline-flex;
gap: 5px;
flex: 1;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-strong);
flex-shrink: 0;
}
.step-dot.done {
background: var(--success-border);
}
.step-dot.active {
background: var(--warning-border);
animation: dot-pulse 1.2s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.action-btn:disabled { .action-btn:disabled {
opacity: 0.35; opacity: 0.35;
cursor: not-allowed; cursor: not-allowed;

View File

@@ -0,0 +1,19 @@
Eine konsolidierte Baustein-Liste zum Thema "{topic}" wurde aus drei Recherchen erstellt. Prüfe sie auf Verluste und Duplikate.
RECHERCHEN:
{results}
KONSOLIDIERTE LISTE:
{auswahl}
Prüfe genau zwei Dinge:
1. FEHLT etwas? Bausteine, die in mindestens einer Recherche belegt sind, aber in der konsolidierten Liste nicht vorkommen — auch nicht unter anderem Titel oder in einem Sammeleintrag.
2. DOPPELT? Einträge der Liste, die dasselbe Konzept beschreiben. Der beste bleibt, die übrigen werden gestrichen.
Antworte AUSSCHLIESSLICH in diesem Format. Leere Abschnitte weglassen; ist nichts zu tun, antworte nur mit OK:
NACHTRÄGE:
Titel — Kurzbeschreibung (max. ~12 Wörter)
Titel — Kurzbeschreibung
STREICHEN:
12 Titel
17 Titel

View File

@@ -1,28 +1,26 @@
Zwei Agenten haben die Bausteine des Themas "{topic}" unabhängig voneinander in KERN/WICHTIG/REST eingeordnet. Triff die finale Einordnung. Die Bausteine des Themas "{topic}" wurden per Mehrheitsentscheid aus drei unabhängigen Einordnungen in KERN/WICHTIG/REST kategorisiert. Verifiziere das Ergebnis.
BAUSTEINE: MEHRHEITS-EINORDNUNG:
{bausteine} {einordnung}
EINORDNUNG 1: STREITFÄLLE (keine Mehrheit gefunden — diese MUSST du einordnen):
{einordnung_1} {streitfaelle}
EINORDNUNG 2:
{einordnung_2}
Kategorien: Kategorien:
- KERN: ohne diese Bausteine kann man das Thema nicht verstehen oder benutzen - KERN: ohne diese Bausteine kann man das Thema nicht verstehen oder benutzen
- WICHTIG: in der echten Praxis nötig, aber nicht Teil des Einstiegs - WICHTIG: in der echten Praxis nötig, aber nicht Teil des Einstiegs
- REST: Spezialfälle, Randthemen, selten Gebrauchtes - REST: Spezialfälle, Randthemen, selten Gebrauchtes
Regeln: Aufgabe:
- Bei Übereinstimmung: Einordnung übernehmen. Bei Abweichung: entscheide selbst anhand der Kategorien-Definitionen. 1. Ordne jeden Streitfall einer Kategorie zu.
- Jeder Baustein landet in GENAU einer Kategorie. Keinen weglassen, keinen neuen erfinden. 2. Prüfe die Mehrheits-Einordnung gegen die Definitionen: liste NUR die Nummern, deren Kategorie du für falsch hältst, unter der korrekten Kategorie.
Antworte AUSSCHLIESSLICH in diesem Format — pro Zeile Nummer und Titel des Bausteins, kein weiterer Text davor oder danach: Nicht gelistete Nummern gelten als bestätigt. Keine neuen Bausteine erfinden.
Antworte AUSSCHLIESSLICH in diesem Format. Gibt es weder Streitfälle noch Korrekturen, antworte nur mit OK:
KERN: KERN:
1 Titel 17 Titel
4 Titel
WICHTIG: WICHTIG:
2 Titel 42 Titel
REST: REST:
3 Titel 8 Titel

View File

@@ -0,0 +1,17 @@
Sortiere die Bausteine des Themas "{topic}" innerhalb ihrer Kategorien in Lernreihenfolge — vom Einfachen/Grundlegenden zum Komplexen/Speziellen.
BAUSTEINE:
{einordnung}
Regeln:
- Kategorien NICHT verändern, keine Bausteine weglassen, keine erfinden — nur die Reihenfolge innerhalb jeder Kategorie ändern.
- Was etwas anderes voraussetzt, kommt nach seinen Voraussetzungen.
Antworte AUSSCHLIESSLICH in diesem Format — alle Nummern jeder Kategorie in der neuen Reihenfolge:
KERN:
3 Titel
1 Titel
WICHTIG:
7 Titel
REST:
2 Titel

View File

@@ -0,0 +1,14 @@
Baue aus der Faktenbasis einen OnePager zum Thema "{topic}" — die wichtigsten Informationen, die auf eine Seite passen.
FAKTENBASIS (alleinige Quelle, nichts hinzuerfinden):
{recherche}
Regeln:
- Wähle die wichtigsten Punkte aus — was man über "{topic}" wissen MUSS. Eine Bildschirmseite, also etwa 1018 Karten.
- Pro Karte GENAU ein prägnanter Merksatz: maximal ~15 Wörter, `inline-code` wo es hilft.
- Reihenfolge: vom Grundlegenden zum Speziellen.
Antworte AUSSCHLIESSLICH in diesem Format, eine Zeile pro Karte, kein weiterer Text:
1. Titel — Merksatz
2. Titel — Merksatz
{extra}

View File

@@ -0,0 +1 @@
Das Thema ist das Projekt unter {project}. Verschaffe dir mit Bash (ls/find) einen Überblick und lies README, Doku und den relevanten Quellcode mit dem Read-Tool. Erfasse Zweck, Architektur und die wichtigsten Konzepte — nichts Erfundenes.

View File

@@ -0,0 +1 @@
Recherchiere per Websuche: aktuelle Version, die Kernkonzepte und die wichtigsten Fakten zu "{topic}". Nimm nur auf, was du in der Recherche belegt hast.

View File

@@ -0,0 +1,8 @@
Sammle die Faktenbasis für einen OnePager — die kompakteste Übersicht — zum Thema "{topic}".
{source}
Schreibe NUR die Markdown-Datei nach: {out_path}
Inhalt der Datei: die wichtigsten Punkte des Themas (Kernkonzepte, zentrale Fakten, Versionen, typische Anwendung) — kompakt, faktenorientiert, mit Quelle (URL bzw. Dateipfad) pro Punkt. Keine Fließtexte, keine Einleitung. Die Datei ist die alleinige Faktenbasis für den OnePager.
{extra}

View File

@@ -0,0 +1,17 @@
Verifiziere einen OnePager zum Thema "{topic}" gegen seine Faktenbasis.
FAKTENBASIS:
{recherche}
ONEPAGER-KARTEN:
{karten}
Prüfe:
1. Stimmen alle Aussagen mit der Faktenbasis überein? Nichts Erfundenes?
2. Fehlt ein Punkt, der für "{topic}" unverzichtbar ist und in der Faktenbasis steht?
3. Sind die Merksätze prägnant (max. ~15 Wörter) und vom Grundlegenden zum Speziellen geordnet?
Ist alles in Ordnung, antworte NUR mit: OK
Sonst antworte AUSSCHLIESSLICH mit der vollständigen, korrigierten Karten-Liste im selben Format:
1. Titel — Merksatz
2. Titel — Merksatz

View File

@@ -1,11 +0,0 @@
Erstelle einen OnePager — die kompakteste Übersicht — zum Thema "{topic}".
BAUSTEINE (der Kern des Themas):
{bausteine}
Aufgabe: GENAU ein prägnanter Merksatz pro Baustein — das eine, was man im Kopf behalten muss. Maximal ~15 Wörter, `inline-code` wo es hilft. Den Baustein-Titel NICHT wiederholen, er steht schon fest.
Antworte AUSSCHLIESSLICH in diesem Format, eine Zeile pro Baustein, kein weiterer Text:
3: Merksatz
7: Merksatz
{extra}