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):** **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` - `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 **Autor:** Claude Code
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System **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_summary` | Generiert `exercises.summary` aus goal + execution |
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung | | `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
| `exercise_instruction_rewrite` | Überarbeitet Anleitung: goal, execution, preparation, trainer_notes (JSON, prägnantes HTML) |
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe | | `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix | | `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten | | `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |

View File

@ -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. 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 ### 2.2 Trennung: Semantik vs. Transport
@ -122,23 +122,25 @@ Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter,
```mermaid ```mermaid
flowchart LR flowchart LR
subgraph heute subgraph laufzeit
A[ai_prompts DB] A[ai_prompts DB]
B[prompt_resolver Mustache] B[prompt_resolver Mustache]
C[ai_prompt_runtime Loader + ContextKind] C[ai_prompt_runtime]
D[exercise_ai] J[ai_prompt_job Pydantic]
D[exercise_ai OpenRouter]
end end
A --> B
A --> C A --> C
C --> B
J --> D
C --> D C --> D
B --> D B --> D
``` ```
| Phase | Inhalt | | Phase | Inhalt |
|-------|--------| |-------|--------|
| **P0 (gestartet)** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI nutzt Laufzeit; Platzhalter-Katalog pro Kontext erweiterbar. | | **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. |
| **P1** | Einheitliche `run_ai_job`-Fassade (Slug + Kind + Pydantic-Payload + Validierung); Router nur noch dünne Adapter. | | **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; optionale Modell-/Temperatur-Overrides pro Slug in DB oder Config-Tabelle. | | **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. | | **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. | | **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` - Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md`
- Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md` - Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md`
- Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.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_API_KEY=your_api_key_here
OPENROUTER_MODEL=anthropic/claude-sonnet-4 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. # Ü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). # 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 __future__ import annotations
from enum import Enum 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_AI_SLUGS = frozenset(
{ {
"exercise_summary", "exercise_summary",
"exercise_skill_suggestions", "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: if active_only:
cur.execute( cur.execute(
""" """
SELECT slug, display_name, template, output_format, active SELECT slug, display_name, template, output_format, active, openrouter_model
FROM ai_prompts FROM ai_prompts
WHERE slug = %s AND active = true WHERE slug = %s AND active = true
""", """,
@ -52,7 +55,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
else: else:
cur.execute( cur.execute(
""" """
SELECT slug, display_name, template, output_format, active SELECT slug, display_name, template, output_format, active, openrouter_model
FROM ai_prompts FROM ai_prompts
WHERE slug = %s WHERE slug = %s
""", """,
@ -67,8 +70,45 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
return d 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__ = [ __all__ = [
"AiPromptContextKind", "AiPromptContextKind",
"AiPromptUnavailableError",
"context_kind_for_slug", "context_kind_for_slug",
"load_ai_prompt_row", "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 from __future__ import annotations
import copy import copy
import html
import json import json
import logging import logging
import math import math
@ -16,10 +17,17 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence,
from fastapi import HTTPException 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 ai_prompt_context import ExerciseFormAiPromptContext
from prompt_resolver import render_mustache_template from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
from exercise_rich_text import collect_inline_exercise_media_ids, normalize_inline_exercise_media_markup
_LOGGER = logging.getLogger("shinkan.exercise_ai") _LOGGER = logging.getLogger("shinkan.exercise_ai")
@ -491,6 +499,146 @@ def build_contextual_skills_catalog_block(
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)" return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
_MAX_INSTRUCTION_GOAL_PLAIN = 4_000
_MAX_INSTRUCTION_EXECUTION_PLAIN = 12_000
_MAX_INSTRUCTION_PREP_PLAIN = 2_500
_MAX_INSTRUCTION_TRAINER_PLAIN = 2_500
_INSTRUCTION_JSON_KEYS = ("goal", "execution", "preparation", "trainer_notes")
_INSTRUCTION_FIELD_MAX_PLAIN = {
"goal": _MAX_INSTRUCTION_GOAL_PLAIN,
"execution": _MAX_INSTRUCTION_EXECUTION_PLAIN,
"preparation": _MAX_INSTRUCTION_PREP_PLAIN,
"trainer_notes": _MAX_INSTRUCTION_TRAINER_PLAIN,
}
_DISALLOWED_HTML_TAG_RE = re.compile(
r"</?\s*(?!p\b|ul\b|ol\b|li\b|strong\b|b\b|em\b|i\b|br\b|span\b)[a-zA-Z][^>]*>",
re.IGNORECASE,
)
_SCRIPT_STYLE_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
def _plain_to_minimal_instruction_html(text: str) -> str:
raw = (text or "").strip()
if not raw:
return ""
parts = [p.strip() for p in re.split(r"\n+", raw) if p.strip()]
if not parts:
return ""
return "".join(f"<p>{html.escape(p)}</p>" for p in parts)
def _truncate_plain(text: str, max_len: int) -> str:
t = (text or "").strip()
if len(t) <= max_len:
return t
return t[: max_len - 1].rstrip() + ""
def _sanitize_instruction_field_html(raw: Any, *, max_plain: int) -> str:
if raw is None:
return ""
s = str(raw).strip()
if not s:
return ""
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
if s.endswith("```"):
s = s[:-3].strip()
s = _SCRIPT_STYLE_RE.sub("", s)
s = _DISALLOWED_HTML_TAG_RE.sub("", s)
if "<" not in s:
s = _plain_to_minimal_instruction_html(s)
else:
s = normalize_inline_exercise_media_markup(s) or ""
plain = strip_html_to_plain(s, max_len=max_plain + 200)
if len(plain) > max_plain:
plain = _truncate_plain(plain, max_plain)
s = _plain_to_minimal_instruction_html(plain)
return (normalize_inline_exercise_media_markup(s) or "").strip()
def _merge_preserved_inline_media(original: Optional[str], revised: str) -> str:
"""Haengt fehlende Medien-Verweise aus dem Ausgangstext ans Ende an."""
out = (revised or "").strip()
orig_ids = collect_inline_exercise_media_ids(original)
if not orig_ids:
return out
new_ids = collect_inline_exercise_media_ids(out)
missing = sorted(orig_ids - new_ids)
if not missing:
return out
spans = []
for mid in missing:
spans.append(
f'<span data-shinkan-exercise-media="{mid}" data-shinkan-exercise-media-size="medium" '
f'class="shinkan-inline-media"></span>'
)
block = f"<p>{''.join(spans)}</p>"
return (out + block).strip() if out else block
def _first_balanced_json_object(text: str) -> Optional[str]:
i = text.find("{")
if i < 0:
return None
depth = 0
in_str = False
esc = False
for j in range(i, len(text)):
ch = text[j]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return text[i : j + 1]
return None
def _extract_instruction_rewrite_object(text: str) -> Dict[str, Any]:
s = (text or "").strip()
if not s:
raise ValueError("leer")
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
if s.endswith("```"):
s = s[:-3].strip()
frag = _first_balanced_json_object(s)
if frag:
s = frag
obj = json.loads(s)
if not isinstance(obj, dict):
raise ValueError("kein JSON-Objekt")
return obj
def _sanitize_instruction_rewrite_payload(
parsed: Mapping[str, Any],
*,
originals: Mapping[str, Optional[str]],
) -> Dict[str, str]:
out: Dict[str, str] = {}
for key in _INSTRUCTION_JSON_KEYS:
max_plain = _INSTRUCTION_FIELD_MAX_PLAIN[key]
html = _sanitize_instruction_field_html(parsed.get(key), max_plain=max_plain)
html = _merge_preserved_inline_media(originals.get(key), html)
out[key] = html
return out
def build_exercise_placeholder_variables( def build_exercise_placeholder_variables(
cur, cur,
*, *,
@ -500,6 +648,8 @@ def build_exercise_placeholder_variables(
execution: Optional[str], execution: Optional[str],
focus_area_hint: Optional[str], focus_area_hint: Optional[str],
focus_areas_context: Optional[Sequence[Tuple[int, bool]]], focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
preparation: Optional[str] = None,
trainer_notes: Optional[str] = None,
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI. Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
@ -509,6 +659,8 @@ def build_exercise_placeholder_variables(
return {} return {}
g_plain = strip_html_to_plain(goal) g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution) e_plain = strip_html_to_plain(execution)
p_plain = strip_html_to_plain(preparation)
n_plain = strip_html_to_plain(trainer_notes)
t_title = (title or "").strip() t_title = (title or "").strip()
focus = (focus_area_hint or "").strip() focus = (focus_area_hint or "").strip()
ctx: Dict[str, str] = { ctx: Dict[str, str] = {
@ -516,8 +668,12 @@ def build_exercise_placeholder_variables(
"exercise_focus_area": focus or "-", "exercise_focus_area": focus or "-",
"exercise_goal": g_plain or "-", "exercise_goal": g_plain or "-",
"exercise_execution": e_plain or "-", "exercise_execution": e_plain or "-",
"exercise_preparation": p_plain or "-",
"exercise_trainer_notes": n_plain or "-",
} }
if s == "exercise_summary": if s == "exercise_summary":
return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")}
if s == "exercise_instruction_rewrite":
return ctx return ctx
if s == "exercise_skill_suggestions": if s == "exercise_skill_suggestions":
catalog = build_contextual_skills_catalog_block( catalog = build_contextual_skills_catalog_block(
@ -664,32 +820,43 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
return out[:5] return out[:5]
def _require_openrouter() -> Tuple[str, str]: def _require_openrouter_key() -> str:
key, model = normalize_openrouter_env() key, _ = normalize_openrouter_env()
if not key: if not key:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).", detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
) )
return key, model return key
def run_exercise_ai_suggestion( def run_exercise_ai_suggestion(
cur, cur,
*, *,
title: Optional[str], form_ctx: ExerciseFormAiPromptContext,
goal: Optional[str],
execution: Optional[str],
focus_area_hint: Optional[str],
focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None,
want_summary: bool, want_summary: bool,
want_skills: bool, want_skills: bool,
want_instructions: bool = False,
) -> Dict[str, Any]: ) -> 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) g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution) e_plain = strip_html_to_plain(execution)
if not (g_plain.strip() or e_plain.strip()): if want_instructions:
if not form_ctx.has_instruction_source_text():
raise HTTPException(
status_code=400,
detail="Fuer Anleitungs-Ueberarbeitung mindestens Titel oder ein Anleitungsfeld ausfuellen.",
)
elif not (g_plain.strip() or e_plain.strip()):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).", detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).",
@ -698,26 +865,25 @@ def run_exercise_ai_suggestion(
t_title = (title or "").strip() t_title = (title or "").strip()
focus = (focus_area_hint 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(): if _ai_debug_on():
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context)) fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
_LOGGER.warning( _LOGGER.warning(
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s " "AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s want_instructions=%s "
"exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]", "title_chars=%s goal_plain_chars=%s exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
want_summary, want_summary,
want_skills, want_skills,
len(t_title), want_instructions,
len((title or "").strip()),
len(g_plain), len(g_plain),
len(e_plain), len(e_plain),
len(focus), len((focus_area_hint or "").strip()),
fid_list, fid_list,
) )
if want_summary: 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: try:
ctx = build_exercise_placeholder_variables( ctx = build_exercise_placeholder_variables(
cur, cur,
@ -730,7 +896,15 @@ def run_exercise_ai_suggestion(
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from 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 prompt = rendered.text
if _ai_debug_on(): if _ai_debug_on():
_LOGGER.warning( _LOGGER.warning(
@ -739,7 +913,7 @@ def run_exercise_ai_suggestion(
len(rendered.placeholders_remaining), len(rendered.placeholders_remaining),
) )
try: 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: except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
if _ai_debug_on(): if _ai_debug_on():
@ -752,15 +926,9 @@ def run_exercise_ai_suggestion(
) )
if len(text) > _MAX_SUMMARY_CHARS: if len(text) > _MAX_SUMMARY_CHARS:
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "" 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: 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: try:
ctx = build_exercise_placeholder_variables( ctx = build_exercise_placeholder_variables(
cur, cur,
@ -773,7 +941,15 @@ def run_exercise_ai_suggestion(
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from 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 prompt = rendered.text
if _ai_debug_on(): if _ai_debug_on():
_LOGGER.warning( _LOGGER.warning(
@ -789,7 +965,7 @@ def run_exercise_ai_suggestion(
try: try:
raw = openrouter_chat_completion( raw = openrouter_chat_completion(
api_key=key, api_key=key,
model=model, model=model_skills,
user_content=prompt, user_content=prompt,
system_content=sys_hint, system_content=sys_hint,
temperature=0.15, temperature=0.15,
@ -827,6 +1003,97 @@ def run_exercise_ai_suggestion(
result["skills"] = skills result["skills"] = skills
if want_instructions:
try:
ctx = build_exercise_placeholder_variables(
cur,
slug="exercise_instruction_rewrite",
title=title,
goal=goal,
execution=execution,
preparation=preparation,
trainer_notes=trainer_notes,
focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context,
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
try:
irow, rendered = load_and_render_ai_prompt(cur, "exercise_instruction_rewrite", ctx)
except AiPromptUnavailableError:
raise HTTPException(
status_code=503,
detail="Prompt exercise_instruction_rewrite nicht aktiv oder fehlt in DB.",
) from None
model_instr = effective_openrouter_model_for_prompt_row(irow)
models_by_slug["exercise_instruction_rewrite"] = model_instr
prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
"AI_DEBUG exercise_ai instructions prompt_slug=exercise_instruction_rewrite prompt_chars=%s",
len(prompt),
)
sys_hint = (
"Du antwortest nur mit validem JSON-Objekt (Schluessel goal, execution, preparation, trainer_notes). "
"Keine Kommentare ausserhalb des JSON."
)
try:
raw = openrouter_chat_completion(
api_key=key,
model=model_instr,
user_content=prompt,
system_content=sys_hint,
temperature=0.2,
)
except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
body = (raw or "").strip()
if not body:
raise HTTPException(
status_code=502,
detail="OpenRouter/KI lieferte leeren Inhalt fuer Anleitungs-Ueberarbeitung.",
)
try:
parsed = _extract_instruction_rewrite_object(body)
except (json.JSONDecodeError, ValueError) as e:
if _ai_debug_on():
_LOGGER.warning(
"AI_DEBUG exercise_ai instructions JSON parse_failed err=%s head=%s",
e,
(body.replace("\r", "").replace("\n", " ").strip())[:400],
)
raise HTTPException(
status_code=502,
detail="KI lieferte kein verwertbares JSON fuer die Anleitung.",
) from e
originals = {
"goal": goal,
"execution": execution,
"preparation": preparation,
"trainer_notes": trainer_notes,
}
fields = _sanitize_instruction_rewrite_payload(parsed, originals=originals)
if not any((fields.get(k) or "").strip() for k in _INSTRUCTION_JSON_KEYS):
raise HTTPException(
status_code=502,
detail="KI lieferte leere Anleitungs-Felder.",
)
result["instructions"] = {
"fields": fields,
"ai_generated": True,
"model": model_instr,
}
result["models_by_slug"] = models_by_slug
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 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 json
import logging import logging
import os import os
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Mapping, Optional
import httpx import httpx
@ -203,3 +203,22 @@ def normalize_openrouter_env() -> tuple[str, str]:
key = (os.getenv("OPENROUTER_API_KEY") or "").strip() key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip() model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
return key, model 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", "key": "exercise_title",
"placeholder": "{{exercise_title}}", "placeholder": "{{exercise_title}}",
"description": "Titel der Uebung (oder Platzhalter, wenn leer).", "description": "Titel der Uebung (oder Platzhalter, wenn leer).",
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
}, },
{ {
"key": "exercise_focus_area", "key": "exercise_focus_area",
"placeholder": "{{exercise_focus_area}}", "placeholder": "{{exercise_focus_area}}",
"description": "Fokuskontext (Text-Hinweis aus Formular, optional).", "description": "Fokuskontext (Text-Hinweis aus Formular, optional).",
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
}, },
{ {
"key": "exercise_goal", "key": "exercise_goal",
"placeholder": "{{exercise_goal}}", "placeholder": "{{exercise_goal}}",
"description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.", "description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.",
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
}, },
{ {
"key": "exercise_execution", "key": "exercise_execution",
"placeholder": "{{exercise_execution}}", "placeholder": "{{exercise_execution}}",
"description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.", "description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.",
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"], "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
},
{
"key": "exercise_preparation",
"placeholder": "{{exercise_preparation}}",
"description": "Vorbereitung/Aufbau als Plaintext ohne HTML.",
"used_by_slugs": ["exercise_instruction_rewrite"],
},
{
"key": "exercise_trainer_notes",
"placeholder": "{{exercise_trainer_notes}}",
"description": "Trainer-Hinweise als Plaintext ohne HTML.",
"used_by_slugs": ["exercise_instruction_rewrite"],
}, },
{ {
"key": "skills_catalog", "key": "skills_catalog",

View File

@ -12,9 +12,11 @@ from pydantic import BaseModel, Field
from auth import require_auth from auth import require_auth
from club_tenancy import is_superadmin 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 db import get_cursor, get_db, r2d
from exercise_ai import build_exercise_placeholder_variables from prompt_resolver import exercise_placeholder_catalog
from prompt_resolver import exercise_placeholder_catalog, render_mustache_template
router = APIRouter(tags=["admin_ai_prompts"]) router = APIRouter(tags=["admin_ai_prompts"])
@ -39,7 +41,7 @@ def _fetch_prompt_any(cur, prompt_id: int) -> Dict[str, Any]:
cur.execute( cur.execute(
""" """
SELECT id, slug, display_name, description, template, category, output_format, 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 active, sort_order, created_at, updated_at
FROM ai_prompts WHERE id = %s FROM ai_prompts WHERE id = %s
""", """,
@ -56,19 +58,11 @@ class AiPromptUpdateBody(BaseModel):
active: Optional[bool] = None active: Optional[bool] = None
display_name: Optional[str] = Field(None, max_length=200) display_name: Optional[str] = Field(None, max_length=200)
description: Optional[str] = Field(None, max_length=8000) description: Optional[str] = Field(None, max_length=8000)
openrouter_model: Optional[str] = Field(None, max_length=200)
class AiPromptPreviewFocus(BaseModel): class AiPromptPreviewBody(ExerciseFormAiPromptContext):
focus_area_id: int = Field(..., ge=1) """Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint)."""
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
@router.get("/api/admin/ai-prompts/catalog/placeholders") @router.get("/api/admin/ai-prompts/catalog/placeholders")
@ -85,7 +79,7 @@ def list_ai_prompts(session: dict = Depends(_require_superadmin)):
cur.execute( cur.execute(
""" """
SELECT id, slug, display_name, description, category, output_format, active, 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 FROM ai_prompts
ORDER BY sort_order ASC NULLS LAST, id ASC 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 = body.description if body.description is not None else old.get("description") or ""
next_desc = (next_desc or "").strip() 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( cur.execute(
""" """
UPDATE ai_prompts 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 WHERE id = %s
RETURNING id, slug, display_name, description, template, category, output_format, 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 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()) row = dict(cur.fetchone())
conn.commit() 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() SET template = default_template, updated_at = NOW()
WHERE id = %s AND default_template IS NOT NULL WHERE id = %s AND default_template IS NOT NULL
RETURNING id, slug, display_name, description, template, category, output_format, 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 created_at, updated_at
""", """,
(prompt_id,), (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.") raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
row = _fetch_prompt_any(cur, prompt_id) row = _fetch_prompt_any(cur, prompt_id)
slug = (row.get("slug") or "").strip().lower() 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] vars_map: Dict[str, str]
warn: Optional[str] = None warn: Optional[str] = None
if slug in ("exercise_summary", "exercise_skill_suggestions"): if slug in ("exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"):
try: try:
vars_map = build_exercise_placeholder_variables( vars_map = resolve_exercise_form_variables(cur, slug, body)
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,
)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
elif slug == "pipeline": elif slug == "pipeline":
@ -242,7 +230,7 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
vars_map = {} vars_map = {}
warn = f"Slug {slug!r}: noch kein Vorschau-Kontext definiert — Roh-Template ohne Ersetzung." 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 { return {
"slug": slug, "slug": slug,
"resolved_template": rendered.text, "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_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_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 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 ( from exercise_rich_text import (
RICH_HTML_EXERCISE_FIELDS, RICH_HTML_EXERCISE_FIELDS,
@ -358,31 +359,44 @@ class ExerciseMediaFromAsset(BaseModel):
media_type: Optional[str] = None media_type: Optional[str] = None
class ExerciseAiFocusCtx(BaseModel): class ExerciseAiFocusCtx(ExerciseFormAiFocusRow):
"""Fokusbereich fuer Skill-Kataloggewichte (Migration 068 ai_skill_retrieval_profiles).""" """Alias fuer OpenAPI — identisch zu ExerciseFormAiFocusRow."""
focus_area_id: int = Field(..., ge=1)
is_primary: Optional[bool] = False
class ExerciseAiSuggestBody(BaseModel): class ExerciseAiSuggestBody(BaseModel):
title: Optional[str] = Field(None, max_length=300) title: Optional[str] = Field(None, max_length=300)
goal: Optional[str] = Field(None, max_length=64000) goal: Optional[str] = Field(None, max_length=64000)
execution: Optional[str] = Field(None, max_length=128000) execution: Optional[str] = Field(None, max_length=128000)
preparation: Optional[str] = Field(None, max_length=64000)
trainer_notes: Optional[str] = Field(None, max_length=64000)
focus_area_hint: Optional[str] = Field(None, max_length=1200) focus_area_hint: Optional[str] = Field(None, max_length=1200)
focus_areas_context: Optional[list[ExerciseAiFocusCtx]] = Field( focus_areas_context: Optional[list[ExerciseFormAiFocusRow]] = Field(
None, None,
description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung", description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung",
) )
include_summary: bool = True include_summary: bool = True
include_skills: bool = True include_skills: bool = True
include_instructions: bool = False
@model_validator(mode="after") @model_validator(mode="after")
def check_include_any(self): def check_include_any(self):
if not self.include_summary and not self.include_skills: if not self.include_summary and not self.include_skills and not self.include_instructions:
raise ValueError("Mindestens include_summary oder include_skills aktivieren.") raise ValueError(
"Mindestens include_summary, include_skills oder include_instructions aktivieren."
)
return self return self
def to_form_context(self) -> ExerciseFormAiPromptContext:
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): class ExerciseAiRegenerateBody(BaseModel):
"""Welche Artefakte neu angefragt werden sollen.""" """Welche Artefakte neu angefragt werden sollen."""
@ -391,7 +405,7 @@ class ExerciseAiRegenerateBody(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def normalize_regs(self): def normalize_regs(self):
allowed = {"summary", "skills"} allowed = {"summary", "skills", "instructions"}
raw = [str(x).strip().lower() for x in (self.regenerate or [])] raw = [str(x).strip().lower() for x in (self.regenerate or [])]
out = [] out = []
seen = set() seen = set()
@ -2306,19 +2320,12 @@ def exercise_ai_suggest_endpoint(
_ = tenant.profile_id _ = tenant.profile_id
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
fctx = None payload = run_exercise_form_ai_suggestion(
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(
cur, cur,
title=(body.title or "").strip(), body.to_form_context(),
goal=body.goal,
execution=body.execution,
focus_area_hint=(body.focus_area_hint or "").strip() or None,
focus_areas_context=fctx,
want_summary=body.include_summary, want_summary=body.include_summary,
want_skills=body.include_skills, want_skills=body.include_skills,
want_instructions=body.include_instructions,
) )
return payload return payload
@ -2332,6 +2339,7 @@ def exercise_ai_regenerate_endpoint(
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT).""" """Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
want_summary = "summary" in body.regenerate want_summary = "summary" in body.regenerate
want_skills = "skills" in body.regenerate want_skills = "skills" in body.regenerate
want_instructions = "instructions" in body.regenerate
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -2344,15 +2352,21 @@ def exercise_ai_regenerate_endpoint(
focus = _focus_area_hint_from_detail(exercise) focus = _focus_area_hint_from_detail(exercise)
fctx = _focus_areas_ai_ctx_from_detail(exercise) fctx = _focus_areas_ai_ctx_from_detail(exercise)
payload = run_exercise_ai_suggestion( ctx = ExerciseFormAiPromptContext.from_focus_tuples(
cur,
title=str(exercise.get("title") or "").strip(), title=str(exercise.get("title") or "").strip(),
goal=exercise.get("goal"), goal=exercise.get("goal"),
execution=exercise.get("execution"), execution=exercise.get("execution"),
focus_area_hint=focus or None, preparation=exercise.get("preparation"),
focus_areas_context=fctx or None, 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_summary=want_summary,
want_skills=want_skills, want_skills=want_skills,
want_instructions=want_instructions,
) )
return payload return payload

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.159" APP_VERSION = "0.8.166"
BUILD_DATE = "2026-05-30" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260530069" DB_SCHEMA_VERSION = "20260531071"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -19,13 +19,15 @@ MODULE_VERSIONS = {
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold) "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 "media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068) "admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
"admin_ai_prompts": "1.0.1", # Prompt-Pflege + Zielarchitektur-Doku; gemeinsamer DB-Load uber ai_prompt_runtime "admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"ai_prompt_runtime": "0.1.0", # AiPromptContextKind, load_ai_prompt_row — Erweiterung Planung ohne Zirkel zu exercise_ai "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", "groups": "0.1.0",
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.31.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_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -40,6 +42,63 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.159",
"date": "2026-05-30", "date": "2026-05-30",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-30 **Stand:** 2026-05-31
**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`). **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**. 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`) - **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions - **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.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. - **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0P4.
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2 - **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
- **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 - **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter
- **DB:** Migration **`067`** **`ai_prompts`** (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) - **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
- **`exercise_ai`:** Gewichtungen, KategorieAnteilCaps (~Token), Keyword-Patches aus Ziel/Durchführung (z.B. Rollenspiel vs. Befreiung/Haltegriff) - **Prompt-Slugs:** `exercise_summary`, `exercise_skill_suggestions`, **`exercise_instruction_rewrite`** (Anleitung JSON, prägnant, HTML p/ul/ol/li)
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas`**Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`), **`/api/admin/ai-prompts*`** (`routers/ai_prompts_admin.py`), UI **`/admin/ai-prompts`** - **API:** `POST /api/exercises/ai/suggest`**`include_instructions`**, Body **`preparation`**, **`trainer_notes`**; Response **`instructions.fields`**; **`POST …/ai/regenerate`** mit **`instructions`** in `regenerate`
- **Diagnose bei leerem Dialog / Fehlern:** Umgebungsvariable **`SHINKAN_AI_DEBUG=1`** auf der API; in den Logs dann **`AI_DEBUG`** (`shinkan.exercise_ai`) und **`[AI_DEBUG/openrouter]`** (`shinkan.openrouter`) mit Prompt-Längen, Token-Zahlen und ggf. JSON-Parse-Anfang - **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`**
- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiPromptsPage.jsx`** (`/admin/ai-prompts`), **`AdminAiSkillRetrievalPage.jsx`** (`/admin/ai-skill-retrieval`) - **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter`
- **Frontend 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 { .exercises-page__title {
margin: 0; 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 { .exercises-page-toolbar-tabs {
margin-bottom: 14px; 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 SkillTreeMultiSelect from './SkillTreeMultiSelect'
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker' import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
import CatalogRulePicker from './CatalogRulePicker' 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 PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS } 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({ export default function ExercisePickerModal({
open, open,
onClose, onClose,
@ -57,12 +61,21 @@ export default function ExercisePickerModal({
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [multiPicked, setMultiPicked] = useState([]) const [multiPicked, setMultiPicked] = useState([])
const [quickOpen, setQuickOpen] = useState(false)
const [quickTitle, setQuickTitle] = useState('')
const [quickSummary, setQuickSummary] = useState('')
const [quickSaving, setQuickSaving] = useState(false) const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const pickerScrollRef = useRef(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) => { const toggleMultiPick = (ex) => {
setMultiPicked((prev) => setMultiPicked((prev) =>
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex] 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) return () => clearTimeout(t)
}, [aiSearchInput]) }, [aiSearchInput])
const showQuickCreateOffer =
enableQuickCreateDraft &&
catalogsReady &&
!loading &&
debouncedSearch.length >= 3 &&
list.length === 0
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
let cancelled = false let cancelled = false
@ -122,10 +142,10 @@ export default function ExercisePickerModal({
setList([]) setList([])
setHasMore(false) setHasMore(false)
setMultiPicked([]) setMultiPicked([])
setQuickOpen(false) resetQuickCreateFields()
setQuickTitle('')
setQuickSummary('')
setQuickSaving(false) setQuickSaving(false)
setQuickAiError('')
setQuickCreateDraft(null)
return return
} }
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
@ -285,43 +305,83 @@ export default function ExercisePickerModal({
const resetFilters = () => setFilters({ ...INITIAL_FILTERS }) 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() const title = (quickTitle || '').trim()
if (title.length < 3) { if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.') alert('Titel: mindestens 3 Zeichen.')
return 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) setQuickSaving(true)
try { try {
const created = await api.createExercise({ const aiRes = await api.suggestExerciseAi({
title, title,
summary: summaryRaw || null, goal: sketch || undefined,
goal: QUICK_CREATE_GOAL_PLACEHOLDER, execution: '',
execution: null, preparation: '',
visibility: 'private', trainer_notes: '',
status: 'draft', focus_area_hint: focusHint || undefined,
equipment: [], focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
focus_areas_multi: [], include_summary: true,
training_styles_multi: [], include_skills: true,
training_types_multi: [], include_instructions: true,
target_groups_multi: [],
age_groups: [],
skills: [],
club_id: null,
}) })
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') { setQuickCreateDraft(
await Promise.resolve(onSelectExercises([created])) aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
} else if (typeof onSelectExercise === 'function') { )
await Promise.resolve(onSelectExercise(created))
}
onClose()
} catch (e) { } catch (e) {
console.error(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 { } finally {
setQuickSaving(false) setQuickSaving(false)
} }
@ -353,75 +413,6 @@ export default function ExercisePickerModal({
</button> </button>
</div> </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={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'grid', gap: '0.65rem' }}> <div style={{ display: 'grid', gap: '0.65rem' }}>
<div> <div>
@ -602,7 +593,28 @@ export default function ExercisePickerModal({
<div className="spinner" /> <div className="spinner" />
</div> </div>
) : list.length === 0 ? ( ) : 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 }}> <p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
@ -770,6 +782,20 @@ export default function ExercisePickerModal({
)} )}
</div> </div>
</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> </div>
) )
} }

View File

@ -90,6 +90,13 @@ function aiPlainSummaryToMinimalHtml(text) {
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('') return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('')
} }
const INSTRUCTION_AI_FIELD_DEFS = [
{ key: 'goal', label: 'Ziel' },
{ key: 'execution', label: 'Durchführung' },
{ key: 'preparation', label: 'Vorbereitung / Aufbau' },
{ key: 'trainer_notes', label: 'Hinweise für Trainer' },
]
function cloneExerciseSkillRows(rows) { function cloneExerciseSkillRows(rows) {
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : [] return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
} }
@ -110,9 +117,16 @@ function buildNormalizedAiSkillRowFromApi(sug) {
} }
} }
function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotSkills, apiRes }) { function buildExerciseAiSuggestionPreview({
const summaryRequested = mode !== 'skills' mode,
const skillsRequested = mode !== 'summary' snapshotSummaryHtml,
snapshotSkills,
snapshotInstructions,
apiRes,
}) {
const summaryRequested = mode !== 'skills' && mode !== 'instructions'
const skillsRequested = mode !== 'summary' && mode !== 'instructions'
const instructionsRequested = mode === 'instructions'
let summaryAfterHtml = null let summaryAfterHtml = null
let summaryAfterPlain = '' let summaryAfterPlain = ''
@ -141,8 +155,29 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS
} }
} }
const instructionChoices = []
if (instructionsRequested && apiRes.instructions?.fields) {
const fields = apiRes.instructions.fields
const snap = snapshotInstructions || {}
for (const def of INSTRUCTION_AI_FIELD_DEFS) {
const afterHtml = fields[def.key]
if (!afterHtml || !String(afterHtml).trim()) continue
const beforeHtml = snap[def.key] || ''
instructionChoices.push({
key: def.key,
field: def.key,
label: def.label,
beforePlain: stripHtmlToText(beforeHtml).trim(),
afterHtml: String(afterHtml),
afterPlain: stripHtmlToText(afterHtml).trim(),
include: true,
})
}
}
const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml) const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
const hasSkillChoices = skillChoices.length > 0 const hasSkillChoices = skillChoices.length > 0
const hasInstructionChoices = instructionChoices.length > 0
return { return {
mode, mode,
@ -151,10 +186,13 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS
summaryAfterPlain, summaryAfterPlain,
summaryAfterHtml, summaryAfterHtml,
skillChoices, skillChoices,
instructionChoices,
hasSummaryProposal, hasSummaryProposal,
hasSkillChoices, hasSkillChoices,
hasInstructionChoices,
summaryRequested, summaryRequested,
skillsRequested, skillsRequested,
instructionsRequested,
} }
} }
@ -1027,20 +1065,96 @@ function ExerciseFormPageRoot() {
} }
} }
const runExerciseAiInstructionRewrite = async () => {
const title = (formData.title || '').trim()
const snapshotInstructions = {
goal: formData.goal || '',
execution: formData.execution || '',
preparation: formData.preparation || '',
trainer_notes: formData.trainer_notes || '',
}
const hasSource =
!!title ||
Object.values(snapshotInstructions).some((html) => stripHtmlToText(html || '').trim())
if (!hasSource) {
toast.error('Titel oder mindestens ein Anleitungsfeld ausfüllen.')
return
}
const focusHint = (formData.focus_areas_multi || [])
.map((row) => {
const id = row?.focus_area_id
const fa = focusAreas.find((x) => Number(x.id) === Number(id))
return (fa?.name || '').trim()
})
.filter(Boolean)
.join(', ')
const focusAreasContext = [...(formData.focus_areas_multi || [])]
.map((row) => ({
focus_area_id: Number(row?.focus_area_id),
is_primary: !!row?.is_primary,
}))
.filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1)
.sort((a, b) => {
const p = Number(!!b.is_primary) - Number(!!a.is_primary)
if (p !== 0) return p
return a.focus_area_id - b.focus_area_id
})
setAiSuggestionPreview(null)
setAiSuggestBusy(true)
try {
const res = await api.suggestExerciseAi({
title,
goal: snapshotInstructions.goal,
execution: snapshotInstructions.execution,
preparation: snapshotInstructions.preparation,
trainer_notes: snapshotInstructions.trainer_notes,
focus_area_hint: focusHint || undefined,
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
include_summary: false,
include_skills: false,
include_instructions: true,
})
const preview = buildExerciseAiSuggestionPreview({
mode: 'instructions',
snapshotInstructions,
apiRes: res,
})
if (!preview.hasInstructionChoices) {
toast.info('Die KI lieferte keinen verwertbaren Anleitungs-Vorschlag.')
return
}
setAiSuggestionPreview(preview)
} catch (err) {
toast.error(err?.message || String(err))
} finally {
setAiSuggestBusy(false)
}
}
const applyExerciseAiSuggestionPreview = () => { const applyExerciseAiSuggestionPreview = () => {
const p = aiSuggestionPreview const p = aiSuggestionPreview
if (!p) return if (!p) return
const takeSummary = !!(p.applySummary && p.summaryAfterHtml) const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after) const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after)
const instrToApply = (p.instructionChoices || []).filter((c) => c.include && c.afterHtml)
if (!takeSummary && skillsToMerge.length === 0) { if (!takeSummary && skillsToMerge.length === 0 && instrToApply.length === 0) {
toast.error('Bitte mindestens eine Kurzfassung oder eine Fähigkeit zur Übernahme auswählen.') toast.error('Bitte mindestens einen Vorschlag zur Übernahme auswählen.')
return return
} }
if (takeSummary) { if (takeSummary) {
updateFormField('summary', p.summaryAfterHtml) updateFormField('summary', p.summaryAfterHtml)
} }
for (const c of instrToApply) {
updateFormField(c.field, c.afterHtml)
}
if (skillsToMerge.length > 0) { if (skillsToMerge.length > 0) {
setFormDirty(true) setFormDirty(true)
setFormData((prev) => { setFormData((prev) => {
@ -2145,6 +2259,29 @@ function ExerciseFormPageRoot() {
title="Anleitung" title="Anleitung"
hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)." hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)."
> >
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginBottom: '12px',
alignItems: 'center',
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiInstructionRewrite()}
>
KI: Anleitung überarbeiten
</button>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
Überarbeitet Ziel, Durchführung, Vorbereitung und Trainer-Hinweise prägnant und strukturiert. Vorschau
im Dialog; nichts wird automatisch gespeichert.
</span>
</div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Ziel *</label> <label className="form-label">Ziel *</label>
<RichTextEditor <RichTextEditor
@ -2780,7 +2917,13 @@ function ExerciseFormPageRoot() {
minHeight: '72px', minHeight: '72px',
} }
const canApplySomething = const canApplySomething =
(p.applySummary && p.summaryAfterHtml) || p.skillChoices.some((c) => c.include) (p.applySummary && p.summaryAfterHtml) ||
p.skillChoices.some((c) => c.include) ||
(p.instructionChoices || []).some((c) => c.include && c.afterHtml)
const dialogTitle =
p.instructionsRequested
? 'KI: Anleitung überarbeiten'
: 'KI-Vorschlag übernehmen'
return ( return (
<div <div
role="dialog" role="dialog"
@ -2808,11 +2951,94 @@ function ExerciseFormPageRoot() {
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>KI-Vorschlag übernehmen</h3> <h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}> <p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>
Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert. {p.instructionsRequested
? 'Vergleichen und nur die gewünschten Felder übernehmen. Eingebettete Medien bleiben erhalten, wenn die KI sie nicht erwähnt.'
: 'Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.'}
</p> </p>
{p.hasInstructionChoices ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-instructions-heading">
<div
id="ai-preview-instructions-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Anleitung ({p.instructionChoices.length}{' '}
{p.instructionChoices.length === 1 ? 'Feld' : 'Felder'})
</div>
{p.instructionChoices.map((c) => (
<div
key={c.key}
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '12px',
marginBottom: '12px',
background: 'var(--surface)',
}}
>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
}}
>
<input
type="checkbox"
checked={c.include}
onChange={(e) =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
instructionChoices: prev.instructionChoices.map((x) =>
x.key === c.key ? { ...x, include: e.target.checked } : x,
),
}
: prev,
)
}
/>
{c.label} übernehmen
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
gap: '12px',
}}
>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
Aktuell (Plaintext)
</div>
<div style={summaryBoxSx}>{c.beforePlain || '(leer)'}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
KI-Vorschlag
</div>
<div
style={{
...summaryBoxSx,
borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))',
}}
>
{c.afterPlain || '(leer)'}
</div>
</div>
</div>
</div>
))}
</section>
) : null}
{p.hasSummaryProposal ? ( {p.hasSummaryProposal ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading"> <section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
<div <div
@ -3032,7 +3258,7 @@ function ExerciseFormPageRoot() {
/> />
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}> <p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
<strong>KI-Unterstützung:</strong> OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung <strong>KI-Unterstützung:</strong> OpenRouter-Vorschläge für Kurzfassung, Fähigkeiten und Anleitung
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern (<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern
wie gewohnt. wie gewohnt.
</p> </p>

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react' import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../../utils/api' import api from '../../utils/api'
import { useAuth } from '../../context/AuthContext' import { useAuth } from '../../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub' import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
@ -11,6 +12,14 @@ import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal' import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
import ExercisePeekModal from '../ExercisePeekModal' import ExercisePeekModal from '../ExercisePeekModal'
import NavStateLink from '../NavStateLink' 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 { buildExercisesListReturnContext } from '../../utils/navReturnContext'
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips' import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree' import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
@ -37,6 +46,7 @@ const EXERCISES_PAGE_TABS = [
] ]
function ExercisesListPageRoot() { function ExercisesListPageRoot() {
const navigate = useNavigate()
const { user, checkAuth } = useAuth() const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
@ -78,6 +88,21 @@ function ExercisesListPageRoot() {
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([]) const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
const [peekExercise, setPeekExercise] = useState(null) const [peekExercise, setPeekExercise] = useState(null)
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false) 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(() => { useEffect(() => {
if (!user?.id) return if (!user?.id) return
@ -151,6 +176,13 @@ function ExercisesListPageRoot() {
loadMore, loadMore,
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) } = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
const showQuickCreateOffer =
pageTab === 'list' &&
catalogsReady &&
!listFetching &&
(aiQuickCreateEnabled ||
(exercises.length === 0 && selectedEntries.length === 0 && debouncedSearch.length >= 3))
const selectedIds = useMemo( const selectedIds = useMemo(
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)), () => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
[selectedEntries] [selectedEntries]
@ -304,6 +336,82 @@ function ExercisesListPageRoot() {
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), []) 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 bulkVisibilityOptions = useMemo(() => {
const base = [ const base = [
{ id: '', label: '— nicht ändern —' }, { id: '', label: '— nicht ändern —' },
@ -486,13 +594,27 @@ function ExercisesListPageRoot() {
<div className="exercises-page__header"> <div className="exercises-page__header">
<h1 className="page-title exercises-page__title">Übungen</h1> <h1 className="page-title exercises-page__title">Übungen</h1>
{pageTab === 'list' ? ( {pageTab === 'list' ? (
<NavStateLink <div className="exercises-page__header-actions">
to="/exercises/new" <label
returnContext={exercisesModuleReturnContext} className="exercises-ai-assistant-toggle"
className="btn btn-primary" title="Neue Übung per KI vorschlagen — Titel, optional Kurzbeschreibung, Fokusbereich"
> >
+ Neu <input
</NavStateLink> 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}
className="btn btn-primary"
>
+ Neu
</NavStateLink>
</div>
) : ( ) : (
<span aria-hidden="true" /> <span aria-hidden="true" />
)} )}
@ -537,6 +659,28 @@ function ExercisesListPageRoot() {
onToggleSelectAllPage={toggleSelectAllPage} 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 <ExerciseListBulkToolbar
selectedCount={selectedIds.size} selectedCount={selectedIds.size}
bulkMaxIds={BULK_MAX_IDS} bulkMaxIds={BULK_MAX_IDS}
@ -625,11 +769,15 @@ function ExercisesListPageRoot() {
Lade Übungen Lade Übungen
</p> </p>
</div> </div>
) : exercises.length === 0 && selectedEntries.length === 0 ? ( ) : exercises.length === 0 && selectedEntries.length === 0 && !showQuickCreateOffer ? (
<div className="card"> <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> </div>
) : ( ) : exercises.length === 0 && selectedEntries.length === 0 && showQuickCreateOffer ? null : (
<> <>
{selectedEntries.length > 0 ? ( {selectedEntries.length > 0 ? (
<section className="exercises-selection-section" data-testid="exercises-selection-section"> <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> </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 [draftName, setDraftName] = useState('')
const [draftDesc, setDraftDesc] = useState('') const [draftDesc, setDraftDesc] = useState('')
const [draftTemplate, setDraftTemplate] = useState('') const [draftTemplate, setDraftTemplate] = useState('')
const [draftOpenrouterModel, setDraftOpenrouterModel] = useState('')
const [draftActive, setDraftActive] = useState(true) const [draftActive, setDraftActive] = useState(true)
const [pvTitle, setPvTitle] = useState('Testübung') const [pvTitle, setPvTitle] = useState('Testübung')
@ -74,6 +75,9 @@ export default function AdminAiPromptsPage() {
setDraftName(d.display_name || '') setDraftName(d.display_name || '')
setDraftDesc(d.description || '') setDraftDesc(d.description || '')
setDraftTemplate(d.template || '') setDraftTemplate(d.template || '')
setDraftOpenrouterModel(
typeof d.openrouter_model === 'string' ? d.openrouter_model : ''
)
setDraftActive(!!d.active) setDraftActive(!!d.active)
setPvPreview(null) setPvPreview(null)
} }
@ -96,6 +100,7 @@ export default function AdminAiPromptsPage() {
display_name: draftName, display_name: draftName,
description: draftDesc, description: draftDesc,
active: draftActive, active: draftActive,
openrouter_model: draftOpenrouterModel.trim(),
}) })
await loadList() await loadList()
const nd = await api.getAdminAiPrompt(detail.id) const nd = await api.getAdminAiPrompt(detail.id)
@ -201,6 +206,11 @@ export default function AdminAiPromptsPage() {
inaktiv inaktiv
</span> </span>
) : null} ) : 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} {p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
</button> </button>
</li> </li>
@ -231,6 +241,17 @@ export default function AdminAiPromptsPage() {
onChange={(e) => setDraftDesc(e.target.value)} onChange={(e) => setDraftDesc(e.target.value)}
/> />
</div> </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 }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} /> <input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
Aktiv 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 })
}