KI Übungen - MVP 0.8 #48

Merged
Lars merged 9 commits from develop into main 2026-05-22 19:51:54 +02:00
24 changed files with 2097 additions and 250 deletions

View File

@ -8,6 +8,7 @@
**Ist-Stand API (Superadmin):**
- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
- Spalte **`openrouter_model`** (Migration **070**): Optional pro Prompt-Zeile; OpenRouter **`model`**-Parameter; **`NULL`/leer ⇒ `OPENROUTER_MODEL`** aus der Umgebung.
**Autor:** Claude Code
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
@ -36,6 +37,7 @@ steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet.
|-------------|-----------|
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
| `exercise_instruction_rewrite` | Überarbeitet Anleitung: goal, execution, preparation, trainer_notes (JSON, prägnantes HTML) |
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |

View File

@ -26,7 +26,7 @@ Alle produktiven KI-Aufrufe sollten mittelfristig über eine **einheitliche Fass
Router und Frontend rufen diese Schicht oder schmale Orchestratoren — **nicht** direkt `httpx`/OpenRouter an jeder Ecke verteilt.
**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` mit **Kontext-Arten** und **gemeinsamen DB-Ladeschritten** für `ai_prompts`; Übungs-KI konsumiert diese Schicht ohne Zirkelschluss zu Domänlogik (`exercise_ai`).
**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` (`load_ai_prompt_row`, `load_and_render_ai_prompt`, Kontext-Arten) sowie `backend/ai_prompt_job.py` (Pydantic `ExerciseFormAiPromptContext` fuer Uebungs-Prompts — Admin-Vorschau + erweiterbare Router-Nutzung); `exercise_ai` orchestriert OpenRouter nach dem Rendern.
### 2.2 Trennung: Semantik vs. Transport
@ -122,23 +122,25 @@ Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter,
```mermaid
flowchart LR
subgraph heute
subgraph laufzeit
A[ai_prompts DB]
B[prompt_resolver Mustache]
C[ai_prompt_runtime Loader + ContextKind]
D[exercise_ai]
C[ai_prompt_runtime]
J[ai_prompt_job Pydantic]
D[exercise_ai OpenRouter]
end
A --> B
A --> C
C --> B
J --> D
C --> D
B --> D
```
| Phase | Inhalt |
|-------|--------|
| **P0 (gestartet)** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI nutzt Laufzeit; Platzhalter-Katalog pro Kontext erweiterbar. |
| **P1** | Einheitliche `run_ai_job`-Fassade (Slug + Kind + Pydantic-Payload + Validierung); Router nur noch dünne Adapter. |
| **P2** | Versionierung oder Audit-Spalten; optionale Modell-/Temperatur-Overrides pro Slug in DB oder Config-Tabelle. |
| **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. |
| **P1** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **`ExerciseFormAiPromptContext`** in `ai_prompt_context.py`; **`run_exercise_form_ai_suggestion`**; Übungs-API und Admin-Vorschau nutzen denselben Kontext. |
| **P2** | Versionierung oder Audit-Spalten; **teilweise:** optionales OpenRouter-Modell pro Zeile (`openrouter_model`, Migration 070, Fallback `OPENROUTER_MODEL`); weitere Overrides (Temperatur) offen. |
| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |
@ -157,7 +159,7 @@ flowchart LR
- Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md`
- Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md`
- Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`
- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`
- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`, `backend/ai_prompt_context.py`, `backend/ai_prompt_job.py`
---

View File

@ -34,6 +34,8 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_MODEL=anthropic/claude-sonnet-4
# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).

View File

@ -0,0 +1,105 @@
"""
Gemeinsame Pydantic-Modelle fuer Uebungs-KI-Kontext (Formularfelder Prompt-Platzhalter).
Keine Imports aus exercise_ai vermeidet Zirkelimporte mit ai_prompt_job / exercise_ai.
"""
from __future__ import annotations
from typing import List, Optional, Sequence, Tuple
from pydantic import BaseModel, Field
class ExerciseFormAiFocusRow(BaseModel):
"""Fokusbereich fuer Skill-Retrieval (ai_skill_retrieval_profiles)."""
focus_area_id: int = Field(..., ge=1)
is_primary: Optional[bool] = False
class ExerciseFormAiPromptContext(BaseModel):
"""
Inhaltliche Eingabe fuer Uebungs-Prompts (Kurzfassung / Skills / Anleitung).
Wird genutzt von Admin-Prompt-Vorschau und POST /exercises/ai/suggest (via Mapping).
"""
title: Optional[str] = ""
goal: Optional[str] = None
execution: Optional[str] = None
preparation: Optional[str] = None
trainer_notes: Optional[str] = None
focus_hint: Optional[str] = None
focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None
def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]:
if not self.focus_areas_context:
return None
return [(int(x.focus_area_id), bool(x.is_primary)) for x in self.focus_areas_context]
def has_instruction_source_text(self) -> bool:
"""Mindestens ein Anleitungsfeld oder Titel fuer instruction_rewrite."""
if (self.title or "").strip():
return True
for val in (self.goal, self.execution, self.preparation, self.trainer_notes):
if val and str(val).strip():
return True
return False
@classmethod
def from_api_suggest(
cls,
*,
title: Optional[str] = None,
goal: Optional[str] = None,
execution: Optional[str] = None,
preparation: Optional[str] = None,
trainer_notes: Optional[str] = None,
focus_area_hint: Optional[str] = None,
focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None,
) -> ExerciseFormAiPromptContext:
"""Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint)."""
hint = (focus_area_hint or "").strip() or None
return cls(
title=(title or "").strip(),
goal=goal,
execution=execution,
preparation=preparation,
trainer_notes=trainer_notes,
focus_hint=hint,
focus_areas_context=list(focus_areas_context) if focus_areas_context else None,
)
@classmethod
def from_focus_tuples(
cls,
*,
title: str = "",
goal: Optional[str] = None,
execution: Optional[str] = None,
preparation: Optional[str] = None,
trainer_notes: Optional[str] = None,
focus_hint: Optional[str] = None,
focus_tuples: Optional[Sequence[Tuple[int, bool]]] = None,
) -> ExerciseFormAiPromptContext:
rows = None
if focus_tuples:
rows = [
ExerciseFormAiFocusRow(focus_area_id=int(fid), is_primary=bool(prim))
for fid, prim in focus_tuples
]
return cls(
title=(title or "").strip(),
goal=goal,
execution=execution,
preparation=preparation,
trainer_notes=trainer_notes,
focus_hint=(focus_hint or "").strip() or None,
focus_areas_context=rows,
)
__all__ = [
"ExerciseFormAiFocusRow",
"ExerciseFormAiPromptContext",
]

58
backend/ai_prompt_job.py Normal file
View File

@ -0,0 +1,58 @@
"""
KI-Prompt Jobs: Resolver + oeffentliche Fassade fuer Uebungs-KI-Aufrufe.
Importiert exercise_ai fuer Platzhalter-Builder und OpenRouter-Orchestrierung.
"""
from __future__ import annotations
from typing import Any, Dict
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
from exercise_ai import build_exercise_placeholder_variables
def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptContext) -> Dict[str, str]:
"""Baut die Mustache-Map fuer exercise_summary / exercise_skill_suggestions."""
return build_exercise_placeholder_variables(
cur,
slug=slug,
title=(ctx.title or "").strip(),
goal=ctx.goal,
execution=ctx.execution,
focus_area_hint=ctx.focus_hint,
focus_areas_context=ctx.focus_area_tuples(),
preparation=ctx.preparation,
trainer_notes=ctx.trainer_notes,
)
def run_exercise_form_ai_suggestion(
cur,
ctx: ExerciseFormAiPromptContext,
*,
want_summary: bool,
want_skills: bool,
want_instructions: bool = False,
) -> Dict[str, Any]:
"""
Fuehrt Uebungs-KI aus (OpenRouter) ein Einstieg fuer Router und kuenftige Jobs.
``ctx`` = Formularinhalt; ``want_*`` = welche Prompt-Slugs angefragt werden.
"""
from exercise_ai import run_exercise_ai_suggestion
return run_exercise_ai_suggestion(
cur,
form_ctx=ctx,
want_summary=want_summary,
want_skills=want_skills,
want_instructions=want_instructions,
)
__all__ = [
"ExerciseFormAiFocusRow",
"ExerciseFormAiPromptContext",
"resolve_exercise_form_variables",
"run_exercise_form_ai_suggestion",
]

View File

@ -7,12 +7,15 @@ load_ai_prompt_row und die Enum; Platzhalter bauen sie selbst oder über geteilt
from __future__ import annotations
from enum import Enum
from typing import Any, Dict, Optional
from typing import Any, Dict, Mapping, Optional, Tuple
from prompt_resolver import MustacheRenderResult, render_mustache_template
_EXERCISE_AI_SLUGS = frozenset(
{
"exercise_summary",
"exercise_skill_suggestions",
"exercise_instruction_rewrite",
}
)
@ -43,7 +46,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
if active_only:
cur.execute(
"""
SELECT slug, display_name, template, output_format, active
SELECT slug, display_name, template, output_format, active, openrouter_model
FROM ai_prompts
WHERE slug = %s AND active = true
""",
@ -52,7 +55,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
else:
cur.execute(
"""
SELECT slug, display_name, template, output_format, active
SELECT slug, display_name, template, output_format, active, openrouter_model
FROM ai_prompts
WHERE slug = %s
""",
@ -67,8 +70,45 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
return d
class AiPromptUnavailableError(LookupError):
"""Kein aktiver Prompt fuer slug (oder Zeile fehlt)."""
def __init__(self, slug: str) -> None:
self.slug = (slug or "").strip()
super().__init__(self.slug)
def render_ai_prompt_template_for_row(
row: Mapping[str, Any],
variables: Mapping[str, str],
) -> MustacheRenderResult:
"""Ersetzt Platzhalter anhand einer bereits geladenen ai_prompts-Zeile (z. B. Admin-Vorschauch, inkl. inaktiv)."""
return render_mustache_template(str(row.get("template") or ""), variables)
def load_and_render_ai_prompt(
cur,
slug: str,
variables: Mapping[str, str],
*,
active_only: bool = True,
) -> Tuple[Dict[str, Any], MustacheRenderResult]:
"""
Laedt einen aktiven Prompt und wendet Mustache-Variablen an.
Wirft AiPromptUnavailableError, wenn die Zeile fehlt oder (bei active_only) inaktiv ist.
"""
row = load_ai_prompt_row(cur, slug, active_only=active_only)
if not row:
raise AiPromptUnavailableError(slug)
rr = render_ai_prompt_template_for_row(row, variables)
return dict(row), rr
__all__ = [
"AiPromptContextKind",
"AiPromptUnavailableError",
"context_kind_for_slug",
"load_ai_prompt_row",
"load_and_render_ai_prompt",
"render_ai_prompt_template_for_row",
]

View File

@ -7,6 +7,7 @@ Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, F
from __future__ import annotations
import copy
import html
import json
import logging
import math
@ -16,10 +17,17 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence,
from fastapi import HTTPException
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
from openrouter_chat import (
OpenRouterError,
default_openrouter_model_id,
effective_openrouter_model_for_prompt_row,
normalize_openrouter_env,
openrouter_chat_completion,
)
from ai_prompt_runtime import load_ai_prompt_row
from prompt_resolver import render_mustache_template
from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
from exercise_rich_text import collect_inline_exercise_media_ids, normalize_inline_exercise_media_markup
_LOGGER = logging.getLogger("shinkan.exercise_ai")
@ -491,6 +499,146 @@ def build_contextual_skills_catalog_block(
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
_MAX_INSTRUCTION_GOAL_PLAIN = 4_000
_MAX_INSTRUCTION_EXECUTION_PLAIN = 12_000
_MAX_INSTRUCTION_PREP_PLAIN = 2_500
_MAX_INSTRUCTION_TRAINER_PLAIN = 2_500
_INSTRUCTION_JSON_KEYS = ("goal", "execution", "preparation", "trainer_notes")
_INSTRUCTION_FIELD_MAX_PLAIN = {
"goal": _MAX_INSTRUCTION_GOAL_PLAIN,
"execution": _MAX_INSTRUCTION_EXECUTION_PLAIN,
"preparation": _MAX_INSTRUCTION_PREP_PLAIN,
"trainer_notes": _MAX_INSTRUCTION_TRAINER_PLAIN,
}
_DISALLOWED_HTML_TAG_RE = re.compile(
r"</?\s*(?!p\b|ul\b|ol\b|li\b|strong\b|b\b|em\b|i\b|br\b|span\b)[a-zA-Z][^>]*>",
re.IGNORECASE,
)
_SCRIPT_STYLE_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
def _plain_to_minimal_instruction_html(text: str) -> str:
raw = (text or "").strip()
if not raw:
return ""
parts = [p.strip() for p in re.split(r"\n+", raw) if p.strip()]
if not parts:
return ""
return "".join(f"<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(
cur,
*,
@ -500,6 +648,8 @@ def build_exercise_placeholder_variables(
execution: Optional[str],
focus_area_hint: Optional[str],
focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
preparation: Optional[str] = None,
trainer_notes: Optional[str] = None,
) -> Dict[str, str]:
"""
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
@ -509,6 +659,8 @@ def build_exercise_placeholder_variables(
return {}
g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution)
p_plain = strip_html_to_plain(preparation)
n_plain = strip_html_to_plain(trainer_notes)
t_title = (title or "").strip()
focus = (focus_area_hint or "").strip()
ctx: Dict[str, str] = {
@ -516,8 +668,12 @@ def build_exercise_placeholder_variables(
"exercise_focus_area": focus or "-",
"exercise_goal": g_plain or "-",
"exercise_execution": e_plain or "-",
"exercise_preparation": p_plain or "-",
"exercise_trainer_notes": n_plain or "-",
}
if s == "exercise_summary":
return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")}
if s == "exercise_instruction_rewrite":
return ctx
if s == "exercise_skill_suggestions":
catalog = build_contextual_skills_catalog_block(
@ -664,32 +820,43 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
return out[:5]
def _require_openrouter() -> Tuple[str, str]:
key, model = normalize_openrouter_env()
def _require_openrouter_key() -> str:
key, _ = normalize_openrouter_env()
if not key:
raise HTTPException(
status_code=503,
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
)
return key, model
return key
def run_exercise_ai_suggestion(
cur,
*,
title: Optional[str],
goal: Optional[str],
execution: Optional[str],
focus_area_hint: Optional[str],
focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None,
form_ctx: ExerciseFormAiPromptContext,
want_summary: bool,
want_skills: bool,
want_instructions: bool = False,
) -> Dict[str, Any]:
key, model = _require_openrouter()
key = _require_openrouter_key()
title = form_ctx.title
goal = form_ctx.goal
execution = form_ctx.execution
preparation = form_ctx.preparation
trainer_notes = form_ctx.trainer_notes
focus_area_hint = form_ctx.focus_hint
focus_areas_context = form_ctx.focus_area_tuples()
g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution)
if not (g_plain.strip() or e_plain.strip()):
if want_instructions:
if not form_ctx.has_instruction_source_text():
raise HTTPException(
status_code=400,
detail="Fuer Anleitungs-Ueberarbeitung mindestens Titel oder ein Anleitungsfeld ausfuellen.",
)
elif not (g_plain.strip() or e_plain.strip()):
raise HTTPException(
status_code=400,
detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).",
@ -698,26 +865,25 @@ def run_exercise_ai_suggestion(
t_title = (title or "").strip()
focus = (focus_area_hint or "").strip()
result: Dict[str, Any] = {"model": model}
result: Dict[str, Any] = {}
models_by_slug: Dict[str, str] = {}
if _ai_debug_on():
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
_LOGGER.warning(
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s "
"exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s want_instructions=%s "
"title_chars=%s goal_plain_chars=%s exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
want_summary,
want_skills,
len(t_title),
want_instructions,
len((title or "").strip()),
len(g_plain),
len(e_plain),
len(focus),
len((focus_area_hint or "").strip()),
fid_list,
)
if want_summary:
prow = load_ai_prompt_row(cur, "exercise_summary")
if not prow:
raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
try:
ctx = build_exercise_placeholder_variables(
cur,
@ -730,7 +896,15 @@ def run_exercise_ai_suggestion(
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
rendered = render_mustache_template(str(prow["template"]), ctx)
try:
prow, rendered = load_and_render_ai_prompt(cur, "exercise_summary", ctx)
except AiPromptUnavailableError:
raise HTTPException(
status_code=503,
detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.",
) from None
model_summary = effective_openrouter_model_for_prompt_row(prow)
models_by_slug["exercise_summary"] = model_summary
prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
@ -739,7 +913,7 @@ def run_exercise_ai_suggestion(
len(rendered.placeholders_remaining),
)
try:
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
raw = openrouter_chat_completion(api_key=key, model=model_summary, user_content=prompt)
except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
if _ai_debug_on():
@ -752,15 +926,9 @@ def run_exercise_ai_suggestion(
)
if len(text) > _MAX_SUMMARY_CHARS:
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + ""
result["summary"] = {"text": text, "ai_generated": True, "model": model}
result["summary"] = {"text": text, "ai_generated": True, "model": model_summary}
if want_skills:
srow = load_ai_prompt_row(cur, "exercise_skill_suggestions")
if not srow:
raise HTTPException(
status_code=503,
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
)
try:
ctx = build_exercise_placeholder_variables(
cur,
@ -773,7 +941,15 @@ def run_exercise_ai_suggestion(
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
rendered = render_mustache_template(str(srow["template"]), ctx)
try:
srow, rendered = load_and_render_ai_prompt(cur, "exercise_skill_suggestions", ctx)
except AiPromptUnavailableError:
raise HTTPException(
status_code=503,
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
) from None
model_skills = effective_openrouter_model_for_prompt_row(srow)
models_by_slug["exercise_skill_suggestions"] = model_skills
prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
@ -789,7 +965,7 @@ def run_exercise_ai_suggestion(
try:
raw = openrouter_chat_completion(
api_key=key,
model=model,
model=model_skills,
user_content=prompt,
system_content=sys_hint,
temperature=0.15,
@ -827,6 +1003,97 @@ def run_exercise_ai_suggestion(
result["skills"] = skills
if want_instructions:
try:
ctx = build_exercise_placeholder_variables(
cur,
slug="exercise_instruction_rewrite",
title=title,
goal=goal,
execution=execution,
preparation=preparation,
trainer_notes=trainer_notes,
focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context,
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
try:
irow, rendered = load_and_render_ai_prompt(cur, "exercise_instruction_rewrite", ctx)
except AiPromptUnavailableError:
raise HTTPException(
status_code=503,
detail="Prompt exercise_instruction_rewrite nicht aktiv oder fehlt in DB.",
) from None
model_instr = effective_openrouter_model_for_prompt_row(irow)
models_by_slug["exercise_instruction_rewrite"] = model_instr
prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
"AI_DEBUG exercise_ai instructions prompt_slug=exercise_instruction_rewrite prompt_chars=%s",
len(prompt),
)
sys_hint = (
"Du antwortest nur mit validem JSON-Objekt (Schluessel goal, execution, preparation, trainer_notes). "
"Keine Kommentare ausserhalb des JSON."
)
try:
raw = openrouter_chat_completion(
api_key=key,
model=model_instr,
user_content=prompt,
system_content=sys_hint,
temperature=0.2,
)
except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
body = (raw or "").strip()
if not body:
raise HTTPException(
status_code=502,
detail="OpenRouter/KI lieferte leeren Inhalt fuer Anleitungs-Ueberarbeitung.",
)
try:
parsed = _extract_instruction_rewrite_object(body)
except (json.JSONDecodeError, ValueError) as e:
if _ai_debug_on():
_LOGGER.warning(
"AI_DEBUG exercise_ai instructions JSON parse_failed err=%s head=%s",
e,
(body.replace("\r", "").replace("\n", " ").strip())[:400],
)
raise HTTPException(
status_code=502,
detail="KI lieferte kein verwertbares JSON fuer die Anleitung.",
) from e
originals = {
"goal": goal,
"execution": execution,
"preparation": preparation,
"trainer_notes": trainer_notes,
}
fields = _sanitize_instruction_rewrite_payload(parsed, originals=originals)
if not any((fields.get(k) or "").strip() for k in _INSTRUCTION_JSON_KEYS):
raise HTTPException(
status_code=502,
detail="KI lieferte leere Anleitungs-Felder.",
)
result["instructions"] = {
"fields": fields,
"ai_generated": True,
"model": model_instr,
}
result["models_by_slug"] = models_by_slug
if want_skills:
result["model"] = models_by_slug["exercise_skill_suggestions"]
elif want_instructions:
result["model"] = models_by_slug["exercise_instruction_rewrite"]
elif want_summary:
result["model"] = models_by_slug["exercise_summary"]
else:
result["model"] = default_openrouter_model_id()
return result

View File

@ -0,0 +1,7 @@
-- Migration 070: optionales OpenRouter-Modell pro Prompt-Zeile
-- Leer/NULL → Umgebungsvariable OPENROUTER_MODEL (wie bisher).
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS openrouter_model VARCHAR(200);
COMMENT ON COLUMN ai_prompts.openrouter_model IS
'Optional: OpenRouter model id (z.B. anthropic/claude-3.5-haiku); NULL = OPENROUTER_MODEL aus Env';

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

@ -6,7 +6,7 @@ from __future__ import annotations
import json
import logging
import os
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Mapping, Optional
import httpx
@ -203,3 +203,22 @@ def normalize_openrouter_env() -> tuple[str, str]:
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
return key, model
def default_openrouter_model_id() -> str:
"""Standard-Modell aus OPENROUTER_MODEL (ohne API-Key zu pruefen)."""
_, model = normalize_openrouter_env()
return model
def effective_openrouter_model_for_prompt_row(row: Optional[Mapping[str, Any]]) -> str:
"""
Pro-Prompt-Override in ai_prompts.openrouter_model, sonst Env-Default.
`row` kann eine partial Row aus load_ai_prompt_row sein (Felder slug, openrouter_model, ).
"""
if row:
custom = str(row.get("openrouter_model") or "").strip()
if custom:
return custom
return default_openrouter_model_id()

View File

@ -87,25 +87,37 @@ def exercise_placeholder_catalog() -> dict:
"key": "exercise_title",
"placeholder": "{{exercise_title}}",
"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",
"placeholder": "{{exercise_focus_area}}",
"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",
"placeholder": "{{exercise_goal}}",
"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",
"placeholder": "{{exercise_execution}}",
"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",

View File

@ -12,9 +12,11 @@ from pydantic import BaseModel, Field
from auth import require_auth
from club_tenancy import is_superadmin
from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_job import resolve_exercise_form_variables
from ai_prompt_runtime import render_ai_prompt_template_for_row
from db import get_cursor, get_db, r2d
from exercise_ai import build_exercise_placeholder_variables
from prompt_resolver import exercise_placeholder_catalog, render_mustache_template
from prompt_resolver import exercise_placeholder_catalog
router = APIRouter(tags=["admin_ai_prompts"])
@ -39,7 +41,7 @@ def _fetch_prompt_any(cur, prompt_id: int) -> Dict[str, Any]:
cur.execute(
"""
SELECT id, slug, display_name, description, template, category, output_format,
output_schema, is_system_default, default_template,
output_schema, is_system_default, default_template, openrouter_model,
active, sort_order, created_at, updated_at
FROM ai_prompts WHERE id = %s
""",
@ -56,19 +58,11 @@ class AiPromptUpdateBody(BaseModel):
active: Optional[bool] = None
display_name: Optional[str] = Field(None, max_length=200)
description: Optional[str] = Field(None, max_length=8000)
openrouter_model: Optional[str] = Field(None, max_length=200)
class AiPromptPreviewFocus(BaseModel):
focus_area_id: int = Field(..., ge=1)
is_primary: Optional[bool] = False
class AiPromptPreviewBody(BaseModel):
title: Optional[str] = ""
goal: Optional[str] = None
execution: Optional[str] = None
focus_hint: Optional[str] = None
focus_areas_context: Optional[List[AiPromptPreviewFocus]] = None
class AiPromptPreviewBody(ExerciseFormAiPromptContext):
"""Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint)."""
@router.get("/api/admin/ai-prompts/catalog/placeholders")
@ -85,7 +79,7 @@ def list_ai_prompts(session: dict = Depends(_require_superadmin)):
cur.execute(
"""
SELECT id, slug, display_name, description, category, output_format, active,
sort_order, is_system_default, default_template
sort_order, is_system_default, default_template, openrouter_model
FROM ai_prompts
ORDER BY sort_order ASC NULLS LAST, id ASC
"""
@ -149,16 +143,25 @@ def update_ai_prompt(
next_desc = body.description if body.description is not None else old.get("description") or ""
next_desc = (next_desc or "").strip()
next_openrouter = old.get("openrouter_model")
if body.openrouter_model is not None:
cand = body.openrouter_model.strip() if isinstance(body.openrouter_model, str) else ""
if any(c in cand for c in ("\r", "\n", "\t")):
raise HTTPException(status_code=400, detail="openrouter_model: keine Steuerzeichen erlaubt.")
next_openrouter = cand or None
cur.execute(
"""
UPDATE ai_prompts
SET template = %s, active = %s, display_name = %s, description = %s, updated_at = NOW()
SET template = %s, active = %s, display_name = %s, description = %s,
openrouter_model = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, slug, display_name, description, template, category, output_format,
output_schema, is_system_default, default_template, active, sort_order,
output_schema, is_system_default, default_template, openrouter_model,
active, sort_order,
created_at, updated_at
""",
(next_template, next_active, next_name, next_desc, prompt_id),
(next_template, next_active, next_name, next_desc, next_openrouter, prompt_id),
)
row = dict(cur.fetchone())
conn.commit()
@ -187,7 +190,8 @@ def reset_ai_prompt_template(prompt_id: int, session: dict = Depends(_require_su
SET template = default_template, updated_at = NOW()
WHERE id = %s AND default_template IS NOT NULL
RETURNING id, slug, display_name, description, template, category, output_format,
output_schema, is_system_default, default_template, active, sort_order,
output_schema, is_system_default, default_template, openrouter_model,
active, sort_order,
created_at, updated_at
""",
(prompt_id,),
@ -211,28 +215,12 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
row = _fetch_prompt_any(cur, prompt_id)
slug = (row.get("slug") or "").strip().lower()
tpl_raw = row.get("template") or ""
fctx_list: Optional[List[tuple[int, bool]]] = None
if body.focus_areas_context:
pairs: List[tuple[int, bool]] = []
for x in body.focus_areas_context:
pairs.append((int(x.focus_area_id), bool(x.is_primary)))
fctx_list = pairs
vars_map: Dict[str, str]
warn: Optional[str] = None
if slug in ("exercise_summary", "exercise_skill_suggestions"):
if slug in ("exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"):
try:
vars_map = build_exercise_placeholder_variables(
cur,
slug=slug,
title=(body.title or "").strip(),
goal=body.goal,
execution=body.execution,
focus_area_hint=body.focus_hint,
focus_areas_context=fctx_list,
)
vars_map = resolve_exercise_form_variables(cur, slug, body)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
elif slug == "pipeline":
@ -242,7 +230,7 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
vars_map = {}
warn = f"Slug {slug!r}: noch kein Vorschau-Kontext definiert — Roh-Template ohne Ersetzung."
rendered = render_mustache_template(str(tpl_raw), vars_map)
rendered = render_ai_prompt_template_for_row(row, vars_map)
return {
"slug": slug,
"resolved_template": rendered.text,

View File

@ -35,7 +35,8 @@ from tenant_context import TenantContext, get_tenant_context, get_tenant_context
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
from media_legal_hold import assert_not_under_legal_hold
from exercise_ai import run_exercise_ai_suggestion
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_rich_text import (
RICH_HTML_EXERCISE_FIELDS,
@ -358,31 +359,44 @@ class ExerciseMediaFromAsset(BaseModel):
media_type: Optional[str] = None
class ExerciseAiFocusCtx(BaseModel):
"""Fokusbereich fuer Skill-Kataloggewichte (Migration 068 ai_skill_retrieval_profiles)."""
focus_area_id: int = Field(..., ge=1)
is_primary: Optional[bool] = False
class ExerciseAiFocusCtx(ExerciseFormAiFocusRow):
"""Alias fuer OpenAPI — identisch zu ExerciseFormAiFocusRow."""
class ExerciseAiSuggestBody(BaseModel):
title: Optional[str] = Field(None, max_length=300)
goal: Optional[str] = Field(None, max_length=64000)
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_areas_context: Optional[list[ExerciseAiFocusCtx]] = Field(
focus_areas_context: Optional[list[ExerciseFormAiFocusRow]] = Field(
None,
description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung",
)
include_summary: bool = True
include_skills: bool = True
include_instructions: bool = False
@model_validator(mode="after")
def check_include_any(self):
if not self.include_summary and not self.include_skills:
raise ValueError("Mindestens include_summary oder include_skills aktivieren.")
if not self.include_summary and not self.include_skills and not self.include_instructions:
raise ValueError(
"Mindestens include_summary, include_skills oder include_instructions aktivieren."
)
return self
def to_form_context(self) -> ExerciseFormAiPromptContext:
return ExerciseFormAiPromptContext.from_api_suggest(
title=self.title,
goal=self.goal,
execution=self.execution,
preparation=self.preparation,
trainer_notes=self.trainer_notes,
focus_area_hint=self.focus_area_hint,
focus_areas_context=self.focus_areas_context,
)
class ExerciseAiRegenerateBody(BaseModel):
"""Welche Artefakte neu angefragt werden sollen."""
@ -391,7 +405,7 @@ class ExerciseAiRegenerateBody(BaseModel):
@model_validator(mode="after")
def normalize_regs(self):
allowed = {"summary", "skills"}
allowed = {"summary", "skills", "instructions"}
raw = [str(x).strip().lower() for x in (self.regenerate or [])]
out = []
seen = set()
@ -2306,19 +2320,12 @@ def exercise_ai_suggest_endpoint(
_ = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
fctx = None
if body.focus_areas_context:
fctx = [(x.focus_area_id, bool(x.is_primary)) for x in body.focus_areas_context]
payload = run_exercise_ai_suggestion(
payload = run_exercise_form_ai_suggestion(
cur,
title=(body.title or "").strip(),
goal=body.goal,
execution=body.execution,
focus_area_hint=(body.focus_area_hint or "").strip() or None,
focus_areas_context=fctx,
body.to_form_context(),
want_summary=body.include_summary,
want_skills=body.include_skills,
want_instructions=body.include_instructions,
)
return payload
@ -2332,6 +2339,7 @@ def exercise_ai_regenerate_endpoint(
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
want_summary = "summary" in body.regenerate
want_skills = "skills" in body.regenerate
want_instructions = "instructions" in body.regenerate
with get_db() as conn:
cur = get_cursor(conn)
@ -2344,15 +2352,21 @@ def exercise_ai_regenerate_endpoint(
focus = _focus_area_hint_from_detail(exercise)
fctx = _focus_areas_ai_ctx_from_detail(exercise)
payload = run_exercise_ai_suggestion(
cur,
ctx = ExerciseFormAiPromptContext.from_focus_tuples(
title=str(exercise.get("title") or "").strip(),
goal=exercise.get("goal"),
execution=exercise.get("execution"),
focus_area_hint=focus or None,
focus_areas_context=fctx or None,
preparation=exercise.get("preparation"),
trainer_notes=exercise.get("trainer_notes"),
focus_hint=focus or None,
focus_tuples=fctx or None,
)
payload = run_exercise_form_ai_suggestion(
cur,
ctx,
want_summary=want_summary,
want_skills=want_skills,
want_instructions=want_instructions,
)
return payload

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.159"
BUILD_DATE = "2026-05-30"
DB_SCHEMA_VERSION = "20260530069"
APP_VERSION = "0.8.166"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260531071"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -19,13 +19,15 @@ MODULE_VERSIONS = {
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"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_prompts": "1.0.1", # Prompt-Pflege + Zielarchitektur-Doku; gemeinsamer DB-Load uber ai_prompt_runtime
"ai_prompt_runtime": "0.1.0", # AiPromptContextKind, load_ai_prompt_row — Erweiterung Planung ohne Zirkel zu exercise_ai
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
"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
"groups": "0.1.0",
"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
"methods": "0.1.0",
"exercises": "2.31.1", # AI nutzt load_ai_prompt_row aus ai_prompt_runtime
"exercises": "2.33.0", # KI Schnellanlage: Suche+Anlage kombiniert; Rich-Text-Editor; Übungsliste KI-Schalter
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -40,6 +42,63 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.166",
"date": "2026-05-22",
"changes": [
"KI Schnellanlage: Suche und Anlage kombiniert (Picker + Übungsliste); Suchstring → Titel/Skizze; Rich-Text-Entwurf bearbeitbar vor Speichern.",
"Übungsliste: Schalter „KI-Anlage“ für direkten Einstieg ohne leere Trefferliste.",
],
},
{
"version": "0.8.165",
"date": "2026-05-31",
"changes": [
"Übungspicker Schnellanlage: KI-Vorschau-Dialog vor Speichern; Live-Bibliothekssuche (Titel+Skizze) mit Übernahme bestehender Übung.",
],
},
{
"version": "0.8.164",
"date": "2026-05-31",
"changes": [
"Planung/Übungspicker: Schnellanlage nutzt suggestExerciseAi (Anleitung, Kurzbeschreibung, Fähigkeiten); Fokusbereich Pflichtfeld.",
],
},
{
"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",
"date": "2026-05-31",
"changes": [
"KI Prompt P1 abgeschlossen: ai_prompt_context (Formular-Kontext), run_exercise_form_ai_suggestion als gemeinsamer Einstieg;",
"POST /exercises/ai/suggest und regenerate bauen ExerciseFormAiPromptContext; Admin-Vorschau nutzt dasselbe Modell.",
],
},
{
"version": "0.8.161",
"date": "2026-05-31",
"changes": [
"Migration 070: ai_prompts.openrouter_model (optional je Prompt; Fallback OPENROUTER_MODEL).",
"exercise_ai: effektives OpenRouter-Modell pro Slug; API-Response models_by_slug + model (Skills bevorzugt).",
"Superadmin „KI Prompts“: OpenRouter-Modell speicherbar.",
],
},
{
"version": "0.8.160",
"date": "2026-05-30",
"changes": [
"KI Prompt P1: ai_prompt_runtime load_and_render_ai_prompt + render_ai_prompt_template_for_row; AiPromptUnavailableError;",
"Neu ai_prompt_job: ExerciseFormAiPromptContext (Pydantic), resolve_exercise_form_variables; Admin-Prompt-Vorschau nutzt gleichen Pfad wie exercise_ai-Logik;",
"Zielarchitektur-Doku: Phasendiagramm P0/P1 angepasst.",
],
},
{
"version": "0.8.159",
"date": "2026-05-30",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-30
**App-Version / DB-Schema:** App **`0.8.159`** u.a. **KI-Prompt-Zielarchitektur** + gemeinsames Modul **`ai_prompt_runtime`**; DB-Schema **`backend/version.py`** → `APP_VERSION`, `DB_SCHEMA_VERSION` (aktuell `20260530069`).
**Stand:** 2026-05-31
**App-Version / DB-Schema:** App **`0.8.166`** (KI Schnellanlage Suche+Anlage); 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**.
@ -89,16 +89,18 @@ 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`)
- **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.159**)
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.166**)
- **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
- **Runtime:** **`backend/ai_prompt_runtime.py`** — `AiPromptContextKind`, `load_ai_prompt_row` (gemeinsamer DB-Lesezugriff, kein Import von `exercise_ai`); **`exercise_ai`** nutzt `load_ai_prompt_row` fuer aktive Prompts
- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`069`** setzt **`default_template`** wo leer; Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
- **`exercise_ai`:** Gewichtungen, KategorieAnteilCaps (~Token), Keyword-Patches aus Ziel/Durchführung (z.B. Rollenspiel vs. Befreiung/Haltegriff)
- **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`**
- **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
- **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`)
- **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:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
- **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`**`include_instructions`**, Body **`preparation`**, **`trainer_notes`**; Response **`instructions.fields`**; **`POST …/ai/regenerate`** mit **`instructions`** in `regenerate`
- **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`**
- **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter`
- **Frontend Formular:** Tab **Anleitung****„KI: Anleitung überarbeiten“**; Vorschau-Dialog pro Feld (**`ExerciseFormPageRoot.jsx`**)
- **Frontend Schnellanlage:** **`ExercisePickerModal`** (Planung/Rahmen) — Volltextsuche; bei keinem Treffer **„Mit KI anlegen“** (Suchstring → Titel/Skizze); Entwurf im **Rich-Text-Dialog** bearbeiten, dann speichern & übernehmen. **`ExercisesListPageRoot`** — gleiches Muster + Schalter **„KI-Anlage“** in der Suchleiste.
---

View File

@ -3733,6 +3733,68 @@ html.modal-scroll-locked .app-main {
.exercises-page__title {
margin: 0;
}
.exercises-page__header-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.exercises-ai-assistant-toggle {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
font-size: 14px;
color: var(--text2);
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface2);
}
.exercises-ai-assistant-toggle:hover {
border-color: var(--accent-dark, rgba(29, 158, 117, 0.45));
}
.exercises-ai-assistant-toggle:has(input:checked) {
border-color: var(--accent);
background: var(--accent-light, rgba(29, 158, 117, 0.12));
color: var(--accent-dark);
font-weight: 600;
}
.exercises-ai-assistant-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.exercises-ai-assistant-toggle__track {
position: relative;
flex-shrink: 0;
width: 40px;
height: 22px;
border-radius: 999px;
background: var(--border);
transition: background 0.15s ease;
}
.exercises-ai-assistant-toggle__track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
transition: transform 0.15s ease;
}
.exercises-ai-assistant-toggle:has(input:checked) .exercises-ai-assistant-toggle__track {
background: var(--accent);
}
.exercises-ai-assistant-toggle:has(input:checked) .exercises-ai-assistant-toggle__track::after {
transform: translateX(18px);
}
.exercises-page-toolbar-tabs {
margin-bottom: 14px;
}

View File

@ -0,0 +1,123 @@
import React from 'react'
/**
* Inline-Angebot: aus Suchstring neue Übung per KI anlegen (Fokusbereich + optional Titel/Skizze).
*/
export default function ExerciseAiQuickCreateOffer({
searchLabel,
title,
onTitleChange,
sketch,
onSketchChange,
focusAreaId,
onFocusAreaChange,
focusAreas = [],
catalogsReady = true,
busy = false,
error = '',
onRunAi,
showSketchField = true,
sketchOptional = true,
hint,
}) {
const canRun =
!busy &&
(title || '').trim().length >= 3 &&
focusAreaId &&
(sketchOptional || (sketch || '').trim().length > 0)
return (
<div
className="card"
style={{
padding: '14px 16px',
marginBottom: '12px',
borderColor: 'var(--accent-dark, rgba(29,158,117,0.35))',
background: 'var(--surface2)',
}}
>
<strong style={{ display: 'block', marginBottom: '6px', fontSize: '0.95rem' }}>
Keine passende Übung gefunden
</strong>
<p style={{ margin: '0 0 12px', fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
{hint ||
(searchLabel
? `Für „${searchLabel}“ lässt sich eine neue Übung mit KI vorschlagen — Texte danach bearbeiten und speichern.`
: 'Neue Übung mit KI vorschlagen — Texte danach bearbeiten und speichern.')}
</p>
<div style={{ display: 'grid', gap: '10px' }}>
<div>
<label className="form-label" htmlFor="ex-ai-quick-title">
Titel *
</label>
<input
id="ex-ai-quick-title"
type="text"
className="form-input"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
autoComplete="off"
maxLength={300}
placeholder="Titel der neuen Übung"
/>
</div>
<div>
<label className="form-label" htmlFor="ex-ai-quick-focus">
Fokusbereich *
</label>
<select
id="ex-ai-quick-focus"
className="form-input"
value={focusAreaId}
onChange={(e) => onFocusAreaChange(e.target.value)}
disabled={!catalogsReady}
>
<option value=""> wählen </option>
{(focusAreas || []).map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{`${fa.icon ? `${fa.icon} ` : ''}${fa.name || `#${fa.id}`}`.trim()}
</option>
))}
</select>
</div>
{showSketchField ? (
<div>
<label className="form-label" htmlFor="ex-ai-quick-sketch">
Kurzbeschreibung / Idee{sketchOptional ? ' (optional)' : ' *'}
</label>
<textarea
id="ex-ai-quick-sketch"
className="form-input"
rows={4}
value={sketch}
onChange={(e) => onSketchChange(e.target.value)}
placeholder={
sketchOptional
? 'Leer lassen: KI schlägt Inhalt frei vor (Titel + Fokus). Oder kurz beschreiben, was die Übung tun soll …'
: 'Kurze Beschreibung für die KI …'
}
/>
{searchLabel && !(sketch || '').trim() ? (
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
Suchbegriff: {searchLabel} wird als Titel übernommen.
</p>
) : null}
</div>
) : null}
{error ? (
<p style={{ margin: 0, fontSize: '13px', color: 'var(--danger)' }}>{error}</p>
) : null}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-primary" disabled={!canRun} onClick={() => onRunAi()}>
{busy ? 'KI erzeugt Vorschlag…' : 'Mit KI anlegen'}
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,227 @@
import React, { useEffect } from 'react'
import RichTextEditor from './RichTextEditor'
import {
INSTRUCTION_AI_FIELD_DEFS,
describeAiSkillRowForPreview,
} from '../utils/exerciseAiQuickCreate'
import { stripHtmlToText } from '../utils/htmlUtils'
/**
* Modal: KI-Entwurf bearbeiten (Rich-Text) und speichern.
*/
export default function ExerciseAiSuggestPreviewModal({
draft,
onDraftChange,
onDiscard,
onApply,
focusAreas = [],
skillsCatalog = [],
dialogTitle = 'Neue Übung — KI-Entwurf bearbeiten',
hint = 'Texte formatiert anzeigen und bei Bedarf anpassen, dann speichern.',
applyLabel = 'Übung anlegen',
applyDisabled = false,
zIndex = 2000,
}) {
useEffect(() => {
if (!draft) return undefined
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onDiscard()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [draft, onDiscard])
if (!draft) return null
const fields = draft.instructionFields || {}
const canApply =
!applyDisabled &&
(draft.title || '').trim().length >= 3 &&
draft.focusAreaId &&
(stripHtmlToText(fields.goal).trim() || stripHtmlToText(fields.execution).trim())
const patchDraft = (patch) => onDraftChange((prev) => (prev ? { ...prev, ...patch } : prev))
const patchInstructionField = (key, html) => {
onDraftChange((prev) =>
prev
? {
...prev,
instructionFields: { ...(prev.instructionFields || {}), [key]: html },
}
: prev,
)
}
return (
<div
role="dialog"
aria-modal="true"
aria-label={dialogTitle}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
zIndex,
overflow: 'auto',
padding: '16px',
}}
onClick={() => onDiscard()}
>
<div
className="card"
style={{
maxWidth: 820,
margin: '2vh auto',
maxHeight: '94vh',
overflow: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>{hint}</p>
<div style={{ display: 'grid', gap: '12px', marginBottom: '18px' }}>
<div>
<label className="form-label" htmlFor="ai-draft-title">
Titel *
</label>
<input
id="ai-draft-title"
type="text"
className="form-input"
value={draft.title || ''}
onChange={(e) => patchDraft({ title: e.target.value })}
maxLength={300}
/>
</div>
<div>
<label className="form-label" htmlFor="ai-draft-focus">
Fokusbereich *
</label>
<select
id="ai-draft-focus"
className="form-input"
value={draft.focusAreaId || ''}
onChange={(e) => patchDraft({ focusAreaId: e.target.value })}
>
<option value=""> wählen </option>
{(focusAreas || []).map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{`${fa.icon ? `${fa.icon} ` : ''}${fa.name || `#${fa.id}`}`.trim()}
</option>
))}
</select>
</div>
</div>
<section style={{ marginBottom: '18px' }} aria-labelledby="ai-draft-summary-heading">
<div id="ai-draft-summary-heading" style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '8px' }}>
Kurzfassung
</div>
<RichTextEditor
value={draft.summaryHtml || ''}
onChange={(html) => patchDraft({ summaryHtml: html })}
placeholder="Kurzbeschreibung …"
minHeight="88px"
/>
</section>
<section style={{ marginBottom: '18px' }} aria-labelledby="ai-draft-instructions-heading">
<div
id="ai-draft-instructions-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Anleitung
</div>
{INSTRUCTION_AI_FIELD_DEFS.map((def) => (
<div key={def.key} style={{ marginBottom: '14px' }}>
<label className="form-label">{def.label}</label>
<RichTextEditor
value={fields[def.key] || ''}
onChange={(html) => patchInstructionField(def.key, html)}
placeholder={`${def.label}`}
minHeight={def.key === 'execution' ? '140px' : '100px'}
/>
</div>
))}
</section>
{(draft.skillChoices || []).length > 0 ? (
<section aria-labelledby="ai-draft-skills-heading">
<div id="ai-draft-skills-heading" style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}>
Fähigkeiten ({draft.skillChoices.length})
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{draft.skillChoices.map((c) => (
<li
key={c.key}
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '10px 12px',
marginBottom: '10px',
background: 'var(--surface)',
}}
>
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
cursor: 'pointer',
fontSize: '14px',
}}
>
<input
type="checkbox"
checked={!!c.include}
style={{ marginTop: 3 }}
onChange={(e) =>
onDraftChange((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) =>
x.key === c.key ? { ...x, include: e.target.checked } : x,
),
}
: prev,
)
}
/>
<span>{describeAiSkillRowForPreview(c.after, skillsCatalog)}</span>
</label>
</li>
))}
</ul>
</section>
) : null}
<div
style={{
marginTop: '20px',
paddingTop: '14px',
borderTop: '1px solid var(--border)',
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'wrap',
gap: '10px',
}}
>
<button type="button" className="btn btn-secondary" onClick={() => onDiscard()}>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={!canApply} onClick={() => onApply()}>
{applyLabel}
</button>
</div>
</div>
</div>
)
}

View File

@ -17,16 +17,20 @@ import {
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
import CatalogRulePicker from './CatalogRulePicker'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import ExerciseAiQuickCreateOffer from './ExerciseAiQuickCreateOffer'
import { useExerciseAiQuickCreateFields } from '../hooks/useExerciseAiQuickCreateFields'
import {
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
aiPreviewToQuickCreateDraft,
} from '../utils/exerciseAiQuickCreate'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
/** Stub-Ziel für API-Validator (mind. Ziel oder Durchführung); Nutzer ergänzt Details in der Übungsbearbeitung. */
const QUICK_CREATE_GOAL_PLACEHOLDER =
'Aus der Trainingsplanung angelegt — bitte Ziel und Durchführung in der Übungsbearbeitung ergänzen.'
export default function ExercisePickerModal({
open,
onClose,
@ -57,12 +61,21 @@ export default function ExercisePickerModal({
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [multiPicked, setMultiPicked] = useState([])
const [quickOpen, setQuickOpen] = useState(false)
const [quickTitle, setQuickTitle] = useState('')
const [quickSummary, setQuickSummary] = useState('')
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const pickerScrollRef = useRef(null)
const {
title: quickTitle,
sketch: quickSketch,
focusAreaId: quickFocusAreaId,
setTitle: setQuickTitle,
setSketch: setQuickSketch,
setFocusAreaId: setQuickFocusAreaId,
resetQuickCreateFields,
} = useExerciseAiQuickCreateFields(debouncedSearch, { enabled: open && enableQuickCreateDraft })
const toggleMultiPick = (ex) => {
setMultiPicked((prev) =>
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
@ -79,6 +92,13 @@ export default function ExercisePickerModal({
return () => clearTimeout(t)
}, [aiSearchInput])
const showQuickCreateOffer =
enableQuickCreateDraft &&
catalogsReady &&
!loading &&
debouncedSearch.length >= 3 &&
list.length === 0
useEffect(() => {
if (!open) return
let cancelled = false
@ -122,10 +142,10 @@ export default function ExercisePickerModal({
setList([])
setHasMore(false)
setMultiPicked([])
setQuickOpen(false)
setQuickTitle('')
setQuickSummary('')
resetQuickCreateFields()
setQuickSaving(false)
setQuickAiError('')
setQuickCreateDraft(null)
return
}
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
@ -285,43 +305,83 @@ export default function ExercisePickerModal({
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
const submitQuickCreate = async () => {
const adoptExistingExercise = async (ex) => {
if (!ex?.id) return
if (multiSelect && typeof onSelectExercises === 'function') {
await Promise.resolve(onSelectExercises([ex]))
} else if (typeof onSelectExercise === 'function') {
await Promise.resolve(onSelectExercise(ex))
}
onClose()
}
const runQuickCreateAiSuggest = async () => {
const title = (quickTitle || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const summaryRaw = (quickSummary || '').trim()
const sketch = (quickSketch || '').trim()
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.')
return
}
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
const focusHint = (focusRow?.name || '').trim()
setQuickAiError('')
setQuickCreateDraft(null)
setQuickSaving(true)
try {
const created = await api.createExercise({
const aiRes = await api.suggestExerciseAi({
title,
summary: summaryRaw || null,
goal: QUICK_CREATE_GOAL_PLACEHOLDER,
execution: null,
visibility: 'private',
status: 'draft',
equipment: [],
focus_areas_multi: [],
training_styles_multi: [],
training_types_multi: [],
target_groups_multi: [],
age_groups: [],
skills: [],
club_id: null,
goal: sketch || undefined,
execution: '',
preparation: '',
trainer_notes: '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
include_summary: true,
include_skills: true,
include_instructions: true,
})
if (!created?.id) {
throw new Error('Anlegen fehlgeschlagen')
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
}
if (multiSelect && typeof onSelectExercises === 'function') {
await Promise.resolve(onSelectExercises([created]))
} else if (typeof onSelectExercise === 'function') {
await Promise.resolve(onSelectExercise(created))
}
onClose()
setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
)
} catch (e) {
console.error(e)
alert(e.message || 'Übung konnte nicht angelegt werden')
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'KI-Vorschlag fehlgeschlagen')
} finally {
setQuickSaving(false)
}
}
const applyQuickCreateDraft = async () => {
if (!quickCreateDraft) return
setQuickSaving(true)
setQuickAiError('')
try {
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
setQuickCreateDraft(null)
await adoptExistingExercise(created)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'Übung konnte nicht angelegt werden')
} finally {
setQuickSaving(false)
}
@ -353,75 +413,6 @@ export default function ExercisePickerModal({
</button>
</div>
{enableQuickCreateDraft ? (
<div
style={{
padding: '10px 1rem 12px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
background: 'var(--surface2)',
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
onClick={() => setQuickOpen((v) => !v)}
aria-expanded={quickOpen}
>
{quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung anlegen (Entwurf, privat)'}
</button>
{quickOpen ? (
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
Wird mit Freigabelevel <strong>privat</strong> und Status <strong>Entwurf</strong> gespeichert und
erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den
Ablauf übernommen.
</p>
<div>
<label className="form-label" htmlFor="ex-picker-quick-title">
Titel
</label>
<input
id="ex-picker-quick-title"
type="text"
className="form-input"
value={quickTitle}
onChange={(e) => setQuickTitle(e.target.value)}
autoComplete="off"
minLength={3}
maxLength={300}
placeholder="z.B. Partnerübung Abwehr"
/>
</div>
<div>
<label className="form-label" htmlFor="ex-picker-quick-summary">
Kurzbeschreibung
</label>
<textarea
id="ex-picker-quick-summary"
className="form-input"
rows={3}
value={quickSummary}
onChange={(e) => setQuickSummary(e.target.value)}
placeholder="Optional: grobe Idee, Kontext aus der Planung …"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-primary"
disabled={quickSaving || (quickTitle || '').trim().length < 3}
onClick={submitQuickCreate}
>
{quickSaving ? 'Wird angelegt…' : 'Entwurf anlegen und übernehmen'}
</button>
</div>
</div>
) : null}
</div>
) : null}
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'grid', gap: '0.65rem' }}>
<div>
@ -602,7 +593,28 @@ export default function ExercisePickerModal({
<div className="spinner" />
</div>
) : list.length === 0 ? (
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>Keine Treffer.</p>
showQuickCreateOffer ? (
<ExerciseAiQuickCreateOffer
searchLabel={debouncedSearch}
title={quickTitle}
onTitleChange={setQuickTitle}
sketch={quickSketch}
onSketchChange={setQuickSketch}
focusAreaId={quickFocusAreaId}
onFocusAreaChange={setQuickFocusAreaId}
focusAreas={catalogs.focusAreas}
catalogsReady={catalogsReady}
busy={quickSaving}
error={quickAiError}
onRunAi={runQuickCreateAiSuggest}
/>
) : (
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
{debouncedSearch.length >= 3
? 'Keine Treffer.'
: 'Suchbegriff eingeben (mind. 3 Zeichen) …'}
</p>
)
) : (
<>
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
@ -770,6 +782,20 @@ export default function ExercisePickerModal({
)}
</div>
</div>
<ExerciseAiSuggestPreviewModal
draft={quickCreateDraft}
onDraftChange={setQuickCreateDraft}
onDiscard={() => setQuickCreateDraft(null)}
onApply={applyQuickCreateDraft}
focusAreas={catalogs.focusAreas}
skillsCatalog={catalogs.skills}
dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
hint="Texte sind formatiert — passe Titel, Kurzfassung, Anleitung und Fähigkeiten an, dann speichern und übernehmen."
applyLabel={quickSaving ? 'Wird angelegt…' : 'Anlegen und übernehmen'}
applyDisabled={quickSaving}
zIndex={2100}
/>
</div>
)
}

View File

@ -90,6 +90,13 @@ function aiPlainSummaryToMinimalHtml(text) {
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) {
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
}
@ -110,9 +117,16 @@ function buildNormalizedAiSkillRowFromApi(sug) {
}
}
function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotSkills, apiRes }) {
const summaryRequested = mode !== 'skills'
const skillsRequested = mode !== 'summary'
function buildExerciseAiSuggestionPreview({
mode,
snapshotSummaryHtml,
snapshotSkills,
snapshotInstructions,
apiRes,
}) {
const summaryRequested = mode !== 'skills' && mode !== 'instructions'
const skillsRequested = mode !== 'summary' && mode !== 'instructions'
const instructionsRequested = mode === 'instructions'
let summaryAfterHtml = null
let summaryAfterPlain = ''
@ -141,8 +155,29 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS
}
}
const instructionChoices = []
if (instructionsRequested && apiRes.instructions?.fields) {
const fields = apiRes.instructions.fields
const snap = snapshotInstructions || {}
for (const def of INSTRUCTION_AI_FIELD_DEFS) {
const afterHtml = fields[def.key]
if (!afterHtml || !String(afterHtml).trim()) continue
const beforeHtml = snap[def.key] || ''
instructionChoices.push({
key: def.key,
field: def.key,
label: def.label,
beforePlain: stripHtmlToText(beforeHtml).trim(),
afterHtml: String(afterHtml),
afterPlain: stripHtmlToText(afterHtml).trim(),
include: true,
})
}
}
const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
const hasSkillChoices = skillChoices.length > 0
const hasInstructionChoices = instructionChoices.length > 0
return {
mode,
@ -151,10 +186,13 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS
summaryAfterPlain,
summaryAfterHtml,
skillChoices,
instructionChoices,
hasSummaryProposal,
hasSkillChoices,
hasInstructionChoices,
summaryRequested,
skillsRequested,
instructionsRequested,
}
}
@ -1027,20 +1065,96 @@ function ExerciseFormPageRoot() {
}
}
const runExerciseAiInstructionRewrite = async () => {
const title = (formData.title || '').trim()
const snapshotInstructions = {
goal: formData.goal || '',
execution: formData.execution || '',
preparation: formData.preparation || '',
trainer_notes: formData.trainer_notes || '',
}
const hasSource =
!!title ||
Object.values(snapshotInstructions).some((html) => stripHtmlToText(html || '').trim())
if (!hasSource) {
toast.error('Titel oder mindestens ein Anleitungsfeld ausfüllen.')
return
}
const focusHint = (formData.focus_areas_multi || [])
.map((row) => {
const id = row?.focus_area_id
const fa = focusAreas.find((x) => Number(x.id) === Number(id))
return (fa?.name || '').trim()
})
.filter(Boolean)
.join(', ')
const focusAreasContext = [...(formData.focus_areas_multi || [])]
.map((row) => ({
focus_area_id: Number(row?.focus_area_id),
is_primary: !!row?.is_primary,
}))
.filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1)
.sort((a, b) => {
const p = Number(!!b.is_primary) - Number(!!a.is_primary)
if (p !== 0) return p
return a.focus_area_id - b.focus_area_id
})
setAiSuggestionPreview(null)
setAiSuggestBusy(true)
try {
const res = await api.suggestExerciseAi({
title,
goal: snapshotInstructions.goal,
execution: snapshotInstructions.execution,
preparation: snapshotInstructions.preparation,
trainer_notes: snapshotInstructions.trainer_notes,
focus_area_hint: focusHint || undefined,
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
include_summary: false,
include_skills: false,
include_instructions: true,
})
const preview = buildExerciseAiSuggestionPreview({
mode: 'instructions',
snapshotInstructions,
apiRes: res,
})
if (!preview.hasInstructionChoices) {
toast.info('Die KI lieferte keinen verwertbaren Anleitungs-Vorschlag.')
return
}
setAiSuggestionPreview(preview)
} catch (err) {
toast.error(err?.message || String(err))
} finally {
setAiSuggestBusy(false)
}
}
const applyExerciseAiSuggestionPreview = () => {
const p = aiSuggestionPreview
if (!p) return
const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after)
const instrToApply = (p.instructionChoices || []).filter((c) => c.include && c.afterHtml)
if (!takeSummary && skillsToMerge.length === 0) {
toast.error('Bitte mindestens eine Kurzfassung oder eine Fähigkeit zur Übernahme auswählen.')
if (!takeSummary && skillsToMerge.length === 0 && instrToApply.length === 0) {
toast.error('Bitte mindestens einen Vorschlag zur Übernahme auswählen.')
return
}
if (takeSummary) {
updateFormField('summary', p.summaryAfterHtml)
}
for (const c of instrToApply) {
updateFormField(c.field, c.afterHtml)
}
if (skillsToMerge.length > 0) {
setFormDirty(true)
setFormData((prev) => {
@ -2145,6 +2259,29 @@ function ExerciseFormPageRoot() {
title="Anleitung"
hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)."
>
<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">
<label className="form-label">Ziel *</label>
<RichTextEditor
@ -2780,7 +2917,13 @@ function ExerciseFormPageRoot() {
minHeight: '72px',
}
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 (
<div
role="dialog"
@ -2808,11 +2951,94 @@ function ExerciseFormPageRoot() {
}}
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' }}>
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.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 ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
<div
@ -3032,7 +3258,7 @@ function ExerciseFormPageRoot() {
/>
<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
wie gewohnt.
</p>

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../../utils/api'
import { useAuth } from '../../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
@ -11,6 +12,14 @@ import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
import ExercisePeekModal from '../ExercisePeekModal'
import NavStateLink from '../NavStateLink'
import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer'
import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal'
import {
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
aiPreviewToQuickCreateDraft,
} from '../../utils/exerciseAiQuickCreate'
import { useExerciseAiQuickCreateFields } from '../../hooks/useExerciseAiQuickCreateFields'
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
@ -37,6 +46,7 @@ const EXERCISES_PAGE_TABS = [
]
function ExercisesListPageRoot() {
const navigate = useNavigate()
const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
@ -78,6 +88,21 @@ function ExercisesListPageRoot() {
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
const [peekExercise, setPeekExercise] = useState(null)
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const {
title: quickTitle,
sketch: quickSketch,
focusAreaId: quickFocusAreaId,
setTitle: setQuickTitle,
setSketch: setQuickSketch,
setFocusAreaId: setQuickFocusAreaId,
} = useExerciseAiQuickCreateFields(debouncedSearch, {
enabled: pageTab === 'list' && (aiQuickCreateEnabled || debouncedSearch.length >= 3),
})
useEffect(() => {
if (!user?.id) return
@ -151,6 +176,13 @@ function ExercisesListPageRoot() {
loadMore,
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
const showQuickCreateOffer =
pageTab === 'list' &&
catalogsReady &&
!listFetching &&
(aiQuickCreateEnabled ||
(exercises.length === 0 && selectedEntries.length === 0 && debouncedSearch.length >= 3))
const selectedIds = useMemo(
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
[selectedEntries]
@ -304,6 +336,82 @@ function ExercisesListPageRoot() {
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), [])
const runQuickCreateAiSuggest = useCallback(async () => {
const title = (quickTitle || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const sketch = (quickSketch || '').trim()
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.')
return
}
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
const focusHint = (focusRow?.name || '').trim()
setQuickAiError('')
setQuickCreateDraft(null)
setQuickSaving(true)
try {
const aiRes = await api.suggestExerciseAi({
title,
goal: sketch || undefined,
execution: '',
preparation: '',
trainer_notes: '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
include_summary: true,
include_skills: true,
include_instructions: true,
})
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
}
setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'KI-Vorschlag fehlgeschlagen')
} finally {
setQuickSaving(false)
}
}, [quickTitle, quickSketch, quickFocusAreaId, catalogs.focusAreas])
const applyQuickCreateDraft = useCallback(async () => {
if (!quickCreateDraft) return
setQuickSaving(true)
setQuickAiError('')
try {
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
setQuickCreateDraft(null)
setAiQuickCreateEnabled(false)
setExercises((prev) => [created, ...prev])
navigate(`/exercises/${created.id}/edit`, {
state: { returnContext: exercisesModuleReturnContext },
})
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'Übung konnte nicht angelegt werden')
} finally {
setQuickSaving(false)
}
}, [quickCreateDraft, setExercises, navigate, exercisesModuleReturnContext])
const bulkVisibilityOptions = useMemo(() => {
const base = [
{ id: '', label: '— nicht ändern —' },
@ -486,6 +594,19 @@ function ExercisesListPageRoot() {
<div className="exercises-page__header">
<h1 className="page-title exercises-page__title">Übungen</h1>
{pageTab === 'list' ? (
<div className="exercises-page__header-actions">
<label
className="exercises-ai-assistant-toggle"
title="Neue Übung per KI vorschlagen — Titel, optional Kurzbeschreibung, Fokusbereich"
>
<input
type="checkbox"
checked={aiQuickCreateEnabled}
onChange={(e) => setAiQuickCreateEnabled(e.target.checked)}
/>
<span className="exercises-ai-assistant-toggle__track" aria-hidden="true" />
<span>Neu mit KI-Assistent</span>
</label>
<NavStateLink
to="/exercises/new"
returnContext={exercisesModuleReturnContext}
@ -493,6 +614,7 @@ function ExercisesListPageRoot() {
>
+ Neu
</NavStateLink>
</div>
) : (
<span aria-hidden="true" />
)}
@ -537,6 +659,28 @@ function ExercisesListPageRoot() {
onToggleSelectAllPage={toggleSelectAllPage}
/>
{showQuickCreateOffer ? (
<ExerciseAiQuickCreateOffer
searchLabel={debouncedSearch || undefined}
title={quickTitle}
onTitleChange={setQuickTitle}
sketch={quickSketch}
onSketchChange={setQuickSketch}
focusAreaId={quickFocusAreaId}
onFocusAreaChange={setQuickFocusAreaId}
focusAreas={catalogs.focusAreas}
catalogsReady={catalogsReady}
busy={quickSaving}
error={quickAiError}
onRunAi={runQuickCreateAiSuggest}
hint={
aiQuickCreateEnabled
? 'Titel aus Suche oder manuell; Kurzbeschreibung optional — leer für freien KI-Vorschlag, ausgefüllt als deine Ausgangsidee.'
: undefined
}
/>
) : null}
<ExerciseListBulkToolbar
selectedCount={selectedIds.size}
bulkMaxIds={BULK_MAX_IDS}
@ -625,11 +769,15 @@ function ExercisesListPageRoot() {
Lade Übungen
</p>
</div>
) : exercises.length === 0 && selectedEntries.length === 0 ? (
) : exercises.length === 0 && selectedEntries.length === 0 && !showQuickCreateOffer ? (
<div className="card">
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
<p className="exercises-empty-text">
{debouncedSearch.length >= 3
? 'Keine Übungen gefunden.'
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'}
</p>
</div>
) : (
) : exercises.length === 0 && selectedEntries.length === 0 && showQuickCreateOffer ? null : (
<>
{selectedEntries.length > 0 ? (
<section className="exercises-selection-section" data-testid="exercises-selection-section">
@ -696,6 +844,19 @@ function ExercisesListPageRoot() {
)}
</>
)}
<ExerciseAiSuggestPreviewModal
draft={quickCreateDraft}
onDraftChange={setQuickCreateDraft}
onDiscard={() => setQuickCreateDraft(null)}
onApply={applyQuickCreateDraft}
focusAreas={catalogs.focusAreas}
skillsCatalog={catalogs.skills}
dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
hint="Texte sind formatiert — passe sie an und lege die Übung als Entwurf an."
applyLabel={quickSaving ? 'Wird angelegt…' : 'Übung anlegen'}
applyDisabled={quickSaving}
/>
</>
)}
</div>

View File

@ -0,0 +1,62 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { parseSearchQueryForQuickCreate } from '../utils/exerciseAiQuickCreate'
/**
* Titel aus Suche vorbelegen; Kurzbeschreibung optional und manuell editierbar.
* Suchwechsel setzt touched zurück und befüllt neu solange der Nutzer nicht editiert hat.
*/
export function useExerciseAiQuickCreateFields(debouncedSearch, { enabled = true } = {}) {
const [title, setTitleState] = useState('')
const [sketch, setSketchState] = useState('')
const [focusAreaId, setFocusAreaId] = useState('')
const titleTouchedRef = useRef(false)
const sketchTouchedRef = useRef(false)
const lastSearchRef = useRef('')
const parsed = useMemo(() => parseSearchQueryForQuickCreate(debouncedSearch), [debouncedSearch])
useEffect(() => {
if (!enabled) return
if (debouncedSearch !== lastSearchRef.current) {
lastSearchRef.current = debouncedSearch
titleTouchedRef.current = false
sketchTouchedRef.current = false
}
if (!debouncedSearch) return
if (!titleTouchedRef.current) {
setTitleState(parsed.title)
}
if (!sketchTouchedRef.current) {
setSketchState('')
}
}, [enabled, debouncedSearch, parsed.title])
const setTitle = useCallback((v) => {
titleTouchedRef.current = true
setTitleState(v)
}, [])
const setSketch = useCallback((v) => {
sketchTouchedRef.current = true
setSketchState(v)
}, [])
const resetQuickCreateFields = useCallback(() => {
setTitleState('')
setSketchState('')
setFocusAreaId('')
titleTouchedRef.current = false
sketchTouchedRef.current = false
lastSearchRef.current = ''
}, [])
return {
title,
sketch,
focusAreaId,
setTitle,
setSketch,
setFocusAreaId,
resetQuickCreateFields,
}
}

View File

@ -23,6 +23,7 @@ export default function AdminAiPromptsPage() {
const [draftName, setDraftName] = useState('')
const [draftDesc, setDraftDesc] = useState('')
const [draftTemplate, setDraftTemplate] = useState('')
const [draftOpenrouterModel, setDraftOpenrouterModel] = useState('')
const [draftActive, setDraftActive] = useState(true)
const [pvTitle, setPvTitle] = useState('Testübung')
@ -74,6 +75,9 @@ export default function AdminAiPromptsPage() {
setDraftName(d.display_name || '')
setDraftDesc(d.description || '')
setDraftTemplate(d.template || '')
setDraftOpenrouterModel(
typeof d.openrouter_model === 'string' ? d.openrouter_model : ''
)
setDraftActive(!!d.active)
setPvPreview(null)
}
@ -96,6 +100,7 @@ export default function AdminAiPromptsPage() {
display_name: draftName,
description: draftDesc,
active: draftActive,
openrouter_model: draftOpenrouterModel.trim(),
})
await loadList()
const nd = await api.getAdminAiPrompt(detail.id)
@ -201,6 +206,11 @@ export default function AdminAiPromptsPage() {
inaktiv
</span>
) : null}
{p.openrouter_model ? (
<span style={{ fontSize: 11, color: 'var(--text3)' }} title="OpenRouter-Modell für diesen Prompt">
Model: <code>{p.openrouter_model}</code>
</span>
) : null}
{p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
</button>
</li>
@ -231,6 +241,17 @@ export default function AdminAiPromptsPage() {
onChange={(e) => setDraftDesc(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">OpenRouter-Modell (optional)</label>
<input
className="form-input"
placeholder="Leer = Server-OPENROUTER_MODEL · z.B. anthropic/claude-3.5-haiku"
autoComplete="off"
spellCheck={false}
value={draftOpenrouterModel}
onChange={(e) => setDraftOpenrouterModel(e.target.value)}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
Aktiv

View File

@ -0,0 +1,303 @@
/**
* KI-gestützte Schnellanlage / Vorschau (Planung / ExercisePickerModal).
*/
import { stripHtmlToText } from './htmlUtils'
import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../constants/skillLevels'
import {
EXERCISE_SKILL_INTENSITY_DEFAULT,
normalizeExerciseSkillIntensity,
formatExerciseSkillIntensityLabel,
} from '../constants/exerciseSkillIntensity'
export 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 escapeHtmlText(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** Suchstring → Titel + Skizze für Schnellanlage (erste Zeile / Satz als Titel). */
export function parseSearchQueryForQuickCreate(searchText) {
const raw = String(searchText || '').trim()
if (!raw) return { title: '', sketch: '' }
const lines = raw.split(/\n/).map((l) => l.trim()).filter(Boolean)
if (lines.length >= 2) {
return {
title: lines[0].slice(0, 300),
sketch: lines.slice(1).join('\n'),
}
}
const single = lines[0] || raw
if (single.length <= 80) {
return { title: single.slice(0, 300), sketch: single }
}
const sentenceMatch = single.match(/^(.{10,120}?[.!?;:])\s+(.+)$/s)
if (sentenceMatch) {
return {
title: sentenceMatch[1].trim().slice(0, 300),
sketch: single,
}
}
const words = single.split(/\s+/).filter(Boolean)
const titleWordCount = Math.min(8, Math.max(3, Math.ceil(words.length / 3)))
const title = words.slice(0, titleWordCount).join(' ').slice(0, 120)
return { title, sketch: single }
}
/** Plaintext → Absätze für RichTextEditor / API. */
export function aiPlainTextToMinimalHtml(text) {
const raw = String(text || '').trim()
if (!raw) return ''
const parts = raw.split(/\n+/).map((p) => p.trim()).filter(Boolean)
const paras = parts.length ? parts : [raw]
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('')
}
export function normalizeAiSkillRowFromApi(sug) {
const sid = Number(sug?.skill_id)
if (!Number.isFinite(sid) || sid < 1) return null
return {
skill_id: sid,
intensity: normalizeExerciseSkillIntensity(sug.intensity) || EXERCISE_SKILL_INTENSITY_DEFAULT,
required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen',
target_level:
normalizeSkillLevelSlug(sug.target_level) ||
normalizeSkillLevelSlug(sug.required_level) ||
'grundlagen',
is_primary: !!sug.is_primary,
ai_suggested: true,
}
}
/** Vorschau für Schnellanlage: Kurzfassung + Anleitung + Fähigkeiten. */
export function buildQuickCreateAiPreview(apiRes, { sketchPlain = '' } = {}) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const snapshotInstructions = {
goal: sketchHtml,
execution: '',
preparation: '',
trainer_notes: '',
}
let summaryAfterHtml = null
let summaryAfterPlain = ''
if (apiRes?.summary?.text) {
summaryAfterPlain = String(apiRes.summary.text).trim()
if (summaryAfterPlain) {
summaryAfterHtml = aiPlainTextToMinimalHtml(apiRes.summary.text)
}
}
const skillChoices = []
if (Array.isArray(apiRes?.skills)) {
for (const sug of apiRes.skills) {
const after = normalizeAiSkillRowFromApi(sug)
if (!after) continue
skillChoices.push({
key: String(after.skill_id),
skill_id: after.skill_id,
kind: 'add',
before: null,
after,
include: true,
})
}
}
const instructionChoices = []
const fields = apiRes?.instructions?.fields || {}
for (const def of INSTRUCTION_AI_FIELD_DEFS) {
const afterHtml = fields[def.key]
if (!afterHtml || !String(afterHtml).trim()) continue
const beforeHtml = snapshotInstructions[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 = !!summaryAfterHtml
const hasSkillChoices = skillChoices.length > 0
const hasInstructionChoices = instructionChoices.length > 0
return {
mode: 'quick_create',
applySummary: hasSummaryProposal,
summaryBeforePlain: stripHtmlToText(sketchPlain).trim(),
summaryAfterPlain,
summaryAfterHtml,
skillChoices,
instructionChoices,
hasSummaryProposal,
hasSkillChoices,
hasInstructionChoices,
summaryRequested: true,
skillsRequested: true,
instructionsRequested: true,
}
}
export function describeAiSkillRowForPreview(row, skillsCatalog) {
if (!row) return ''
const sk = (skillsCatalog || []).find((x) => Number(x.id) === Number(row.skill_id))
const name = sk?.name || `Fähigkeit #${row.skill_id}`
const int = formatExerciseSkillIntensityLabel(row.intensity)
const from = formatSkillLevelSlug(row.required_level) || '—'
const to = formatSkillLevelSlug(row.target_level) || '—'
const prim = row.is_primary ? ' · Primär' : ''
return `${name}: Intensität ${int}, Niveau ${from}${to}${prim}`
}
/** KI-Vorschau → bearbeitbarer Entwurf (Rich-Text-Felder). */
export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const instructionFields = {
goal: sketchHtml,
execution: '',
preparation: '',
trainer_notes: '',
}
for (const c of preview?.instructionChoices || []) {
if (c.field && c.include !== false && c.afterHtml) {
instructionFields[c.field] = String(c.afterHtml)
}
}
return {
title: (title || '').trim(),
focusAreaId: focusAreaId != null && focusAreaId !== '' ? String(focusAreaId) : '',
summaryHtml:
preview?.applySummary !== false && preview?.summaryAfterHtml ? String(preview.summaryAfterHtml) : '',
instructionFields,
skillChoices: (preview?.skillChoices || []).map((c) => ({ ...c })),
}
}
/**
* createExercise-Payload aus bearbeitetem Entwurf.
* @throws {Error}
*/
export function buildQuickCreateExercisePayloadFromDraft(draft) {
const title = (draft?.title || '').trim()
if (title.length < 3) {
throw new Error('Titel: mindestens 3 Zeichen.')
}
const fid = Number(draft?.focusAreaId)
if (!Number.isFinite(fid) || fid < 1) {
throw new Error('Fokusbereich ist erforderlich.')
}
const fields = draft?.instructionFields || {}
let goal = (fields.goal || '').trim()
let execution = (fields.execution || '').trim()
const prep = (fields.preparation || '').trim() || null
const trainerNotes = (fields.trainer_notes || '').trim() || null
if (!stripHtmlToText(goal).trim() && !stripHtmlToText(execution).trim()) {
throw new Error('Mindestens Ziel oder Durchführung ausfüllen.')
}
if (!stripHtmlToText(goal).trim()) goal = execution
if (!stripHtmlToText(execution).trim()) execution = goal
let summary = (draft?.summaryHtml || '').trim() || null
if (summary && !stripHtmlToText(summary).trim()) summary = null
const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
return {
title,
summary,
goal,
execution,
preparation: prep,
trainer_notes: trainerNotes,
visibility: 'private',
status: 'draft',
equipment: [],
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
training_styles_multi: [],
training_types_multi: [],
target_groups_multi: [],
age_groups: [],
skills,
club_id: null,
}
}
/**
* createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus).
* @throws {Error}
*/
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const fieldMap = {}
for (const c of preview?.instructionChoices || []) {
if (c.include && c.afterHtml) fieldMap[c.field] = c.afterHtml
}
let goal = (fieldMap.goal || '').trim() || sketchHtml
let execution = (fieldMap.execution || '').trim()
const prep = (fieldMap.preparation || '').trim() || null
const trainerNotes = (fieldMap.trainer_notes || '').trim() || null
if (!stripHtmlToText(goal).trim() && !stripHtmlToText(execution).trim()) {
throw new Error('Mindestens Ziel oder Durchführung muss übernommen werden.')
}
if (!stripHtmlToText(goal).trim()) goal = execution
if (!stripHtmlToText(execution).trim()) execution = goal
let summary = null
if (preview?.applySummary && preview?.summaryAfterHtml) {
summary = preview.summaryAfterHtml
}
const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
const fid = Number(focusAreaId)
if (!Number.isFinite(fid) || fid < 1) {
throw new Error('Fokusbereich ist erforderlich.')
}
return {
title: (title || '').trim(),
summary,
goal,
execution,
preparation: prep,
trainer_notes: trainerNotes,
visibility: 'private',
status: 'draft',
equipment: [],
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
training_styles_multi: [],
training_types_multi: [],
target_groups_multi: [],
age_groups: [],
skills,
club_id: null,
}
}
/** @deprecated Direkt aus API — nutze Preview + buildQuickCreateExercisePayloadFromPreview */
export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlain, apiRes }) {
const preview = buildQuickCreateAiPreview(apiRes, { sketchPlain })
return buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain })
}