diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
index b26eef7..0198951 100644
--- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
+++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
@@ -8,6 +8,7 @@
**Ist-Stand API (Superadmin):**
- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
+- Spalte **`openrouter_model`** (Migration **070**): Optional pro Prompt-Zeile; OpenRouter **`model`**-Parameter; **`NULL`/leer ⇒ `OPENROUTER_MODEL`** aus der Umgebung.
**Autor:** Claude Code
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
@@ -36,6 +37,7 @@ steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet.
|-------------|-----------|
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
+| `exercise_instruction_rewrite` | Überarbeitet Anleitung: goal, execution, preparation, trainer_notes (JSON, prägnantes HTML) |
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |
diff --git a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
index e75de69..b7535f5 100644
--- a/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
+++ b/.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md
@@ -26,7 +26,7 @@ Alle produktiven KI-Aufrufe sollten mittelfristig über eine **einheitliche Fass
Router und Frontend rufen diese Schicht oder schmale Orchestratoren — **nicht** direkt `httpx`/OpenRouter an jeder Ecke verteilt.
-**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` mit **Kontext-Arten** und **gemeinsamen DB-Ladeschritten** für `ai_prompts`; Übungs-KI konsumiert diese Schicht ohne Zirkelschluss zu Domänlogik (`exercise_ai`).
+**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` (`load_ai_prompt_row`, `load_and_render_ai_prompt`, Kontext-Arten) sowie `backend/ai_prompt_job.py` (Pydantic `ExerciseFormAiPromptContext` fuer Uebungs-Prompts — Admin-Vorschau + erweiterbare Router-Nutzung); `exercise_ai` orchestriert OpenRouter nach dem Rendern.
### 2.2 Trennung: Semantik vs. Transport
@@ -122,23 +122,25 @@ Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter,
```mermaid
flowchart LR
- subgraph heute
+ subgraph laufzeit
A[ai_prompts DB]
B[prompt_resolver Mustache]
- C[ai_prompt_runtime Loader + ContextKind]
- D[exercise_ai]
+ C[ai_prompt_runtime]
+ J[ai_prompt_job Pydantic]
+ D[exercise_ai OpenRouter]
end
- A --> B
A --> C
+ C --> B
+ J --> D
C --> D
B --> D
```
| Phase | Inhalt |
|-------|--------|
-| **P0 (gestartet)** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI nutzt Laufzeit; Platzhalter-Katalog pro Kontext erweiterbar. |
-| **P1** | Einheitliche `run_ai_job`-Fassade (Slug + Kind + Pydantic-Payload + Validierung); Router nur noch dünne Adapter. |
-| **P2** | Versionierung oder Audit-Spalten; optionale Modell-/Temperatur-Overrides pro Slug in DB oder Config-Tabelle. |
+| **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. |
+| **P1** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **`ExerciseFormAiPromptContext`** in `ai_prompt_context.py`; **`run_exercise_form_ai_suggestion`**; Übungs-API und Admin-Vorschau nutzen denselben Kontext. |
+| **P2** | Versionierung oder Audit-Spalten; **teilweise:** optionales OpenRouter-Modell pro Zeile (`openrouter_model`, Migration 070, Fallback `OPENROUTER_MODEL`); weitere Overrides (Temperatur) offen. |
| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |
@@ -157,7 +159,7 @@ flowchart LR
- Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md`
- Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md`
- Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`
-- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`
+- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`, `backend/ai_prompt_context.py`, `backend/ai_prompt_job.py`
---
diff --git a/.env.example b/.env.example
index 91f9c07..66c24e7 100644
--- a/.env.example
+++ b/.env.example
@@ -34,6 +34,8 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_MODEL=anthropic/claude-sonnet-4
+# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
+# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).
diff --git a/backend/ai_prompt_context.py b/backend/ai_prompt_context.py
new file mode 100644
index 0000000..bd441b4
--- /dev/null
+++ b/backend/ai_prompt_context.py
@@ -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",
+]
diff --git a/backend/ai_prompt_job.py b/backend/ai_prompt_job.py
new file mode 100644
index 0000000..38074ba
--- /dev/null
+++ b/backend/ai_prompt_job.py
@@ -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",
+]
diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py
index 4ca8560..315c1d2 100644
--- a/backend/ai_prompt_runtime.py
+++ b/backend/ai_prompt_runtime.py
@@ -7,12 +7,15 @@ load_ai_prompt_row und die Enum; Platzhalter bauen sie selbst oder über geteilt
from __future__ import annotations
from enum import Enum
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Mapping, Optional, Tuple
+
+from prompt_resolver import MustacheRenderResult, render_mustache_template
_EXERCISE_AI_SLUGS = frozenset(
{
"exercise_summary",
"exercise_skill_suggestions",
+ "exercise_instruction_rewrite",
}
)
@@ -43,7 +46,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
if active_only:
cur.execute(
"""
- SELECT slug, display_name, template, output_format, active
+ SELECT slug, display_name, template, output_format, active, openrouter_model
FROM ai_prompts
WHERE slug = %s AND active = true
""",
@@ -52,7 +55,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
else:
cur.execute(
"""
- SELECT slug, display_name, template, output_format, active
+ SELECT slug, display_name, template, output_format, active, openrouter_model
FROM ai_prompts
WHERE slug = %s
""",
@@ -67,8 +70,45 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
return d
+class AiPromptUnavailableError(LookupError):
+ """Kein aktiver Prompt fuer slug (oder Zeile fehlt)."""
+
+ def __init__(self, slug: str) -> None:
+ self.slug = (slug or "").strip()
+ super().__init__(self.slug)
+
+
+def render_ai_prompt_template_for_row(
+ row: Mapping[str, Any],
+ variables: Mapping[str, str],
+) -> MustacheRenderResult:
+ """Ersetzt Platzhalter anhand einer bereits geladenen ai_prompts-Zeile (z. B. Admin-Vorschauch, inkl. inaktiv)."""
+ return render_mustache_template(str(row.get("template") or ""), variables)
+
+
+def load_and_render_ai_prompt(
+ cur,
+ slug: str,
+ variables: Mapping[str, str],
+ *,
+ active_only: bool = True,
+) -> Tuple[Dict[str, Any], MustacheRenderResult]:
+ """
+ Laedt einen aktiven Prompt und wendet Mustache-Variablen an.
+ Wirft AiPromptUnavailableError, wenn die Zeile fehlt oder (bei active_only) inaktiv ist.
+ """
+ row = load_ai_prompt_row(cur, slug, active_only=active_only)
+ if not row:
+ raise AiPromptUnavailableError(slug)
+ rr = render_ai_prompt_template_for_row(row, variables)
+ return dict(row), rr
+
+
__all__ = [
"AiPromptContextKind",
+ "AiPromptUnavailableError",
"context_kind_for_slug",
"load_ai_prompt_row",
+ "load_and_render_ai_prompt",
+ "render_ai_prompt_template_for_row",
]
diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py
index 6391740..5b1c73e 100644
--- a/backend/exercise_ai.py
+++ b/backend/exercise_ai.py
@@ -7,6 +7,7 @@ Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, F
from __future__ import annotations
import copy
+import html
import json
import logging
import math
@@ -16,10 +17,17 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence,
from fastapi import HTTPException
-from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
+from openrouter_chat import (
+ OpenRouterError,
+ default_openrouter_model_id,
+ effective_openrouter_model_for_prompt_row,
+ normalize_openrouter_env,
+ openrouter_chat_completion,
+)
-from ai_prompt_runtime import load_ai_prompt_row
-from prompt_resolver import render_mustache_template
+from ai_prompt_context import ExerciseFormAiPromptContext
+from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
+from exercise_rich_text import collect_inline_exercise_media_ids, normalize_inline_exercise_media_markup
_LOGGER = logging.getLogger("shinkan.exercise_ai")
@@ -491,6 +499,146 @@ def build_contextual_skills_catalog_block(
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
+_MAX_INSTRUCTION_GOAL_PLAIN = 4_000
+_MAX_INSTRUCTION_EXECUTION_PLAIN = 12_000
+_MAX_INSTRUCTION_PREP_PLAIN = 2_500
+_MAX_INSTRUCTION_TRAINER_PLAIN = 2_500
+
+_INSTRUCTION_JSON_KEYS = ("goal", "execution", "preparation", "trainer_notes")
+_INSTRUCTION_FIELD_MAX_PLAIN = {
+ "goal": _MAX_INSTRUCTION_GOAL_PLAIN,
+ "execution": _MAX_INSTRUCTION_EXECUTION_PLAIN,
+ "preparation": _MAX_INSTRUCTION_PREP_PLAIN,
+ "trainer_notes": _MAX_INSTRUCTION_TRAINER_PLAIN,
+}
+
+_DISALLOWED_HTML_TAG_RE = re.compile(
+ r"?\s*(?!p\b|ul\b|ol\b|li\b|strong\b|b\b|em\b|i\b|br\b|span\b)[a-zA-Z][^>]*>",
+ re.IGNORECASE,
+)
+_SCRIPT_STYLE_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?\1>")
+
+
+def _plain_to_minimal_instruction_html(text: str) -> str:
+ raw = (text or "").strip()
+ if not raw:
+ return ""
+ parts = [p.strip() for p in re.split(r"\n+", raw) if p.strip()]
+ if not parts:
+ return ""
+ return "".join(f"
{html.escape(p)}
" for p in parts)
+
+
+def _truncate_plain(text: str, max_len: int) -> str:
+ t = (text or "").strip()
+ if len(t) <= max_len:
+ return t
+ return t[: max_len - 1].rstrip() + "…"
+
+
+def _sanitize_instruction_field_html(raw: Any, *, max_plain: int) -> str:
+ if raw is None:
+ return ""
+ s = str(raw).strip()
+ if not s:
+ return ""
+ if s.startswith("```"):
+ s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
+ if s.endswith("```"):
+ s = s[:-3].strip()
+ s = _SCRIPT_STYLE_RE.sub("", s)
+ s = _DISALLOWED_HTML_TAG_RE.sub("", s)
+ if "<" not in s:
+ s = _plain_to_minimal_instruction_html(s)
+ else:
+ s = normalize_inline_exercise_media_markup(s) or ""
+ plain = strip_html_to_plain(s, max_len=max_plain + 200)
+ if len(plain) > max_plain:
+ plain = _truncate_plain(plain, max_plain)
+ s = _plain_to_minimal_instruction_html(plain)
+ return (normalize_inline_exercise_media_markup(s) or "").strip()
+
+
+def _merge_preserved_inline_media(original: Optional[str], revised: str) -> str:
+ """Haengt fehlende Medien-Verweise aus dem Ausgangstext ans Ende an."""
+ out = (revised or "").strip()
+ orig_ids = collect_inline_exercise_media_ids(original)
+ if not orig_ids:
+ return out
+ new_ids = collect_inline_exercise_media_ids(out)
+ missing = sorted(orig_ids - new_ids)
+ if not missing:
+ return out
+ spans = []
+ for mid in missing:
+ spans.append(
+ f''
+ )
+ block = f"
{''.join(spans)}
"
+ return (out + block).strip() if out else block
+
+
+def _first_balanced_json_object(text: str) -> Optional[str]:
+ i = text.find("{")
+ if i < 0:
+ return None
+ depth = 0
+ in_str = False
+ esc = False
+ for j in range(i, len(text)):
+ ch = text[j]
+ if in_str:
+ if esc:
+ esc = False
+ elif ch == "\\":
+ esc = True
+ elif ch == '"':
+ in_str = False
+ continue
+ if ch == '"':
+ in_str = True
+ continue
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ return text[i : j + 1]
+ return None
+
+
+def _extract_instruction_rewrite_object(text: str) -> Dict[str, Any]:
+ s = (text or "").strip()
+ if not s:
+ raise ValueError("leer")
+ if s.startswith("```"):
+ s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
+ if s.endswith("```"):
+ s = s[:-3].strip()
+ frag = _first_balanced_json_object(s)
+ if frag:
+ s = frag
+ obj = json.loads(s)
+ if not isinstance(obj, dict):
+ raise ValueError("kein JSON-Objekt")
+ return obj
+
+
+def _sanitize_instruction_rewrite_payload(
+ parsed: Mapping[str, Any],
+ *,
+ originals: Mapping[str, Optional[str]],
+) -> Dict[str, str]:
+ out: Dict[str, str] = {}
+ for key in _INSTRUCTION_JSON_KEYS:
+ max_plain = _INSTRUCTION_FIELD_MAX_PLAIN[key]
+ html = _sanitize_instruction_field_html(parsed.get(key), max_plain=max_plain)
+ html = _merge_preserved_inline_media(originals.get(key), html)
+ out[key] = html
+ return out
+
+
def build_exercise_placeholder_variables(
cur,
*,
@@ -500,6 +648,8 @@ def build_exercise_placeholder_variables(
execution: Optional[str],
focus_area_hint: Optional[str],
focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
+ preparation: Optional[str] = None,
+ trainer_notes: Optional[str] = None,
) -> Dict[str, str]:
"""
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
@@ -509,6 +659,8 @@ def build_exercise_placeholder_variables(
return {}
g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution)
+ p_plain = strip_html_to_plain(preparation)
+ n_plain = strip_html_to_plain(trainer_notes)
t_title = (title or "").strip()
focus = (focus_area_hint or "").strip()
ctx: Dict[str, str] = {
@@ -516,8 +668,12 @@ def build_exercise_placeholder_variables(
"exercise_focus_area": focus or "-",
"exercise_goal": g_plain or "-",
"exercise_execution": e_plain or "-",
+ "exercise_preparation": p_plain or "-",
+ "exercise_trainer_notes": n_plain or "-",
}
if s == "exercise_summary":
+ return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")}
+ if s == "exercise_instruction_rewrite":
return ctx
if s == "exercise_skill_suggestions":
catalog = build_contextual_skills_catalog_block(
@@ -664,32 +820,43 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
return out[:5]
-def _require_openrouter() -> Tuple[str, str]:
- key, model = normalize_openrouter_env()
+def _require_openrouter_key() -> str:
+ key, _ = normalize_openrouter_env()
if not key:
raise HTTPException(
status_code=503,
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
)
- return key, model
+ return key
def run_exercise_ai_suggestion(
cur,
*,
- title: Optional[str],
- goal: Optional[str],
- execution: Optional[str],
- focus_area_hint: Optional[str],
- focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None,
+ form_ctx: ExerciseFormAiPromptContext,
want_summary: bool,
want_skills: bool,
+ want_instructions: bool = False,
) -> Dict[str, Any]:
- key, model = _require_openrouter()
+ key = _require_openrouter_key()
+
+ title = form_ctx.title
+ goal = form_ctx.goal
+ execution = form_ctx.execution
+ preparation = form_ctx.preparation
+ trainer_notes = form_ctx.trainer_notes
+ focus_area_hint = form_ctx.focus_hint
+ focus_areas_context = form_ctx.focus_area_tuples()
g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution)
- if not (g_plain.strip() or e_plain.strip()):
+ if want_instructions:
+ if not form_ctx.has_instruction_source_text():
+ raise HTTPException(
+ status_code=400,
+ detail="Fuer Anleitungs-Ueberarbeitung mindestens Titel oder ein Anleitungsfeld ausfuellen.",
+ )
+ elif not (g_plain.strip() or e_plain.strip()):
raise HTTPException(
status_code=400,
detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).",
@@ -698,26 +865,25 @@ def run_exercise_ai_suggestion(
t_title = (title or "").strip()
focus = (focus_area_hint or "").strip()
- result: Dict[str, Any] = {"model": model}
+ result: Dict[str, Any] = {}
+ models_by_slug: Dict[str, str] = {}
if _ai_debug_on():
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
_LOGGER.warning(
- "AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s "
- "exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
+ "AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s want_instructions=%s "
+ "title_chars=%s goal_plain_chars=%s exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
want_summary,
want_skills,
- len(t_title),
+ want_instructions,
+ len((title or "").strip()),
len(g_plain),
len(e_plain),
- len(focus),
+ len((focus_area_hint or "").strip()),
fid_list,
)
if want_summary:
- prow = load_ai_prompt_row(cur, "exercise_summary")
- if not prow:
- raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
try:
ctx = build_exercise_placeholder_variables(
cur,
@@ -730,7 +896,15 @@ def run_exercise_ai_suggestion(
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
- rendered = render_mustache_template(str(prow["template"]), ctx)
+ try:
+ prow, rendered = load_and_render_ai_prompt(cur, "exercise_summary", ctx)
+ except AiPromptUnavailableError:
+ raise HTTPException(
+ status_code=503,
+ detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.",
+ ) from None
+ model_summary = effective_openrouter_model_for_prompt_row(prow)
+ models_by_slug["exercise_summary"] = model_summary
prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
@@ -739,7 +913,7 @@ def run_exercise_ai_suggestion(
len(rendered.placeholders_remaining),
)
try:
- raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
+ raw = openrouter_chat_completion(api_key=key, model=model_summary, user_content=prompt)
except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
if _ai_debug_on():
@@ -752,15 +926,9 @@ def run_exercise_ai_suggestion(
)
if len(text) > _MAX_SUMMARY_CHARS:
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…"
- result["summary"] = {"text": text, "ai_generated": True, "model": model}
+ result["summary"] = {"text": text, "ai_generated": True, "model": model_summary}
if want_skills:
- srow = load_ai_prompt_row(cur, "exercise_skill_suggestions")
- if not srow:
- raise HTTPException(
- status_code=503,
- detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
- )
try:
ctx = build_exercise_placeholder_variables(
cur,
@@ -773,7 +941,15 @@ def run_exercise_ai_suggestion(
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
- rendered = render_mustache_template(str(srow["template"]), ctx)
+ try:
+ srow, rendered = load_and_render_ai_prompt(cur, "exercise_skill_suggestions", ctx)
+ except AiPromptUnavailableError:
+ raise HTTPException(
+ status_code=503,
+ detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
+ ) from None
+ model_skills = effective_openrouter_model_for_prompt_row(srow)
+ models_by_slug["exercise_skill_suggestions"] = model_skills
prompt = rendered.text
if _ai_debug_on():
_LOGGER.warning(
@@ -789,7 +965,7 @@ def run_exercise_ai_suggestion(
try:
raw = openrouter_chat_completion(
api_key=key,
- model=model,
+ model=model_skills,
user_content=prompt,
system_content=sys_hint,
temperature=0.15,
@@ -827,6 +1003,97 @@ def run_exercise_ai_suggestion(
result["skills"] = skills
+ if want_instructions:
+ try:
+ ctx = build_exercise_placeholder_variables(
+ cur,
+ slug="exercise_instruction_rewrite",
+ title=title,
+ goal=goal,
+ execution=execution,
+ preparation=preparation,
+ trainer_notes=trainer_notes,
+ focus_area_hint=focus_area_hint,
+ focus_areas_context=focus_areas_context,
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=500, detail=str(e)) from e
+ try:
+ irow, rendered = load_and_render_ai_prompt(cur, "exercise_instruction_rewrite", ctx)
+ except AiPromptUnavailableError:
+ raise HTTPException(
+ status_code=503,
+ detail="Prompt exercise_instruction_rewrite nicht aktiv oder fehlt in DB.",
+ ) from None
+ model_instr = effective_openrouter_model_for_prompt_row(irow)
+ models_by_slug["exercise_instruction_rewrite"] = model_instr
+ prompt = rendered.text
+ if _ai_debug_on():
+ _LOGGER.warning(
+ "AI_DEBUG exercise_ai instructions prompt_slug=exercise_instruction_rewrite prompt_chars=%s",
+ len(prompt),
+ )
+ sys_hint = (
+ "Du antwortest nur mit validem JSON-Objekt (Schluessel goal, execution, preparation, trainer_notes). "
+ "Keine Kommentare ausserhalb des JSON."
+ )
+ try:
+ raw = openrouter_chat_completion(
+ api_key=key,
+ model=model_instr,
+ user_content=prompt,
+ system_content=sys_hint,
+ temperature=0.2,
+ )
+ except OpenRouterError as e:
+ raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
+ body = (raw or "").strip()
+ if not body:
+ raise HTTPException(
+ status_code=502,
+ detail="OpenRouter/KI lieferte leeren Inhalt fuer Anleitungs-Ueberarbeitung.",
+ )
+ try:
+ parsed = _extract_instruction_rewrite_object(body)
+ except (json.JSONDecodeError, ValueError) as e:
+ if _ai_debug_on():
+ _LOGGER.warning(
+ "AI_DEBUG exercise_ai instructions JSON parse_failed err=%s head=%s",
+ e,
+ (body.replace("\r", "").replace("\n", " ").strip())[:400],
+ )
+ raise HTTPException(
+ status_code=502,
+ detail="KI lieferte kein verwertbares JSON fuer die Anleitung.",
+ ) from e
+ originals = {
+ "goal": goal,
+ "execution": execution,
+ "preparation": preparation,
+ "trainer_notes": trainer_notes,
+ }
+ fields = _sanitize_instruction_rewrite_payload(parsed, originals=originals)
+ if not any((fields.get(k) or "").strip() for k in _INSTRUCTION_JSON_KEYS):
+ raise HTTPException(
+ status_code=502,
+ detail="KI lieferte leere Anleitungs-Felder.",
+ )
+ result["instructions"] = {
+ "fields": fields,
+ "ai_generated": True,
+ "model": model_instr,
+ }
+
+ result["models_by_slug"] = models_by_slug
+ if want_skills:
+ result["model"] = models_by_slug["exercise_skill_suggestions"]
+ elif want_instructions:
+ result["model"] = models_by_slug["exercise_instruction_rewrite"]
+ elif want_summary:
+ result["model"] = models_by_slug["exercise_summary"]
+ else:
+ result["model"] = default_openrouter_model_id()
+
return result
diff --git a/backend/migrations/070_ai_prompts_openrouter_model.sql b/backend/migrations/070_ai_prompts_openrouter_model.sql
new file mode 100644
index 0000000..2977037
--- /dev/null
+++ b/backend/migrations/070_ai_prompts_openrouter_model.sql
@@ -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';
diff --git a/backend/migrations/071_ai_prompt_exercise_instruction_rewrite.sql b/backend/migrations/071_ai_prompt_exercise_instruction_rewrite.sql
new file mode 100644
index 0000000..612c189
--- /dev/null
+++ b/backend/migrations/071_ai_prompt_exercise_instruction_rewrite.sql
@@ -0,0 +1,59 @@
+-- Migration 071: KI-Prompt fuer Anleitungs-Ueberarbeitung (Ziel, Durchfuehrung, Vorbereitung, Trainer-Hinweise)
+-- JSON-Ausgabe; praezise HTML-Fragmente fuer RichTextEditor.
+
+INSERT INTO ai_prompts (
+ slug, display_name, description, template,
+ category, output_format, output_schema, is_system_default, default_template, active, sort_order
+)
+SELECT
+ 'exercise_instruction_rewrite',
+ 'Anleitung ueberarbeiten',
+ 'Ueberarbeitet Ziel, Durchfuehrung, Vorbereitung und Trainer-Hinweise — praezise, strukturiert, ohne Aufblaehen.',
+ $t$Du bist Assistent fuer Kampfsport-Trainer.
+Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
+Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
+
+Stil:
+- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
+- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
+- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
+- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
+- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
+
+Format (HTML fuer Rich-Text-Editor):
+- Erlaubt:
,
, ,
, , ,
+- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
+- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
+
+Eingabe:
+Titel: {{exercise_title}}
+Fokuskontext: {{exercise_focus_area}}
+
+Ziel (Plaintext, Ausgang): {{exercise_goal}}
+Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
+Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
+Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
+
+Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
+{
+ "goal": "
…
",
+ "execution": "
…
",
+ "preparation": "
…
oder \"\"",
+ "trainer_notes": "
…
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) = '');
diff --git a/backend/openrouter_chat.py b/backend/openrouter_chat.py
index 96a1d2f..8a7dd88 100644
--- a/backend/openrouter_chat.py
+++ b/backend/openrouter_chat.py
@@ -6,7 +6,7 @@ from __future__ import annotations
import json
import logging
import os
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Mapping, Optional
import httpx
@@ -203,3 +203,22 @@ def normalize_openrouter_env() -> tuple[str, str]:
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
return key, model
+
+
+def default_openrouter_model_id() -> str:
+ """Standard-Modell aus OPENROUTER_MODEL (ohne API-Key zu pruefen)."""
+ _, model = normalize_openrouter_env()
+ return model
+
+
+def effective_openrouter_model_for_prompt_row(row: Optional[Mapping[str, Any]]) -> str:
+ """
+ Pro-Prompt-Override in ai_prompts.openrouter_model, sonst Env-Default.
+
+ `row` kann eine partial Row aus load_ai_prompt_row sein (Felder slug, openrouter_model, …).
+ """
+ if row:
+ custom = str(row.get("openrouter_model") or "").strip()
+ if custom:
+ return custom
+ return default_openrouter_model_id()
diff --git a/backend/prompt_resolver.py b/backend/prompt_resolver.py
index 191d16c..e03d777 100644
--- a/backend/prompt_resolver.py
+++ b/backend/prompt_resolver.py
@@ -87,25 +87,37 @@ def exercise_placeholder_catalog() -> dict:
"key": "exercise_title",
"placeholder": "{{exercise_title}}",
"description": "Titel der Uebung (oder Platzhalter, wenn leer).",
- "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
+ "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
},
{
"key": "exercise_focus_area",
"placeholder": "{{exercise_focus_area}}",
"description": "Fokuskontext (Text-Hinweis aus Formular, optional).",
- "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
+ "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
},
{
"key": "exercise_goal",
"placeholder": "{{exercise_goal}}",
"description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.",
- "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
+ "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
},
{
"key": "exercise_execution",
"placeholder": "{{exercise_execution}}",
"description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.",
- "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
+ "used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
+ },
+ {
+ "key": "exercise_preparation",
+ "placeholder": "{{exercise_preparation}}",
+ "description": "Vorbereitung/Aufbau als Plaintext ohne HTML.",
+ "used_by_slugs": ["exercise_instruction_rewrite"],
+ },
+ {
+ "key": "exercise_trainer_notes",
+ "placeholder": "{{exercise_trainer_notes}}",
+ "description": "Trainer-Hinweise als Plaintext ohne HTML.",
+ "used_by_slugs": ["exercise_instruction_rewrite"],
},
{
"key": "skills_catalog",
diff --git a/backend/routers/ai_prompts_admin.py b/backend/routers/ai_prompts_admin.py
index b0bb55d..2a4e106 100644
--- a/backend/routers/ai_prompts_admin.py
+++ b/backend/routers/ai_prompts_admin.py
@@ -12,9 +12,11 @@ from pydantic import BaseModel, Field
from auth import require_auth
from club_tenancy import is_superadmin
+from ai_prompt_context import ExerciseFormAiPromptContext
+from ai_prompt_job import resolve_exercise_form_variables
+from ai_prompt_runtime import render_ai_prompt_template_for_row
from db import get_cursor, get_db, r2d
-from exercise_ai import build_exercise_placeholder_variables
-from prompt_resolver import exercise_placeholder_catalog, render_mustache_template
+from prompt_resolver import exercise_placeholder_catalog
router = APIRouter(tags=["admin_ai_prompts"])
@@ -39,7 +41,7 @@ def _fetch_prompt_any(cur, prompt_id: int) -> Dict[str, Any]:
cur.execute(
"""
SELECT id, slug, display_name, description, template, category, output_format,
- output_schema, is_system_default, default_template,
+ output_schema, is_system_default, default_template, openrouter_model,
active, sort_order, created_at, updated_at
FROM ai_prompts WHERE id = %s
""",
@@ -56,19 +58,11 @@ class AiPromptUpdateBody(BaseModel):
active: Optional[bool] = None
display_name: Optional[str] = Field(None, max_length=200)
description: Optional[str] = Field(None, max_length=8000)
+ openrouter_model: Optional[str] = Field(None, max_length=200)
-class AiPromptPreviewFocus(BaseModel):
- focus_area_id: int = Field(..., ge=1)
- is_primary: Optional[bool] = False
-
-
-class AiPromptPreviewBody(BaseModel):
- title: Optional[str] = ""
- goal: Optional[str] = None
- execution: Optional[str] = None
- focus_hint: Optional[str] = None
- focus_areas_context: Optional[List[AiPromptPreviewFocus]] = None
+class AiPromptPreviewBody(ExerciseFormAiPromptContext):
+ """Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint)."""
@router.get("/api/admin/ai-prompts/catalog/placeholders")
@@ -85,7 +79,7 @@ def list_ai_prompts(session: dict = Depends(_require_superadmin)):
cur.execute(
"""
SELECT id, slug, display_name, description, category, output_format, active,
- sort_order, is_system_default, default_template
+ sort_order, is_system_default, default_template, openrouter_model
FROM ai_prompts
ORDER BY sort_order ASC NULLS LAST, id ASC
"""
@@ -149,16 +143,25 @@ def update_ai_prompt(
next_desc = body.description if body.description is not None else old.get("description") or ""
next_desc = (next_desc or "").strip()
+ next_openrouter = old.get("openrouter_model")
+ if body.openrouter_model is not None:
+ cand = body.openrouter_model.strip() if isinstance(body.openrouter_model, str) else ""
+ if any(c in cand for c in ("\r", "\n", "\t")):
+ raise HTTPException(status_code=400, detail="openrouter_model: keine Steuerzeichen erlaubt.")
+ next_openrouter = cand or None
+
cur.execute(
"""
UPDATE ai_prompts
- SET template = %s, active = %s, display_name = %s, description = %s, updated_at = NOW()
+ SET template = %s, active = %s, display_name = %s, description = %s,
+ openrouter_model = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, slug, display_name, description, template, category, output_format,
- output_schema, is_system_default, default_template, active, sort_order,
+ output_schema, is_system_default, default_template, openrouter_model,
+ active, sort_order,
created_at, updated_at
""",
- (next_template, next_active, next_name, next_desc, prompt_id),
+ (next_template, next_active, next_name, next_desc, next_openrouter, prompt_id),
)
row = dict(cur.fetchone())
conn.commit()
@@ -187,7 +190,8 @@ def reset_ai_prompt_template(prompt_id: int, session: dict = Depends(_require_su
SET template = default_template, updated_at = NOW()
WHERE id = %s AND default_template IS NOT NULL
RETURNING id, slug, display_name, description, template, category, output_format,
- output_schema, is_system_default, default_template, active, sort_order,
+ output_schema, is_system_default, default_template, openrouter_model,
+ active, sort_order,
created_at, updated_at
""",
(prompt_id,),
@@ -211,28 +215,12 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
row = _fetch_prompt_any(cur, prompt_id)
slug = (row.get("slug") or "").strip().lower()
- tpl_raw = row.get("template") or ""
-
- fctx_list: Optional[List[tuple[int, bool]]] = None
- if body.focus_areas_context:
- pairs: List[tuple[int, bool]] = []
- for x in body.focus_areas_context:
- pairs.append((int(x.focus_area_id), bool(x.is_primary)))
- fctx_list = pairs
vars_map: Dict[str, str]
warn: Optional[str] = None
- if slug in ("exercise_summary", "exercise_skill_suggestions"):
+ if slug in ("exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"):
try:
- vars_map = build_exercise_placeholder_variables(
- cur,
- slug=slug,
- title=(body.title or "").strip(),
- goal=body.goal,
- execution=body.execution,
- focus_area_hint=body.focus_hint,
- focus_areas_context=fctx_list,
- )
+ vars_map = resolve_exercise_form_variables(cur, slug, body)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
elif slug == "pipeline":
@@ -242,7 +230,7 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
vars_map = {}
warn = f"Slug {slug!r}: noch kein Vorschau-Kontext definiert — Roh-Template ohne Ersetzung."
- rendered = render_mustache_template(str(tpl_raw), vars_map)
+ rendered = render_ai_prompt_template_for_row(row, vars_map)
return {
"slug": slug,
"resolved_template": rendered.text,
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index d79b0b9..e6cb706 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -35,7 +35,8 @@ from tenant_context import TenantContext, get_tenant_context, get_tenant_context
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
from media_legal_hold import assert_not_under_legal_hold
-from exercise_ai import run_exercise_ai_suggestion
+from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
+from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_rich_text import (
RICH_HTML_EXERCISE_FIELDS,
@@ -358,31 +359,44 @@ class ExerciseMediaFromAsset(BaseModel):
media_type: Optional[str] = None
-class ExerciseAiFocusCtx(BaseModel):
- """Fokusbereich fuer Skill-Kataloggewichte (Migration 068 ai_skill_retrieval_profiles)."""
-
- focus_area_id: int = Field(..., ge=1)
- is_primary: Optional[bool] = False
+class ExerciseAiFocusCtx(ExerciseFormAiFocusRow):
+ """Alias fuer OpenAPI — identisch zu ExerciseFormAiFocusRow."""
class ExerciseAiSuggestBody(BaseModel):
title: Optional[str] = Field(None, max_length=300)
goal: Optional[str] = Field(None, max_length=64000)
execution: Optional[str] = Field(None, max_length=128000)
+ preparation: Optional[str] = Field(None, max_length=64000)
+ trainer_notes: Optional[str] = Field(None, max_length=64000)
focus_area_hint: Optional[str] = Field(None, max_length=1200)
- focus_areas_context: Optional[list[ExerciseAiFocusCtx]] = Field(
+ focus_areas_context: Optional[list[ExerciseFormAiFocusRow]] = Field(
None,
description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung",
)
include_summary: bool = True
include_skills: bool = True
+ include_instructions: bool = False
@model_validator(mode="after")
def check_include_any(self):
- if not self.include_summary and not self.include_skills:
- raise ValueError("Mindestens include_summary oder include_skills aktivieren.")
+ if not self.include_summary and not self.include_skills and not self.include_instructions:
+ raise ValueError(
+ "Mindestens include_summary, include_skills oder include_instructions aktivieren."
+ )
return self
+ def to_form_context(self) -> ExerciseFormAiPromptContext:
+ return ExerciseFormAiPromptContext.from_api_suggest(
+ title=self.title,
+ goal=self.goal,
+ execution=self.execution,
+ preparation=self.preparation,
+ trainer_notes=self.trainer_notes,
+ focus_area_hint=self.focus_area_hint,
+ focus_areas_context=self.focus_areas_context,
+ )
+
class ExerciseAiRegenerateBody(BaseModel):
"""Welche Artefakte neu angefragt werden sollen."""
@@ -391,7 +405,7 @@ class ExerciseAiRegenerateBody(BaseModel):
@model_validator(mode="after")
def normalize_regs(self):
- allowed = {"summary", "skills"}
+ allowed = {"summary", "skills", "instructions"}
raw = [str(x).strip().lower() for x in (self.regenerate or [])]
out = []
seen = set()
@@ -2306,19 +2320,12 @@ def exercise_ai_suggest_endpoint(
_ = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
- fctx = None
- if body.focus_areas_context:
- fctx = [(x.focus_area_id, bool(x.is_primary)) for x in body.focus_areas_context]
-
- payload = run_exercise_ai_suggestion(
+ payload = run_exercise_form_ai_suggestion(
cur,
- title=(body.title or "").strip(),
- goal=body.goal,
- execution=body.execution,
- focus_area_hint=(body.focus_area_hint or "").strip() or None,
- focus_areas_context=fctx,
+ body.to_form_context(),
want_summary=body.include_summary,
want_skills=body.include_skills,
+ want_instructions=body.include_instructions,
)
return payload
@@ -2332,6 +2339,7 @@ def exercise_ai_regenerate_endpoint(
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
want_summary = "summary" in body.regenerate
want_skills = "skills" in body.regenerate
+ want_instructions = "instructions" in body.regenerate
with get_db() as conn:
cur = get_cursor(conn)
@@ -2344,15 +2352,21 @@ def exercise_ai_regenerate_endpoint(
focus = _focus_area_hint_from_detail(exercise)
fctx = _focus_areas_ai_ctx_from_detail(exercise)
- payload = run_exercise_ai_suggestion(
- cur,
+ ctx = ExerciseFormAiPromptContext.from_focus_tuples(
title=str(exercise.get("title") or "").strip(),
goal=exercise.get("goal"),
execution=exercise.get("execution"),
- focus_area_hint=focus or None,
- focus_areas_context=fctx or None,
+ preparation=exercise.get("preparation"),
+ trainer_notes=exercise.get("trainer_notes"),
+ focus_hint=focus or None,
+ focus_tuples=fctx or None,
+ )
+ payload = run_exercise_form_ai_suggestion(
+ cur,
+ ctx,
want_summary=want_summary,
want_skills=want_skills,
+ want_instructions=want_instructions,
)
return payload
diff --git a/backend/version.py b/backend/version.py
index 3a1f6d6..9a1a661 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.159"
-BUILD_DATE = "2026-05-30"
-DB_SCHEMA_VERSION = "20260530069"
+APP_VERSION = "0.8.166"
+BUILD_DATE = "2026-05-22"
+DB_SCHEMA_VERSION = "20260531071"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -19,13 +19,15 @@ MODULE_VERSIONS = {
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
- "admin_ai_prompts": "1.0.1", # Prompt-Pflege + Zielarchitektur-Doku; gemeinsamer DB-Load uber ai_prompt_runtime
- "ai_prompt_runtime": "0.1.0", # AiPromptContextKind, load_ai_prompt_row — Erweiterung Planung ohne Zirkel zu exercise_ai
+ "admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
+ "ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
+ "ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
+ "ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row
"groups": "0.1.0",
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
- "exercises": "2.31.1", # AI nutzt load_ai_prompt_row aus ai_prompt_runtime
+ "exercises": "2.33.0", # KI Schnellanlage: Suche+Anlage kombiniert; Rich-Text-Editor; Übungsliste KI-Schalter
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@@ -40,6 +42,63 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.166",
+ "date": "2026-05-22",
+ "changes": [
+ "KI Schnellanlage: Suche und Anlage kombiniert (Picker + Übungsliste); Suchstring → Titel/Skizze; Rich-Text-Entwurf bearbeitbar vor Speichern.",
+ "Übungsliste: Schalter „KI-Anlage“ für direkten Einstieg ohne leere Trefferliste.",
+ ],
+ },
+ {
+ "version": "0.8.165",
+ "date": "2026-05-31",
+ "changes": [
+ "Übungspicker Schnellanlage: KI-Vorschau-Dialog vor Speichern; Live-Bibliothekssuche (Titel+Skizze) mit Übernahme bestehender Übung.",
+ ],
+ },
+ {
+ "version": "0.8.164",
+ "date": "2026-05-31",
+ "changes": [
+ "Planung/Übungspicker: Schnellanlage nutzt suggestExerciseAi (Anleitung, Kurzbeschreibung, Fähigkeiten); Fokusbereich Pflichtfeld.",
+ ],
+ },
+ {
+ "version": "0.8.163",
+ "date": "2026-05-31",
+ "changes": [
+ "KI Anleitung: Migration 071 Prompt exercise_instruction_rewrite (JSON: goal, execution, preparation, trainer_notes);",
+ "POST /exercises/ai/suggest include_instructions; Sanitize/Plaintext-Limits; Medien-Verweise bleiben erhalten;",
+ "Ueungsformular Tab Anleitung: Button „KI: Anleitung ueberarbeiten“ mit Vorschau-Dialog pro Feld.",
+ ],
+ },
+ {
+ "version": "0.8.162",
+ "date": "2026-05-31",
+ "changes": [
+ "KI Prompt P1 abgeschlossen: ai_prompt_context (Formular-Kontext), run_exercise_form_ai_suggestion als gemeinsamer Einstieg;",
+ "POST /exercises/ai/suggest und regenerate bauen ExerciseFormAiPromptContext; Admin-Vorschau nutzt dasselbe Modell.",
+ ],
+ },
+ {
+ "version": "0.8.161",
+ "date": "2026-05-31",
+ "changes": [
+ "Migration 070: ai_prompts.openrouter_model (optional je Prompt; Fallback OPENROUTER_MODEL).",
+ "exercise_ai: effektives OpenRouter-Modell pro Slug; API-Response models_by_slug + model (Skills bevorzugt).",
+ "Superadmin „KI Prompts“: OpenRouter-Modell speicherbar.",
+ ],
+ },
+ {
+ "version": "0.8.160",
+ "date": "2026-05-30",
+ "changes": [
+ "KI Prompt P1: ai_prompt_runtime load_and_render_ai_prompt + render_ai_prompt_template_for_row; AiPromptUnavailableError;",
+ "Neu ai_prompt_job: ExerciseFormAiPromptContext (Pydantic), resolve_exercise_form_variables; Admin-Prompt-Vorschau nutzt gleichen Pfad wie exercise_ai-Logik;",
+ "Zielarchitektur-Doku: Phasendiagramm P0/P1 angepasst.",
+ ],
+ },
{
"version": "0.8.159",
"date": "2026-05-30",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 48abe41..38485c8 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
-**Stand:** 2026-05-30
-**App-Version / DB-Schema:** App **`0.8.159`** u. a. **KI-Prompt-Zielarchitektur** + gemeinsames Modul **`ai_prompt_runtime`**; DB-Schema **`backend/version.py`** → `APP_VERSION`, `DB_SCHEMA_VERSION` (aktuell `20260530069`).
+**Stand:** 2026-05-31
+**App-Version / DB-Schema:** App **`0.8.166`** (KI Schnellanlage Suche+Anlage); DB **`20260531071`** — maßgeblich **`backend/version.py`**.
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@@ -89,16 +89,18 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
-### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.159**)
+### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.166**)
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
-- **Runtime:** **`backend/ai_prompt_runtime.py`** — `AiPromptContextKind`, `load_ai_prompt_row` (gemeinsamer DB-Lesezugriff, kein Import von `exercise_ai`); **`exercise_ai`** nutzt `load_ai_prompt_row` fuer aktive Prompts
-- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`069`** setzt **`default_template`** wo leer; Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
-- **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff)
-- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas` — **Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`), **`/api/admin/ai-prompts*`** (`routers/ai_prompts_admin.py`), UI **`/admin/ai-prompts`**
-- **Diagnose bei leerem Dialog / Fehlern:** Umgebungsvariable **`SHINKAN_AI_DEBUG=1`** auf der API; in den Logs dann **`AI_DEBUG`** (`shinkan.exercise_ai`) und **`[AI_DEBUG/openrouter]`** (`shinkan.openrouter`) mit Prompt-Längen, Token-Zahlen und ggf. JSON-Parse-Anfang
-- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiPromptsPage.jsx`** (`/admin/ai-prompts`), **`AdminAiSkillRetrievalPage.jsx`** (`/admin/ai-skill-retrieval`)
+- **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter
+- **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
+- **Prompt-Slugs:** `exercise_summary`, `exercise_skill_suggestions`, **`exercise_instruction_rewrite`** (Anleitung JSON, prägnant, HTML p/ul/ol/li)
+- **API:** `POST /api/exercises/ai/suggest` — **`include_instructions`**, Body **`preparation`**, **`trainer_notes`**; Response **`instructions.fields`**; **`POST …/ai/regenerate`** mit **`instructions`** in `regenerate`
+- **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`**
+- **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter`
+- **Frontend Formular:** Tab **Anleitung** — **„KI: Anleitung überarbeiten“**; Vorschau-Dialog pro Feld (**`ExerciseFormPageRoot.jsx`**)
+- **Frontend Schnellanlage:** **`ExercisePickerModal`** (Planung/Rahmen) — Volltextsuche; bei keinem Treffer **„Mit KI anlegen“** (Suchstring → Titel/Skizze); Entwurf im **Rich-Text-Dialog** bearbeiten, dann speichern & übernehmen. **`ExercisesListPageRoot`** — gleiches Muster + Schalter **„KI-Anlage“** in der Suchleiste.
---
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 757bb70..00308ee 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -3733,6 +3733,68 @@ html.modal-scroll-locked .app-main {
.exercises-page__title {
margin: 0;
}
+.exercises-page__header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+}
+.exercises-ai-assistant-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ user-select: none;
+ font-size: 14px;
+ color: var(--text2);
+ padding: 6px 10px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+}
+.exercises-ai-assistant-toggle:hover {
+ border-color: var(--accent-dark, rgba(29, 158, 117, 0.45));
+}
+.exercises-ai-assistant-toggle:has(input:checked) {
+ border-color: var(--accent);
+ background: var(--accent-light, rgba(29, 158, 117, 0.12));
+ color: var(--accent-dark);
+ font-weight: 600;
+}
+.exercises-ai-assistant-toggle input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ pointer-events: none;
+}
+.exercises-ai-assistant-toggle__track {
+ position: relative;
+ flex-shrink: 0;
+ width: 40px;
+ height: 22px;
+ border-radius: 999px;
+ background: var(--border);
+ transition: background 0.15s ease;
+}
+.exercises-ai-assistant-toggle__track::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+ transition: transform 0.15s ease;
+}
+.exercises-ai-assistant-toggle:has(input:checked) .exercises-ai-assistant-toggle__track {
+ background: var(--accent);
+}
+.exercises-ai-assistant-toggle:has(input:checked) .exercises-ai-assistant-toggle__track::after {
+ transform: translateX(18px);
+}
.exercises-page-toolbar-tabs {
margin-bottom: 14px;
}
diff --git a/frontend/src/components/ExerciseAiQuickCreateOffer.jsx b/frontend/src/components/ExerciseAiQuickCreateOffer.jsx
new file mode 100644
index 0000000..206d6b4
--- /dev/null
+++ b/frontend/src/components/ExerciseAiQuickCreateOffer.jsx
@@ -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 (
+
+
+ Keine passende Übung gefunden
+
+
+ {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.')}
+
+
+
+
+
+ onTitleChange(e.target.value)}
+ autoComplete="off"
+ maxLength={300}
+ placeholder="Titel der neuen Übung"
+ />
+
- Wird mit Freigabelevel privat und Status Entwurf gespeichert und
- erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den
- Ablauf übernommen.
-
- 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.'}
- KI-Unterstützung: OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung
+ KI-Unterstützung: OpenRouter-Vorschläge für Kurzfassung, Fähigkeiten und Anleitung
(suggestExerciseAi / regenerateExerciseAi). Übernahme im Dialog ins Formular; Speichern
wie gewohnt.