Implement exercise_instruction_rewrite for AI Prompt System
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 36s
Test Suite / playwright-tests (push) Successful in 1m16s

- Added `exercise_instruction_rewrite` functionality to enhance AI-generated instructions, incorporating fields for goal, execution, preparation, and trainer notes.
- Updated `ExerciseFormAiPromptContext` to include new fields and methods for instruction handling.
- Enhanced the `run_exercise_form_ai_suggestion` function to support instruction rewriting and validation.
- Modified API endpoints and frontend components to integrate instruction features, including a new button for AI instruction revision.
- Incremented application version to 0.8.163 and updated changelog to reflect these changes, including migration details and new functionality.
This commit is contained in:
Lars 2026-05-22 18:53:36 +02:00
parent 5331eab39c
commit 9f4678f418
12 changed files with 623 additions and 36 deletions

View File

@ -37,6 +37,7 @@ steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet.
|-------------|-----------| |-------------|-----------|
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution | | `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung | | `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 | | `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix | | `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten | | `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |

View File

@ -19,7 +19,7 @@ class ExerciseFormAiFocusRow(BaseModel):
class ExerciseFormAiPromptContext(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). Wird genutzt von Admin-Prompt-Vorschau und POST /exercises/ai/suggest (via Mapping).
""" """
@ -27,6 +27,8 @@ class ExerciseFormAiPromptContext(BaseModel):
title: Optional[str] = "" title: Optional[str] = ""
goal: Optional[str] = None goal: Optional[str] = None
execution: Optional[str] = None execution: Optional[str] = None
preparation: Optional[str] = None
trainer_notes: Optional[str] = None
focus_hint: Optional[str] = None focus_hint: Optional[str] = None
focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None
@ -35,6 +37,15 @@ class ExerciseFormAiPromptContext(BaseModel):
return None return None
return [(int(x.focus_area_id), bool(x.is_primary)) for x in self.focus_areas_context] 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 @classmethod
def from_api_suggest( def from_api_suggest(
cls, cls,
@ -42,6 +53,8 @@ class ExerciseFormAiPromptContext(BaseModel):
title: Optional[str] = None, title: Optional[str] = None,
goal: Optional[str] = None, goal: Optional[str] = None,
execution: Optional[str] = None, execution: Optional[str] = None,
preparation: Optional[str] = None,
trainer_notes: Optional[str] = None,
focus_area_hint: Optional[str] = None, focus_area_hint: Optional[str] = None,
focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None, focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None,
) -> ExerciseFormAiPromptContext: ) -> ExerciseFormAiPromptContext:
@ -51,6 +64,8 @@ class ExerciseFormAiPromptContext(BaseModel):
title=(title or "").strip(), title=(title or "").strip(),
goal=goal, goal=goal,
execution=execution, execution=execution,
preparation=preparation,
trainer_notes=trainer_notes,
focus_hint=hint, focus_hint=hint,
focus_areas_context=list(focus_areas_context) if focus_areas_context else None, focus_areas_context=list(focus_areas_context) if focus_areas_context else None,
) )
@ -62,6 +77,8 @@ class ExerciseFormAiPromptContext(BaseModel):
title: str = "", title: str = "",
goal: Optional[str] = None, goal: Optional[str] = None,
execution: Optional[str] = None, execution: Optional[str] = None,
preparation: Optional[str] = None,
trainer_notes: Optional[str] = None,
focus_hint: Optional[str] = None, focus_hint: Optional[str] = None,
focus_tuples: Optional[Sequence[Tuple[int, bool]]] = None, focus_tuples: Optional[Sequence[Tuple[int, bool]]] = None,
) -> ExerciseFormAiPromptContext: ) -> ExerciseFormAiPromptContext:
@ -75,6 +92,8 @@ class ExerciseFormAiPromptContext(BaseModel):
title=(title or "").strip(), title=(title or "").strip(),
goal=goal, goal=goal,
execution=execution, execution=execution,
preparation=preparation,
trainer_notes=trainer_notes,
focus_hint=(focus_hint or "").strip() or None, focus_hint=(focus_hint or "").strip() or None,
focus_areas_context=rows, focus_areas_context=rows,
) )

View File

@ -21,6 +21,8 @@ def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptCon
execution=ctx.execution, execution=ctx.execution,
focus_area_hint=ctx.focus_hint, focus_area_hint=ctx.focus_hint,
focus_areas_context=ctx.focus_area_tuples(), 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_summary: bool,
want_skills: bool, want_skills: bool,
want_instructions: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Fuehrt Uebungs-KI aus (OpenRouter) ein Einstieg fuer Router und kuenftige Jobs. 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, form_ctx=ctx,
want_summary=want_summary, want_summary=want_summary,
want_skills=want_skills, want_skills=want_skills,
want_instructions=want_instructions,
) )

View File

@ -15,6 +15,7 @@ _EXERCISE_AI_SLUGS = frozenset(
{ {
"exercise_summary", "exercise_summary",
"exercise_skill_suggestions", "exercise_skill_suggestions",
"exercise_instruction_rewrite",
} }
) )

View File

@ -7,6 +7,7 @@ Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, F
from __future__ import annotations from __future__ import annotations
import copy import copy
import html
import json import json
import logging import logging
import math import math
@ -26,6 +27,7 @@ from openrouter_chat import (
from ai_prompt_context import ExerciseFormAiPromptContext from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt 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") _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)" 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"<p>{html.escape(p)}</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'<span data-shinkan-exercise-media="{mid}" data-shinkan-exercise-media-size="medium" '
f'class="shinkan-inline-media"></span>'
)
block = f"<p>{''.join(spans)}</p>"
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( def build_exercise_placeholder_variables(
cur, cur,
*, *,
@ -506,6 +648,8 @@ def build_exercise_placeholder_variables(
execution: Optional[str], execution: Optional[str],
focus_area_hint: Optional[str], focus_area_hint: Optional[str],
focus_areas_context: Optional[Sequence[Tuple[int, bool]]], focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
preparation: Optional[str] = None,
trainer_notes: Optional[str] = None,
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI. Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
@ -515,6 +659,8 @@ def build_exercise_placeholder_variables(
return {} return {}
g_plain = strip_html_to_plain(goal) g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution) 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() t_title = (title or "").strip()
focus = (focus_area_hint or "").strip() focus = (focus_area_hint or "").strip()
ctx: Dict[str, str] = { ctx: Dict[str, str] = {
@ -522,8 +668,12 @@ def build_exercise_placeholder_variables(
"exercise_focus_area": focus or "-", "exercise_focus_area": focus or "-",
"exercise_goal": g_plain or "-", "exercise_goal": g_plain or "-",
"exercise_execution": e_plain or "-", "exercise_execution": e_plain or "-",
"exercise_preparation": p_plain or "-",
"exercise_trainer_notes": n_plain or "-",
} }
if s == "exercise_summary": 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 return ctx
if s == "exercise_skill_suggestions": if s == "exercise_skill_suggestions":
catalog = build_contextual_skills_catalog_block( catalog = build_contextual_skills_catalog_block(
@ -686,18 +836,27 @@ def run_exercise_ai_suggestion(
form_ctx: ExerciseFormAiPromptContext, form_ctx: ExerciseFormAiPromptContext,
want_summary: bool, want_summary: bool,
want_skills: bool, want_skills: bool,
want_instructions: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
key = _require_openrouter_key() key = _require_openrouter_key()
title = form_ctx.title title = form_ctx.title
goal = form_ctx.goal goal = form_ctx.goal
execution = form_ctx.execution execution = form_ctx.execution
preparation = form_ctx.preparation
trainer_notes = form_ctx.trainer_notes
focus_area_hint = form_ctx.focus_hint focus_area_hint = form_ctx.focus_hint
focus_areas_context = form_ctx.focus_area_tuples() focus_areas_context = form_ctx.focus_area_tuples()
g_plain = strip_html_to_plain(goal) g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution) 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( raise HTTPException(
status_code=400, status_code=400,
detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).", 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(): if _ai_debug_on():
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context)) fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
_LOGGER.warning( _LOGGER.warning(
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s " "AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s want_instructions=%s "
"exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]", "title_chars=%s goal_plain_chars=%s exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
want_summary, want_summary,
want_skills, want_skills,
len(t_title), want_instructions,
len((title or "").strip()),
len(g_plain), len(g_plain),
len(e_plain), len(e_plain),
len(focus), len((focus_area_hint or "").strip()),
fid_list, fid_list,
) )
@ -843,9 +1003,92 @@ def run_exercise_ai_suggestion(
result["skills"] = skills 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 result["models_by_slug"] = models_by_slug
if want_skills: if want_skills:
result["model"] = models_by_slug["exercise_skill_suggestions"] result["model"] = models_by_slug["exercise_skill_suggestions"]
elif want_instructions:
result["model"] = models_by_slug["exercise_instruction_rewrite"]
elif want_summary: elif want_summary:
result["model"] = models_by_slug["exercise_summary"] result["model"] = models_by_slug["exercise_summary"]
else: else:

View File

@ -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: 13 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: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
- Keine Ueberschriften (h1h6), keine Tabellen, kein Markdown, keine Code-Fences
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
Eingabe:
Titel: {{exercise_title}}
Fokuskontext: {{exercise_focus_area}}
Ziel (Plaintext, Ausgang): {{exercise_goal}}
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
{
"goal": "<p>…</p>",
"execution": "<ol><li>…</li></ol>",
"preparation": "<p>…</p> oder \"\"",
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
}
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$,
'exercise',
'json',
'{"type":"object","required":["goal","execution","preparation","trainer_notes"],"properties":{"goal":{"type":"string"},"execution":{"type":"string"},"preparation":{"type":"string"},"trainer_notes":{"type":"string"}}}'::jsonb,
true,
NULL,
true,
3
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'exercise_instruction_rewrite');
-- Referenztext fuer Admin-Ruecksetzen (wie 069)
UPDATE ai_prompts
SET default_template = template
WHERE slug = 'exercise_instruction_rewrite'
AND (default_template IS NULL OR TRIM(default_template) = '');

View File

@ -87,25 +87,37 @@ def exercise_placeholder_catalog() -> dict:
"key": "exercise_title", "key": "exercise_title",
"placeholder": "{{exercise_title}}", "placeholder": "{{exercise_title}}",
"description": "Titel der Uebung (oder Platzhalter, wenn leer).", "description": "Titel der Uebung (oder Platzhalter, wenn leer).",
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
}, },
{ {
"key": "exercise_focus_area", "key": "exercise_focus_area",
"placeholder": "{{exercise_focus_area}}", "placeholder": "{{exercise_focus_area}}",
"description": "Fokuskontext (Text-Hinweis aus Formular, optional).", "description": "Fokuskontext (Text-Hinweis aus Formular, optional).",
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
}, },
{ {
"key": "exercise_goal", "key": "exercise_goal",
"placeholder": "{{exercise_goal}}", "placeholder": "{{exercise_goal}}",
"description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.", "description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.",
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
}, },
{ {
"key": "exercise_execution", "key": "exercise_execution",
"placeholder": "{{exercise_execution}}", "placeholder": "{{exercise_execution}}",
"description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.", "description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.",
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
},
{
"key": "exercise_preparation",
"placeholder": "{{exercise_preparation}}",
"description": "Vorbereitung/Aufbau als Plaintext ohne HTML.",
"used_by_slugs": ["exercise_instruction_rewrite"],
},
{
"key": "exercise_trainer_notes",
"placeholder": "{{exercise_trainer_notes}}",
"description": "Trainer-Hinweise als Plaintext ohne HTML.",
"used_by_slugs": ["exercise_instruction_rewrite"],
}, },
{ {
"key": "skills_catalog", "key": "skills_catalog",

View File

@ -218,7 +218,7 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
vars_map: Dict[str, str] vars_map: Dict[str, str]
warn: Optional[str] = None warn: Optional[str] = None
if slug in ("exercise_summary", "exercise_skill_suggestions"): if slug in ("exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"):
try: try:
vars_map = resolve_exercise_form_variables(cur, slug, body) vars_map = resolve_exercise_form_variables(cur, slug, body)
except ValueError as e: except ValueError as e:

View File

@ -367,6 +367,8 @@ class ExerciseAiSuggestBody(BaseModel):
title: Optional[str] = Field(None, max_length=300) title: Optional[str] = Field(None, max_length=300)
goal: Optional[str] = Field(None, max_length=64000) goal: Optional[str] = Field(None, max_length=64000)
execution: Optional[str] = Field(None, max_length=128000) execution: Optional[str] = Field(None, max_length=128000)
preparation: Optional[str] = Field(None, max_length=64000)
trainer_notes: Optional[str] = Field(None, max_length=64000)
focus_area_hint: Optional[str] = Field(None, max_length=1200) focus_area_hint: Optional[str] = Field(None, max_length=1200)
focus_areas_context: Optional[list[ExerciseFormAiFocusRow]] = Field( focus_areas_context: Optional[list[ExerciseFormAiFocusRow]] = Field(
None, None,
@ -374,11 +376,14 @@ class ExerciseAiSuggestBody(BaseModel):
) )
include_summary: bool = True include_summary: bool = True
include_skills: bool = True include_skills: bool = True
include_instructions: bool = False
@model_validator(mode="after") @model_validator(mode="after")
def check_include_any(self): def check_include_any(self):
if not self.include_summary and not self.include_skills: if not self.include_summary and not self.include_skills and not self.include_instructions:
raise ValueError("Mindestens include_summary oder include_skills aktivieren.") raise ValueError(
"Mindestens include_summary, include_skills oder include_instructions aktivieren."
)
return self return self
def to_form_context(self) -> ExerciseFormAiPromptContext: def to_form_context(self) -> ExerciseFormAiPromptContext:
@ -386,6 +391,8 @@ class ExerciseAiSuggestBody(BaseModel):
title=self.title, title=self.title,
goal=self.goal, goal=self.goal,
execution=self.execution, execution=self.execution,
preparation=self.preparation,
trainer_notes=self.trainer_notes,
focus_area_hint=self.focus_area_hint, focus_area_hint=self.focus_area_hint,
focus_areas_context=self.focus_areas_context, focus_areas_context=self.focus_areas_context,
) )
@ -398,7 +405,7 @@ class ExerciseAiRegenerateBody(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def normalize_regs(self): def normalize_regs(self):
allowed = {"summary", "skills"} allowed = {"summary", "skills", "instructions"}
raw = [str(x).strip().lower() for x in (self.regenerate or [])] raw = [str(x).strip().lower() for x in (self.regenerate or [])]
out = [] out = []
seen = set() seen = set()
@ -2318,6 +2325,7 @@ def exercise_ai_suggest_endpoint(
body.to_form_context(), body.to_form_context(),
want_summary=body.include_summary, want_summary=body.include_summary,
want_skills=body.include_skills, want_skills=body.include_skills,
want_instructions=body.include_instructions,
) )
return payload return payload
@ -2331,6 +2339,7 @@ def exercise_ai_regenerate_endpoint(
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT).""" """Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
want_summary = "summary" in body.regenerate want_summary = "summary" in body.regenerate
want_skills = "skills" in body.regenerate want_skills = "skills" in body.regenerate
want_instructions = "instructions" in body.regenerate
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -2347,6 +2356,8 @@ def exercise_ai_regenerate_endpoint(
title=str(exercise.get("title") or "").strip(), title=str(exercise.get("title") or "").strip(),
goal=exercise.get("goal"), goal=exercise.get("goal"),
execution=exercise.get("execution"), execution=exercise.get("execution"),
preparation=exercise.get("preparation"),
trainer_notes=exercise.get("trainer_notes"),
focus_hint=focus or None, focus_hint=focus or None,
focus_tuples=fctx or None, focus_tuples=fctx or None,
) )
@ -2355,6 +2366,7 @@ def exercise_ai_regenerate_endpoint(
ctx, ctx,
want_summary=want_summary, want_summary=want_summary,
want_skills=want_skills, want_skills=want_skills,
want_instructions=want_instructions,
) )
return payload return payload

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.162" APP_VERSION = "0.8.163"
BUILD_DATE = "2026-05-31" BUILD_DATE = "2026-05-31"
DB_SCHEMA_VERSION = "20260531070" DB_SCHEMA_VERSION = "20260531071"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -20,14 +20,14 @@ MODULE_VERSIONS = {
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets "media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068) "admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail "admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"ai_prompt_job": "0.2.0", # run_exercise_form_ai_suggestion; Kontext in ai_prompt_context "ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
"ai_prompt_context": "0.1.0", # ExerciseFormAiPromptContext — Formularfelder fuer Uebungs-Prompts "ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
"ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row "ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.31.4", # ai/suggest + regenerate: ExerciseFormAiPromptContext via run_exercise_form_ai_suggestion "exercises": "2.32.0", # KI Anleitung: exercise_instruction_rewrite, include_instructions API + UI
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -42,6 +42,15 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.163",
"date": "2026-05-31",
"changes": [
"KI Anleitung: Migration 071 Prompt exercise_instruction_rewrite (JSON: goal, execution, preparation, trainer_notes);",
"POST /exercises/ai/suggest include_instructions; Sanitize/Plaintext-Limits; Medien-Verweise bleiben erhalten;",
"Ueungsformular Tab Anleitung: Button „KI: Anleitung ueberarbeiten“ mit Vorschau-Dialog pro Feld.",
],
},
{ {
"version": "0.8.162", "version": "0.8.162",
"date": "2026-05-31", "date": "2026-05-31",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-31 **Stand:** 2026-05-31
**App-Version / DB-Schema:** App **`0.8.162`** (KI Prompt P1: gemeinsamer Formular-Kontext `ExerciseFormAiPromptContext`, `run_exercise_form_ai_suggestion`); Zielarchitektur **`.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md`**. DB: **`backend/version.py`** → `DB_SCHEMA_VERSION` (aktuell `20260531070`). **App-Version / DB-Schema:** App **`0.8.163`** (KI Anleitung `exercise_instruction_rewrite`); DB **`20260531071`** — maßgeblich **`backend/version.py`**.
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -89,16 +89,17 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`) - **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions - **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.162**) ### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.163**)
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0P4. - **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0P4.
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2 - **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
- **Kontext / Job:** **`ai_prompt_context`** — **`ExerciseFormAiPromptContext`** (Titel, Ziel, Durchführung, Fokus — ein Modell fuer Admin-Vorschau und Übungs-API); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**, **`resolve_exercise_form_variables`**; **`ai_prompt_runtime`** — Laden/Rendern aus DB; **`exercise_ai`** — OpenRouter nach Rendern - **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter
- **DB:** Migration **`067`** **`ai_prompts`**; **`069`** **`default_template`**; **`068`** **`ai_skill_retrieval_profiles`**; **`070`** **`openrouter_model`** (optional pro Prompt, Fallback **`OPENROUTER_MODEL`**) - **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
- **`exercise_ai`:** Gewichtungen, KategorieAnteilCaps (~Token), Keyword-Patches aus Ziel/Durchführung (z.B. Rollenspiel vs. Befreiung/Haltegriff) - **Prompt-Slugs:** `exercise_summary`, `exercise_skill_suggestions`, **`exercise_instruction_rewrite`** (Anleitung JSON, prägnant, HTML p/ul/ol/li)
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas`**Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`), **`/api/admin/ai-prompts*`** (`routers/ai_prompts_admin.py`), UI **`/admin/ai-prompts`** - **API:** `POST /api/exercises/ai/suggest`**`include_instructions`**, Body **`preparation`**, **`trainer_notes`**; Response **`instructions.fields`**; **`POST …/ai/regenerate`** mit **`instructions`** in `regenerate`
- **Diagnose bei leerem Dialog / Fehlern:** Umgebungsvariable **`SHINKAN_AI_DEBUG=1`** auf der API; in den Logs dann **`AI_DEBUG`** (`shinkan.exercise_ai`) und **`[AI_DEBUG/openrouter]`** (`shinkan.openrouter`) mit Prompt-Längen, Token-Zahlen und ggf. JSON-Parse-Anfang - **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`**
- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiPromptsPage.jsx`** (`/admin/ai-prompts`), **`AdminAiSkillRetrievalPage.jsx`** (`/admin/ai-skill-retrieval`) - **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter`
- **Frontend:** Tab **Anleitung****„KI: Anleitung überarbeiten“**; Vorschau-Dialog pro Feld; Kurzfassung/Fähigkeiten unverändert (**`ExerciseFormPageRoot.jsx`**)
--- ---

View File

@ -90,6 +90,13 @@ function aiPlainSummaryToMinimalHtml(text) {
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('') return paras.map((p) => `<p>${escapeHtmlText(p)}</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) { function cloneExerciseSkillRows(rows) {
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : [] return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
} }
@ -110,9 +117,16 @@ function buildNormalizedAiSkillRowFromApi(sug) {
} }
} }
function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotSkills, apiRes }) { function buildExerciseAiSuggestionPreview({
const summaryRequested = mode !== 'skills' mode,
const skillsRequested = mode !== 'summary' snapshotSummaryHtml,
snapshotSkills,
snapshotInstructions,
apiRes,
}) {
const summaryRequested = mode !== 'skills' && mode !== 'instructions'
const skillsRequested = mode !== 'summary' && mode !== 'instructions'
const instructionsRequested = mode === 'instructions'
let summaryAfterHtml = null let summaryAfterHtml = null
let summaryAfterPlain = '' 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 hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
const hasSkillChoices = skillChoices.length > 0 const hasSkillChoices = skillChoices.length > 0
const hasInstructionChoices = instructionChoices.length > 0
return { return {
mode, mode,
@ -151,10 +186,13 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS
summaryAfterPlain, summaryAfterPlain,
summaryAfterHtml, summaryAfterHtml,
skillChoices, skillChoices,
instructionChoices,
hasSummaryProposal, hasSummaryProposal,
hasSkillChoices, hasSkillChoices,
hasInstructionChoices,
summaryRequested, summaryRequested,
skillsRequested, 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 applyExerciseAiSuggestionPreview = () => {
const p = aiSuggestionPreview const p = aiSuggestionPreview
if (!p) return if (!p) return
const takeSummary = !!(p.applySummary && p.summaryAfterHtml) const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after) 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) { if (!takeSummary && skillsToMerge.length === 0 && instrToApply.length === 0) {
toast.error('Bitte mindestens eine Kurzfassung oder eine Fähigkeit zur Übernahme auswählen.') toast.error('Bitte mindestens einen Vorschlag zur Übernahme auswählen.')
return return
} }
if (takeSummary) { if (takeSummary) {
updateFormField('summary', p.summaryAfterHtml) updateFormField('summary', p.summaryAfterHtml)
} }
for (const c of instrToApply) {
updateFormField(c.field, c.afterHtml)
}
if (skillsToMerge.length > 0) { if (skillsToMerge.length > 0) {
setFormDirty(true) setFormDirty(true)
setFormData((prev) => { setFormData((prev) => {
@ -2145,6 +2259,29 @@ function ExerciseFormPageRoot() {
title="Anleitung" title="Anleitung"
hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)." hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)."
> >
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginBottom: '12px',
alignItems: 'center',
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiInstructionRewrite()}
>
KI: Anleitung überarbeiten
</button>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
Überarbeitet Ziel, Durchführung, Vorbereitung und Trainer-Hinweise prägnant und strukturiert. Vorschau
im Dialog; nichts wird automatisch gespeichert.
</span>
</div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Ziel *</label> <label className="form-label">Ziel *</label>
<RichTextEditor <RichTextEditor
@ -2780,7 +2917,13 @@ function ExerciseFormPageRoot() {
minHeight: '72px', minHeight: '72px',
} }
const canApplySomething = const canApplySomething =
(p.applySummary && p.summaryAfterHtml) || p.skillChoices.some((c) => c.include) (p.applySummary && p.summaryAfterHtml) ||
p.skillChoices.some((c) => c.include) ||
(p.instructionChoices || []).some((c) => c.include && c.afterHtml)
const dialogTitle =
p.instructionsRequested
? 'KI: Anleitung überarbeiten'
: 'KI-Vorschlag übernehmen'
return ( return (
<div <div
role="dialog" role="dialog"
@ -2808,11 +2951,94 @@ function ExerciseFormPageRoot() {
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>KI-Vorschlag übernehmen</h3> <h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}> <p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>
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> </p>
{p.hasInstructionChoices ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-instructions-heading">
<div
id="ai-preview-instructions-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Anleitung ({p.instructionChoices.length}{' '}
{p.instructionChoices.length === 1 ? 'Feld' : 'Felder'})
</div>
{p.instructionChoices.map((c) => (
<div
key={c.key}
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '12px',
marginBottom: '12px',
background: 'var(--surface)',
}}
>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
}}
>
<input
type="checkbox"
checked={c.include}
onChange={(e) =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
instructionChoices: prev.instructionChoices.map((x) =>
x.key === c.key ? { ...x, include: e.target.checked } : x,
),
}
: prev,
)
}
/>
{c.label} übernehmen
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
gap: '12px',
}}
>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
Aktuell (Plaintext)
</div>
<div style={summaryBoxSx}>{c.beforePlain || '(leer)'}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
KI-Vorschlag
</div>
<div
style={{
...summaryBoxSx,
borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))',
}}
>
{c.afterPlain || '(leer)'}
</div>
</div>
</div>
</div>
))}
</section>
) : null}
{p.hasSummaryProposal ? ( {p.hasSummaryProposal ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading"> <section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
<div <div
@ -3032,7 +3258,7 @@ function ExerciseFormPageRoot() {
/> />
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}> <p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
<strong>KI-Unterstützung:</strong> OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung <strong>KI-Unterstützung:</strong> OpenRouter-Vorschläge für Kurzfassung, Fähigkeiten und Anleitung
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern (<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern
wie gewohnt. wie gewohnt.
</p> </p>