KI Übungen - MVP 0.8 #48
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
105
backend/ai_prompt_context.py
Normal file
105
backend/ai_prompt_context.py
Normal 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
58
backend/ai_prompt_job.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, F
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
|
|
@ -16,10 +17,17 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence,
|
|||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
|
||||
from openrouter_chat import (
|
||||
OpenRouterError,
|
||||
default_openrouter_model_id,
|
||||
effective_openrouter_model_for_prompt_row,
|
||||
normalize_openrouter_env,
|
||||
openrouter_chat_completion,
|
||||
)
|
||||
|
||||
from ai_prompt_runtime import load_ai_prompt_row
|
||||
from prompt_resolver import render_mustache_template
|
||||
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||
from exercise_rich_text import collect_inline_exercise_media_ids, normalize_inline_exercise_media_markup
|
||||
|
||||
_LOGGER = logging.getLogger("shinkan.exercise_ai")
|
||||
|
||||
|
|
@ -491,6 +499,146 @@ def build_contextual_skills_catalog_block(
|
|||
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
|
||||
|
||||
|
||||
_MAX_INSTRUCTION_GOAL_PLAIN = 4_000
|
||||
_MAX_INSTRUCTION_EXECUTION_PLAIN = 12_000
|
||||
_MAX_INSTRUCTION_PREP_PLAIN = 2_500
|
||||
_MAX_INSTRUCTION_TRAINER_PLAIN = 2_500
|
||||
|
||||
_INSTRUCTION_JSON_KEYS = ("goal", "execution", "preparation", "trainer_notes")
|
||||
_INSTRUCTION_FIELD_MAX_PLAIN = {
|
||||
"goal": _MAX_INSTRUCTION_GOAL_PLAIN,
|
||||
"execution": _MAX_INSTRUCTION_EXECUTION_PLAIN,
|
||||
"preparation": _MAX_INSTRUCTION_PREP_PLAIN,
|
||||
"trainer_notes": _MAX_INSTRUCTION_TRAINER_PLAIN,
|
||||
}
|
||||
|
||||
_DISALLOWED_HTML_TAG_RE = re.compile(
|
||||
r"</?\s*(?!p\b|ul\b|ol\b|li\b|strong\b|b\b|em\b|i\b|br\b|span\b)[a-zA-Z][^>]*>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SCRIPT_STYLE_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
|
||||
|
||||
|
||||
def _plain_to_minimal_instruction_html(text: str) -> str:
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
parts = [p.strip() for p in re.split(r"\n+", raw) if p.strip()]
|
||||
if not parts:
|
||||
return ""
|
||||
return "".join(f"<p>{html.escape(p)}</p>" for p in parts)
|
||||
|
||||
|
||||
def _truncate_plain(text: str, max_len: int) -> str:
|
||||
t = (text or "").strip()
|
||||
if len(t) <= max_len:
|
||||
return t
|
||||
return t[: max_len - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _sanitize_instruction_field_html(raw: Any, *, max_plain: int) -> str:
|
||||
if raw is None:
|
||||
return ""
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return ""
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||
if s.endswith("```"):
|
||||
s = s[:-3].strip()
|
||||
s = _SCRIPT_STYLE_RE.sub("", s)
|
||||
s = _DISALLOWED_HTML_TAG_RE.sub("", s)
|
||||
if "<" not in s:
|
||||
s = _plain_to_minimal_instruction_html(s)
|
||||
else:
|
||||
s = normalize_inline_exercise_media_markup(s) or ""
|
||||
plain = strip_html_to_plain(s, max_len=max_plain + 200)
|
||||
if len(plain) > max_plain:
|
||||
plain = _truncate_plain(plain, max_plain)
|
||||
s = _plain_to_minimal_instruction_html(plain)
|
||||
return (normalize_inline_exercise_media_markup(s) or "").strip()
|
||||
|
||||
|
||||
def _merge_preserved_inline_media(original: Optional[str], revised: str) -> str:
|
||||
"""Haengt fehlende Medien-Verweise aus dem Ausgangstext ans Ende an."""
|
||||
out = (revised or "").strip()
|
||||
orig_ids = collect_inline_exercise_media_ids(original)
|
||||
if not orig_ids:
|
||||
return out
|
||||
new_ids = collect_inline_exercise_media_ids(out)
|
||||
missing = sorted(orig_ids - new_ids)
|
||||
if not missing:
|
||||
return out
|
||||
spans = []
|
||||
for mid in missing:
|
||||
spans.append(
|
||||
f'<span data-shinkan-exercise-media="{mid}" data-shinkan-exercise-media-size="medium" '
|
||||
f'class="shinkan-inline-media"></span>'
|
||||
)
|
||||
block = f"<p>{''.join(spans)}</p>"
|
||||
return (out + block).strip() if out else block
|
||||
|
||||
|
||||
def _first_balanced_json_object(text: str) -> Optional[str]:
|
||||
i = text.find("{")
|
||||
if i < 0:
|
||||
return None
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
for j in range(i, len(text)):
|
||||
ch = text[j]
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif ch == "\\":
|
||||
esc = True
|
||||
elif ch == '"':
|
||||
in_str = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
continue
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[i : j + 1]
|
||||
return None
|
||||
|
||||
|
||||
def _extract_instruction_rewrite_object(text: str) -> Dict[str, Any]:
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
raise ValueError("leer")
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||
if s.endswith("```"):
|
||||
s = s[:-3].strip()
|
||||
frag = _first_balanced_json_object(s)
|
||||
if frag:
|
||||
s = frag
|
||||
obj = json.loads(s)
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("kein JSON-Objekt")
|
||||
return obj
|
||||
|
||||
|
||||
def _sanitize_instruction_rewrite_payload(
|
||||
parsed: Mapping[str, Any],
|
||||
*,
|
||||
originals: Mapping[str, Optional[str]],
|
||||
) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
for key in _INSTRUCTION_JSON_KEYS:
|
||||
max_plain = _INSTRUCTION_FIELD_MAX_PLAIN[key]
|
||||
html = _sanitize_instruction_field_html(parsed.get(key), max_plain=max_plain)
|
||||
html = _merge_preserved_inline_media(originals.get(key), html)
|
||||
out[key] = html
|
||||
return out
|
||||
|
||||
|
||||
def build_exercise_placeholder_variables(
|
||||
cur,
|
||||
*,
|
||||
|
|
@ -500,6 +648,8 @@ def build_exercise_placeholder_variables(
|
|||
execution: Optional[str],
|
||||
focus_area_hint: Optional[str],
|
||||
focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
|
||||
preparation: Optional[str] = None,
|
||||
trainer_notes: Optional[str] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
|
||||
|
|
@ -509,6 +659,8 @@ def build_exercise_placeholder_variables(
|
|||
return {}
|
||||
g_plain = strip_html_to_plain(goal)
|
||||
e_plain = strip_html_to_plain(execution)
|
||||
p_plain = strip_html_to_plain(preparation)
|
||||
n_plain = strip_html_to_plain(trainer_notes)
|
||||
t_title = (title or "").strip()
|
||||
focus = (focus_area_hint or "").strip()
|
||||
ctx: Dict[str, str] = {
|
||||
|
|
@ -516,8 +668,12 @@ def build_exercise_placeholder_variables(
|
|||
"exercise_focus_area": focus or "-",
|
||||
"exercise_goal": g_plain or "-",
|
||||
"exercise_execution": e_plain or "-",
|
||||
"exercise_preparation": p_plain or "-",
|
||||
"exercise_trainer_notes": n_plain or "-",
|
||||
}
|
||||
if s == "exercise_summary":
|
||||
return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")}
|
||||
if s == "exercise_instruction_rewrite":
|
||||
return ctx
|
||||
if s == "exercise_skill_suggestions":
|
||||
catalog = build_contextual_skills_catalog_block(
|
||||
|
|
@ -664,32 +820,43 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
|
|||
return out[:5]
|
||||
|
||||
|
||||
def _require_openrouter() -> Tuple[str, str]:
|
||||
key, model = normalize_openrouter_env()
|
||||
def _require_openrouter_key() -> str:
|
||||
key, _ = normalize_openrouter_env()
|
||||
if not key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
|
||||
)
|
||||
return key, model
|
||||
return key
|
||||
|
||||
|
||||
def run_exercise_ai_suggestion(
|
||||
cur,
|
||||
*,
|
||||
title: Optional[str],
|
||||
goal: Optional[str],
|
||||
execution: Optional[str],
|
||||
focus_area_hint: Optional[str],
|
||||
focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None,
|
||||
form_ctx: ExerciseFormAiPromptContext,
|
||||
want_summary: bool,
|
||||
want_skills: bool,
|
||||
want_instructions: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
key, model = _require_openrouter()
|
||||
key = _require_openrouter_key()
|
||||
|
||||
title = form_ctx.title
|
||||
goal = form_ctx.goal
|
||||
execution = form_ctx.execution
|
||||
preparation = form_ctx.preparation
|
||||
trainer_notes = form_ctx.trainer_notes
|
||||
focus_area_hint = form_ctx.focus_hint
|
||||
focus_areas_context = form_ctx.focus_area_tuples()
|
||||
|
||||
g_plain = strip_html_to_plain(goal)
|
||||
e_plain = strip_html_to_plain(execution)
|
||||
if not (g_plain.strip() or e_plain.strip()):
|
||||
if want_instructions:
|
||||
if not form_ctx.has_instruction_source_text():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Fuer Anleitungs-Ueberarbeitung mindestens Titel oder ein Anleitungsfeld ausfuellen.",
|
||||
)
|
||||
elif not (g_plain.strip() or e_plain.strip()):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).",
|
||||
|
|
@ -698,26 +865,25 @@ def run_exercise_ai_suggestion(
|
|||
t_title = (title or "").strip()
|
||||
focus = (focus_area_hint or "").strip()
|
||||
|
||||
result: Dict[str, Any] = {"model": model}
|
||||
result: Dict[str, Any] = {}
|
||||
models_by_slug: Dict[str, str] = {}
|
||||
|
||||
if _ai_debug_on():
|
||||
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
|
||||
_LOGGER.warning(
|
||||
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s "
|
||||
"exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
|
||||
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s want_instructions=%s "
|
||||
"title_chars=%s goal_plain_chars=%s exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
|
||||
want_summary,
|
||||
want_skills,
|
||||
len(t_title),
|
||||
want_instructions,
|
||||
len((title or "").strip()),
|
||||
len(g_plain),
|
||||
len(e_plain),
|
||||
len(focus),
|
||||
len((focus_area_hint or "").strip()),
|
||||
fid_list,
|
||||
)
|
||||
|
||||
if want_summary:
|
||||
prow = load_ai_prompt_row(cur, "exercise_summary")
|
||||
if not prow:
|
||||
raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
|
||||
try:
|
||||
ctx = build_exercise_placeholder_variables(
|
||||
cur,
|
||||
|
|
@ -730,7 +896,15 @@ def run_exercise_ai_suggestion(
|
|||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
rendered = render_mustache_template(str(prow["template"]), ctx)
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, "exercise_summary", ctx)
|
||||
except AiPromptUnavailableError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.",
|
||||
) from None
|
||||
model_summary = effective_openrouter_model_for_prompt_row(prow)
|
||||
models_by_slug["exercise_summary"] = model_summary
|
||||
prompt = rendered.text
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
|
|
@ -739,7 +913,7 @@ def run_exercise_ai_suggestion(
|
|||
len(rendered.placeholders_remaining),
|
||||
)
|
||||
try:
|
||||
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
|
||||
raw = openrouter_chat_completion(api_key=key, model=model_summary, user_content=prompt)
|
||||
except OpenRouterError as e:
|
||||
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
||||
if _ai_debug_on():
|
||||
|
|
@ -752,15 +926,9 @@ def run_exercise_ai_suggestion(
|
|||
)
|
||||
if len(text) > _MAX_SUMMARY_CHARS:
|
||||
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…"
|
||||
result["summary"] = {"text": text, "ai_generated": True, "model": model}
|
||||
result["summary"] = {"text": text, "ai_generated": True, "model": model_summary}
|
||||
|
||||
if want_skills:
|
||||
srow = load_ai_prompt_row(cur, "exercise_skill_suggestions")
|
||||
if not srow:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
|
||||
)
|
||||
try:
|
||||
ctx = build_exercise_placeholder_variables(
|
||||
cur,
|
||||
|
|
@ -773,7 +941,15 @@ def run_exercise_ai_suggestion(
|
|||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
rendered = render_mustache_template(str(srow["template"]), ctx)
|
||||
try:
|
||||
srow, rendered = load_and_render_ai_prompt(cur, "exercise_skill_suggestions", ctx)
|
||||
except AiPromptUnavailableError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
|
||||
) from None
|
||||
model_skills = effective_openrouter_model_for_prompt_row(srow)
|
||||
models_by_slug["exercise_skill_suggestions"] = model_skills
|
||||
prompt = rendered.text
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
|
|
@ -789,7 +965,7 @@ def run_exercise_ai_suggestion(
|
|||
try:
|
||||
raw = openrouter_chat_completion(
|
||||
api_key=key,
|
||||
model=model,
|
||||
model=model_skills,
|
||||
user_content=prompt,
|
||||
system_content=sys_hint,
|
||||
temperature=0.15,
|
||||
|
|
@ -827,6 +1003,97 @@ def run_exercise_ai_suggestion(
|
|||
|
||||
result["skills"] = skills
|
||||
|
||||
if want_instructions:
|
||||
try:
|
||||
ctx = build_exercise_placeholder_variables(
|
||||
cur,
|
||||
slug="exercise_instruction_rewrite",
|
||||
title=title,
|
||||
goal=goal,
|
||||
execution=execution,
|
||||
preparation=preparation,
|
||||
trainer_notes=trainer_notes,
|
||||
focus_area_hint=focus_area_hint,
|
||||
focus_areas_context=focus_areas_context,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
try:
|
||||
irow, rendered = load_and_render_ai_prompt(cur, "exercise_instruction_rewrite", ctx)
|
||||
except AiPromptUnavailableError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Prompt exercise_instruction_rewrite nicht aktiv oder fehlt in DB.",
|
||||
) from None
|
||||
model_instr = effective_openrouter_model_for_prompt_row(irow)
|
||||
models_by_slug["exercise_instruction_rewrite"] = model_instr
|
||||
prompt = rendered.text
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
"AI_DEBUG exercise_ai instructions prompt_slug=exercise_instruction_rewrite prompt_chars=%s",
|
||||
len(prompt),
|
||||
)
|
||||
sys_hint = (
|
||||
"Du antwortest nur mit validem JSON-Objekt (Schluessel goal, execution, preparation, trainer_notes). "
|
||||
"Keine Kommentare ausserhalb des JSON."
|
||||
)
|
||||
try:
|
||||
raw = openrouter_chat_completion(
|
||||
api_key=key,
|
||||
model=model_instr,
|
||||
user_content=prompt,
|
||||
system_content=sys_hint,
|
||||
temperature=0.2,
|
||||
)
|
||||
except OpenRouterError as e:
|
||||
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
||||
body = (raw or "").strip()
|
||||
if not body:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="OpenRouter/KI lieferte leeren Inhalt fuer Anleitungs-Ueberarbeitung.",
|
||||
)
|
||||
try:
|
||||
parsed = _extract_instruction_rewrite_object(body)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
if _ai_debug_on():
|
||||
_LOGGER.warning(
|
||||
"AI_DEBUG exercise_ai instructions JSON parse_failed err=%s head=%s",
|
||||
e,
|
||||
(body.replace("\r", "").replace("\n", " ").strip())[:400],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="KI lieferte kein verwertbares JSON fuer die Anleitung.",
|
||||
) from e
|
||||
originals = {
|
||||
"goal": goal,
|
||||
"execution": execution,
|
||||
"preparation": preparation,
|
||||
"trainer_notes": trainer_notes,
|
||||
}
|
||||
fields = _sanitize_instruction_rewrite_payload(parsed, originals=originals)
|
||||
if not any((fields.get(k) or "").strip() for k in _INSTRUCTION_JSON_KEYS):
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="KI lieferte leere Anleitungs-Felder.",
|
||||
)
|
||||
result["instructions"] = {
|
||||
"fields": fields,
|
||||
"ai_generated": True,
|
||||
"model": model_instr,
|
||||
}
|
||||
|
||||
result["models_by_slug"] = models_by_slug
|
||||
if want_skills:
|
||||
result["model"] = models_by_slug["exercise_skill_suggestions"]
|
||||
elif want_instructions:
|
||||
result["model"] = models_by_slug["exercise_instruction_rewrite"]
|
||||
elif want_summary:
|
||||
result["model"] = models_by_slug["exercise_summary"]
|
||||
else:
|
||||
result["model"] = default_openrouter_model_id()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
7
backend/migrations/070_ai_prompts_openrouter_model.sql
Normal file
7
backend/migrations/070_ai_prompts_openrouter_model.sql
Normal 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';
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
-- Migration 071: KI-Prompt fuer Anleitungs-Ueberarbeitung (Ziel, Durchfuehrung, Vorbereitung, Trainer-Hinweise)
|
||||
-- JSON-Ausgabe; praezise HTML-Fragmente fuer RichTextEditor.
|
||||
|
||||
INSERT INTO ai_prompts (
|
||||
slug, display_name, description, template,
|
||||
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||
)
|
||||
SELECT
|
||||
'exercise_instruction_rewrite',
|
||||
'Anleitung ueberarbeiten',
|
||||
'Ueberarbeitet Ziel, Durchfuehrung, Vorbereitung und Trainer-Hinweise — praezise, strukturiert, ohne Aufblaehen.',
|
||||
$t$Du bist Assistent fuer Kampfsport-Trainer.
|
||||
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
|
||||
Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
|
||||
|
||||
Stil:
|
||||
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
|
||||
- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
|
||||
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
|
||||
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
|
||||
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
|
||||
|
||||
Format (HTML fuer Rich-Text-Editor):
|
||||
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
|
||||
- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
|
||||
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
|
||||
|
||||
Eingabe:
|
||||
Titel: {{exercise_title}}
|
||||
Fokuskontext: {{exercise_focus_area}}
|
||||
|
||||
Ziel (Plaintext, Ausgang): {{exercise_goal}}
|
||||
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
|
||||
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
|
||||
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
|
||||
|
||||
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
|
||||
{
|
||||
"goal": "<p>…</p>",
|
||||
"execution": "<ol><li>…</li></ol>",
|
||||
"preparation": "<p>…</p> oder \"\"",
|
||||
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
|
||||
}
|
||||
|
||||
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$,
|
||||
'exercise',
|
||||
'json',
|
||||
'{"type":"object","required":["goal","execution","preparation","trainer_notes"],"properties":{"goal":{"type":"string"},"execution":{"type":"string"},"preparation":{"type":"string"},"trainer_notes":{"type":"string"}}}'::jsonb,
|
||||
true,
|
||||
NULL,
|
||||
true,
|
||||
3
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'exercise_instruction_rewrite');
|
||||
|
||||
-- Referenztext fuer Admin-Ruecksetzen (wie 069)
|
||||
UPDATE ai_prompts
|
||||
SET default_template = template
|
||||
WHERE slug = 'exercise_instruction_rewrite'
|
||||
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
123
frontend/src/components/ExerciseAiQuickCreateOffer.jsx
Normal file
123
frontend/src/components/ExerciseAiQuickCreateOffer.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
227
frontend/src/components/ExerciseAiSuggestPreviewModal.jsx
Normal file
227
frontend/src/components/ExerciseAiSuggestPreviewModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,16 +17,20 @@ import {
|
|||
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
|
||||
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||||
import CatalogRulePicker from './CatalogRulePicker'
|
||||
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
||||
import ExerciseAiQuickCreateOffer from './ExerciseAiQuickCreateOffer'
|
||||
import { useExerciseAiQuickCreateFields } from '../hooks/useExerciseAiQuickCreateFields'
|
||||
import {
|
||||
buildQuickCreateAiPreview,
|
||||
buildQuickCreateExercisePayloadFromDraft,
|
||||
aiPreviewToQuickCreateDraft,
|
||||
} from '../utils/exerciseAiQuickCreate'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||
|
||||
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
||||
|
||||
/** Stub-Ziel für API-Validator (mind. Ziel oder Durchführung); Nutzer ergänzt Details in der Übungsbearbeitung. */
|
||||
const QUICK_CREATE_GOAL_PLACEHOLDER =
|
||||
'Aus der Trainingsplanung angelegt — bitte Ziel und Durchführung in der Übungsbearbeitung ergänzen.'
|
||||
|
||||
export default function ExercisePickerModal({
|
||||
open,
|
||||
onClose,
|
||||
|
|
@ -57,12 +61,21 @@ export default function ExercisePickerModal({
|
|||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [multiPicked, setMultiPicked] = useState([])
|
||||
const [quickOpen, setQuickOpen] = useState(false)
|
||||
const [quickTitle, setQuickTitle] = useState('')
|
||||
const [quickSummary, setQuickSummary] = useState('')
|
||||
const [quickSaving, setQuickSaving] = useState(false)
|
||||
const [quickAiError, setQuickAiError] = useState('')
|
||||
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||
const pickerScrollRef = useRef(null)
|
||||
|
||||
const {
|
||||
title: quickTitle,
|
||||
sketch: quickSketch,
|
||||
focusAreaId: quickFocusAreaId,
|
||||
setTitle: setQuickTitle,
|
||||
setSketch: setQuickSketch,
|
||||
setFocusAreaId: setQuickFocusAreaId,
|
||||
resetQuickCreateFields,
|
||||
} = useExerciseAiQuickCreateFields(debouncedSearch, { enabled: open && enableQuickCreateDraft })
|
||||
|
||||
const toggleMultiPick = (ex) => {
|
||||
setMultiPicked((prev) =>
|
||||
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
|
||||
|
|
@ -79,6 +92,13 @@ export default function ExercisePickerModal({
|
|||
return () => clearTimeout(t)
|
||||
}, [aiSearchInput])
|
||||
|
||||
const showQuickCreateOffer =
|
||||
enableQuickCreateDraft &&
|
||||
catalogsReady &&
|
||||
!loading &&
|
||||
debouncedSearch.length >= 3 &&
|
||||
list.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
|
|
@ -122,10 +142,10 @@ export default function ExercisePickerModal({
|
|||
setList([])
|
||||
setHasMore(false)
|
||||
setMultiPicked([])
|
||||
setQuickOpen(false)
|
||||
setQuickTitle('')
|
||||
setQuickSummary('')
|
||||
resetQuickCreateFields()
|
||||
setQuickSaving(false)
|
||||
setQuickAiError('')
|
||||
setQuickCreateDraft(null)
|
||||
return
|
||||
}
|
||||
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
|
||||
|
|
@ -285,43 +305,83 @@ export default function ExercisePickerModal({
|
|||
|
||||
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
|
||||
|
||||
const submitQuickCreate = async () => {
|
||||
const adoptExistingExercise = async (ex) => {
|
||||
if (!ex?.id) return
|
||||
if (multiSelect && typeof onSelectExercises === 'function') {
|
||||
await Promise.resolve(onSelectExercises([ex]))
|
||||
} else if (typeof onSelectExercise === 'function') {
|
||||
await Promise.resolve(onSelectExercise(ex))
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const runQuickCreateAiSuggest = async () => {
|
||||
const title = (quickTitle || '').trim()
|
||||
if (title.length < 3) {
|
||||
alert('Titel: mindestens 3 Zeichen.')
|
||||
return
|
||||
}
|
||||
const summaryRaw = (quickSummary || '').trim()
|
||||
const sketch = (quickSketch || '').trim()
|
||||
|
||||
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
|
||||
if (!Number.isFinite(focusId) || focusId < 1) {
|
||||
alert('Bitte einen Fokusbereich wählen.')
|
||||
return
|
||||
}
|
||||
|
||||
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
|
||||
const focusHint = (focusRow?.name || '').trim()
|
||||
|
||||
setQuickAiError('')
|
||||
setQuickCreateDraft(null)
|
||||
setQuickSaving(true)
|
||||
try {
|
||||
const created = await api.createExercise({
|
||||
const aiRes = await api.suggestExerciseAi({
|
||||
title,
|
||||
summary: summaryRaw || null,
|
||||
goal: QUICK_CREATE_GOAL_PLACEHOLDER,
|
||||
execution: null,
|
||||
visibility: 'private',
|
||||
status: 'draft',
|
||||
equipment: [],
|
||||
focus_areas_multi: [],
|
||||
training_styles_multi: [],
|
||||
training_types_multi: [],
|
||||
target_groups_multi: [],
|
||||
age_groups: [],
|
||||
skills: [],
|
||||
club_id: null,
|
||||
goal: sketch || undefined,
|
||||
execution: '',
|
||||
preparation: '',
|
||||
trainer_notes: '',
|
||||
focus_area_hint: focusHint || undefined,
|
||||
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
|
||||
include_summary: true,
|
||||
include_skills: true,
|
||||
include_instructions: true,
|
||||
})
|
||||
if (!created?.id) {
|
||||
throw new Error('Anlegen fehlgeschlagen')
|
||||
|
||||
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
|
||||
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
|
||||
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
|
||||
}
|
||||
if (multiSelect && typeof onSelectExercises === 'function') {
|
||||
await Promise.resolve(onSelectExercises([created]))
|
||||
} else if (typeof onSelectExercise === 'function') {
|
||||
await Promise.resolve(onSelectExercise(created))
|
||||
}
|
||||
onClose()
|
||||
setQuickCreateDraft(
|
||||
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert(e.message || 'Übung konnte nicht angelegt werden')
|
||||
const msg = e?.message || String(e)
|
||||
setQuickAiError(msg)
|
||||
alert(msg || 'KI-Vorschlag fehlgeschlagen')
|
||||
} finally {
|
||||
setQuickSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyQuickCreateDraft = async () => {
|
||||
if (!quickCreateDraft) return
|
||||
|
||||
setQuickSaving(true)
|
||||
setQuickAiError('')
|
||||
try {
|
||||
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
|
||||
const created = await api.createExercise(payload)
|
||||
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||
setQuickCreateDraft(null)
|
||||
await adoptExistingExercise(created)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
const msg = e?.message || String(e)
|
||||
setQuickAiError(msg)
|
||||
alert(msg || 'Übung konnte nicht angelegt werden')
|
||||
} finally {
|
||||
setQuickSaving(false)
|
||||
}
|
||||
|
|
@ -353,75 +413,6 @@ export default function ExercisePickerModal({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{enableQuickCreateDraft ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 1rem 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => setQuickOpen((v) => !v)}
|
||||
aria-expanded={quickOpen}
|
||||
>
|
||||
{quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung anlegen (Entwurf, privat)'}
|
||||
</button>
|
||||
{quickOpen ? (
|
||||
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
|
||||
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||
Wird mit Freigabelevel <strong>privat</strong> und Status <strong>Entwurf</strong> gespeichert und
|
||||
erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den
|
||||
Ablauf übernommen.
|
||||
</p>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="ex-picker-quick-title">
|
||||
Titel
|
||||
</label>
|
||||
<input
|
||||
id="ex-picker-quick-title"
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={quickTitle}
|
||||
onChange={(e) => setQuickTitle(e.target.value)}
|
||||
autoComplete="off"
|
||||
minLength={3}
|
||||
maxLength={300}
|
||||
placeholder="z. B. Partnerübung Abwehr"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="ex-picker-quick-summary">
|
||||
Kurzbeschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="ex-picker-quick-summary"
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={quickSummary}
|
||||
onChange={(e) => setQuickSummary(e.target.value)}
|
||||
placeholder="Optional: grobe Idee, Kontext aus der Planung …"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={quickSaving || (quickTitle || '').trim().length < 3}
|
||||
onClick={submitQuickCreate}
|
||||
>
|
||||
{quickSaving ? 'Wird angelegt…' : 'Entwurf anlegen und übernehmen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
||||
<div>
|
||||
|
|
@ -602,7 +593,28 @@ export default function ExercisePickerModal({
|
|||
<div className="spinner" />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>Keine Treffer.</p>
|
||||
showQuickCreateOffer ? (
|
||||
<ExerciseAiQuickCreateOffer
|
||||
searchLabel={debouncedSearch}
|
||||
title={quickTitle}
|
||||
onTitleChange={setQuickTitle}
|
||||
sketch={quickSketch}
|
||||
onSketchChange={setQuickSketch}
|
||||
focusAreaId={quickFocusAreaId}
|
||||
onFocusAreaChange={setQuickFocusAreaId}
|
||||
focusAreas={catalogs.focusAreas}
|
||||
catalogsReady={catalogsReady}
|
||||
busy={quickSaving}
|
||||
error={quickAiError}
|
||||
onRunAi={runQuickCreateAiSuggest}
|
||||
/>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||
{debouncedSearch.length >= 3
|
||||
? 'Keine Treffer.'
|
||||
: 'Suchbegriff eingeben (mind. 3 Zeichen) …'}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
|
||||
|
|
@ -770,6 +782,20 @@ export default function ExercisePickerModal({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExerciseAiSuggestPreviewModal
|
||||
draft={quickCreateDraft}
|
||||
onDraftChange={setQuickCreateDraft}
|
||||
onDiscard={() => setQuickCreateDraft(null)}
|
||||
onApply={applyQuickCreateDraft}
|
||||
focusAreas={catalogs.focusAreas}
|
||||
skillsCatalog={catalogs.skills}
|
||||
dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
|
||||
hint="Texte sind formatiert — passe Titel, Kurzfassung, Anleitung und Fähigkeiten an, dann speichern und übernehmen."
|
||||
applyLabel={quickSaving ? 'Wird angelegt…' : 'Anlegen und übernehmen'}
|
||||
applyDisabled={quickSaving}
|
||||
zIndex={2100}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,13 @@ function aiPlainSummaryToMinimalHtml(text) {
|
|||
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('')
|
||||
}
|
||||
|
||||
const INSTRUCTION_AI_FIELD_DEFS = [
|
||||
{ key: 'goal', label: 'Ziel' },
|
||||
{ key: 'execution', label: 'Durchführung' },
|
||||
{ key: 'preparation', label: 'Vorbereitung / Aufbau' },
|
||||
{ key: 'trainer_notes', label: 'Hinweise für Trainer' },
|
||||
]
|
||||
|
||||
function cloneExerciseSkillRows(rows) {
|
||||
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
|
||||
}
|
||||
|
|
@ -110,9 +117,16 @@ function buildNormalizedAiSkillRowFromApi(sug) {
|
|||
}
|
||||
}
|
||||
|
||||
function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotSkills, apiRes }) {
|
||||
const summaryRequested = mode !== 'skills'
|
||||
const skillsRequested = mode !== 'summary'
|
||||
function buildExerciseAiSuggestionPreview({
|
||||
mode,
|
||||
snapshotSummaryHtml,
|
||||
snapshotSkills,
|
||||
snapshotInstructions,
|
||||
apiRes,
|
||||
}) {
|
||||
const summaryRequested = mode !== 'skills' && mode !== 'instructions'
|
||||
const skillsRequested = mode !== 'summary' && mode !== 'instructions'
|
||||
const instructionsRequested = mode === 'instructions'
|
||||
|
||||
let summaryAfterHtml = null
|
||||
let summaryAfterPlain = ''
|
||||
|
|
@ -141,8 +155,29 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS
|
|||
}
|
||||
}
|
||||
|
||||
const instructionChoices = []
|
||||
if (instructionsRequested && apiRes.instructions?.fields) {
|
||||
const fields = apiRes.instructions.fields
|
||||
const snap = snapshotInstructions || {}
|
||||
for (const def of INSTRUCTION_AI_FIELD_DEFS) {
|
||||
const afterHtml = fields[def.key]
|
||||
if (!afterHtml || !String(afterHtml).trim()) continue
|
||||
const beforeHtml = snap[def.key] || ''
|
||||
instructionChoices.push({
|
||||
key: def.key,
|
||||
field: def.key,
|
||||
label: def.label,
|
||||
beforePlain: stripHtmlToText(beforeHtml).trim(),
|
||||
afterHtml: String(afterHtml),
|
||||
afterPlain: stripHtmlToText(afterHtml).trim(),
|
||||
include: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
|
||||
const hasSkillChoices = skillChoices.length > 0
|
||||
const hasInstructionChoices = instructionChoices.length > 0
|
||||
|
||||
return {
|
||||
mode,
|
||||
|
|
@ -151,10 +186,13 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS
|
|||
summaryAfterPlain,
|
||||
summaryAfterHtml,
|
||||
skillChoices,
|
||||
instructionChoices,
|
||||
hasSummaryProposal,
|
||||
hasSkillChoices,
|
||||
hasInstructionChoices,
|
||||
summaryRequested,
|
||||
skillsRequested,
|
||||
instructionsRequested,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1027,20 +1065,96 @@ function ExerciseFormPageRoot() {
|
|||
}
|
||||
}
|
||||
|
||||
const runExerciseAiInstructionRewrite = async () => {
|
||||
const title = (formData.title || '').trim()
|
||||
const snapshotInstructions = {
|
||||
goal: formData.goal || '',
|
||||
execution: formData.execution || '',
|
||||
preparation: formData.preparation || '',
|
||||
trainer_notes: formData.trainer_notes || '',
|
||||
}
|
||||
const hasSource =
|
||||
!!title ||
|
||||
Object.values(snapshotInstructions).some((html) => stripHtmlToText(html || '').trim())
|
||||
if (!hasSource) {
|
||||
toast.error('Titel oder mindestens ein Anleitungsfeld ausfüllen.')
|
||||
return
|
||||
}
|
||||
|
||||
const focusHint = (formData.focus_areas_multi || [])
|
||||
.map((row) => {
|
||||
const id = row?.focus_area_id
|
||||
const fa = focusAreas.find((x) => Number(x.id) === Number(id))
|
||||
return (fa?.name || '').trim()
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
|
||||
const focusAreasContext = [...(formData.focus_areas_multi || [])]
|
||||
.map((row) => ({
|
||||
focus_area_id: Number(row?.focus_area_id),
|
||||
is_primary: !!row?.is_primary,
|
||||
}))
|
||||
.filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1)
|
||||
.sort((a, b) => {
|
||||
const p = Number(!!b.is_primary) - Number(!!a.is_primary)
|
||||
if (p !== 0) return p
|
||||
return a.focus_area_id - b.focus_area_id
|
||||
})
|
||||
|
||||
setAiSuggestionPreview(null)
|
||||
setAiSuggestBusy(true)
|
||||
try {
|
||||
const res = await api.suggestExerciseAi({
|
||||
title,
|
||||
goal: snapshotInstructions.goal,
|
||||
execution: snapshotInstructions.execution,
|
||||
preparation: snapshotInstructions.preparation,
|
||||
trainer_notes: snapshotInstructions.trainer_notes,
|
||||
focus_area_hint: focusHint || undefined,
|
||||
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
|
||||
include_summary: false,
|
||||
include_skills: false,
|
||||
include_instructions: true,
|
||||
})
|
||||
|
||||
const preview = buildExerciseAiSuggestionPreview({
|
||||
mode: 'instructions',
|
||||
snapshotInstructions,
|
||||
apiRes: res,
|
||||
})
|
||||
|
||||
if (!preview.hasInstructionChoices) {
|
||||
toast.info('Die KI lieferte keinen verwertbaren Anleitungs-Vorschlag.')
|
||||
return
|
||||
}
|
||||
|
||||
setAiSuggestionPreview(preview)
|
||||
} catch (err) {
|
||||
toast.error(err?.message || String(err))
|
||||
} finally {
|
||||
setAiSuggestBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyExerciseAiSuggestionPreview = () => {
|
||||
const p = aiSuggestionPreview
|
||||
if (!p) return
|
||||
const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
|
||||
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after)
|
||||
const instrToApply = (p.instructionChoices || []).filter((c) => c.include && c.afterHtml)
|
||||
|
||||
if (!takeSummary && skillsToMerge.length === 0) {
|
||||
toast.error('Bitte mindestens eine Kurzfassung oder eine Fähigkeit zur Übernahme auswählen.')
|
||||
if (!takeSummary && skillsToMerge.length === 0 && instrToApply.length === 0) {
|
||||
toast.error('Bitte mindestens einen Vorschlag zur Übernahme auswählen.')
|
||||
return
|
||||
}
|
||||
|
||||
if (takeSummary) {
|
||||
updateFormField('summary', p.summaryAfterHtml)
|
||||
}
|
||||
for (const c of instrToApply) {
|
||||
updateFormField(c.field, c.afterHtml)
|
||||
}
|
||||
if (skillsToMerge.length > 0) {
|
||||
setFormDirty(true)
|
||||
setFormData((prev) => {
|
||||
|
|
@ -2145,6 +2259,29 @@ function ExerciseFormPageRoot() {
|
|||
title="Anleitung"
|
||||
hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)."
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
marginBottom: '12px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
onClick={() => runExerciseAiInstructionRewrite()}
|
||||
>
|
||||
KI: Anleitung überarbeiten
|
||||
</button>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||
Überarbeitet Ziel, Durchführung, Vorbereitung und Trainer-Hinweise — prägnant und strukturiert. Vorschau
|
||||
im Dialog; nichts wird automatisch gespeichert.
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Ziel *</label>
|
||||
<RichTextEditor
|
||||
|
|
@ -2780,7 +2917,13 @@ function ExerciseFormPageRoot() {
|
|||
minHeight: '72px',
|
||||
}
|
||||
const canApplySomething =
|
||||
(p.applySummary && p.summaryAfterHtml) || p.skillChoices.some((c) => c.include)
|
||||
(p.applySummary && p.summaryAfterHtml) ||
|
||||
p.skillChoices.some((c) => c.include) ||
|
||||
(p.instructionChoices || []).some((c) => c.include && c.afterHtml)
|
||||
const dialogTitle =
|
||||
p.instructionsRequested
|
||||
? 'KI: Anleitung überarbeiten'
|
||||
: 'KI-Vorschlag übernehmen'
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
|
|
@ -2808,11 +2951,94 @@ function ExerciseFormPageRoot() {
|
|||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>KI-Vorschlag übernehmen</h3>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>
|
||||
Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.
|
||||
{p.instructionsRequested
|
||||
? 'Vergleichen und nur die gewünschten Felder übernehmen. Eingebettete Medien bleiben erhalten, wenn die KI sie nicht erwähnt.'
|
||||
: 'Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.'}
|
||||
</p>
|
||||
|
||||
{p.hasInstructionChoices ? (
|
||||
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-instructions-heading">
|
||||
<div
|
||||
id="ai-preview-instructions-heading"
|
||||
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
|
||||
>
|
||||
Anleitung ({p.instructionChoices.length}{' '}
|
||||
{p.instructionChoices.length === 1 ? 'Feld' : 'Felder'})
|
||||
</div>
|
||||
{p.instructionChoices.map((c) => (
|
||||
<div
|
||||
key={c.key}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginBottom: '12px',
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c.include}
|
||||
onChange={(e) =>
|
||||
setAiSuggestionPreview((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
instructionChoices: prev.instructionChoices.map((x) =>
|
||||
x.key === c.key ? { ...x, include: e.target.checked } : x,
|
||||
),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{c.label} übernehmen
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
|
||||
Aktuell (Plaintext)
|
||||
</div>
|
||||
<div style={summaryBoxSx}>{c.beforePlain || '(leer)'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
|
||||
KI-Vorschlag
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...summaryBoxSx,
|
||||
borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))',
|
||||
}}
|
||||
>
|
||||
{c.afterPlain || '(leer)'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{p.hasSummaryProposal ? (
|
||||
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
|
||||
<div
|
||||
|
|
@ -3032,7 +3258,7 @@ function ExerciseFormPageRoot() {
|
|||
/>
|
||||
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
||||
<strong>KI-Unterstützung:</strong> OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung
|
||||
<strong>KI-Unterstützung:</strong> OpenRouter-Vorschläge für Kurzfassung, Fähigkeiten und Anleitung
|
||||
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern
|
||||
wie gewohnt.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../../utils/api'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
|
||||
|
|
@ -11,6 +12,14 @@ import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
|
|||
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
||||
import ExercisePeekModal from '../ExercisePeekModal'
|
||||
import NavStateLink from '../NavStateLink'
|
||||
import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer'
|
||||
import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal'
|
||||
import {
|
||||
buildQuickCreateAiPreview,
|
||||
buildQuickCreateExercisePayloadFromDraft,
|
||||
aiPreviewToQuickCreateDraft,
|
||||
} from '../../utils/exerciseAiQuickCreate'
|
||||
import { useExerciseAiQuickCreateFields } from '../../hooks/useExerciseAiQuickCreateFields'
|
||||
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
||||
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||
|
|
@ -37,6 +46,7 @@ const EXERCISES_PAGE_TABS = [
|
|||
]
|
||||
|
||||
function ExercisesListPageRoot() {
|
||||
const navigate = useNavigate()
|
||||
const { user, checkAuth } = useAuth()
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
|
@ -78,6 +88,21 @@ function ExercisesListPageRoot() {
|
|||
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
|
||||
const [peekExercise, setPeekExercise] = useState(null)
|
||||
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
|
||||
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
|
||||
const [quickSaving, setQuickSaving] = useState(false)
|
||||
const [quickAiError, setQuickAiError] = useState('')
|
||||
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||
|
||||
const {
|
||||
title: quickTitle,
|
||||
sketch: quickSketch,
|
||||
focusAreaId: quickFocusAreaId,
|
||||
setTitle: setQuickTitle,
|
||||
setSketch: setQuickSketch,
|
||||
setFocusAreaId: setQuickFocusAreaId,
|
||||
} = useExerciseAiQuickCreateFields(debouncedSearch, {
|
||||
enabled: pageTab === 'list' && (aiQuickCreateEnabled || debouncedSearch.length >= 3),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return
|
||||
|
|
@ -151,6 +176,13 @@ function ExercisesListPageRoot() {
|
|||
loadMore,
|
||||
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
|
||||
|
||||
const showQuickCreateOffer =
|
||||
pageTab === 'list' &&
|
||||
catalogsReady &&
|
||||
!listFetching &&
|
||||
(aiQuickCreateEnabled ||
|
||||
(exercises.length === 0 && selectedEntries.length === 0 && debouncedSearch.length >= 3))
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
|
||||
[selectedEntries]
|
||||
|
|
@ -304,6 +336,82 @@ function ExercisesListPageRoot() {
|
|||
|
||||
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), [])
|
||||
|
||||
const runQuickCreateAiSuggest = useCallback(async () => {
|
||||
const title = (quickTitle || '').trim()
|
||||
if (title.length < 3) {
|
||||
alert('Titel: mindestens 3 Zeichen.')
|
||||
return
|
||||
}
|
||||
const sketch = (quickSketch || '').trim()
|
||||
|
||||
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
|
||||
if (!Number.isFinite(focusId) || focusId < 1) {
|
||||
alert('Bitte einen Fokusbereich wählen.')
|
||||
return
|
||||
}
|
||||
|
||||
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
|
||||
const focusHint = (focusRow?.name || '').trim()
|
||||
|
||||
setQuickAiError('')
|
||||
setQuickCreateDraft(null)
|
||||
setQuickSaving(true)
|
||||
try {
|
||||
const aiRes = await api.suggestExerciseAi({
|
||||
title,
|
||||
goal: sketch || undefined,
|
||||
execution: '',
|
||||
preparation: '',
|
||||
trainer_notes: '',
|
||||
focus_area_hint: focusHint || undefined,
|
||||
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
|
||||
include_summary: true,
|
||||
include_skills: true,
|
||||
include_instructions: true,
|
||||
})
|
||||
|
||||
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
|
||||
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
|
||||
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
|
||||
}
|
||||
setQuickCreateDraft(
|
||||
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
const msg = e?.message || String(e)
|
||||
setQuickAiError(msg)
|
||||
alert(msg || 'KI-Vorschlag fehlgeschlagen')
|
||||
} finally {
|
||||
setQuickSaving(false)
|
||||
}
|
||||
}, [quickTitle, quickSketch, quickFocusAreaId, catalogs.focusAreas])
|
||||
|
||||
const applyQuickCreateDraft = useCallback(async () => {
|
||||
if (!quickCreateDraft) return
|
||||
|
||||
setQuickSaving(true)
|
||||
setQuickAiError('')
|
||||
try {
|
||||
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
|
||||
const created = await api.createExercise(payload)
|
||||
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||
setQuickCreateDraft(null)
|
||||
setAiQuickCreateEnabled(false)
|
||||
setExercises((prev) => [created, ...prev])
|
||||
navigate(`/exercises/${created.id}/edit`, {
|
||||
state: { returnContext: exercisesModuleReturnContext },
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
const msg = e?.message || String(e)
|
||||
setQuickAiError(msg)
|
||||
alert(msg || 'Übung konnte nicht angelegt werden')
|
||||
} finally {
|
||||
setQuickSaving(false)
|
||||
}
|
||||
}, [quickCreateDraft, setExercises, navigate, exercisesModuleReturnContext])
|
||||
|
||||
const bulkVisibilityOptions = useMemo(() => {
|
||||
const base = [
|
||||
{ id: '', label: '— nicht ändern —' },
|
||||
|
|
@ -486,6 +594,19 @@ function ExercisesListPageRoot() {
|
|||
<div className="exercises-page__header">
|
||||
<h1 className="page-title exercises-page__title">Übungen</h1>
|
||||
{pageTab === 'list' ? (
|
||||
<div className="exercises-page__header-actions">
|
||||
<label
|
||||
className="exercises-ai-assistant-toggle"
|
||||
title="Neue Übung per KI vorschlagen — Titel, optional Kurzbeschreibung, Fokusbereich"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiQuickCreateEnabled}
|
||||
onChange={(e) => setAiQuickCreateEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="exercises-ai-assistant-toggle__track" aria-hidden="true" />
|
||||
<span>Neu mit KI-Assistent</span>
|
||||
</label>
|
||||
<NavStateLink
|
||||
to="/exercises/new"
|
||||
returnContext={exercisesModuleReturnContext}
|
||||
|
|
@ -493,6 +614,7 @@ function ExercisesListPageRoot() {
|
|||
>
|
||||
+ Neu
|
||||
</NavStateLink>
|
||||
</div>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
|
|
@ -537,6 +659,28 @@ function ExercisesListPageRoot() {
|
|||
onToggleSelectAllPage={toggleSelectAllPage}
|
||||
/>
|
||||
|
||||
{showQuickCreateOffer ? (
|
||||
<ExerciseAiQuickCreateOffer
|
||||
searchLabel={debouncedSearch || undefined}
|
||||
title={quickTitle}
|
||||
onTitleChange={setQuickTitle}
|
||||
sketch={quickSketch}
|
||||
onSketchChange={setQuickSketch}
|
||||
focusAreaId={quickFocusAreaId}
|
||||
onFocusAreaChange={setQuickFocusAreaId}
|
||||
focusAreas={catalogs.focusAreas}
|
||||
catalogsReady={catalogsReady}
|
||||
busy={quickSaving}
|
||||
error={quickAiError}
|
||||
onRunAi={runQuickCreateAiSuggest}
|
||||
hint={
|
||||
aiQuickCreateEnabled
|
||||
? 'Titel aus Suche oder manuell; Kurzbeschreibung optional — leer für freien KI-Vorschlag, ausgefüllt als deine Ausgangsidee.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ExerciseListBulkToolbar
|
||||
selectedCount={selectedIds.size}
|
||||
bulkMaxIds={BULK_MAX_IDS}
|
||||
|
|
@ -625,11 +769,15 @@ function ExercisesListPageRoot() {
|
|||
Lade Übungen…
|
||||
</p>
|
||||
</div>
|
||||
) : exercises.length === 0 && selectedEntries.length === 0 ? (
|
||||
) : exercises.length === 0 && selectedEntries.length === 0 && !showQuickCreateOffer ? (
|
||||
<div className="card">
|
||||
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
|
||||
<p className="exercises-empty-text">
|
||||
{debouncedSearch.length >= 3
|
||||
? 'Keine Übungen gefunden.'
|
||||
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
) : exercises.length === 0 && selectedEntries.length === 0 && showQuickCreateOffer ? null : (
|
||||
<>
|
||||
{selectedEntries.length > 0 ? (
|
||||
<section className="exercises-selection-section" data-testid="exercises-selection-section">
|
||||
|
|
@ -696,6 +844,19 @@ function ExercisesListPageRoot() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExerciseAiSuggestPreviewModal
|
||||
draft={quickCreateDraft}
|
||||
onDraftChange={setQuickCreateDraft}
|
||||
onDiscard={() => setQuickCreateDraft(null)}
|
||||
onApply={applyQuickCreateDraft}
|
||||
focusAreas={catalogs.focusAreas}
|
||||
skillsCatalog={catalogs.skills}
|
||||
dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
|
||||
hint="Texte sind formatiert — passe sie an und lege die Übung als Entwurf an."
|
||||
applyLabel={quickSaving ? 'Wird angelegt…' : 'Übung anlegen'}
|
||||
applyDisabled={quickSaving}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
62
frontend/src/hooks/useExerciseAiQuickCreateFields.js
Normal file
62
frontend/src/hooks/useExerciseAiQuickCreateFields.js
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ export default function AdminAiPromptsPage() {
|
|||
const [draftName, setDraftName] = useState('')
|
||||
const [draftDesc, setDraftDesc] = useState('')
|
||||
const [draftTemplate, setDraftTemplate] = useState('')
|
||||
const [draftOpenrouterModel, setDraftOpenrouterModel] = useState('')
|
||||
const [draftActive, setDraftActive] = useState(true)
|
||||
|
||||
const [pvTitle, setPvTitle] = useState('Testübung')
|
||||
|
|
@ -74,6 +75,9 @@ export default function AdminAiPromptsPage() {
|
|||
setDraftName(d.display_name || '')
|
||||
setDraftDesc(d.description || '')
|
||||
setDraftTemplate(d.template || '')
|
||||
setDraftOpenrouterModel(
|
||||
typeof d.openrouter_model === 'string' ? d.openrouter_model : ''
|
||||
)
|
||||
setDraftActive(!!d.active)
|
||||
setPvPreview(null)
|
||||
}
|
||||
|
|
@ -96,6 +100,7 @@ export default function AdminAiPromptsPage() {
|
|||
display_name: draftName,
|
||||
description: draftDesc,
|
||||
active: draftActive,
|
||||
openrouter_model: draftOpenrouterModel.trim(),
|
||||
})
|
||||
await loadList()
|
||||
const nd = await api.getAdminAiPrompt(detail.id)
|
||||
|
|
@ -201,6 +206,11 @@ export default function AdminAiPromptsPage() {
|
|||
inaktiv
|
||||
</span>
|
||||
) : null}
|
||||
{p.openrouter_model ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text3)' }} title="OpenRouter-Modell für diesen Prompt">
|
||||
Model: <code>{p.openrouter_model}</code>
|
||||
</span>
|
||||
) : null}
|
||||
{p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
|
|
@ -231,6 +241,17 @@ export default function AdminAiPromptsPage() {
|
|||
onChange={(e) => setDraftDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">OpenRouter-Modell (optional)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="Leer = Server-OPENROUTER_MODEL · z.B. anthropic/claude-3.5-haiku"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={draftOpenrouterModel}
|
||||
onChange={(e) => setDraftOpenrouterModel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
|
||||
Aktiv
|
||||
|
|
|
|||
303
frontend/src/utils/exerciseAiQuickCreate.js
Normal file
303
frontend/src/utils/exerciseAiQuickCreate.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/** 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 })
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user