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
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:
parent
5331eab39c
commit
9f4678f418
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ _EXERCISE_AI_SLUGS = frozenset(
|
||||||
{
|
{
|
||||||
"exercise_summary",
|
"exercise_summary",
|
||||||
"exercise_skill_suggestions",
|
"exercise_skill_suggestions",
|
||||||
|
"exercise_instruction_rewrite",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
|
||||||
|
- Keine Ueberschriften (h1–h6), 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) = '');
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 P0–P4.
|
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
|
||||||
- **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, Kategorie‑Anteil‑Caps (~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`**)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user