From af5c0950ea4f27abd32cab6909ec696d1c7913c7 Mon Sep 17 00:00:00 2001 From: team3 Date: Sun, 7 Jun 2026 15:17:50 +0200 Subject: [PATCH] update --- backend/database.py | 73 ++ backend/generator.py | 229 +++++ backend/models.py | 60 ++ backend/routes.py | 73 ++ frontend/src/App.vue | 42 +- frontend/src/api.js | 56 ++ frontend/src/components/ElementsOverview.vue | 205 ++++ frontend/src/components/ElementsSidebar.vue | 986 +++++++++++++++++++ frontend/src/components/TopicDetail.vue | 39 +- frontend/src/components/TopicSidebar.vue | 20 +- frontend/src/markdown.js | 31 + templates/Prompt/Element-Chat.md | 22 + templates/Prompt/Element-Check.md | 19 + templates/Prompt/Element-Create.md | 24 + templates/Prompt/Element-Stil.md | 29 + templates/Prompt/Element-Verify.md | 23 + 16 files changed, 1897 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/ElementsOverview.vue create mode 100644 frontend/src/components/ElementsSidebar.vue create mode 100644 frontend/src/markdown.js create mode 100644 templates/Prompt/Element-Chat.md create mode 100644 templates/Prompt/Element-Check.md create mode 100644 templates/Prompt/Element-Create.md create mode 100644 templates/Prompt/Element-Stil.md create mode 100644 templates/Prompt/Element-Verify.md 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.

+ diff --git a/frontend/src/api.js b/frontend/src/api.js index 8fb56a2..b0a811a 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -118,3 +118,59 @@ export async function chatGuide(id, { section, outline, messages, provider = 'cl }) return res.json() } + +export async function fetchElements(topic) { + const res = await fetch(`${BASE}/elements?topic=${encodeURIComponent(topic)}`) + return res.json() +} + +export async function createElement(topic, hint = '', provider = 'claude') { + const res = await fetch(`${BASE}/elements`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic, hint, provider }), + }) + return res.json() +} + +export async function chatElement(id, messages, provider = 'claude') { + const res = await fetch(`${BASE}/elements/${id}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages, provider }), + }) + return res.json() +} + +export async function deleteElement(id) { + await fetch(`${BASE}/elements/${id}`, { method: 'DELETE' }) +} + +export async function updateElement(id, fields) { + const res = await fetch(`${BASE}/elements/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }) + return res.json() +} + +export async function styleElement(id, provider = 'claude') { + const res = await fetch(`${BASE}/elements/${id}/style`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider }), + }) + if (!res.ok) throw new Error(`Stil-Prüfung fehlgeschlagen (${res.status})`) + return res.json() +} + +export async function checkElement(id, provider = 'claude') { + const res = await fetch(`${BASE}/elements/${id}/check`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider }), + }) + if (!res.ok) throw new Error(`Prüfung fehlgeschlagen (${res.status})`) + return res.json() +} diff --git a/frontend/src/components/ElementsOverview.vue b/frontend/src/components/ElementsOverview.vue new file mode 100644 index 0000000..719170f --- /dev/null +++ b/frontend/src/components/ElementsOverview.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/components/ElementsSidebar.vue b/frontend/src/components/ElementsSidebar.vue new file mode 100644 index 0000000..b07a072 --- /dev/null +++ b/frontend/src/components/ElementsSidebar.vue @@ -0,0 +1,986 @@ + + + + + diff --git a/frontend/src/components/TopicDetail.vue b/frontend/src/components/TopicDetail.vue index e79c5b0..49cbc25 100644 --- a/frontend/src/components/TopicDetail.vue +++ b/frontend/src/components/TopicDetail.vue @@ -1,37 +1,7 @@