From 58fd20917495c58730132335ce8352566db58559 Mon Sep 17 00:00:00 2001 From: team3 Date: Sun, 7 Jun 2026 16:34:17 +0200 Subject: [PATCH] update --- backend/generator.py | 127 ++++++----- backend/models.py | 12 +- backend/routes.py | 22 +- frontend/src/api.js | 10 + frontend/src/components/ElementSuggestion.vue | 212 ++++++++++++++++++ frontend/src/components/ElementsSidebar.vue | 196 +++++++--------- templates/Prompt/Element-Chat.md | 23 +- templates/Prompt/Element-Refine.md | 24 ++ 8 files changed, 437 insertions(+), 189 deletions(-) create mode 100644 frontend/src/components/ElementSuggestion.vue create mode 100644 templates/Prompt/Element-Refine.md diff --git a/backend/generator.py b/backend/generator.py index 9fb1eee..8168423 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -1500,48 +1500,69 @@ async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list 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).""" +def _element_json(element: dict) -> str: + return json.dumps( + {k: element[k] for k in ("title", "description", "examples", "hints")}, + ensure_ascii=False, indent=1, + ) + + +def _validate_change(c, element: dict) -> dict | None: + """Validiert einen Änderungs-Vorschlag aus KI-Output gegen das Element.""" + if not isinstance(c, dict): + return None + 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"): + return None + if target not in ("title", "description", "examples", "hints"): + return None + if action in ("anpassen", "hinzufuegen") and not content: + return None + if action == "entfernen" and target not in ("examples", "hints"): + return None + # 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])): + return None + else: + index = None + if target == "examples" and action in ("anpassen", "hinzufuegen"): + content = _fence(content) + return {"text": text, "action": action, "target": target, "index": index, "content": content} + + +async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, list[dict]]: + """Chat zum Element. Gibt (Antwort, Änderungs-Vorschläge) zurück — ändert nichts direkt.""" 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, + transcript = "\n".join( + f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}" + for m in messages ) - prompt = _prompt(template, topic=element["topic"], element_json=element_json, **extra) + prompt = _prompt("Element-Chat", topic=element["topic"], element_json=_element_json(element), transcript=transcript) returncode, stdout, _ = await run_agent( - f"element-{template.lower()}-" + str(uuid.uuid4()), prompt, 240, - provider=provider, role="fast", capabilities="none", + "element-chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none" ) if returncode != 0: - return fehler, None + return fehler, [] 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 + return fehler, [] + changes = [v for c in data.get("changes", []) if (v := _validate_change(c, element))] + reply = str(data.get("reply", "")).strip() or ("Vorschläge erstellt." if changes else fehler) + return reply, changes 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) + return fehler, [] 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) + prompt = _prompt("Element-Stil", topic=element["topic"], element_json=_element_json(element)) returncode, stdout, _ = await run_agent( "element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none" ) @@ -1550,32 +1571,28 @@ async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list 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 + return [v for c in data.get("changes", []) if (v := _validate_change(c, element))] + except Exception: + return None + + +async def refine_suggestion(element: dict, suggestion: dict, instruction: str, provider: str = DEFAULT_PROVIDER) -> dict | None: + """Überarbeitet einen einzelnen Vorschlag nach Nutzer-Anweisung. None bei Fehler.""" + try: + prompt = _prompt( + "Element-Refine", + topic=element["topic"], element_json=_element_json(element), + suggestion_json=json.dumps(suggestion, ensure_ascii=False, indent=1), + instruction=instruction, + ) + returncode, stdout, _ = await run_agent( + "element-refine-" + 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 + return _validate_change(data.get("change"), element) except Exception: return None diff --git a/backend/models.py b/backend/models.py index accf6a5..1e5e501 100644 --- a/backend/models.py +++ b/backend/models.py @@ -136,7 +136,17 @@ class ElementChatRequest(BaseModel): class ElementChatResponse(BaseModel): reply: str - element: ElementResponse + changes: list[ElementStyleChange] = [] + + +class ElementRefineRequest(BaseModel): + suggestion: ElementStyleChange + instruction: str = Field(min_length=1, max_length=2000) + provider: ProviderType = "claude" + + +class ElementRefineResponse(BaseModel): + change: ElementStyleChange class ProgressUpdate(BaseModel): diff --git a/backend/routes.py b/backend/routes.py index 223e822..31a4af9 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -18,7 +18,7 @@ from database import ( 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, + generate_element, chat_with_element, check_element, style_element, refine_suggestion, ) from models import ( GuideCreateRequest, GuideResponse, @@ -27,6 +27,7 @@ from models import ( GuideChatRequest, GuideChatResponse, ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse, ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse, + ElementRefineRequest, ElementRefineResponse, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, ) from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir, topic_dir @@ -288,12 +289,19 @@ 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} + reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider) + return {"reply": reply, "changes": changes} + + +@router.post("/elements/{element_id}/refine", response_model=ElementRefineResponse) +async def element_refine(element_id: str, req: ElementRefineRequest): + element = await get_element(element_id) + if element is None: + raise HTTPException(404, "Element nicht gefunden") + change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider) + if change is None: + raise HTTPException(502, "Überarbeitung fehlgeschlagen — bitte erneut versuchen") + return {"change": change} @router.put("/elements/{element_id}", response_model=ElementResponse) diff --git a/frontend/src/api.js b/frontend/src/api.js index b0a811a..1d4d7b6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -165,6 +165,16 @@ export async function styleElement(id, provider = 'claude') { return res.json() } +export async function refineSuggestion(id, suggestion, instruction, provider = 'claude') { + const res = await fetch(`${BASE}/elements/${id}/refine`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ suggestion, instruction, provider }), + }) + if (!res.ok) throw new Error(`Überarbeitung fehlgeschlagen (${res.status})`) + return res.json() +} + export async function checkElement(id, provider = 'claude') { const res = await fetch(`${BASE}/elements/${id}/check`, { method: 'POST', diff --git a/frontend/src/components/ElementSuggestion.vue b/frontend/src/components/ElementSuggestion.vue new file mode 100644 index 0000000..acd6532 --- /dev/null +++ b/frontend/src/components/ElementSuggestion.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/frontend/src/components/ElementsSidebar.vue b/frontend/src/components/ElementsSidebar.vue index b07a072..c2b3e3c 100644 --- a/frontend/src/components/ElementsSidebar.vue +++ b/frontend/src/components/ElementsSidebar.vue @@ -1,7 +1,8 @@ @@ -324,59 +357,41 @@ async function send() { @@ -709,70 +729,6 @@ async function send() { font-size: 0.85em; } -/* --- Stil-Vorschläge inline am Ziel --- */ -.style-sugg { - margin: 0.3rem 0 0.6rem; - padding: 0.5rem 0.6rem; - border: 1px dashed var(--accent); - border-radius: 8px; - background: var(--panel-soft); -} - -.style-sugg-text { - font-size: 0.76rem; - line-height: 1.4; - color: var(--text); -} - -.style-sugg-text strong { - color: var(--accent); -} - -.style-sugg-preview { - margin-top: 0.35rem; - font-size: 0.76rem; - line-height: 1.45; - color: var(--text-muted); -} - -.style-sugg-actions { - display: flex; - gap: 6px; - margin-top: 0.45rem; -} - -.sugg-ok, -.sugg-no { - padding: 4px 10px; - border-radius: 6px; - font-size: 0.74rem; - font-weight: 600; - cursor: pointer; -} - -.sugg-ok { - border: none; - background: var(--accent); - color: var(--on-accent); -} - -.sugg-no { - border: 1px solid var(--border-strong); - background: none; - color: var(--text-muted); -} - -.sugg-no:hover { - border-color: var(--danger); - color: var(--danger); -} - -.sugg-ok:disabled, -.sugg-no:disabled { - opacity: 0.4; - cursor: not-allowed; -} - /* --- KI-Prüfung --- */ .el-check { margin-top: 1rem; @@ -983,4 +939,8 @@ async function send() { opacity: 0.4; cursor: not-allowed; } + +.chat-input button.cancel { + background: var(--danger); +} diff --git a/templates/Prompt/Element-Chat.md b/templates/Prompt/Element-Chat.md index 05b1587..215b1f5 100644 --- a/templates/Prompt/Element-Chat.md +++ b/templates/Prompt/Element-Chat.md @@ -1,4 +1,4 @@ -Du hilfst, ein Lern-Element einer persönlichen Zusammenfassung zum Thema "{topic}" anzupassen. +Du hilfst, ein Lern-Element einer persönlichen Zusammenfassung zum Thema "{topic}" anzupassen. Du änderst NICHTS direkt — du schlägst Änderungen vor, der Nutzer bestätigt jede einzeln. AKTUELLES ELEMENT (JSON): {element_json} @@ -6,17 +6,24 @@ AKTUELLES ELEMENT (JSON): BISHERIGER CHAT-VERLAUF: {transcript} -Setze die letzte Nutzer-Anweisung am Element um. Halte die Element-Regeln ein: +Setze die letzte Nutzer-Anweisung in Änderungs-Vorschläge um. Halte die Element-Regeln ein: 1. title — prägnanter Titel (max. 8 Wörter, reiner Text ohne Markdown/Backticks) 2. description — was es ist und wozu: MAXIMAL 1–2 Sätze -3. examples — KURZ und SIMPEL: wenige Zeilen Code, das Minimalbeispiel, keine Realwelt-Komplexität. Ein Beispiel pro relevanter Variante, geordnet vom Üblichen zum Speziellen. Jedes beginnt mit einem kurzen Kommentar in der Code-Syntax (z. B. ``), der die Variante benennt. -4. hints — jeder Hinweis muss WICHTIG oder NÜTZLICH sein: Stolperfalle, Merksatz oder Best Practice mit echtem Praxiswert. Telegrammstil: nur die Kernaussage. Beispiel: "Keine Blockelemente in `

`." +3. examples — KURZ und SIMPEL: wenige Zeilen Code, das Minimalbeispiel. Jedes beginnt mit einem kurzen Kommentar in der Code-Syntax (z. B. ``), der die Variante benennt. Als Codeblock mit Sprachangabe (```sprache). +4. hints — jeder Hinweis muss WICHTIG oder NÜTZLICH sein. Telegrammstil: nur die Kernaussage. Beispiel: "Keine Blockelemente in `

`." -Umfang: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH — gilt für description, examples und hints. Jedes Wort muss seinen Platz verdienen: Füllwörter, Nebensätze ohne Informationswert und Selbstverständliches streichen. Aber: Kürze nie auf Kosten der Verständlichkeit. +Umfang: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. Markdown: `inline-code` für Bezeichner, Tags und Befehle — IMMER in Backticks. Tonalität: klares Deutsch, direkt, keine Füllsätze. -Tonalität: klares Deutsch, direkt, praxisorientiert. Keine Füllsätze. +Jeder Vorschlag: +- text: kurz, was geändert wird (max. 12 Wörter, reiner Text) +- action: "entfernen" | "anpassen" | "hinzufuegen" +- target: "title" | "description" | "examples" | "hints" +- index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description und hinzufuegen: null) +- content: der neue vollständige Inhalt (bei entfernen: leer) -Markdown in description, examples und hints: `inline-code` für Bezeichner, Codeblöcke mit Sprachangabe (```sprache), **fett** sparsam. Keine Überschriften. Code-Beispiele IMMER als Codeblock, nie als Inline-Code. Bezeichner, Tags und Befehle (z. B. `

`, `git add`) im Fließtext IMMER in Backticks — nie nackt. +"entfernen" nur für examples/hints. Nur Vorschläge machen, die die Nutzer-Anweisung verlangt. Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text: -{{"reply": "kurze Antwort an den Nutzer (1–2 Sätze)", "element": {{"title": "...", "description": "...", "examples": ["```sprache\n...\n```"], "hints": ["..."]}}}} +{{"reply": "kurze Antwort an den Nutzer (1–2 Sätze)", "changes": [{{"text": "...", "action": "anpassen", "target": "hints", "index": 0, "content": "..."}}]}} + +Reine Frage ohne Änderungswunsch → beantworte sie in reply, "changes": [] diff --git a/templates/Prompt/Element-Refine.md b/templates/Prompt/Element-Refine.md new file mode 100644 index 0000000..7a9825b --- /dev/null +++ b/templates/Prompt/Element-Refine.md @@ -0,0 +1,24 @@ +Du überarbeitest GENAU EINEN Änderungs-Vorschlag für ein Lern-Element zum Thema "{topic}" nach einer Nutzer-Anweisung. + +AKTUELLES ELEMENT (JSON): +{element_json} + +AKTUELLER VORSCHLAG (JSON): +{suggestion_json} + +ANWEISUNG DES NUTZERS: +{instruction} + +Passe den Vorschlag gemäß der Anweisung an. Behalte action/target/index bei, außer die Anweisung verlangt anderes. + +Stil-Regeln für content: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. `inline-code` für Bezeichner, Tags und Befehle — IMMER in Backticks. examples als Codeblock mit Sprachangabe und kurzem Varianten-Kommentar (z. B. ``). hints im Telegrammstil: nur die Kernaussage. + +Felder: +- text: kurz, was geändert wird (max. 12 Wörter, reiner Text) +- action: "entfernen" | "anpassen" | "hinzufuegen" +- target: "title" | "description" | "examples" | "hints" +- index: 0-basierte Position im examples-/hints-Array (sonst null) +- content: der neue vollständige Inhalt (bei entfernen: leer) + +Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text: +{{"change": {{"text": "...", "action": "anpassen", "target": "hints", "index": 0, "content": "..."}}}}