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 @@
+
+
+
+ Hinweise
-
`." +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": "..."}}}}