This commit is contained in:
Team3
2026-05-27 01:00:33 +02:00
parent ad2f3e4786
commit 351f330db0
10 changed files with 1184 additions and 13 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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"
/>
<BausteineView
v-if="selectedTopic && showBausteine"
:topic="selectedTopic"
/>
<TopicDetail
v-if="selectedTopic"
v-else-if="selectedTopic"
:previewGuide="previewGuide"
/>
<div v-else class="empty-main">

View File

@@ -43,3 +43,44 @@ export function pdfUrl(id) {
export function htmlUrl(id) {
return `${BASE}/guides/${id}/html`
}
export async function fetchBausteine(topic) {
const res = await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function createBaustein(topic, title) {
const res = await fetch(`${BASE}/bausteine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, title }),
})
return res.json()
}
export async function deleteBaustein(id) {
await fetch(`${BASE}/bausteine/${id}`, { method: 'DELETE' })
}
export async function fetchSuggestions(topic) {
const res = await fetch(`${BASE}/bausteine/suggestions?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function generateSuggestions(topic) {
await fetch(`${BASE}/bausteine/suggestions/generate?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
}
export async function fetchSuggestionsStatus(topic) {
const res = await fetch(`${BASE}/bausteine/suggestions/status?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function addSuggestion(id) {
const res = await fetch(`${BASE}/bausteine/suggestions/${id}/add`, { method: 'POST' })
return res.json()
}
export async function ignoreSuggestion(id) {
await fetch(`${BASE}/bausteine/suggestions/${id}/ignore`, { method: 'POST' })
}

View File

@@ -0,0 +1,460 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import {
fetchBausteine,
createBaustein,
deleteBaustein,
fetchSuggestions,
generateSuggestions,
fetchSuggestionsStatus,
addSuggestion,
ignoreSuggestion,
} from '../api.js'
const props = defineProps({
topic: { type: String, required: true },
})
const bausteine = ref([])
const suggestions = ref([])
const suggestionsLoading = ref(false)
const newTitle = ref('')
let pollTimer = null
const pendingSuggestions = computed(() => suggestions.value.filter((s) => s.status === 'pending'))
const ignoredSuggestions = computed(() => suggestions.value.filter((s) => s.status === 'ignored'))
function parseExamples(example) {
if (!example) return []
try {
const parsed = JSON.parse(example)
if (Array.isArray(parsed)) return parsed
} catch {}
return [{ label: '', code: example }]
}
async function loadData() {
const [b, s] = await Promise.all([fetchBausteine(props.topic), fetchSuggestions(props.topic)])
bausteine.value = b
suggestions.value = s
}
async function checkAndGenerate() {
const status = await fetchSuggestionsStatus(props.topic)
if (status.generating) {
suggestionsLoading.value = true
startPolling()
} else if (suggestions.value.length === 0) {
suggestionsLoading.value = true
await generateSuggestions(props.topic)
startPolling()
}
}
async function handleAdd() {
const title = newTitle.value.trim()
if (!title) return
newTitle.value = ''
const created = await createBaustein(props.topic, title)
bausteine.value.push(created)
}
async function handleDelete(id) {
await deleteBaustein(id)
bausteine.value = bausteine.value.filter((b) => b.id !== id)
}
async function handleAccept(s) {
const created = await addSuggestion(s.id)
suggestions.value = suggestions.value.filter((x) => x.id !== s.id)
bausteine.value.push(created)
}
async function handleIgnore(s) {
await ignoreSuggestion(s.id)
const idx = suggestions.value.findIndex((x) => x.id === s.id)
if (idx !== -1) suggestions.value[idx].status = 'ignored'
}
async function handleRestore(s) {
const created = await addSuggestion(s.id)
suggestions.value = suggestions.value.filter((x) => x.id !== s.id)
bausteine.value.push(created)
}
async function handleRegenerate() {
suggestionsLoading.value = true
await generateSuggestions(props.topic)
startPolling()
}
function startPolling() {
stopPolling()
pollTimer = setInterval(async () => {
const status = await fetchSuggestionsStatus(props.topic)
if (!status.generating) {
suggestionsLoading.value = false
stopPolling()
await loadData()
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function init() {
await loadData()
await checkAndGenerate()
}
watch(
() => props.topic,
() => {
stopPolling()
suggestionsLoading.value = false
init()
},
)
onMounted(init)
onUnmounted(stopPolling)
</script>
<template>
<div class="bausteine-view">
<div class="bausteine-grid">
<div class="card new-card">
<input
v-model="newTitle"
placeholder="Neuer Baustein…"
@keyup.enter="handleAdd"
/>
<button @click="handleAdd" :disabled="!newTitle.trim()">+</button>
</div>
<div v-for="b in bausteine" :key="b.id" class="card">
<div class="card-header">
<h3>{{ b.title }}</h3>
<button class="card-delete" @click="handleDelete(b.id)">&times;</button>
</div>
<p v-if="b.description" class="desc">{{ b.description }}</p>
<p v-if="b.purpose" class="purpose">{{ b.purpose }}</p>
<div v-if="b.example" class="examples">
<div v-for="(ex, i) in parseExamples(b.example)" :key="i" class="code-block">
<span v-if="ex.label" class="code-label">{{ ex.label }}</span>
<pre>{{ ex.code }}</pre>
</div>
</div>
<p v-if="!b.description && !b.purpose" class="loading-text">Wird generiert</p>
</div>
</div>
<hr v-if="pendingSuggestions.length || suggestionsLoading" class="divider" />
<div v-if="suggestionsLoading" class="loading-indicator">Generiere Vorschläge</div>
<div v-if="pendingSuggestions.length" class="suggestions-section">
<div class="section-header">
<h3>Vorschläge</h3>
<button class="regenerate-btn" @click="handleRegenerate" :disabled="suggestionsLoading">Neu generieren</button>
</div>
<div class="suggestions-grid">
<div v-for="s in pendingSuggestions" :key="s.id" class="card suggestion-card">
<h3>{{ s.title }}</h3>
<p v-if="s.description" class="desc">{{ s.description }}</p>
<p v-if="s.purpose" class="purpose">{{ s.purpose }}</p>
<div v-if="s.example" class="examples">
<div v-for="(ex, i) in parseExamples(s.example)" :key="i" class="code-block">
<span v-if="ex.label" class="code-label">{{ ex.label }}</span>
<pre>{{ ex.code }}</pre>
</div>
</div>
<div class="suggestion-actions">
<button class="btn-add" @click="handleAccept(s)">Hinzufügen</button>
<button class="btn-ignore" @click="handleIgnore(s)">Ignorieren</button>
</div>
</div>
</div>
</div>
<hr v-if="ignoredSuggestions.length" class="divider" />
<div v-if="ignoredSuggestions.length" class="ignored-section">
<h3>Ignoriert</h3>
<div v-for="s in ignoredSuggestions" :key="s.id" class="ignored-item">
<span>{{ s.title }}</span>
<button class="btn-restore" @click="handleRestore(s)">Hinzufügen</button>
</div>
</div>
</div>
</template>
<style scoped>
.bausteine-view {
flex: 1;
height: 100vh;
overflow-y: auto;
padding: 1.5rem;
}
.bausteine-grid,
.suggestions-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.card {
background: #fff;
border: 1px solid #e2e5e9;
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.new-card {
flex-direction: row;
align-items: center;
gap: 8px;
background: #f8f9fb;
border-style: dashed;
}
.new-card input {
flex: 1;
padding: 8px 10px;
border: 1px solid #d8dde3;
border-radius: 6px;
font-size: 0.9rem;
outline: none;
}
.new-card input:focus {
border-color: #6366f1;
}
.new-card button {
padding: 8px 12px;
border: none;
background: #6366f1;
color: white;
border-radius: 6px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
}
.new-card button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.card-header h3 {
font-size: 0.95rem;
color: #1a1a1a;
margin: 0;
}
.card-delete {
background: none;
border: none;
color: #991b1b;
font-size: 1.2rem;
cursor: pointer;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
}
.card:hover .card-delete {
opacity: 1;
}
.desc {
font-size: 0.85rem;
color: #4b5563;
}
.purpose {
font-size: 0.8rem;
color: #6b7280;
font-style: italic;
}
.examples {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.code-block {
background: #1e2a3a;
color: #e6e6e6;
font-family: "SF Mono", Consolas, monospace;
font-size: 12px;
line-height: 1.5;
padding: 12px 14px;
border-radius: 6px;
}
.code-block pre {
margin: 0;
white-space: pre-wrap;
font: inherit;
color: inherit;
}
.code-label {
font-family: -apple-system, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #8be9fd;
margin-bottom: 4px;
display: block;
}
.loading-text {
font-size: 0.8rem;
color: #9ca3af;
font-style: italic;
}
.divider {
border: none;
border-top: 1px solid #e2e5e9;
margin: 1.5rem 0;
}
.loading-indicator {
font-size: 0.85rem;
color: #92400e;
background: #fef3c7;
padding: 0.5rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
animation: pulse 1.5s ease-in-out infinite;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
font-size: 0.9rem;
color: #4b5563;
margin: 0;
}
.regenerate-btn {
padding: 4px 10px;
border: 1px solid #d8dde3;
border-radius: 4px;
background: #fff;
font-size: 0.8rem;
cursor: pointer;
color: #4b5563;
}
.regenerate-btn:hover {
border-color: #6366f1;
color: #6366f1;
}
.regenerate-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.suggestion-card {
border-color: #c7d2fe;
background: #fafafe;
}
.suggestion-actions {
display: flex;
gap: 0.5rem;
margin-top: auto;
padding-top: 0.5rem;
}
.btn-add {
padding: 4px 10px;
border: 1px solid #34d399;
border-radius: 4px;
background: #d1fae5;
color: #065f46;
font-size: 0.8rem;
cursor: pointer;
}
.btn-add:hover {
background: #a7f3d0;
}
.btn-ignore {
padding: 4px 10px;
border: 1px solid #d8dde3;
border-radius: 4px;
background: #fff;
color: #6b7280;
font-size: 0.8rem;
cursor: pointer;
}
.btn-ignore:hover {
background: #f3f4f6;
}
.ignored-section h3 {
font-size: 0.85rem;
color: #9ca3af;
margin-bottom: 0.5rem;
}
.ignored-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0;
font-size: 0.85rem;
color: #9ca3af;
}
.btn-restore {
padding: 2px 8px;
border: 1px solid #d8dde3;
border-radius: 4px;
background: #fff;
color: #6b7280;
font-size: 0.75rem;
cursor: pointer;
}
.btn-restore:hover {
border-color: #34d399;
color: #065f46;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
</style>

View File

@@ -6,9 +6,10 @@ const props = defineProps({
selectedTopic: { type: String, default: null },
guidesByFormat: { type: Object, default: () => ({}) },
allGuides: { type: Array, default: () => [] },
bausteineActive: { type: Boolean, default: false },
})
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview', 'rework'])
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview', 'rework', 'showBausteine'])
const formats = [
{ key: 'OnePager', label: 'OnePager' },
@@ -155,6 +156,13 @@ function submit() {
/>
</div>
</div>
<div class="bausteine-btn-wrapper">
<button
class="bausteine-btn"
:class="{ active: bausteineActive }"
@click="emit('showBausteine')"
>Bausteine</button>
</div>
</div>
</aside>
</template>
@@ -399,4 +407,34 @@ function submit() {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
.bausteine-btn-wrapper {
padding: 0.5rem 0.75rem;
border-top: 1px solid #e2e5e9;
}
.bausteine-btn {
width: 100%;
padding: 8px 12px;
border: 1px solid #d8dde3;
border-radius: 6px;
background: #f8f9fb;
color: #4b5563;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.bausteine-btn:hover {
background: #ede9fe;
border-color: #a5b4fc;
color: #4f46e5;
}
.bausteine-btn.active {
background: #6366f1;
border-color: #6366f1;
color: white;
}
</style>

View File

@@ -0,0 +1,104 @@
```
BAUSTEIN-CARD-STIL (HTML+CSS, in Browser anzeigbar)
ZWECK
- Einzelner Baustein als kompakte Card-Darstellung
- Schnelles Nachschlagen einzelner Konzepte
- Card ist Teil einer größeren Baustein-Sammlung
- Nicht zum Lernen, sondern zum Wiedererkennen
- Pro Card genau 1 Baustein
FORMAT
- HTML mit eingebettetem CSS
- Im Browser anzeigbar (kein PDF-Output)
- Card auf hellgrauem Hintergrund zentriert
- Max-width 1000px, padding 28px 36px
- Border-radius 12px, dezenter Schatten
- Page-Hintergrund #f0f0f5
STRUKTUR — STRIKT NUR DIESE 4 ELEMENTE
1. Titel (h1, fett, ohne Label, ohne Logo)
2. Beschreibung (ein knapper Satz)
3. Zweck (kursiv, grau, ein knapper Satz)
4. Code-Beispiele (untereinander, mit Mini-Label oben)
KEIN Logo, KEIN "Baustein"-Label, KEIN Sprach-Tag, KEINE Sektion-Überschriften ("Beschreibung", "Zweck", "Relevante Beispiele"), KEINE Info-Blöcke, KEINE Warn/Tip/Note-Boxen, KEINE Meta-Informationen, KEINE Trennlinien zwischen Sektionen.
TYPOGRAFIE
- Body: -apple-system, "Segoe UI", sans-serif, 14px, line-height 1.5
- Titel h1: 28px, font-weight 800, letter-spacing -0.5px, line-height 1.1
- Beschreibung: 14px, line-height 1.55
- Zweck: 14px, italic, color #5a6470
- Code: "SF Mono", Consolas, monospace, 12px, line-height 1.5
- Code-Label: 10px, uppercase, letter-spacing 1px
FARBEN
- Card-Hintergrund: #ffffff
- Page-Hintergrund: #f0f0f5
- Text: #1a1a1a
- Muted (Zweck): #5a6470
- Code-Hintergrund: #1e2a3a
- Code-Text: #e6e6e6
- Syntax-Highlighting:
- Keywords (.k): #ff79c6 (pink)
- Variablen (.v): #ffb86c (orange)
- Strings (.s): #f1c40f (gelb)
- Funktionen (.f): #50fa7b (grün)
- Typen (.t): #8be9fd (cyan)
- Kommentare (.c): #6b8aae italic
- Label-Farbe in Code: #8be9fd (cyan)
INHALTLICHE PRINZIPIEN
- Titel: nur der Baustein-Name, kein Präfix, keine Variante
- Beschreibung: 1 Satz, was es macht (mechanisch, neutral)
- Zweck: 1 Satz, wofür man es nutzt (Anwendungsfall)
- Beide Sätze knapp wie möglich, jedes überflüssige Wort raus
- Pro Beispiel ein knappes Label oben (2-4 Wörter, uppercase)
LAYOUT-DETAILS
- Header und Body keine separaten Sektionen, alles in einer Card
- Titel zuerst, darunter direkt Beschreibung, darunter Zweck (mit margin-bottom)
- Margin-bottom Beschreibung: 6px (eng zum Zweck)
- Margin-bottom Zweck: 22px (Abstand zu Code)
- Margin-bottom Titel: 14px
- Code-Blöcke untereinander, gap 14px
- Code-Block padding: 14px 16px, border-radius 8px
CODE-BEISPIELE
- So viele wie nötig, so wenige wie möglich
- Untereinander gestapelt (grid-template-columns: 1fr)
- Jedes Beispiel zeigt eine sinnvolle Variante / Use-Case
- Sehr kurz: ideal 3-6 Zeilen, max 8 Zeilen
- Mit kurzem Label was die Variante zeigt
- Syntax-Highlighting durch span-Klassen (.k, .v, .s, etc.)
- Triviale Bausteine: 1-2 Beispiele
- Komplexere Bausteine: 4-6 Beispiele
- Anzahl ergibt sich aus dem Inhalt, nicht aus Vorgabe
VERMEIDEN
- Lange Erklärungstexte
- Mehrere Sätze für Beschreibung oder Zweck
- Performance-Tipps, Trade-Offs, Edge Cases
- Verwandte Bausteine, Varianten, Anti-Patterns
- Tabellen, Listen, Aufzählungen
- Icons, Emojis, Symbole
- Komplexe Code-Beispiele (über 8 Zeilen)
THEMENSPEZIFISCHE ANPASSUNGEN
- Bei anderen Sprachen: Syntax-Highlighting-Klassen anpassen
- Titel-Größe und Spacing bleiben gleich
- Card-Layout bleibt gleich
- Inhalte (Beschreibung, Zweck, Beispiele) sprachspezifisch
GENERIERUNG MIT FEEDBACK-LOOP
1. HTML schreiben
2. In Browser anzeigen (Playwright-Screenshot oder direkt)
3. Prüfen:
- Wirklich nur 4 Elemente (Titel, Beschreibung, Zweck, Beispiele)?
- Beschreibung und Zweck unter 15 Wörtern?
- Code-Beispiele unter 8 Zeilen?
- Labels über Code-Blöcken kurz und prägnant?
- Card kompakt, kein leerer Raum?
4. Wenn etwas zu viel: weglassen, nicht hinzufügen
5. Bei jeder Iteration prüfen: lässt sich noch was weglassen?
```

View File

@@ -0,0 +1,119 @@
```
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Baustein: for-Schleife</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "Segoe UI", sans-serif;
background: #f0f0f5;
padding: 40px 20px;
display: flex;
justify-content: center;
min-height: 100vh;
}
.card {
background: #ffffff;
width: 100%;
max-width: 1000px;
border-radius: 12px;
padding: 28px 36px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
color: #1a1a1a;
font-size: 14px;
line-height: 1.5;
}
h1 {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
line-height: 1.1;
margin-bottom: 14px;
}
.beschreibung {
font-size: 14px;
line-height: 1.55;
margin-bottom: 6px;
}
.zweck {
font-size: 14px;
font-style: italic;
color: #5a6470;
margin-bottom: 22px;
}
.examples {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.code-block {
background: #1e2a3a;
color: #e6e6e6;
font-family: "SF Mono", Consolas, monospace;
font-size: 12px;
line-height: 1.5;
padding: 14px 16px;
border-radius: 8px;
white-space: pre;
}
.code-block .label {
font-family: -apple-system, sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #8be9fd;
margin-bottom: 6px;
display: block;
}
.k { color: #ff79c6; }
.v { color: #ffb86c; }
.n { color: #ffb86c; }
</style>
</head>
<body>
<div class="card">
<h1>for-Schleife</h1>
<p class="beschreibung">Führt einen Code-Block wiederholt aus.</p>
<p class="zweck">Wiederholung wenn Anzahl Durchläufe bekannt ist oder Index gebraucht wird.</p>
<div class="examples">
<div class="code-block"><span class="label">Vorwärts zählen</span><span class="k">for</span> (<span class="v">$i</span> = <span class="n">0</span>; <span class="v">$i</span> &lt; <span class="n">10</span>; <span class="v">$i</span>++) {
<span class="k">echo</span> <span class="v">$i</span>;
}</div>
<div class="code-block"><span class="label">Rückwärts zählen</span><span class="k">for</span> (<span class="v">$i</span> = <span class="n">10</span>; <span class="v">$i</span> &gt; <span class="n">0</span>; <span class="v">$i</span>--) {
<span class="k">echo</span> <span class="v">$i</span>;
}</div>
<div class="code-block"><span class="label">In 2er-Schritten</span><span class="k">for</span> (<span class="v">$i</span> = <span class="n">0</span>; <span class="v">$i</span> &lt;= <span class="n">20</span>; <span class="v">$i</span> += <span class="n">2</span>) {
<span class="k">echo</span> <span class="v">$i</span>;
}</div>
<div class="code-block"><span class="label">Mit Array-Index</span><span class="k">for</span> (<span class="v">$i</span> = <span class="n">0</span>; <span class="v">$i</span> &lt; <span class="n">3</span>; <span class="v">$i</span>++) {
<span class="k">echo</span> <span class="v">$arr</span>[<span class="v">$i</span>];
}</div>
</div>
</div>
</body>
</html>
```