{{ plain(el.title) }}
+ + +Hinweise
+-
+
+
diff --git a/backend/database.py b/backend/database.py
index e1010ab..151acdf 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -1,3 +1,5 @@
+import json
+
import aiosqlite
from config import DB_PATH
@@ -32,6 +34,19 @@ CREATE TABLE IF NOT EXISTS topics (
)
"""
+CREATE_ELEMENTS = """
+CREATE TABLE IF NOT EXISTS elements (
+ id TEXT PRIMARY KEY,
+ topic TEXT NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ examples TEXT NOT NULL DEFAULT '[]',
+ hints TEXT NOT NULL DEFAULT '[]',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+)
+"""
+
_db: aiosqlite.Connection | None = None
@@ -48,6 +63,7 @@ async def init_db():
await db.execute(CREATE_GUIDES)
await db.execute(CREATE_PROGRESS)
await db.execute(CREATE_TOPICS)
+ await db.execute(CREATE_ELEMENTS)
try: # Migration für Bestands-DBs ohne step-Spalte
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
except aiosqlite.OperationalError:
@@ -138,6 +154,63 @@ async def delete_topic(name: str) -> None:
await db.commit()
+# --- Elemente ---
+
+def _element_row(row, cursor) -> dict:
+ el = _row_to_dict(row, cursor)
+ el["examples"] = json.loads(el["examples"] or "[]")
+ el["hints"] = json.loads(el["hints"] or "[]")
+ return el
+
+
+async def create_element(element: dict) -> dict:
+ db = await get_db()
+ await db.execute(
+ """INSERT INTO elements (id, topic, title, description, examples, hints, created_at, updated_at)
+ VALUES (:id, :topic, :title, :description, :examples, :hints, :created_at, :updated_at)""",
+ {**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
+ "hints": json.dumps(element["hints"], ensure_ascii=False)},
+ )
+ await db.commit()
+ return element
+
+
+async def list_elements(topic: str) -> list[dict]:
+ db = await get_db()
+ cursor = await db.execute(
+ "SELECT * FROM elements WHERE topic = ? ORDER BY updated_at DESC", (topic,)
+ )
+ rows = await cursor.fetchall()
+ return [_element_row(row, cursor) for row in rows]
+
+
+async def get_element(element_id: str) -> dict | None:
+ db = await get_db()
+ cursor = await db.execute("SELECT * FROM elements WHERE id = ?", (element_id,))
+ row = await cursor.fetchone()
+ if row is None:
+ return None
+ return _element_row(row, cursor)
+
+
+async def update_element(element_id: str, **fields) -> None:
+ for key in ("examples", "hints"):
+ if key in fields:
+ fields[key] = json.dumps(fields[key], ensure_ascii=False)
+ sets = ", ".join(f"{k} = :{k}" for k in fields)
+ fields["id"] = element_id
+ db = await get_db()
+ await db.execute(f"UPDATE elements SET {sets} WHERE id = :id", fields)
+ await db.commit()
+
+
+async def delete_element(element_id: str) -> bool:
+ db = await get_db()
+ cursor = await db.execute("DELETE FROM elements WHERE id = ?", (element_id,))
+ await db.commit()
+ return cursor.rowcount > 0
+
+
# --- Kapitel-Fortschritt ---
async def list_progress(guide_id: str) -> list[str]:
diff --git a/backend/generator.py b/backend/generator.py
index a89f966..83bc3a6 100644
--- a/backend/generator.py
+++ b/backend/generator.py
@@ -1350,3 +1350,232 @@ async def chat_with_guide(topic: str, format_name: str, section: str, outline: s
return reply or "Entschuldigung, ich habe keine Antwort erhalten."
except Exception:
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
+
+
+# --- Elemente (persönliche Zusammenfassung) ---
+
+def _parse_json_text(text: str):
+ """Parst JSON aus KI-Output (Code-Fences und Drumherum-Text tolerant).
+
+ Repariert unescapte Anführungszeichen in Strings (z. B. MiniMax: "Titel „p" geändert"):
+ das letzte `"` vor der Fehlerstelle escapen und erneut parsen.
+ """
+ text = re.sub(r"^```(?:json)?\s*|\s*```$", "", (text or "").strip())
+ start, end = text.find("{"), text.rfind("}")
+ if start == -1 or end <= start:
+ return None
+ candidate = text[start:end + 1]
+ for _ in range(20):
+ try:
+ return json.loads(candidate)
+ except json.JSONDecodeError as e:
+ if not e.msg.startswith(("Expecting ',' delimiter", "Expecting ':' delimiter")):
+ return None
+ q = candidate.rfind('"', 0, e.pos)
+ if q <= 0:
+ return None
+ candidate = candidate[:q] + '\\"' + candidate[q + 1:]
+ except Exception:
+ return None
+ return None
+
+
+def _element_fields(data: dict) -> dict | None:
+ """Validiert KI-Element-JSON und normalisiert auf die DB-Felder."""
+ if not isinstance(data, dict):
+ return None
+ title = str(data.get("title", "")).strip()
+ if not title:
+ return None
+ listen = {}
+ for key in ("examples", "hints"):
+ raw = data.get(key, [])
+ listen[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
+ return {
+ "title": title[:200],
+ "description": str(data.get("description", "")).strip(),
+ "examples": listen["examples"][:5],
+ "hints": listen["hints"][:5],
+ }
+
+
+def _topic_context(topic: str, limit: int = 12000) -> str:
+ """Bausteine + Guide-Inhalte des Themas als Kontext-Text (gekürzt)."""
+ parts: list[str] = []
+ bp = bausteine_path(topic)
+ if bp.exists():
+ parts.append(bp.read_text(encoding="utf-8"))
+ for fmt in ("FullGuide", "Guide", "MiniGuide", "OnePager"):
+ content = _json_datei(guide_content_path(topic, fmt))
+ if content:
+ for ch in content.get("chapters", []):
+ for sec in ch.get("sections", []):
+ parts.append(sec if isinstance(sec, str) else json.dumps(sec, ensure_ascii=False))
+ break # bester verfügbarer Guide reicht
+ text = "\n\n".join(parts).strip()
+ return text[:limit] if text else "(kein Material vorhanden)"
+
+
+async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER) -> dict:
+ """Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort."""
+ fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []}
+ try:
+ prompt = _prompt(
+ "Element-Create",
+ topic=topic, hint=hint.strip() or "(keins — wähle selbst ein Kernkonzept)",
+ context=_topic_context(topic),
+ )
+ returncode, stdout, _ = await run_agent(
+ "element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
+ )
+ if returncode != 0:
+ return fallback
+ return _element_fields(_parse_json_text(stdout)) or fallback
+ except Exception:
+ return fallback
+
+
+def _fence(content: str) -> str:
+ """Beispiele müssen Codeblöcke sein — fehlende Fences nachrüsten."""
+ if content.startswith("```"):
+ return content
+ return f"```\n{content}\n```"
+
+
+def _parse_suggestions(stdout: str) -> list[dict] | None:
+ """Validiert Vorschlags-JSON aus KI-Output. None bei ungültigem JSON."""
+ data = _parse_json_text(stdout)
+ if not isinstance(data, dict):
+ return None
+ suggestions = []
+ for s in data.get("suggestions", []):
+ if not isinstance(s, dict):
+ continue
+ text = str(s.get("text", "")).strip()
+ target = s.get("target")
+ content = str(s.get("content", "")).strip()
+ if text and content and target in ("description", "examples", "hints"):
+ if target == "examples":
+ content = _fence(content)
+ suggestions.append({"text": text, "target": target, "content": content})
+ return suggestions
+
+
+async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
+ """Zweischrittige Prüfung auf fehlende Infos: Recherche → Verifizieren. None bei Fehler."""
+ try:
+ element_json = json.dumps(
+ {k: element[k] for k in ("title", "description", "examples", "hints")},
+ ensure_ascii=False, indent=1,
+ )
+ context = _topic_context(element["topic"])
+
+ # Schritt 1: Recherche — breit Kandidaten sammeln
+ prompt = _prompt("Element-Check", topic=element["topic"], element_json=element_json, context=context)
+ returncode, stdout, _ = await run_agent(
+ "element-check-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
+ )
+ if returncode != 0:
+ return None
+ candidates = _parse_suggestions(stdout)
+ if candidates is None:
+ return None
+ if not candidates:
+ return []
+
+ # Schritt 2: Verifizieren — nur Wichtiges, nicht Redundantes durchlassen
+ prompt = _prompt(
+ "Element-Verify",
+ topic=element["topic"], element_json=element_json,
+ candidates_json=json.dumps({"suggestions": candidates}, ensure_ascii=False, indent=1),
+ context=context,
+ )
+ returncode, stdout, _ = await run_agent(
+ "element-verify-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
+ )
+ if returncode != 0:
+ return None
+ return _parse_suggestions(stdout)
+ except Exception:
+ return None
+
+
+async def _element_rewrite(template: str, element: dict, provider: str, **extra) -> tuple[str, dict | None]:
+ """Gemeinsames Muster: Element + Template → (Antwort, neue Felder|None)."""
+ fehler = "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
+ try:
+ element_json = json.dumps(
+ {k: element[k] for k in ("title", "description", "examples", "hints")},
+ ensure_ascii=False, indent=1,
+ )
+ prompt = _prompt(template, topic=element["topic"], element_json=element_json, **extra)
+ returncode, stdout, _ = await run_agent(
+ f"element-{template.lower()}-" + str(uuid.uuid4()), prompt, 240,
+ provider=provider, role="fast", capabilities="none",
+ )
+ if returncode != 0:
+ return fehler, None
+ data = _parse_json_text(stdout)
+ if not isinstance(data, dict):
+ return fehler, None
+ fields = _element_fields(data.get("element"))
+ reply = str(data.get("reply", "")).strip() or ("Erledigt." if fields else fehler)
+ return reply, fields
+ except Exception:
+ return fehler, None
+
+
+async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, dict | None]:
+ """Passt ein Element per Chat an. Gibt (Antwort, neue Felder|None) zurück."""
+ transcript = "\n".join(
+ f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
+ for m in messages
+ )
+ return await _element_rewrite("Element-Chat", element, provider, transcript=transcript)
+
+
+async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
+ """Prüft ein Element auf die Stil-Regeln und schlägt Änderungen vor. None bei Fehler."""
+ try:
+ element_json = json.dumps(
+ {k: element[k] for k in ("title", "description", "examples", "hints")},
+ ensure_ascii=False, indent=1,
+ )
+ prompt = _prompt("Element-Stil", topic=element["topic"], element_json=element_json)
+ returncode, stdout, _ = await run_agent(
+ "element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
+ )
+ if returncode != 0:
+ return None
+ data = _parse_json_text(stdout)
+ if not isinstance(data, dict):
+ return None
+ changes = []
+ for c in data.get("changes", []):
+ if not isinstance(c, dict):
+ continue
+ text = str(c.get("text", "")).strip()
+ action = c.get("action")
+ target = c.get("target")
+ index = c.get("index")
+ content = str(c.get("content", "")).strip()
+ if not text or action not in ("entfernen", "anpassen", "hinzufuegen"):
+ continue
+ if target not in ("title", "description", "examples", "hints"):
+ continue
+ if action in ("anpassen", "hinzufuegen") and not content:
+ continue
+ if action == "entfernen" and target not in ("examples", "hints"):
+ continue
+ # Index nur für anpassen/entfernen in Listen-Feldern; muss existieren
+ if target in ("examples", "hints") and action in ("anpassen", "entfernen"):
+ if not isinstance(index, int) or not (0 <= index < len(element[target])):
+ continue
+ else:
+ index = None
+ if target == "examples" and action in ("anpassen", "hinzufuegen"):
+ content = _fence(content)
+ changes.append({"text": text, "action": action, "target": target, "index": index, "content": content})
+ return changes
+ except Exception:
+ return None
diff --git a/backend/models.py b/backend/models.py
index 2a19b45..accf6a5 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -79,6 +79,66 @@ class GuideChatResponse(BaseModel):
reply: str
+class ElementResponse(BaseModel):
+ id: str
+ topic: str
+ title: str
+ description: str = ""
+ examples: list[str] = []
+ hints: list[str] = []
+ created_at: str
+ updated_at: str
+
+
+class ElementCreateRequest(BaseModel):
+ topic: str = Field(min_length=1, max_length=100)
+ hint: str = Field(default="", max_length=500)
+ provider: ProviderType = "claude"
+
+
+class ElementUpdateRequest(BaseModel):
+ title: str | None = Field(default=None, max_length=200)
+ description: str | None = None
+ examples: list[str] | None = None
+ hints: list[str] | None = None
+
+
+class ElementCheckRequest(BaseModel):
+ provider: ProviderType = "claude"
+
+
+class ElementSuggestion(BaseModel):
+ text: str
+ target: Literal["description", "examples", "hints"]
+ content: str
+
+
+class ElementCheckResponse(BaseModel):
+ suggestions: list[ElementSuggestion]
+
+
+class ElementStyleChange(BaseModel):
+ text: str
+ action: Literal["entfernen", "anpassen", "hinzufuegen"]
+ target: Literal["title", "description", "examples", "hints"]
+ index: int | None = None
+ content: str = ""
+
+
+class ElementStyleResponse(BaseModel):
+ changes: list[ElementStyleChange]
+
+
+class ElementChatRequest(BaseModel):
+ messages: list[ChatMessage] = Field(min_length=1)
+ provider: ProviderType = "claude"
+
+
+class ElementChatResponse(BaseModel):
+ reply: str
+ element: ElementResponse
+
+
class ProgressUpdate(BaseModel):
chapter: str = Field(min_length=1, max_length=100)
done: bool
diff --git a/backend/routes.py b/backend/routes.py
index 3b363b2..223e822 100644
--- a/backend/routes.py
+++ b/backend/routes.py
@@ -13,16 +13,20 @@ from database import (
create_guide, delete_guide, get_guide, list_guides,
create_topic, list_topics as db_list_topics, delete_topic,
list_progress, set_progress, delete_progress,
+ create_element, list_elements, get_element, update_element, delete_element,
)
from generator import (
generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien,
generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
+ generate_element, chat_with_element, check_element, style_element,
)
from models import (
GuideCreateRequest, GuideResponse,
TopicCreateRequest,
BausteineCreateRequest, BausteineStatusResponse,
GuideChatRequest, GuideChatResponse,
+ ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
+ ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
)
from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir, topic_dir
@@ -263,6 +267,75 @@ async def guide_chat(guide_id: str, req: GuideChatRequest):
return {"reply": reply}
+# --- Elemente (persönliche Zusammenfassung) ---
+
+@router.get("/elements", response_model=list[ElementResponse])
+async def get_elements(topic: str):
+ return await list_elements(topic)
+
+
+@router.post("/elements", response_model=ElementResponse)
+async def post_element(req: ElementCreateRequest):
+ fields = await generate_element(req.topic, req.hint, provider=req.provider)
+ now = datetime.now(timezone.utc).isoformat()
+ element = {"id": str(uuid.uuid4()), "topic": req.topic, **fields, "created_at": now, "updated_at": now}
+ await create_element(element)
+ return element
+
+
+@router.post("/elements/{element_id}/chat", response_model=ElementChatResponse)
+async def element_chat(element_id: str, req: ElementChatRequest):
+ element = await get_element(element_id)
+ if element is None:
+ raise HTTPException(404, "Element nicht gefunden")
+ reply, fields = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
+ if fields:
+ now = datetime.now(timezone.utc).isoformat()
+ await update_element(element_id, **fields, updated_at=now)
+ element = await get_element(element_id)
+ return {"reply": reply, "element": element}
+
+
+@router.put("/elements/{element_id}", response_model=ElementResponse)
+async def put_element(element_id: str, req: ElementUpdateRequest):
+ if await get_element(element_id) is None:
+ raise HTTPException(404, "Element nicht gefunden")
+ fields = req.model_dump(exclude_unset=True, exclude_none=True)
+ if fields:
+ now = datetime.now(timezone.utc).isoformat()
+ await update_element(element_id, **fields, updated_at=now)
+ return await get_element(element_id)
+
+
+@router.post("/elements/{element_id}/style", response_model=ElementStyleResponse)
+async def element_style(element_id: str, req: ElementCheckRequest):
+ element = await get_element(element_id)
+ if element is None:
+ raise HTTPException(404, "Element nicht gefunden")
+ changes = await style_element(element, provider=req.provider)
+ if changes is None:
+ raise HTTPException(502, "Stil-Prüfung fehlgeschlagen — bitte erneut versuchen")
+ return {"changes": changes}
+
+
+@router.post("/elements/{element_id}/check", response_model=ElementCheckResponse)
+async def element_check(element_id: str, req: ElementCheckRequest):
+ element = await get_element(element_id)
+ if element is None:
+ raise HTTPException(404, "Element nicht gefunden")
+ suggestions = await check_element(element, provider=req.provider)
+ if suggestions is None:
+ raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
+ return {"suggestions": suggestions}
+
+
+@router.delete("/elements/{element_id}")
+async def remove_element(element_id: str):
+ if not await delete_element(element_id):
+ raise HTTPException(404, "Element nicht gefunden")
+ return {"ok": True}
+
+
@router.post("/guides/{guide_id}/cancel")
async def cancel(guide_id: str):
cancelled = await cancel_guide(guide_id)
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index b83e77b..b292c5b 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -3,6 +3,8 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
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, fetchStats, fetchTopicFortschritt } from './api.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
+import ElementsSidebar from './components/ElementsSidebar.vue'
+import ElementsOverview from './components/ElementsOverview.vue'
const guides = ref([])
const projects = ref([])
@@ -23,6 +25,11 @@ const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([])
const stats = ref(null)
const fortschritt = ref({})
+const elementsOpen = ref(false) // rechte Sidebar
+const elementsView = ref(false) // Übersicht im Hauptbereich
+const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden
+const elementOpenId = ref(null) // Element aus Übersicht in Sidebar öffnen
+const elementOpenTick = ref(0)
async function loadStats() {
try {
@@ -182,6 +189,9 @@ function selectTopic(topic) {
selectedTopic.value = topic
previewGuide.value = null
sidebarSticky.value = false
+ elementsOpen.value = false
+ elementsView.value = false
+ elementOpenId.value = null
localStorage.setItem('lastTopic', topic)
loadBausteine()
nextTick(autoPreview)
@@ -243,6 +253,19 @@ async function handleDeleteProject(name) {
function handlePreview(guide) {
previewGuide.value = guide
+ elementsView.value = false
+}
+
+function handleOpenElements() {
+ if (!selectedTopic.value) return
+ elementsView.value = true
+ elementsOpen.value = true
+}
+
+function handleOpenElementDetail(el) {
+ elementOpenId.value = el.id
+ elementOpenTick.value++
+ elementsOpen.value = true
}
async function handleDeleteGuide(guideId, slots = false) {
@@ -353,19 +376,36 @@ onUnmounted(() => {
@deleteGuide="handleDeleteGuide"
@dismissError="handleDismissError"
@preview="handlePreview"
+ @openElements="handleOpenElements"
@togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave"
/>
+
Thema in der Sidebar anlegen oder auswählen.
+ Noch keine Elemente. Rechts in der Sidebar Stichwort eingeben und + klicken. +
+