diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md index c92003e..0198951 100644 --- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md +++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md @@ -37,6 +37,7 @@ steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet. |-------------|-----------| | `exercise_summary` | Generiert `exercises.summary` aus goal + execution | | `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung | +| `exercise_instruction_rewrite` | Überarbeitet Anleitung: goal, execution, preparation, trainer_notes (JSON, prägnantes HTML) | | `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe | | `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix | | `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten | diff --git a/backend/ai_prompt_context.py b/backend/ai_prompt_context.py index 1d15f36..bd441b4 100644 --- a/backend/ai_prompt_context.py +++ b/backend/ai_prompt_context.py @@ -19,7 +19,7 @@ class ExerciseFormAiFocusRow(BaseModel): class ExerciseFormAiPromptContext(BaseModel): """ - Inhaltliche Eingabe fuer Uebungs-Prompts (Kurzfassung / Skill-Vorschlaege). + Inhaltliche Eingabe fuer Uebungs-Prompts (Kurzfassung / Skills / Anleitung). Wird genutzt von Admin-Prompt-Vorschau und POST /exercises/ai/suggest (via Mapping). """ @@ -27,6 +27,8 @@ class ExerciseFormAiPromptContext(BaseModel): title: Optional[str] = "" goal: Optional[str] = None execution: Optional[str] = None + preparation: Optional[str] = None + trainer_notes: Optional[str] = None focus_hint: Optional[str] = None focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None @@ -35,6 +37,15 @@ class ExerciseFormAiPromptContext(BaseModel): return None return [(int(x.focus_area_id), bool(x.is_primary)) for x in self.focus_areas_context] + def has_instruction_source_text(self) -> bool: + """Mindestens ein Anleitungsfeld oder Titel fuer instruction_rewrite.""" + if (self.title or "").strip(): + return True + for val in (self.goal, self.execution, self.preparation, self.trainer_notes): + if val and str(val).strip(): + return True + return False + @classmethod def from_api_suggest( cls, @@ -42,6 +53,8 @@ class ExerciseFormAiPromptContext(BaseModel): title: Optional[str] = None, goal: Optional[str] = None, execution: Optional[str] = None, + preparation: Optional[str] = None, + trainer_notes: Optional[str] = None, focus_area_hint: Optional[str] = None, focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None, ) -> ExerciseFormAiPromptContext: @@ -51,6 +64,8 @@ class ExerciseFormAiPromptContext(BaseModel): title=(title or "").strip(), goal=goal, execution=execution, + preparation=preparation, + trainer_notes=trainer_notes, focus_hint=hint, focus_areas_context=list(focus_areas_context) if focus_areas_context else None, ) @@ -62,6 +77,8 @@ class ExerciseFormAiPromptContext(BaseModel): title: str = "", goal: Optional[str] = None, execution: Optional[str] = None, + preparation: Optional[str] = None, + trainer_notes: Optional[str] = None, focus_hint: Optional[str] = None, focus_tuples: Optional[Sequence[Tuple[int, bool]]] = None, ) -> ExerciseFormAiPromptContext: @@ -75,6 +92,8 @@ class ExerciseFormAiPromptContext(BaseModel): title=(title or "").strip(), goal=goal, execution=execution, + preparation=preparation, + trainer_notes=trainer_notes, focus_hint=(focus_hint or "").strip() or None, focus_areas_context=rows, ) diff --git a/backend/ai_prompt_job.py b/backend/ai_prompt_job.py index daf73fc..38074ba 100644 --- a/backend/ai_prompt_job.py +++ b/backend/ai_prompt_job.py @@ -21,6 +21,8 @@ def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptCon execution=ctx.execution, focus_area_hint=ctx.focus_hint, focus_areas_context=ctx.focus_area_tuples(), + preparation=ctx.preparation, + trainer_notes=ctx.trainer_notes, ) @@ -30,6 +32,7 @@ def run_exercise_form_ai_suggestion( *, want_summary: bool, want_skills: bool, + want_instructions: bool = False, ) -> Dict[str, Any]: """ Fuehrt Uebungs-KI aus (OpenRouter) — ein Einstieg fuer Router und kuenftige Jobs. @@ -43,6 +46,7 @@ def run_exercise_form_ai_suggestion( form_ctx=ctx, want_summary=want_summary, want_skills=want_skills, + want_instructions=want_instructions, ) diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py index 7a52d22..315c1d2 100644 --- a/backend/ai_prompt_runtime.py +++ b/backend/ai_prompt_runtime.py @@ -15,6 +15,7 @@ _EXERCISE_AI_SLUGS = frozenset( { "exercise_summary", "exercise_skill_suggestions", + "exercise_instruction_rewrite", } ) diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py index 9d2c622..5b1c73e 100644 --- a/backend/exercise_ai.py +++ b/backend/exercise_ai.py @@ -7,6 +7,7 @@ Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, F from __future__ import annotations import copy +import html import json import logging import math @@ -26,6 +27,7 @@ from openrouter_chat import ( from ai_prompt_context import ExerciseFormAiPromptContext from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt +from exercise_rich_text import collect_inline_exercise_media_ids, normalize_inline_exercise_media_markup _LOGGER = logging.getLogger("shinkan.exercise_ai") @@ -497,6 +499,146 @@ def build_contextual_skills_catalog_block( return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)" +_MAX_INSTRUCTION_GOAL_PLAIN = 4_000 +_MAX_INSTRUCTION_EXECUTION_PLAIN = 12_000 +_MAX_INSTRUCTION_PREP_PLAIN = 2_500 +_MAX_INSTRUCTION_TRAINER_PLAIN = 2_500 + +_INSTRUCTION_JSON_KEYS = ("goal", "execution", "preparation", "trainer_notes") +_INSTRUCTION_FIELD_MAX_PLAIN = { + "goal": _MAX_INSTRUCTION_GOAL_PLAIN, + "execution": _MAX_INSTRUCTION_EXECUTION_PLAIN, + "preparation": _MAX_INSTRUCTION_PREP_PLAIN, + "trainer_notes": _MAX_INSTRUCTION_TRAINER_PLAIN, +} + +_DISALLOWED_HTML_TAG_RE = re.compile( + r"?\s*(?!p\b|ul\b|ol\b|li\b|strong\b|b\b|em\b|i\b|br\b|span\b)[a-zA-Z][^>]*>", + re.IGNORECASE, +) +_SCRIPT_STYLE_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?\1>") + + +def _plain_to_minimal_instruction_html(text: str) -> str: + raw = (text or "").strip() + if not raw: + return "" + parts = [p.strip() for p in re.split(r"\n+", raw) if p.strip()] + if not parts: + return "" + return "".join(f"
{html.escape(p)}
" for p in parts) + + +def _truncate_plain(text: str, max_len: int) -> str: + t = (text or "").strip() + if len(t) <= max_len: + return t + return t[: max_len - 1].rstrip() + "…" + + +def _sanitize_instruction_field_html(raw: Any, *, max_plain: int) -> str: + if raw is None: + return "" + s = str(raw).strip() + if not s: + return "" + if s.startswith("```"): + s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) + if s.endswith("```"): + s = s[:-3].strip() + s = _SCRIPT_STYLE_RE.sub("", s) + s = _DISALLOWED_HTML_TAG_RE.sub("", s) + if "<" not in s: + s = _plain_to_minimal_instruction_html(s) + else: + s = normalize_inline_exercise_media_markup(s) or "" + plain = strip_html_to_plain(s, max_len=max_plain + 200) + if len(plain) > max_plain: + plain = _truncate_plain(plain, max_plain) + s = _plain_to_minimal_instruction_html(plain) + return (normalize_inline_exercise_media_markup(s) or "").strip() + + +def _merge_preserved_inline_media(original: Optional[str], revised: str) -> str: + """Haengt fehlende Medien-Verweise aus dem Ausgangstext ans Ende an.""" + out = (revised or "").strip() + orig_ids = collect_inline_exercise_media_ids(original) + if not orig_ids: + return out + new_ids = collect_inline_exercise_media_ids(out) + missing = sorted(orig_ids - new_ids) + if not missing: + return out + spans = [] + for mid in missing: + spans.append( + f'' + ) + block = f"{''.join(spans)}
" + return (out + block).strip() if out else block + + +def _first_balanced_json_object(text: str) -> Optional[str]: + i = text.find("{") + if i < 0: + return None + depth = 0 + in_str = False + esc = False + for j in range(i, len(text)): + ch = text[j] + if in_str: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return text[i : j + 1] + return None + + +def _extract_instruction_rewrite_object(text: str) -> Dict[str, Any]: + s = (text or "").strip() + if not s: + raise ValueError("leer") + if s.startswith("```"): + s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) + if s.endswith("```"): + s = s[:-3].strip() + frag = _first_balanced_json_object(s) + if frag: + s = frag + obj = json.loads(s) + if not isinstance(obj, dict): + raise ValueError("kein JSON-Objekt") + return obj + + +def _sanitize_instruction_rewrite_payload( + parsed: Mapping[str, Any], + *, + originals: Mapping[str, Optional[str]], +) -> Dict[str, str]: + out: Dict[str, str] = {} + for key in _INSTRUCTION_JSON_KEYS: + max_plain = _INSTRUCTION_FIELD_MAX_PLAIN[key] + html = _sanitize_instruction_field_html(parsed.get(key), max_plain=max_plain) + html = _merge_preserved_inline_media(originals.get(key), html) + out[key] = html + return out + + def build_exercise_placeholder_variables( cur, *, @@ -506,6 +648,8 @@ def build_exercise_placeholder_variables( execution: Optional[str], focus_area_hint: Optional[str], focus_areas_context: Optional[Sequence[Tuple[int, bool]]], + preparation: Optional[str] = None, + trainer_notes: Optional[str] = None, ) -> Dict[str, str]: """ Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI. @@ -515,6 +659,8 @@ def build_exercise_placeholder_variables( return {} g_plain = strip_html_to_plain(goal) e_plain = strip_html_to_plain(execution) + p_plain = strip_html_to_plain(preparation) + n_plain = strip_html_to_plain(trainer_notes) t_title = (title or "").strip() focus = (focus_area_hint or "").strip() ctx: Dict[str, str] = { @@ -522,8 +668,12 @@ def build_exercise_placeholder_variables( "exercise_focus_area": focus or "-", "exercise_goal": g_plain or "-", "exercise_execution": e_plain or "-", + "exercise_preparation": p_plain or "-", + "exercise_trainer_notes": n_plain or "-", } if s == "exercise_summary": + return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")} + if s == "exercise_instruction_rewrite": return ctx if s == "exercise_skill_suggestions": catalog = build_contextual_skills_catalog_block( @@ -686,18 +836,27 @@ def run_exercise_ai_suggestion( form_ctx: ExerciseFormAiPromptContext, want_summary: bool, want_skills: bool, + want_instructions: bool = False, ) -> Dict[str, Any]: key = _require_openrouter_key() title = form_ctx.title goal = form_ctx.goal execution = form_ctx.execution + preparation = form_ctx.preparation + trainer_notes = form_ctx.trainer_notes focus_area_hint = form_ctx.focus_hint focus_areas_context = form_ctx.focus_area_tuples() g_plain = strip_html_to_plain(goal) e_plain = strip_html_to_plain(execution) - if not (g_plain.strip() or e_plain.strip()): + if want_instructions: + if not form_ctx.has_instruction_source_text(): + raise HTTPException( + status_code=400, + detail="Fuer Anleitungs-Ueberarbeitung mindestens Titel oder ein Anleitungsfeld ausfuellen.", + ) + elif not (g_plain.strip() or e_plain.strip()): raise HTTPException( status_code=400, detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).", @@ -712,14 +871,15 @@ def run_exercise_ai_suggestion( if _ai_debug_on(): fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context)) _LOGGER.warning( - "AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s " - "exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]", + "AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s want_instructions=%s " + "title_chars=%s goal_plain_chars=%s exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]", want_summary, want_skills, - len(t_title), + want_instructions, + len((title or "").strip()), len(g_plain), len(e_plain), - len(focus), + len((focus_area_hint or "").strip()), fid_list, ) @@ -843,9 +1003,92 @@ def run_exercise_ai_suggestion( result["skills"] = skills + if want_instructions: + try: + ctx = build_exercise_placeholder_variables( + cur, + slug="exercise_instruction_rewrite", + title=title, + goal=goal, + execution=execution, + preparation=preparation, + trainer_notes=trainer_notes, + focus_area_hint=focus_area_hint, + focus_areas_context=focus_areas_context, + ) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + try: + irow, rendered = load_and_render_ai_prompt(cur, "exercise_instruction_rewrite", ctx) + except AiPromptUnavailableError: + raise HTTPException( + status_code=503, + detail="Prompt exercise_instruction_rewrite nicht aktiv oder fehlt in DB.", + ) from None + model_instr = effective_openrouter_model_for_prompt_row(irow) + models_by_slug["exercise_instruction_rewrite"] = model_instr + prompt = rendered.text + if _ai_debug_on(): + _LOGGER.warning( + "AI_DEBUG exercise_ai instructions prompt_slug=exercise_instruction_rewrite prompt_chars=%s", + len(prompt), + ) + sys_hint = ( + "Du antwortest nur mit validem JSON-Objekt (Schluessel goal, execution, preparation, trainer_notes). " + "Keine Kommentare ausserhalb des JSON." + ) + try: + raw = openrouter_chat_completion( + api_key=key, + model=model_instr, + user_content=prompt, + system_content=sys_hint, + temperature=0.2, + ) + except OpenRouterError as e: + raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e + body = (raw or "").strip() + if not body: + raise HTTPException( + status_code=502, + detail="OpenRouter/KI lieferte leeren Inhalt fuer Anleitungs-Ueberarbeitung.", + ) + try: + parsed = _extract_instruction_rewrite_object(body) + except (json.JSONDecodeError, ValueError) as e: + if _ai_debug_on(): + _LOGGER.warning( + "AI_DEBUG exercise_ai instructions JSON parse_failed err=%s head=%s", + e, + (body.replace("\r", "").replace("\n", " ").strip())[:400], + ) + raise HTTPException( + status_code=502, + detail="KI lieferte kein verwertbares JSON fuer die Anleitung.", + ) from e + originals = { + "goal": goal, + "execution": execution, + "preparation": preparation, + "trainer_notes": trainer_notes, + } + fields = _sanitize_instruction_rewrite_payload(parsed, originals=originals) + if not any((fields.get(k) or "").strip() for k in _INSTRUCTION_JSON_KEYS): + raise HTTPException( + status_code=502, + detail="KI lieferte leere Anleitungs-Felder.", + ) + result["instructions"] = { + "fields": fields, + "ai_generated": True, + "model": model_instr, + } + result["models_by_slug"] = models_by_slug if want_skills: result["model"] = models_by_slug["exercise_skill_suggestions"] + elif want_instructions: + result["model"] = models_by_slug["exercise_instruction_rewrite"] elif want_summary: result["model"] = models_by_slug["exercise_summary"] else: diff --git a/backend/migrations/071_ai_prompt_exercise_instruction_rewrite.sql b/backend/migrations/071_ai_prompt_exercise_instruction_rewrite.sql new file mode 100644 index 0000000..612c189 --- /dev/null +++ b/backend/migrations/071_ai_prompt_exercise_instruction_rewrite.sql @@ -0,0 +1,59 @@ +-- Migration 071: KI-Prompt fuer Anleitungs-Ueberarbeitung (Ziel, Durchfuehrung, Vorbereitung, Trainer-Hinweise) +-- JSON-Ausgabe; praezise HTML-Fragmente fuer RichTextEditor. + +INSERT INTO ai_prompts ( + slug, display_name, description, template, + category, output_format, output_schema, is_system_default, default_template, active, sort_order +) +SELECT + 'exercise_instruction_rewrite', + 'Anleitung ueberarbeiten', + 'Ueberarbeitet Ziel, Durchfuehrung, Vorbereitung und Trainer-Hinweise — praezise, strukturiert, ohne Aufblaehen.', + $t$Du bist Assistent fuer Kampfsport-Trainer. +Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen. +Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing. + +Stil: +- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte) +- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels) +- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze) +- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String +- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze + +Format (HTML fuer Rich-Text-Editor): +- Erlaubt:,
…
", + "execution": "…
oder \"\"", + "trainer_notes": "${escapeHtmlText(p)}
`).join('') } +const INSTRUCTION_AI_FIELD_DEFS = [ + { key: 'goal', label: 'Ziel' }, + { key: 'execution', label: 'Durchführung' }, + { key: 'preparation', label: 'Vorbereitung / Aufbau' }, + { key: 'trainer_notes', label: 'Hinweise für Trainer' }, +] + function cloneExerciseSkillRows(rows) { return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : [] } @@ -110,9 +117,16 @@ function buildNormalizedAiSkillRowFromApi(sug) { } } -function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotSkills, apiRes }) { - const summaryRequested = mode !== 'skills' - const skillsRequested = mode !== 'summary' +function buildExerciseAiSuggestionPreview({ + mode, + snapshotSummaryHtml, + snapshotSkills, + snapshotInstructions, + apiRes, +}) { + const summaryRequested = mode !== 'skills' && mode !== 'instructions' + const skillsRequested = mode !== 'summary' && mode !== 'instructions' + const instructionsRequested = mode === 'instructions' let summaryAfterHtml = null let summaryAfterPlain = '' @@ -141,8 +155,29 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS } } + const instructionChoices = [] + if (instructionsRequested && apiRes.instructions?.fields) { + const fields = apiRes.instructions.fields + const snap = snapshotInstructions || {} + for (const def of INSTRUCTION_AI_FIELD_DEFS) { + const afterHtml = fields[def.key] + if (!afterHtml || !String(afterHtml).trim()) continue + const beforeHtml = snap[def.key] || '' + instructionChoices.push({ + key: def.key, + field: def.key, + label: def.label, + beforePlain: stripHtmlToText(beforeHtml).trim(), + afterHtml: String(afterHtml), + afterPlain: stripHtmlToText(afterHtml).trim(), + include: true, + }) + } + } + const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml) const hasSkillChoices = skillChoices.length > 0 + const hasInstructionChoices = instructionChoices.length > 0 return { mode, @@ -151,10 +186,13 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS summaryAfterPlain, summaryAfterHtml, skillChoices, + instructionChoices, hasSummaryProposal, hasSkillChoices, + hasInstructionChoices, summaryRequested, skillsRequested, + instructionsRequested, } } @@ -1027,20 +1065,96 @@ function ExerciseFormPageRoot() { } } + const runExerciseAiInstructionRewrite = async () => { + const title = (formData.title || '').trim() + const snapshotInstructions = { + goal: formData.goal || '', + execution: formData.execution || '', + preparation: formData.preparation || '', + trainer_notes: formData.trainer_notes || '', + } + const hasSource = + !!title || + Object.values(snapshotInstructions).some((html) => stripHtmlToText(html || '').trim()) + if (!hasSource) { + toast.error('Titel oder mindestens ein Anleitungsfeld ausfüllen.') + return + } + + const focusHint = (formData.focus_areas_multi || []) + .map((row) => { + const id = row?.focus_area_id + const fa = focusAreas.find((x) => Number(x.id) === Number(id)) + return (fa?.name || '').trim() + }) + .filter(Boolean) + .join(', ') + + const focusAreasContext = [...(formData.focus_areas_multi || [])] + .map((row) => ({ + focus_area_id: Number(row?.focus_area_id), + is_primary: !!row?.is_primary, + })) + .filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1) + .sort((a, b) => { + const p = Number(!!b.is_primary) - Number(!!a.is_primary) + if (p !== 0) return p + return a.focus_area_id - b.focus_area_id + }) + + setAiSuggestionPreview(null) + setAiSuggestBusy(true) + try { + const res = await api.suggestExerciseAi({ + title, + goal: snapshotInstructions.goal, + execution: snapshotInstructions.execution, + preparation: snapshotInstructions.preparation, + trainer_notes: snapshotInstructions.trainer_notes, + focus_area_hint: focusHint || undefined, + focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined, + include_summary: false, + include_skills: false, + include_instructions: true, + }) + + const preview = buildExerciseAiSuggestionPreview({ + mode: 'instructions', + snapshotInstructions, + apiRes: res, + }) + + if (!preview.hasInstructionChoices) { + toast.info('Die KI lieferte keinen verwertbaren Anleitungs-Vorschlag.') + return + } + + setAiSuggestionPreview(preview) + } catch (err) { + toast.error(err?.message || String(err)) + } finally { + setAiSuggestBusy(false) + } + } + const applyExerciseAiSuggestionPreview = () => { const p = aiSuggestionPreview if (!p) return const takeSummary = !!(p.applySummary && p.summaryAfterHtml) const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after) + const instrToApply = (p.instructionChoices || []).filter((c) => c.include && c.afterHtml) - if (!takeSummary && skillsToMerge.length === 0) { - toast.error('Bitte mindestens eine Kurzfassung oder eine Fähigkeit zur Übernahme auswählen.') + if (!takeSummary && skillsToMerge.length === 0 && instrToApply.length === 0) { + toast.error('Bitte mindestens einen Vorschlag zur Übernahme auswählen.') return } if (takeSummary) { updateFormField('summary', p.summaryAfterHtml) } + for (const c of instrToApply) { + updateFormField(c.field, c.afterHtml) + } if (skillsToMerge.length > 0) { setFormDirty(true) setFormData((prev) => { @@ -2145,6 +2259,29 @@ function ExerciseFormPageRoot() { title="Anleitung" hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)." > +- Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert. + {p.instructionsRequested + ? 'Vergleichen und nur die gewünschten Felder übernehmen. Eingebettete Medien bleiben erhalten, wenn die KI sie nicht erwähnt.' + : 'Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.'}
+ {p.hasInstructionChoices ? ( +
- KI-Unterstützung: OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung
+ KI-Unterstützung: OpenRouter-Vorschläge für Kurzfassung, Fähigkeiten und Anleitung
(suggestExerciseAi / regenerateExerciseAi). Übernahme im Dialog ins Formular; Speichern
wie gewohnt.