diff --git a/backend/database.py b/backend/database.py
index 2c89416..a13cd60 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -1,7 +1,7 @@
import aiosqlite
from config import DB_PATH
-CREATE_TABLE = """
+CREATE_GUIDES = """
CREATE TABLE IF NOT EXISTS guides (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
@@ -17,6 +17,32 @@ CREATE TABLE IF NOT EXISTS guides (
)
"""
+CREATE_BAUSTEINE = """
+CREATE TABLE IF NOT EXISTS bausteine (
+ id TEXT PRIMARY KEY,
+ topic TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ purpose TEXT NOT NULL DEFAULT '',
+ example TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+)
+"""
+
+CREATE_SUGGESTIONS = """
+CREATE TABLE IF NOT EXISTS baustein_suggestions (
+ id TEXT PRIMARY KEY,
+ topic TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ purpose TEXT NOT NULL DEFAULT '',
+ example TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'pending',
+ created_at TEXT NOT NULL
+)
+"""
+
_db: aiosqlite.Connection | None = None
@@ -30,7 +56,9 @@ async def get_db() -> aiosqlite.Connection:
async def init_db():
db = await get_db()
- await db.execute(CREATE_TABLE)
+ await db.execute(CREATE_GUIDES)
+ await db.execute(CREATE_BAUSTEINE)
+ await db.execute(CREATE_SUGGESTIONS)
cursor = await db.execute("PRAGMA table_info(guides)")
columns = {row[1] for row in await cursor.fetchall()}
if "instructions" not in columns:
@@ -94,3 +122,102 @@ async def delete_guide(guide_id: str) -> bool:
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
await db.commit()
return cursor.rowcount > 0
+
+
+# --- Bausteine ---
+
+async def create_baustein(baustein: dict) -> dict:
+ db = await get_db()
+ await db.execute(
+ """INSERT INTO bausteine (id, topic, title, description, purpose, example, created_at, updated_at)
+ VALUES (:id, :topic, :title, :description, :purpose, :example, :created_at, :updated_at)""",
+ baustein,
+ )
+ await db.commit()
+ return baustein
+
+
+async def list_bausteine(topic: str) -> list[dict]:
+ db = await get_db()
+ cursor = await db.execute(
+ "SELECT * FROM bausteine WHERE topic = ? ORDER BY created_at ASC", (topic,)
+ )
+ rows = await cursor.fetchall()
+ return [_row_to_dict(row, cursor) for row in rows]
+
+
+async def get_baustein(baustein_id: str) -> dict | None:
+ db = await get_db()
+ cursor = await db.execute("SELECT * FROM bausteine WHERE id = ?", (baustein_id,))
+ row = await cursor.fetchone()
+ if row is None:
+ return None
+ return _row_to_dict(row, cursor)
+
+
+async def update_baustein(baustein_id: str, **fields) -> None:
+ sets = ", ".join(f"{k} = :{k}" for k in fields)
+ fields["id"] = baustein_id
+ db = await get_db()
+ await db.execute(f"UPDATE bausteine SET {sets} WHERE id = :id", fields)
+ await db.commit()
+
+
+async def delete_baustein(baustein_id: str) -> bool:
+ db = await get_db()
+ cursor = await db.execute("DELETE FROM bausteine WHERE id = ?", (baustein_id,))
+ await db.commit()
+ return cursor.rowcount > 0
+
+
+# --- Baustein Suggestions ---
+
+async def create_suggestions(suggestions: list[dict]) -> None:
+ db = await get_db()
+ await db.executemany(
+ """INSERT INTO baustein_suggestions (id, topic, title, description, purpose, example, status, created_at)
+ VALUES (:id, :topic, :title, :description, :purpose, :example, :status, :created_at)""",
+ suggestions,
+ )
+ await db.commit()
+
+
+async def list_suggestions(topic: str) -> list[dict]:
+ db = await get_db()
+ cursor = await db.execute(
+ "SELECT * FROM baustein_suggestions WHERE topic = ? ORDER BY created_at ASC", (topic,)
+ )
+ rows = await cursor.fetchall()
+ return [_row_to_dict(row, cursor) for row in rows]
+
+
+async def get_suggestion(suggestion_id: str) -> dict | None:
+ db = await get_db()
+ cursor = await db.execute("SELECT * FROM baustein_suggestions WHERE id = ?", (suggestion_id,))
+ row = await cursor.fetchone()
+ if row is None:
+ return None
+ return _row_to_dict(row, cursor)
+
+
+async def update_suggestion(suggestion_id: str, **fields) -> None:
+ sets = ", ".join(f"{k} = :{k}" for k in fields)
+ fields["id"] = suggestion_id
+ db = await get_db()
+ await db.execute(f"UPDATE baustein_suggestions SET {sets} WHERE id = :id", fields)
+ await db.commit()
+
+
+async def delete_suggestion(suggestion_id: str) -> bool:
+ db = await get_db()
+ cursor = await db.execute("DELETE FROM baustein_suggestions WHERE id = ?", (suggestion_id,))
+ await db.commit()
+ return cursor.rowcount > 0
+
+
+async def delete_pending_suggestions(topic: str) -> None:
+ db = await get_db()
+ await db.execute(
+ "DELETE FROM baustein_suggestions WHERE topic = ? AND status = 'pending'", (topic,)
+ )
+ await db.commit()
diff --git a/backend/generator.py b/backend/generator.py
index 0afec01..7660a17 100644
--- a/backend/generator.py
+++ b/backend/generator.py
@@ -1,6 +1,9 @@
import asyncio
+import json
+import re
import subprocess
import tempfile
+import uuid
from datetime import datetime, timezone
from pathlib import Path
@@ -13,7 +16,14 @@ from config import (
REVIEW_TIMEOUTS,
STORAGE_DIR,
)
-from database import update_guide
+from database import (
+ update_guide,
+ create_baustein,
+ create_suggestions,
+ delete_pending_suggestions,
+ list_bausteine,
+ update_baustein,
+)
_semaphore = asyncio.Semaphore(MAX_CONCURRENT_GENERATIONS)
_active_processes: dict[str, asyncio.subprocess.Process] = {}
@@ -35,12 +45,13 @@ async def _set_progress(guide_id: str, progress: str) -> None:
await update_guide(guide_id, progress=progress, updated_at=now)
-async def _run_claude(guide_id: str, prompt: str, timeout: int) -> tuple[int, str, str]:
+async def _run_claude(guide_id: str, prompt: str, timeout: int, tools: str | None = "Write,Bash,Read,WebSearch,WebFetch") -> tuple[int, str, str]:
+ cmd = [CLAUDE_CLI, "-p"]
+ if tools:
+ cmd += ["--allowedTools", tools]
+ cmd += ["--dangerously-skip-permissions"]
process = await asyncio.create_subprocess_exec(
- CLAUDE_CLI,
- "-p",
- "--allowedTools", "Write,Bash,Read,WebSearch,WebFetch",
- "--dangerously-skip-permissions",
+ *cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -397,3 +408,136 @@ async def rework_guide(guide_id: str, topic: str, format_name: str, instructions
async def _fail(guide_id: str, msg: str) -> None:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="error", progress=None, error_msg=msg, updated_at=now)
+
+
+# --- Bausteine ---
+
+_suggestions_generating: set[str] = set()
+
+
+def is_suggestions_generating(topic: str) -> bool:
+ return topic in _suggestions_generating
+
+
+def _parse_json(text: str):
+ text = text.strip()
+ text = re.sub(r"^```(?:json)?\s*", "", text)
+ text = re.sub(r"\s*```$", "", text)
+ return json.loads(text)
+
+
+def _build_suggestions_prompt(topic: str, html_paths: list[Path], existing_titles: list[str]) -> str:
+ spec = (TEMPLATES_DIR / "Format" / "Baustein.md").read_text(encoding="utf-8")
+ reference = (TEMPLATES_DIR / "Referenz" / "Baustein.md").read_text(encoding="utf-8")
+ existing_list = "\n".join(f"- {t}" for t in existing_titles) if existing_titles else "(keine)"
+
+ if html_paths:
+ read_instructions = "\n".join(f"- Lies: {p}" for p in html_paths)
+ guides_section = f"""SCHRITT 1 — Guides lesen:
+{read_instructions}
+
+"""
+ else:
+ guides_section = ""
+
+ return f"""Schlage fundamentale Bausteine (Kernkonzepte) zum Thema "{topic}" vor.
+
+{guides_section}Bereits vorhandene Bausteine (NICHT erneut vorschlagen):
+{existing_list}
+
+FORMAT-SPEZIFIKATION:
+{spec}
+
+REFERENZ-BEISPIEL:
+{reference}
+
+Schlage bis zu 20 Bausteine vor. Antworte AUSSCHLIESSLICH mit einem JSON-Array. Jedes Element hat:
+- "title"
+- "description"
+- "purpose"
+- "examples": Array mit 4 Objekten {{"label": "...", "code": "..."}}
+
+Orientiere dich an der Spezifikation und Referenz. NUR das JSON-Array, kein weiterer Text.
+"""
+
+
+def _build_baustein_detail_prompt(topic: str, title: str) -> str:
+ spec = (TEMPLATES_DIR / "Format" / "Baustein.md").read_text(encoding="utf-8")
+ reference = (TEMPLATES_DIR / "Referenz" / "Baustein.md").read_text(encoding="utf-8")
+
+ return f"""Generiere Details für den Baustein "{title}" im Kontext des Themas "{topic}".
+
+FORMAT-SPEZIFIKATION:
+{spec}
+
+REFERENZ-BEISPIEL:
+{reference}
+
+Antworte AUSSCHLIESSLICH mit einem JSON-Objekt mit den Feldern "description", "purpose", "examples".
+"examples" ist ein Array mit 4 Objekten {{"label": "...", "code": "..."}}.
+Orientiere dich an der Spezifikation und Referenz. Kein weiterer Text, nur das JSON.
+"""
+
+
+async def generate_suggestions(topic: str, html_paths: list[Path]) -> None:
+ _suggestions_generating.add(topic)
+ try:
+ existing = await list_bausteine(topic)
+ existing_titles = [b["title"] for b in existing]
+
+ await delete_pending_suggestions(topic)
+
+ prompt = _build_suggestions_prompt(topic, html_paths, existing_titles)
+ tools = "Read" if html_paths else None
+ returncode, stdout, stderr = await _run_claude("suggestions-" + topic, prompt, 180, tools=tools)
+
+ if returncode != 0:
+ return
+
+ items = _parse_json(stdout)
+ if not isinstance(items, list):
+ return
+
+ now = datetime.now(timezone.utc).isoformat()
+ suggestions = []
+ for item in items[:20]:
+ suggestions.append({
+ "id": str(uuid.uuid4()),
+ "topic": topic,
+ "title": item.get("title", ""),
+ "description": item.get("description", ""),
+ "purpose": item.get("purpose", ""),
+ "example": json.dumps(item.get("examples", []), ensure_ascii=False),
+ "status": "pending",
+ "created_at": now,
+ })
+ if suggestions:
+ await create_suggestions(suggestions)
+ except Exception:
+ pass
+ finally:
+ _suggestions_generating.discard(topic)
+
+
+async def generate_baustein_detail(baustein_id: str, topic: str, title: str) -> None:
+ try:
+ prompt = _build_baustein_detail_prompt(topic, title)
+ returncode, stdout, stderr = await _run_claude("baustein-" + baustein_id, prompt, 60, tools=None)
+
+ if returncode != 0:
+ return
+
+ data = _parse_json(stdout)
+ if not isinstance(data, dict):
+ return
+
+ now = datetime.now(timezone.utc).isoformat()
+ await update_baustein(
+ baustein_id,
+ description=data.get("description", ""),
+ purpose=data.get("purpose", ""),
+ example=json.dumps(data.get("examples", []), ensure_ascii=False),
+ updated_at=now,
+ )
+ except Exception:
+ pass
diff --git a/backend/models.py b/backend/models.py
index 5bc9d76..7f4be73 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -32,3 +32,30 @@ class GuideResponse(BaseModel):
pdf_path: str | None = None
created_at: str
updated_at: str
+
+
+class BausteinCreateRequest(BaseModel):
+ topic: str = Field(min_length=1, max_length=100)
+ title: str = Field(min_length=1, max_length=200)
+
+
+class BausteinResponse(BaseModel):
+ id: str
+ topic: str
+ title: str
+ description: str
+ purpose: str
+ example: str
+ created_at: str
+ updated_at: str
+
+
+class SuggestionResponse(BaseModel):
+ id: str
+ topic: str
+ title: str
+ description: str
+ purpose: str
+ example: str
+ status: str
+ created_at: str
diff --git a/backend/routes.py b/backend/routes.py
index 9f75c34..e3ffcd8 100644
--- a/backend/routes.py
+++ b/backend/routes.py
@@ -7,9 +7,16 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from config import FORMAT_META, STORAGE_DIR
-from database import create_guide, delete_guide, get_guide, list_guides
-from generator import generate_guide, rework_guide, cancel_guide
-from models import GuideCreateRequest, GuideReworkRequest, GuideResponse
+from database import (
+ create_guide, delete_guide, get_guide, list_guides,
+ create_baustein as db_create_baustein, list_bausteine, get_baustein, delete_baustein as db_delete_baustein,
+ list_suggestions, get_suggestion, update_suggestion, delete_suggestion,
+)
+from generator import generate_guide, rework_guide, cancel_guide, generate_suggestions, generate_baustein_detail, is_suggestions_generating
+from models import (
+ GuideCreateRequest, GuideReworkRequest, GuideResponse,
+ BausteinCreateRequest, BausteinResponse, SuggestionResponse,
+)
router = APIRouter(prefix="/api")
@@ -108,3 +115,92 @@ async def remove(guide_id: str):
Path(guide["pdf_path"]).unlink(missing_ok=True)
await delete_guide(guide_id)
return {"ok": True}
+
+
+# --- Bausteine ---
+
+@router.get("/bausteine", response_model=list[BausteinResponse])
+async def get_bausteine(topic: str):
+ return await list_bausteine(topic)
+
+
+@router.post("/bausteine", response_model=BausteinResponse)
+async def add_baustein(req: BausteinCreateRequest):
+ now = datetime.now(timezone.utc).isoformat()
+ baustein = {
+ "id": str(uuid.uuid4()),
+ "topic": req.topic.strip(),
+ "title": req.title.strip(),
+ "description": "",
+ "purpose": "",
+ "example": "",
+ "created_at": now,
+ "updated_at": now,
+ }
+ await db_create_baustein(baustein)
+ asyncio.create_task(generate_baustein_detail(baustein["id"], baustein["topic"], baustein["title"]))
+ return baustein
+
+
+@router.delete("/bausteine/{baustein_id}")
+async def remove_baustein(baustein_id: str):
+ b = await get_baustein(baustein_id)
+ if b is None:
+ raise HTTPException(404, "Baustein nicht gefunden")
+ await db_delete_baustein(baustein_id)
+ return {"ok": True}
+
+
+# --- Baustein Suggestions ---
+
+@router.get("/bausteine/suggestions", response_model=list[SuggestionResponse])
+async def get_suggestions(topic: str):
+ return await list_suggestions(topic)
+
+
+@router.post("/bausteine/suggestions/generate")
+async def trigger_suggestions(topic: str):
+ if is_suggestions_generating(topic):
+ return {"ok": True, "status": "already_generating"}
+ guides = await list_guides()
+ html_paths = []
+ for g in guides:
+ if g["topic"] == topic and g["status"] == "done" and g["html_path"]:
+ html_paths.append(Path(g["html_path"]))
+ asyncio.create_task(generate_suggestions(topic, html_paths))
+ return {"ok": True}
+
+
+@router.get("/bausteine/suggestions/status")
+async def suggestions_status(topic: str):
+ return {"generating": is_suggestions_generating(topic)}
+
+
+@router.post("/bausteine/suggestions/{suggestion_id}/add")
+async def accept_suggestion(suggestion_id: str):
+ s = await get_suggestion(suggestion_id)
+ if s is None:
+ raise HTTPException(404, "Vorschlag nicht gefunden")
+ now = datetime.now(timezone.utc).isoformat()
+ baustein = {
+ "id": str(uuid.uuid4()),
+ "topic": s["topic"],
+ "title": s["title"],
+ "description": s["description"],
+ "purpose": s["purpose"],
+ "example": s["example"],
+ "created_at": now,
+ "updated_at": now,
+ }
+ await db_create_baustein(baustein)
+ await delete_suggestion(suggestion_id)
+ return baustein
+
+
+@router.post("/bausteine/suggestions/{suggestion_id}/ignore")
+async def ignore_suggestion(suggestion_id: str):
+ s = await get_suggestion(suggestion_id)
+ if s is None:
+ raise HTTPException(404, "Vorschlag nicht gefunden")
+ await update_suggestion(suggestion_id, status="ignored")
+ return {"ok": True}
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index fdf2b0b..8614099 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -3,11 +3,13 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, reworkGuide as apiRework } from './api.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
+import BausteineView from './components/BausteineView.vue'
const guides = ref([])
const manualTopics = ref([])
const selectedTopic = ref(null)
const previewGuide = ref(null)
+const showBausteine = ref(false)
let pollTimer = null
const topics = computed(() => {
@@ -62,6 +64,7 @@ function autoPreview() {
function selectTopic(topic) {
selectedTopic.value = topic
previewGuide.value = null
+ showBausteine.value = false
nextTick(autoPreview)
}
@@ -88,6 +91,12 @@ async function handleRework({ guideId, instructions }) {
function handlePreview(guide) {
previewGuide.value = guide
+ showBausteine.value = false
+}
+
+function handleShowBausteine() {
+ showBausteine.value = true
+ previewGuide.value = null
}
async function handleDeleteGuide(guideId) {
@@ -161,6 +170,7 @@ onUnmounted(() => {
:selectedTopic="selectedTopic"
:guidesByFormat="guidesByFormat"
:allGuides="guides"
+ :bausteineActive="showBausteine"
@select="selectTopic"
@create="createTopic"
@formatClick="handleFormatClick"
@@ -169,9 +179,14 @@ onUnmounted(() => {
@deleteGuide="handleDeleteGuide"
@preview="handlePreview"
@rework="handleRework"
+ @showBausteine="handleShowBausteine"
+ />
+
{{ b.description }}
+{{ b.purpose }}
+{{ ex.code }}
+ Wird generiert…
+{{ s.description }}
+{{ s.purpose }}
+{{ ex.code }}
+ Führt einen Code-Block wiederholt aus.
+ +Wiederholung wenn Anzahl Durchläufe bekannt ist oder Index gebraucht wird.
+ +