KI Übungen - MVP 0.8 #48
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
**Ist-Stand API (Superadmin):**
|
**Ist-Stand API (Superadmin):**
|
||||||
- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
|
- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
|
||||||
|
- Spalte **`openrouter_model`** (Migration **070**): Optional pro Prompt-Zeile; OpenRouter **`model`**-Parameter; **`NULL`/leer ⇒ `OPENROUTER_MODEL`** aus der Umgebung.
|
||||||
|
|
||||||
**Autor:** Claude Code
|
**Autor:** Claude Code
|
||||||
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
||||||
|
|
@ -36,6 +37,7 @@ steuerbar. Kein KI-Aufruf ist fest im Code verdrahtet.
|
||||||
|-------------|-----------|
|
|-------------|-----------|
|
||||||
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
|
| `exercise_summary` | Generiert `exercises.summary` aus goal + execution |
|
||||||
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
|
| `exercise_skill_suggestions` | Empfiehlt Skills + Stufen für eine Übung |
|
||||||
|
| `exercise_instruction_rewrite` | Überarbeitet Anleitung: goal, execution, preparation, trainer_notes (JSON, prägnantes HTML) |
|
||||||
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
|
| `exercise_category_suggestions` | Empfiehlt Fokusbereich, Stil, Zielgruppe |
|
||||||
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
|
| `model_skill_level_description` | Generiert Stufen-Beschreibung in der Fähigkeitsmatrix |
|
||||||
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |
|
| `training_plan_notes` | Erzeugt Trainer-Notizen für Trainingseinheiten |
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ Alle produktiven KI-Aufrufe sollten mittelfristig über eine **einheitliche Fass
|
||||||
|
|
||||||
Router und Frontend rufen diese Schicht oder schmale Orchestratoren — **nicht** direkt `httpx`/OpenRouter an jeder Ecke verteilt.
|
Router und Frontend rufen diese Schicht oder schmale Orchestratoren — **nicht** direkt `httpx`/OpenRouter an jeder Ecke verteilt.
|
||||||
|
|
||||||
**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` mit **Kontext-Arten** und **gemeinsamen DB-Ladeschritten** für `ai_prompts`; Übungs-KI konsumiert diese Schicht ohne Zirkelschluss zu Domänlogik (`exercise_ai`).
|
**Frühere Konkretisierung (Umsetzung gestartet):** Modul `backend/ai_prompt_runtime.py` (`load_ai_prompt_row`, `load_and_render_ai_prompt`, Kontext-Arten) sowie `backend/ai_prompt_job.py` (Pydantic `ExerciseFormAiPromptContext` fuer Uebungs-Prompts — Admin-Vorschau + erweiterbare Router-Nutzung); `exercise_ai` orchestriert OpenRouter nach dem Rendern.
|
||||||
|
|
||||||
### 2.2 Trennung: Semantik vs. Transport
|
### 2.2 Trennung: Semantik vs. Transport
|
||||||
|
|
||||||
|
|
@ -122,23 +122,25 @@ Konzeptionell **gleiche Bausteine** (admin-konfigurierbare Prompts, Platzhalter,
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph heute
|
subgraph laufzeit
|
||||||
A[ai_prompts DB]
|
A[ai_prompts DB]
|
||||||
B[prompt_resolver Mustache]
|
B[prompt_resolver Mustache]
|
||||||
C[ai_prompt_runtime Loader + ContextKind]
|
C[ai_prompt_runtime]
|
||||||
D[exercise_ai]
|
J[ai_prompt_job Pydantic]
|
||||||
|
D[exercise_ai OpenRouter]
|
||||||
end
|
end
|
||||||
A --> B
|
|
||||||
A --> C
|
A --> C
|
||||||
|
C --> B
|
||||||
|
J --> D
|
||||||
C --> D
|
C --> D
|
||||||
B --> D
|
B --> D
|
||||||
```
|
```
|
||||||
|
|
||||||
| Phase | Inhalt |
|
| Phase | Inhalt |
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| **P0 (gestartet)** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI nutzt Laufzeit; Platzhalter-Katalog pro Kontext erweiterbar. |
|
| **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. |
|
||||||
| **P1** | Einheitliche `run_ai_job`-Fassade (Slug + Kind + Pydantic-Payload + Validierung); Router nur noch dünne Adapter. |
|
| **P1** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **`ExerciseFormAiPromptContext`** in `ai_prompt_context.py`; **`run_exercise_form_ai_suggestion`**; Übungs-API und Admin-Vorschau nutzen denselben Kontext. |
|
||||||
| **P2** | Versionierung oder Audit-Spalten; optionale Modell-/Temperatur-Overrides pro Slug in DB oder Config-Tabelle. |
|
| **P2** | Versionierung oder Audit-Spalten; **teilweise:** optionales OpenRouter-Modell pro Zeile (`openrouter_model`, Migration 070, Fallback `OPENROUTER_MODEL`); weitere Overrides (Temperatur) offen. |
|
||||||
| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
|
| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
|
||||||
| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |
|
| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |
|
||||||
|
|
||||||
|
|
@ -157,7 +159,7 @@ flowchart LR
|
||||||
- Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md`
|
- Ist-Implementierung Prompts/UI: `AI_PROMPT_SYSTEM_SPEC.md`
|
||||||
- Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
- Zugriffsrecht Admin-Prompts: `ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
||||||
- Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`
|
- Retrieval-Profile: `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`
|
||||||
- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`
|
- Übungs-KI-Codepfad: `backend/exercise_ai.py`, `backend/prompt_resolver.py`, `backend/ai_prompt_runtime.py`, `backend/ai_prompt_context.py`, `backend/ai_prompt_job.py`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
||||||
|
|
||||||
OPENROUTER_API_KEY=your_api_key_here
|
OPENROUTER_API_KEY=your_api_key_here
|
||||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
||||||
|
# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
|
||||||
|
# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).
|
||||||
|
|
||||||
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
|
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
|
||||||
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).
|
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).
|
||||||
|
|
|
||||||
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 __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
from prompt_resolver import MustacheRenderResult, render_mustache_template
|
||||||
|
|
||||||
_EXERCISE_AI_SLUGS = frozenset(
|
_EXERCISE_AI_SLUGS = frozenset(
|
||||||
{
|
{
|
||||||
"exercise_summary",
|
"exercise_summary",
|
||||||
"exercise_skill_suggestions",
|
"exercise_skill_suggestions",
|
||||||
|
"exercise_instruction_rewrite",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -43,7 +46,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
|
||||||
if active_only:
|
if active_only:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT slug, display_name, template, output_format, active
|
SELECT slug, display_name, template, output_format, active, openrouter_model
|
||||||
FROM ai_prompts
|
FROM ai_prompts
|
||||||
WHERE slug = %s AND active = true
|
WHERE slug = %s AND active = true
|
||||||
""",
|
""",
|
||||||
|
|
@ -52,7 +55,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
|
||||||
else:
|
else:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT slug, display_name, template, output_format, active
|
SELECT slug, display_name, template, output_format, active, openrouter_model
|
||||||
FROM ai_prompts
|
FROM ai_prompts
|
||||||
WHERE slug = %s
|
WHERE slug = %s
|
||||||
""",
|
""",
|
||||||
|
|
@ -67,8 +70,45 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class AiPromptUnavailableError(LookupError):
|
||||||
|
"""Kein aktiver Prompt fuer slug (oder Zeile fehlt)."""
|
||||||
|
|
||||||
|
def __init__(self, slug: str) -> None:
|
||||||
|
self.slug = (slug or "").strip()
|
||||||
|
super().__init__(self.slug)
|
||||||
|
|
||||||
|
|
||||||
|
def render_ai_prompt_template_for_row(
|
||||||
|
row: Mapping[str, Any],
|
||||||
|
variables: Mapping[str, str],
|
||||||
|
) -> MustacheRenderResult:
|
||||||
|
"""Ersetzt Platzhalter anhand einer bereits geladenen ai_prompts-Zeile (z. B. Admin-Vorschauch, inkl. inaktiv)."""
|
||||||
|
return render_mustache_template(str(row.get("template") or ""), variables)
|
||||||
|
|
||||||
|
|
||||||
|
def load_and_render_ai_prompt(
|
||||||
|
cur,
|
||||||
|
slug: str,
|
||||||
|
variables: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> Tuple[Dict[str, Any], MustacheRenderResult]:
|
||||||
|
"""
|
||||||
|
Laedt einen aktiven Prompt und wendet Mustache-Variablen an.
|
||||||
|
Wirft AiPromptUnavailableError, wenn die Zeile fehlt oder (bei active_only) inaktiv ist.
|
||||||
|
"""
|
||||||
|
row = load_ai_prompt_row(cur, slug, active_only=active_only)
|
||||||
|
if not row:
|
||||||
|
raise AiPromptUnavailableError(slug)
|
||||||
|
rr = render_ai_prompt_template_for_row(row, variables)
|
||||||
|
return dict(row), rr
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AiPromptContextKind",
|
"AiPromptContextKind",
|
||||||
|
"AiPromptUnavailableError",
|
||||||
"context_kind_for_slug",
|
"context_kind_for_slug",
|
||||||
"load_ai_prompt_row",
|
"load_ai_prompt_row",
|
||||||
|
"load_and_render_ai_prompt",
|
||||||
|
"render_ai_prompt_template_for_row",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, F
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
@ -16,10 +17,17 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence,
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion
|
from openrouter_chat import (
|
||||||
|
OpenRouterError,
|
||||||
|
default_openrouter_model_id,
|
||||||
|
effective_openrouter_model_for_prompt_row,
|
||||||
|
normalize_openrouter_env,
|
||||||
|
openrouter_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
from ai_prompt_runtime import load_ai_prompt_row
|
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||||
from prompt_resolver import render_mustache_template
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||||
|
from exercise_rich_text import collect_inline_exercise_media_ids, normalize_inline_exercise_media_markup
|
||||||
|
|
||||||
_LOGGER = logging.getLogger("shinkan.exercise_ai")
|
_LOGGER = logging.getLogger("shinkan.exercise_ai")
|
||||||
|
|
||||||
|
|
@ -491,6 +499,146 @@ def build_contextual_skills_catalog_block(
|
||||||
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
|
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_INSTRUCTION_GOAL_PLAIN = 4_000
|
||||||
|
_MAX_INSTRUCTION_EXECUTION_PLAIN = 12_000
|
||||||
|
_MAX_INSTRUCTION_PREP_PLAIN = 2_500
|
||||||
|
_MAX_INSTRUCTION_TRAINER_PLAIN = 2_500
|
||||||
|
|
||||||
|
_INSTRUCTION_JSON_KEYS = ("goal", "execution", "preparation", "trainer_notes")
|
||||||
|
_INSTRUCTION_FIELD_MAX_PLAIN = {
|
||||||
|
"goal": _MAX_INSTRUCTION_GOAL_PLAIN,
|
||||||
|
"execution": _MAX_INSTRUCTION_EXECUTION_PLAIN,
|
||||||
|
"preparation": _MAX_INSTRUCTION_PREP_PLAIN,
|
||||||
|
"trainer_notes": _MAX_INSTRUCTION_TRAINER_PLAIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
_DISALLOWED_HTML_TAG_RE = re.compile(
|
||||||
|
r"</?\s*(?!p\b|ul\b|ol\b|li\b|strong\b|b\b|em\b|i\b|br\b|span\b)[a-zA-Z][^>]*>",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_SCRIPT_STYLE_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_to_minimal_instruction_html(text: str) -> str:
|
||||||
|
raw = (text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
parts = [p.strip() for p in re.split(r"\n+", raw) if p.strip()]
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
return "".join(f"<p>{html.escape(p)}</p>" for p in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_plain(text: str, max_len: int) -> str:
|
||||||
|
t = (text or "").strip()
|
||||||
|
if len(t) <= max_len:
|
||||||
|
return t
|
||||||
|
return t[: max_len - 1].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_instruction_field_html(raw: Any, *, max_plain: int) -> str:
|
||||||
|
if raw is None:
|
||||||
|
return ""
|
||||||
|
s = str(raw).strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
if s.startswith("```"):
|
||||||
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||||
|
if s.endswith("```"):
|
||||||
|
s = s[:-3].strip()
|
||||||
|
s = _SCRIPT_STYLE_RE.sub("", s)
|
||||||
|
s = _DISALLOWED_HTML_TAG_RE.sub("", s)
|
||||||
|
if "<" not in s:
|
||||||
|
s = _plain_to_minimal_instruction_html(s)
|
||||||
|
else:
|
||||||
|
s = normalize_inline_exercise_media_markup(s) or ""
|
||||||
|
plain = strip_html_to_plain(s, max_len=max_plain + 200)
|
||||||
|
if len(plain) > max_plain:
|
||||||
|
plain = _truncate_plain(plain, max_plain)
|
||||||
|
s = _plain_to_minimal_instruction_html(plain)
|
||||||
|
return (normalize_inline_exercise_media_markup(s) or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_preserved_inline_media(original: Optional[str], revised: str) -> str:
|
||||||
|
"""Haengt fehlende Medien-Verweise aus dem Ausgangstext ans Ende an."""
|
||||||
|
out = (revised or "").strip()
|
||||||
|
orig_ids = collect_inline_exercise_media_ids(original)
|
||||||
|
if not orig_ids:
|
||||||
|
return out
|
||||||
|
new_ids = collect_inline_exercise_media_ids(out)
|
||||||
|
missing = sorted(orig_ids - new_ids)
|
||||||
|
if not missing:
|
||||||
|
return out
|
||||||
|
spans = []
|
||||||
|
for mid in missing:
|
||||||
|
spans.append(
|
||||||
|
f'<span data-shinkan-exercise-media="{mid}" data-shinkan-exercise-media-size="medium" '
|
||||||
|
f'class="shinkan-inline-media"></span>'
|
||||||
|
)
|
||||||
|
block = f"<p>{''.join(spans)}</p>"
|
||||||
|
return (out + block).strip() if out else block
|
||||||
|
|
||||||
|
|
||||||
|
def _first_balanced_json_object(text: str) -> Optional[str]:
|
||||||
|
i = text.find("{")
|
||||||
|
if i < 0:
|
||||||
|
return None
|
||||||
|
depth = 0
|
||||||
|
in_str = False
|
||||||
|
esc = False
|
||||||
|
for j in range(i, len(text)):
|
||||||
|
ch = text[j]
|
||||||
|
if in_str:
|
||||||
|
if esc:
|
||||||
|
esc = False
|
||||||
|
elif ch == "\\":
|
||||||
|
esc = True
|
||||||
|
elif ch == '"':
|
||||||
|
in_str = False
|
||||||
|
continue
|
||||||
|
if ch == '"':
|
||||||
|
in_str = True
|
||||||
|
continue
|
||||||
|
if ch == "{":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return text[i : j + 1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_instruction_rewrite_object(text: str) -> Dict[str, Any]:
|
||||||
|
s = (text or "").strip()
|
||||||
|
if not s:
|
||||||
|
raise ValueError("leer")
|
||||||
|
if s.startswith("```"):
|
||||||
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||||
|
if s.endswith("```"):
|
||||||
|
s = s[:-3].strip()
|
||||||
|
frag = _first_balanced_json_object(s)
|
||||||
|
if frag:
|
||||||
|
s = frag
|
||||||
|
obj = json.loads(s)
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("kein JSON-Objekt")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_instruction_rewrite_payload(
|
||||||
|
parsed: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
originals: Mapping[str, Optional[str]],
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for key in _INSTRUCTION_JSON_KEYS:
|
||||||
|
max_plain = _INSTRUCTION_FIELD_MAX_PLAIN[key]
|
||||||
|
html = _sanitize_instruction_field_html(parsed.get(key), max_plain=max_plain)
|
||||||
|
html = _merge_preserved_inline_media(originals.get(key), html)
|
||||||
|
out[key] = html
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def build_exercise_placeholder_variables(
|
def build_exercise_placeholder_variables(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
|
|
@ -500,6 +648,8 @@ def build_exercise_placeholder_variables(
|
||||||
execution: Optional[str],
|
execution: Optional[str],
|
||||||
focus_area_hint: Optional[str],
|
focus_area_hint: Optional[str],
|
||||||
focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
|
focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
|
||||||
|
preparation: Optional[str] = None,
|
||||||
|
trainer_notes: Optional[str] = None,
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
|
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
|
||||||
|
|
@ -509,6 +659,8 @@ def build_exercise_placeholder_variables(
|
||||||
return {}
|
return {}
|
||||||
g_plain = strip_html_to_plain(goal)
|
g_plain = strip_html_to_plain(goal)
|
||||||
e_plain = strip_html_to_plain(execution)
|
e_plain = strip_html_to_plain(execution)
|
||||||
|
p_plain = strip_html_to_plain(preparation)
|
||||||
|
n_plain = strip_html_to_plain(trainer_notes)
|
||||||
t_title = (title or "").strip()
|
t_title = (title or "").strip()
|
||||||
focus = (focus_area_hint or "").strip()
|
focus = (focus_area_hint or "").strip()
|
||||||
ctx: Dict[str, str] = {
|
ctx: Dict[str, str] = {
|
||||||
|
|
@ -516,8 +668,12 @@ def build_exercise_placeholder_variables(
|
||||||
"exercise_focus_area": focus or "-",
|
"exercise_focus_area": focus or "-",
|
||||||
"exercise_goal": g_plain or "-",
|
"exercise_goal": g_plain or "-",
|
||||||
"exercise_execution": e_plain or "-",
|
"exercise_execution": e_plain or "-",
|
||||||
|
"exercise_preparation": p_plain or "-",
|
||||||
|
"exercise_trainer_notes": n_plain or "-",
|
||||||
}
|
}
|
||||||
if s == "exercise_summary":
|
if s == "exercise_summary":
|
||||||
|
return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")}
|
||||||
|
if s == "exercise_instruction_rewrite":
|
||||||
return ctx
|
return ctx
|
||||||
if s == "exercise_skill_suggestions":
|
if s == "exercise_skill_suggestions":
|
||||||
catalog = build_contextual_skills_catalog_block(
|
catalog = build_contextual_skills_catalog_block(
|
||||||
|
|
@ -664,32 +820,43 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
|
||||||
return out[:5]
|
return out[:5]
|
||||||
|
|
||||||
|
|
||||||
def _require_openrouter() -> Tuple[str, str]:
|
def _require_openrouter_key() -> str:
|
||||||
key, model = normalize_openrouter_env()
|
key, _ = normalize_openrouter_env()
|
||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
|
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
|
||||||
)
|
)
|
||||||
return key, model
|
return key
|
||||||
|
|
||||||
|
|
||||||
def run_exercise_ai_suggestion(
|
def run_exercise_ai_suggestion(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
title: Optional[str],
|
form_ctx: ExerciseFormAiPromptContext,
|
||||||
goal: Optional[str],
|
|
||||||
execution: Optional[str],
|
|
||||||
focus_area_hint: Optional[str],
|
|
||||||
focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None,
|
|
||||||
want_summary: bool,
|
want_summary: bool,
|
||||||
want_skills: bool,
|
want_skills: bool,
|
||||||
|
want_instructions: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
key, model = _require_openrouter()
|
key = _require_openrouter_key()
|
||||||
|
|
||||||
|
title = form_ctx.title
|
||||||
|
goal = form_ctx.goal
|
||||||
|
execution = form_ctx.execution
|
||||||
|
preparation = form_ctx.preparation
|
||||||
|
trainer_notes = form_ctx.trainer_notes
|
||||||
|
focus_area_hint = form_ctx.focus_hint
|
||||||
|
focus_areas_context = form_ctx.focus_area_tuples()
|
||||||
|
|
||||||
g_plain = strip_html_to_plain(goal)
|
g_plain = strip_html_to_plain(goal)
|
||||||
e_plain = strip_html_to_plain(execution)
|
e_plain = strip_html_to_plain(execution)
|
||||||
if not (g_plain.strip() or e_plain.strip()):
|
if want_instructions:
|
||||||
|
if not form_ctx.has_instruction_source_text():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Fuer Anleitungs-Ueberarbeitung mindestens Titel oder ein Anleitungsfeld ausfuellen.",
|
||||||
|
)
|
||||||
|
elif not (g_plain.strip() or e_plain.strip()):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).",
|
detail="Mindestens Ziel oder Durchfuehrung muss Inhalt liefern (nach Entfernen von leerem HTML).",
|
||||||
|
|
@ -698,26 +865,25 @@ def run_exercise_ai_suggestion(
|
||||||
t_title = (title or "").strip()
|
t_title = (title or "").strip()
|
||||||
focus = (focus_area_hint or "").strip()
|
focus = (focus_area_hint or "").strip()
|
||||||
|
|
||||||
result: Dict[str, Any] = {"model": model}
|
result: Dict[str, Any] = {}
|
||||||
|
models_by_slug: Dict[str, str] = {}
|
||||||
|
|
||||||
if _ai_debug_on():
|
if _ai_debug_on():
|
||||||
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
|
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s title_chars=%s goal_plain_chars=%s "
|
"AI_DEBUG exercise_ai suggest want_summary=%s want_skills=%s want_instructions=%s "
|
||||||
"exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
|
"title_chars=%s goal_plain_chars=%s exec_plain_chars=%s focus_hint_chars=%s focus_ctx_ids=[%s]",
|
||||||
want_summary,
|
want_summary,
|
||||||
want_skills,
|
want_skills,
|
||||||
len(t_title),
|
want_instructions,
|
||||||
|
len((title or "").strip()),
|
||||||
len(g_plain),
|
len(g_plain),
|
||||||
len(e_plain),
|
len(e_plain),
|
||||||
len(focus),
|
len((focus_area_hint or "").strip()),
|
||||||
fid_list,
|
fid_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
if want_summary:
|
if want_summary:
|
||||||
prow = load_ai_prompt_row(cur, "exercise_summary")
|
|
||||||
if not prow:
|
|
||||||
raise HTTPException(status_code=503, detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.")
|
|
||||||
try:
|
try:
|
||||||
ctx = build_exercise_placeholder_variables(
|
ctx = build_exercise_placeholder_variables(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -730,7 +896,15 @@ def run_exercise_ai_suggestion(
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||||
rendered = render_mustache_template(str(prow["template"]), ctx)
|
try:
|
||||||
|
prow, rendered = load_and_render_ai_prompt(cur, "exercise_summary", ctx)
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.",
|
||||||
|
) from None
|
||||||
|
model_summary = effective_openrouter_model_for_prompt_row(prow)
|
||||||
|
models_by_slug["exercise_summary"] = model_summary
|
||||||
prompt = rendered.text
|
prompt = rendered.text
|
||||||
if _ai_debug_on():
|
if _ai_debug_on():
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
|
|
@ -739,7 +913,7 @@ def run_exercise_ai_suggestion(
|
||||||
len(rendered.placeholders_remaining),
|
len(rendered.placeholders_remaining),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt)
|
raw = openrouter_chat_completion(api_key=key, model=model_summary, user_content=prompt)
|
||||||
except OpenRouterError as e:
|
except OpenRouterError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
||||||
if _ai_debug_on():
|
if _ai_debug_on():
|
||||||
|
|
@ -752,15 +926,9 @@ def run_exercise_ai_suggestion(
|
||||||
)
|
)
|
||||||
if len(text) > _MAX_SUMMARY_CHARS:
|
if len(text) > _MAX_SUMMARY_CHARS:
|
||||||
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…"
|
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…"
|
||||||
result["summary"] = {"text": text, "ai_generated": True, "model": model}
|
result["summary"] = {"text": text, "ai_generated": True, "model": model_summary}
|
||||||
|
|
||||||
if want_skills:
|
if want_skills:
|
||||||
srow = load_ai_prompt_row(cur, "exercise_skill_suggestions")
|
|
||||||
if not srow:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
ctx = build_exercise_placeholder_variables(
|
ctx = build_exercise_placeholder_variables(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -773,7 +941,15 @@ def run_exercise_ai_suggestion(
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||||
rendered = render_mustache_template(str(srow["template"]), ctx)
|
try:
|
||||||
|
srow, rendered = load_and_render_ai_prompt(cur, "exercise_skill_suggestions", ctx)
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
|
||||||
|
) from None
|
||||||
|
model_skills = effective_openrouter_model_for_prompt_row(srow)
|
||||||
|
models_by_slug["exercise_skill_suggestions"] = model_skills
|
||||||
prompt = rendered.text
|
prompt = rendered.text
|
||||||
if _ai_debug_on():
|
if _ai_debug_on():
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
|
|
@ -789,7 +965,7 @@ def run_exercise_ai_suggestion(
|
||||||
try:
|
try:
|
||||||
raw = openrouter_chat_completion(
|
raw = openrouter_chat_completion(
|
||||||
api_key=key,
|
api_key=key,
|
||||||
model=model,
|
model=model_skills,
|
||||||
user_content=prompt,
|
user_content=prompt,
|
||||||
system_content=sys_hint,
|
system_content=sys_hint,
|
||||||
temperature=0.15,
|
temperature=0.15,
|
||||||
|
|
@ -827,6 +1003,97 @@ def run_exercise_ai_suggestion(
|
||||||
|
|
||||||
result["skills"] = skills
|
result["skills"] = skills
|
||||||
|
|
||||||
|
if want_instructions:
|
||||||
|
try:
|
||||||
|
ctx = build_exercise_placeholder_variables(
|
||||||
|
cur,
|
||||||
|
slug="exercise_instruction_rewrite",
|
||||||
|
title=title,
|
||||||
|
goal=goal,
|
||||||
|
execution=execution,
|
||||||
|
preparation=preparation,
|
||||||
|
trainer_notes=trainer_notes,
|
||||||
|
focus_area_hint=focus_area_hint,
|
||||||
|
focus_areas_context=focus_areas_context,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||||
|
try:
|
||||||
|
irow, rendered = load_and_render_ai_prompt(cur, "exercise_instruction_rewrite", ctx)
|
||||||
|
except AiPromptUnavailableError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Prompt exercise_instruction_rewrite nicht aktiv oder fehlt in DB.",
|
||||||
|
) from None
|
||||||
|
model_instr = effective_openrouter_model_for_prompt_row(irow)
|
||||||
|
models_by_slug["exercise_instruction_rewrite"] = model_instr
|
||||||
|
prompt = rendered.text
|
||||||
|
if _ai_debug_on():
|
||||||
|
_LOGGER.warning(
|
||||||
|
"AI_DEBUG exercise_ai instructions prompt_slug=exercise_instruction_rewrite prompt_chars=%s",
|
||||||
|
len(prompt),
|
||||||
|
)
|
||||||
|
sys_hint = (
|
||||||
|
"Du antwortest nur mit validem JSON-Objekt (Schluessel goal, execution, preparation, trainer_notes). "
|
||||||
|
"Keine Kommentare ausserhalb des JSON."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw = openrouter_chat_completion(
|
||||||
|
api_key=key,
|
||||||
|
model=model_instr,
|
||||||
|
user_content=prompt,
|
||||||
|
system_content=sys_hint,
|
||||||
|
temperature=0.2,
|
||||||
|
)
|
||||||
|
except OpenRouterError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
||||||
|
body = (raw or "").strip()
|
||||||
|
if not body:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="OpenRouter/KI lieferte leeren Inhalt fuer Anleitungs-Ueberarbeitung.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
parsed = _extract_instruction_rewrite_object(body)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
if _ai_debug_on():
|
||||||
|
_LOGGER.warning(
|
||||||
|
"AI_DEBUG exercise_ai instructions JSON parse_failed err=%s head=%s",
|
||||||
|
e,
|
||||||
|
(body.replace("\r", "").replace("\n", " ").strip())[:400],
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="KI lieferte kein verwertbares JSON fuer die Anleitung.",
|
||||||
|
) from e
|
||||||
|
originals = {
|
||||||
|
"goal": goal,
|
||||||
|
"execution": execution,
|
||||||
|
"preparation": preparation,
|
||||||
|
"trainer_notes": trainer_notes,
|
||||||
|
}
|
||||||
|
fields = _sanitize_instruction_rewrite_payload(parsed, originals=originals)
|
||||||
|
if not any((fields.get(k) or "").strip() for k in _INSTRUCTION_JSON_KEYS):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="KI lieferte leere Anleitungs-Felder.",
|
||||||
|
)
|
||||||
|
result["instructions"] = {
|
||||||
|
"fields": fields,
|
||||||
|
"ai_generated": True,
|
||||||
|
"model": model_instr,
|
||||||
|
}
|
||||||
|
|
||||||
|
result["models_by_slug"] = models_by_slug
|
||||||
|
if want_skills:
|
||||||
|
result["model"] = models_by_slug["exercise_skill_suggestions"]
|
||||||
|
elif want_instructions:
|
||||||
|
result["model"] = models_by_slug["exercise_instruction_rewrite"]
|
||||||
|
elif want_summary:
|
||||||
|
result["model"] = models_by_slug["exercise_summary"]
|
||||||
|
else:
|
||||||
|
result["model"] = default_openrouter_model_id()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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 json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -203,3 +203,22 @@ def normalize_openrouter_env() -> tuple[str, str]:
|
||||||
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
|
key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
|
||||||
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
|
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
|
||||||
return key, model
|
return key, model
|
||||||
|
|
||||||
|
|
||||||
|
def default_openrouter_model_id() -> str:
|
||||||
|
"""Standard-Modell aus OPENROUTER_MODEL (ohne API-Key zu pruefen)."""
|
||||||
|
_, model = normalize_openrouter_env()
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def effective_openrouter_model_for_prompt_row(row: Optional[Mapping[str, Any]]) -> str:
|
||||||
|
"""
|
||||||
|
Pro-Prompt-Override in ai_prompts.openrouter_model, sonst Env-Default.
|
||||||
|
|
||||||
|
`row` kann eine partial Row aus load_ai_prompt_row sein (Felder slug, openrouter_model, …).
|
||||||
|
"""
|
||||||
|
if row:
|
||||||
|
custom = str(row.get("openrouter_model") or "").strip()
|
||||||
|
if custom:
|
||||||
|
return custom
|
||||||
|
return default_openrouter_model_id()
|
||||||
|
|
|
||||||
|
|
@ -87,25 +87,37 @@ def exercise_placeholder_catalog() -> dict:
|
||||||
"key": "exercise_title",
|
"key": "exercise_title",
|
||||||
"placeholder": "{{exercise_title}}",
|
"placeholder": "{{exercise_title}}",
|
||||||
"description": "Titel der Uebung (oder Platzhalter, wenn leer).",
|
"description": "Titel der Uebung (oder Platzhalter, wenn leer).",
|
||||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
|
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "exercise_focus_area",
|
"key": "exercise_focus_area",
|
||||||
"placeholder": "{{exercise_focus_area}}",
|
"placeholder": "{{exercise_focus_area}}",
|
||||||
"description": "Fokuskontext (Text-Hinweis aus Formular, optional).",
|
"description": "Fokuskontext (Text-Hinweis aus Formular, optional).",
|
||||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
|
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "exercise_goal",
|
"key": "exercise_goal",
|
||||||
"placeholder": "{{exercise_goal}}",
|
"placeholder": "{{exercise_goal}}",
|
||||||
"description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.",
|
"description": "Ziel aus dem Formular, als Plaintext ohne HTML-Zeichen.",
|
||||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
|
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "exercise_execution",
|
"key": "exercise_execution",
|
||||||
"placeholder": "{{exercise_execution}}",
|
"placeholder": "{{exercise_execution}}",
|
||||||
"description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.",
|
"description": "Durchfuehrung als Plaintext ohne HTML-Zeichen.",
|
||||||
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions"],
|
"used_by_slugs": ["exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "exercise_preparation",
|
||||||
|
"placeholder": "{{exercise_preparation}}",
|
||||||
|
"description": "Vorbereitung/Aufbau als Plaintext ohne HTML.",
|
||||||
|
"used_by_slugs": ["exercise_instruction_rewrite"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "exercise_trainer_notes",
|
||||||
|
"placeholder": "{{exercise_trainer_notes}}",
|
||||||
|
"description": "Trainer-Hinweise als Plaintext ohne HTML.",
|
||||||
|
"used_by_slugs": ["exercise_instruction_rewrite"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "skills_catalog",
|
"key": "skills_catalog",
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@ from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from club_tenancy import is_superadmin
|
from club_tenancy import is_superadmin
|
||||||
|
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||||
|
from ai_prompt_job import resolve_exercise_form_variables
|
||||||
|
from ai_prompt_runtime import render_ai_prompt_template_for_row
|
||||||
from db import get_cursor, get_db, r2d
|
from db import get_cursor, get_db, r2d
|
||||||
from exercise_ai import build_exercise_placeholder_variables
|
from prompt_resolver import exercise_placeholder_catalog
|
||||||
from prompt_resolver import exercise_placeholder_catalog, render_mustache_template
|
|
||||||
|
|
||||||
router = APIRouter(tags=["admin_ai_prompts"])
|
router = APIRouter(tags=["admin_ai_prompts"])
|
||||||
|
|
||||||
|
|
@ -39,7 +41,7 @@ def _fetch_prompt_any(cur, prompt_id: int) -> Dict[str, Any]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, slug, display_name, description, template, category, output_format,
|
SELECT id, slug, display_name, description, template, category, output_format,
|
||||||
output_schema, is_system_default, default_template,
|
output_schema, is_system_default, default_template, openrouter_model,
|
||||||
active, sort_order, created_at, updated_at
|
active, sort_order, created_at, updated_at
|
||||||
FROM ai_prompts WHERE id = %s
|
FROM ai_prompts WHERE id = %s
|
||||||
""",
|
""",
|
||||||
|
|
@ -56,19 +58,11 @@ class AiPromptUpdateBody(BaseModel):
|
||||||
active: Optional[bool] = None
|
active: Optional[bool] = None
|
||||||
display_name: Optional[str] = Field(None, max_length=200)
|
display_name: Optional[str] = Field(None, max_length=200)
|
||||||
description: Optional[str] = Field(None, max_length=8000)
|
description: Optional[str] = Field(None, max_length=8000)
|
||||||
|
openrouter_model: Optional[str] = Field(None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
class AiPromptPreviewFocus(BaseModel):
|
class AiPromptPreviewBody(ExerciseFormAiPromptContext):
|
||||||
focus_area_id: int = Field(..., ge=1)
|
"""Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint)."""
|
||||||
is_primary: Optional[bool] = False
|
|
||||||
|
|
||||||
|
|
||||||
class AiPromptPreviewBody(BaseModel):
|
|
||||||
title: Optional[str] = ""
|
|
||||||
goal: Optional[str] = None
|
|
||||||
execution: Optional[str] = None
|
|
||||||
focus_hint: Optional[str] = None
|
|
||||||
focus_areas_context: Optional[List[AiPromptPreviewFocus]] = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/admin/ai-prompts/catalog/placeholders")
|
@router.get("/api/admin/ai-prompts/catalog/placeholders")
|
||||||
|
|
@ -85,7 +79,7 @@ def list_ai_prompts(session: dict = Depends(_require_superadmin)):
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, slug, display_name, description, category, output_format, active,
|
SELECT id, slug, display_name, description, category, output_format, active,
|
||||||
sort_order, is_system_default, default_template
|
sort_order, is_system_default, default_template, openrouter_model
|
||||||
FROM ai_prompts
|
FROM ai_prompts
|
||||||
ORDER BY sort_order ASC NULLS LAST, id ASC
|
ORDER BY sort_order ASC NULLS LAST, id ASC
|
||||||
"""
|
"""
|
||||||
|
|
@ -149,16 +143,25 @@ def update_ai_prompt(
|
||||||
next_desc = body.description if body.description is not None else old.get("description") or ""
|
next_desc = body.description if body.description is not None else old.get("description") or ""
|
||||||
next_desc = (next_desc or "").strip()
|
next_desc = (next_desc or "").strip()
|
||||||
|
|
||||||
|
next_openrouter = old.get("openrouter_model")
|
||||||
|
if body.openrouter_model is not None:
|
||||||
|
cand = body.openrouter_model.strip() if isinstance(body.openrouter_model, str) else ""
|
||||||
|
if any(c in cand for c in ("\r", "\n", "\t")):
|
||||||
|
raise HTTPException(status_code=400, detail="openrouter_model: keine Steuerzeichen erlaubt.")
|
||||||
|
next_openrouter = cand or None
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE ai_prompts
|
UPDATE ai_prompts
|
||||||
SET template = %s, active = %s, display_name = %s, description = %s, updated_at = NOW()
|
SET template = %s, active = %s, display_name = %s, description = %s,
|
||||||
|
openrouter_model = %s, updated_at = NOW()
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING id, slug, display_name, description, template, category, output_format,
|
RETURNING id, slug, display_name, description, template, category, output_format,
|
||||||
output_schema, is_system_default, default_template, active, sort_order,
|
output_schema, is_system_default, default_template, openrouter_model,
|
||||||
|
active, sort_order,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
""",
|
""",
|
||||||
(next_template, next_active, next_name, next_desc, prompt_id),
|
(next_template, next_active, next_name, next_desc, next_openrouter, prompt_id),
|
||||||
)
|
)
|
||||||
row = dict(cur.fetchone())
|
row = dict(cur.fetchone())
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -187,7 +190,8 @@ def reset_ai_prompt_template(prompt_id: int, session: dict = Depends(_require_su
|
||||||
SET template = default_template, updated_at = NOW()
|
SET template = default_template, updated_at = NOW()
|
||||||
WHERE id = %s AND default_template IS NOT NULL
|
WHERE id = %s AND default_template IS NOT NULL
|
||||||
RETURNING id, slug, display_name, description, template, category, output_format,
|
RETURNING id, slug, display_name, description, template, category, output_format,
|
||||||
output_schema, is_system_default, default_template, active, sort_order,
|
output_schema, is_system_default, default_template, openrouter_model,
|
||||||
|
active, sort_order,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
""",
|
""",
|
||||||
(prompt_id,),
|
(prompt_id,),
|
||||||
|
|
@ -211,28 +215,12 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
|
||||||
raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
|
raise HTTPException(status_code=503, detail="Tabelle ai_prompts fehlt.")
|
||||||
row = _fetch_prompt_any(cur, prompt_id)
|
row = _fetch_prompt_any(cur, prompt_id)
|
||||||
slug = (row.get("slug") or "").strip().lower()
|
slug = (row.get("slug") or "").strip().lower()
|
||||||
tpl_raw = row.get("template") or ""
|
|
||||||
|
|
||||||
fctx_list: Optional[List[tuple[int, bool]]] = None
|
|
||||||
if body.focus_areas_context:
|
|
||||||
pairs: List[tuple[int, bool]] = []
|
|
||||||
for x in body.focus_areas_context:
|
|
||||||
pairs.append((int(x.focus_area_id), bool(x.is_primary)))
|
|
||||||
fctx_list = pairs
|
|
||||||
|
|
||||||
vars_map: Dict[str, str]
|
vars_map: Dict[str, str]
|
||||||
warn: Optional[str] = None
|
warn: Optional[str] = None
|
||||||
if slug in ("exercise_summary", "exercise_skill_suggestions"):
|
if slug in ("exercise_summary", "exercise_skill_suggestions", "exercise_instruction_rewrite"):
|
||||||
try:
|
try:
|
||||||
vars_map = build_exercise_placeholder_variables(
|
vars_map = resolve_exercise_form_variables(cur, slug, body)
|
||||||
cur,
|
|
||||||
slug=slug,
|
|
||||||
title=(body.title or "").strip(),
|
|
||||||
goal=body.goal,
|
|
||||||
execution=body.execution,
|
|
||||||
focus_area_hint=body.focus_hint,
|
|
||||||
focus_areas_context=fctx_list,
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
elif slug == "pipeline":
|
elif slug == "pipeline":
|
||||||
|
|
@ -242,7 +230,7 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
|
||||||
vars_map = {}
|
vars_map = {}
|
||||||
warn = f"Slug {slug!r}: noch kein Vorschau-Kontext definiert — Roh-Template ohne Ersetzung."
|
warn = f"Slug {slug!r}: noch kein Vorschau-Kontext definiert — Roh-Template ohne Ersetzung."
|
||||||
|
|
||||||
rendered = render_mustache_template(str(tpl_raw), vars_map)
|
rendered = render_ai_prompt_template_for_row(row, vars_map)
|
||||||
return {
|
return {
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"resolved_template": rendered.text,
|
"resolved_template": rendered.text,
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ from tenant_context import TenantContext, get_tenant_context, get_tenant_context
|
||||||
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
|
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
|
||||||
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
|
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
|
||||||
from media_legal_hold import assert_not_under_legal_hold
|
from media_legal_hold import assert_not_under_legal_hold
|
||||||
from exercise_ai import run_exercise_ai_suggestion
|
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
|
||||||
|
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||||
|
|
||||||
from exercise_rich_text import (
|
from exercise_rich_text import (
|
||||||
RICH_HTML_EXERCISE_FIELDS,
|
RICH_HTML_EXERCISE_FIELDS,
|
||||||
|
|
@ -358,31 +359,44 @@ class ExerciseMediaFromAsset(BaseModel):
|
||||||
media_type: Optional[str] = None
|
media_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ExerciseAiFocusCtx(BaseModel):
|
class ExerciseAiFocusCtx(ExerciseFormAiFocusRow):
|
||||||
"""Fokusbereich fuer Skill-Kataloggewichte (Migration 068 ai_skill_retrieval_profiles)."""
|
"""Alias fuer OpenAPI — identisch zu ExerciseFormAiFocusRow."""
|
||||||
|
|
||||||
focus_area_id: int = Field(..., ge=1)
|
|
||||||
is_primary: Optional[bool] = False
|
|
||||||
|
|
||||||
|
|
||||||
class ExerciseAiSuggestBody(BaseModel):
|
class ExerciseAiSuggestBody(BaseModel):
|
||||||
title: Optional[str] = Field(None, max_length=300)
|
title: Optional[str] = Field(None, max_length=300)
|
||||||
goal: Optional[str] = Field(None, max_length=64000)
|
goal: Optional[str] = Field(None, max_length=64000)
|
||||||
execution: Optional[str] = Field(None, max_length=128000)
|
execution: Optional[str] = Field(None, max_length=128000)
|
||||||
|
preparation: Optional[str] = Field(None, max_length=64000)
|
||||||
|
trainer_notes: Optional[str] = Field(None, max_length=64000)
|
||||||
focus_area_hint: Optional[str] = Field(None, max_length=1200)
|
focus_area_hint: Optional[str] = Field(None, max_length=1200)
|
||||||
focus_areas_context: Optional[list[ExerciseAiFocusCtx]] = Field(
|
focus_areas_context: Optional[list[ExerciseFormAiFocusRow]] = Field(
|
||||||
None,
|
None,
|
||||||
description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung",
|
description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung",
|
||||||
)
|
)
|
||||||
include_summary: bool = True
|
include_summary: bool = True
|
||||||
include_skills: bool = True
|
include_skills: bool = True
|
||||||
|
include_instructions: bool = False
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def check_include_any(self):
|
def check_include_any(self):
|
||||||
if not self.include_summary and not self.include_skills:
|
if not self.include_summary and not self.include_skills and not self.include_instructions:
|
||||||
raise ValueError("Mindestens include_summary oder include_skills aktivieren.")
|
raise ValueError(
|
||||||
|
"Mindestens include_summary, include_skills oder include_instructions aktivieren."
|
||||||
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def to_form_context(self) -> ExerciseFormAiPromptContext:
|
||||||
|
return ExerciseFormAiPromptContext.from_api_suggest(
|
||||||
|
title=self.title,
|
||||||
|
goal=self.goal,
|
||||||
|
execution=self.execution,
|
||||||
|
preparation=self.preparation,
|
||||||
|
trainer_notes=self.trainer_notes,
|
||||||
|
focus_area_hint=self.focus_area_hint,
|
||||||
|
focus_areas_context=self.focus_areas_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExerciseAiRegenerateBody(BaseModel):
|
class ExerciseAiRegenerateBody(BaseModel):
|
||||||
"""Welche Artefakte neu angefragt werden sollen."""
|
"""Welche Artefakte neu angefragt werden sollen."""
|
||||||
|
|
@ -391,7 +405,7 @@ class ExerciseAiRegenerateBody(BaseModel):
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def normalize_regs(self):
|
def normalize_regs(self):
|
||||||
allowed = {"summary", "skills"}
|
allowed = {"summary", "skills", "instructions"}
|
||||||
raw = [str(x).strip().lower() for x in (self.regenerate or [])]
|
raw = [str(x).strip().lower() for x in (self.regenerate or [])]
|
||||||
out = []
|
out = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
@ -2306,19 +2320,12 @@ def exercise_ai_suggest_endpoint(
|
||||||
_ = tenant.profile_id
|
_ = tenant.profile_id
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
fctx = None
|
payload = run_exercise_form_ai_suggestion(
|
||||||
if body.focus_areas_context:
|
|
||||||
fctx = [(x.focus_area_id, bool(x.is_primary)) for x in body.focus_areas_context]
|
|
||||||
|
|
||||||
payload = run_exercise_ai_suggestion(
|
|
||||||
cur,
|
cur,
|
||||||
title=(body.title or "").strip(),
|
body.to_form_context(),
|
||||||
goal=body.goal,
|
|
||||||
execution=body.execution,
|
|
||||||
focus_area_hint=(body.focus_area_hint or "").strip() or None,
|
|
||||||
focus_areas_context=fctx,
|
|
||||||
want_summary=body.include_summary,
|
want_summary=body.include_summary,
|
||||||
want_skills=body.include_skills,
|
want_skills=body.include_skills,
|
||||||
|
want_instructions=body.include_instructions,
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
@ -2332,6 +2339,7 @@ def exercise_ai_regenerate_endpoint(
|
||||||
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
|
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
|
||||||
want_summary = "summary" in body.regenerate
|
want_summary = "summary" in body.regenerate
|
||||||
want_skills = "skills" in body.regenerate
|
want_skills = "skills" in body.regenerate
|
||||||
|
want_instructions = "instructions" in body.regenerate
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
@ -2344,15 +2352,21 @@ def exercise_ai_regenerate_endpoint(
|
||||||
focus = _focus_area_hint_from_detail(exercise)
|
focus = _focus_area_hint_from_detail(exercise)
|
||||||
fctx = _focus_areas_ai_ctx_from_detail(exercise)
|
fctx = _focus_areas_ai_ctx_from_detail(exercise)
|
||||||
|
|
||||||
payload = run_exercise_ai_suggestion(
|
ctx = ExerciseFormAiPromptContext.from_focus_tuples(
|
||||||
cur,
|
|
||||||
title=str(exercise.get("title") or "").strip(),
|
title=str(exercise.get("title") or "").strip(),
|
||||||
goal=exercise.get("goal"),
|
goal=exercise.get("goal"),
|
||||||
execution=exercise.get("execution"),
|
execution=exercise.get("execution"),
|
||||||
focus_area_hint=focus or None,
|
preparation=exercise.get("preparation"),
|
||||||
focus_areas_context=fctx or None,
|
trainer_notes=exercise.get("trainer_notes"),
|
||||||
|
focus_hint=focus or None,
|
||||||
|
focus_tuples=fctx or None,
|
||||||
|
)
|
||||||
|
payload = run_exercise_form_ai_suggestion(
|
||||||
|
cur,
|
||||||
|
ctx,
|
||||||
want_summary=want_summary,
|
want_summary=want_summary,
|
||||||
want_skills=want_skills,
|
want_skills=want_skills,
|
||||||
|
want_instructions=want_instructions,
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.159"
|
APP_VERSION = "0.8.166"
|
||||||
BUILD_DATE = "2026-05-30"
|
BUILD_DATE = "2026-05-22"
|
||||||
DB_SCHEMA_VERSION = "20260530069"
|
DB_SCHEMA_VERSION = "20260531071"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
|
|
@ -19,13 +19,15 @@ MODULE_VERSIONS = {
|
||||||
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
|
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
|
||||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||||
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
||||||
"admin_ai_prompts": "1.0.1", # Prompt-Pflege + Zielarchitektur-Doku; gemeinsamer DB-Load uber ai_prompt_runtime
|
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
|
||||||
"ai_prompt_runtime": "0.1.0", # AiPromptContextKind, load_ai_prompt_row — Erweiterung Planung ohne Zirkel zu exercise_ai
|
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
|
||||||
|
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
|
||||||
|
"ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.31.1", # AI nutzt load_ai_prompt_row aus ai_prompt_runtime
|
"exercises": "2.33.0", # KI Schnellanlage: Suche+Anlage kombiniert; Rich-Text-Editor; Übungsliste KI-Schalter
|
||||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -40,6 +42,63 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.166",
|
||||||
|
"date": "2026-05-22",
|
||||||
|
"changes": [
|
||||||
|
"KI Schnellanlage: Suche und Anlage kombiniert (Picker + Übungsliste); Suchstring → Titel/Skizze; Rich-Text-Entwurf bearbeitbar vor Speichern.",
|
||||||
|
"Übungsliste: Schalter „KI-Anlage“ für direkten Einstieg ohne leere Trefferliste.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.8.165",
|
||||||
|
"date": "2026-05-31",
|
||||||
|
"changes": [
|
||||||
|
"Übungspicker Schnellanlage: KI-Vorschau-Dialog vor Speichern; Live-Bibliothekssuche (Titel+Skizze) mit Übernahme bestehender Übung.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.8.164",
|
||||||
|
"date": "2026-05-31",
|
||||||
|
"changes": [
|
||||||
|
"Planung/Übungspicker: Schnellanlage nutzt suggestExerciseAi (Anleitung, Kurzbeschreibung, Fähigkeiten); Fokusbereich Pflichtfeld.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.8.163",
|
||||||
|
"date": "2026-05-31",
|
||||||
|
"changes": [
|
||||||
|
"KI Anleitung: Migration 071 Prompt exercise_instruction_rewrite (JSON: goal, execution, preparation, trainer_notes);",
|
||||||
|
"POST /exercises/ai/suggest include_instructions; Sanitize/Plaintext-Limits; Medien-Verweise bleiben erhalten;",
|
||||||
|
"Ueungsformular Tab Anleitung: Button „KI: Anleitung ueberarbeiten“ mit Vorschau-Dialog pro Feld.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.8.162",
|
||||||
|
"date": "2026-05-31",
|
||||||
|
"changes": [
|
||||||
|
"KI Prompt P1 abgeschlossen: ai_prompt_context (Formular-Kontext), run_exercise_form_ai_suggestion als gemeinsamer Einstieg;",
|
||||||
|
"POST /exercises/ai/suggest und regenerate bauen ExerciseFormAiPromptContext; Admin-Vorschau nutzt dasselbe Modell.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.8.161",
|
||||||
|
"date": "2026-05-31",
|
||||||
|
"changes": [
|
||||||
|
"Migration 070: ai_prompts.openrouter_model (optional je Prompt; Fallback OPENROUTER_MODEL).",
|
||||||
|
"exercise_ai: effektives OpenRouter-Modell pro Slug; API-Response models_by_slug + model (Skills bevorzugt).",
|
||||||
|
"Superadmin „KI Prompts“: OpenRouter-Modell speicherbar.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.8.160",
|
||||||
|
"date": "2026-05-30",
|
||||||
|
"changes": [
|
||||||
|
"KI Prompt P1: ai_prompt_runtime load_and_render_ai_prompt + render_ai_prompt_template_for_row; AiPromptUnavailableError;",
|
||||||
|
"Neu ai_prompt_job: ExerciseFormAiPromptContext (Pydantic), resolve_exercise_form_variables; Admin-Prompt-Vorschau nutzt gleichen Pfad wie exercise_ai-Logik;",
|
||||||
|
"Zielarchitektur-Doku: Phasendiagramm P0/P1 angepasst.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.159",
|
"version": "0.8.159",
|
||||||
"date": "2026-05-30",
|
"date": "2026-05-30",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-30
|
**Stand:** 2026-05-31
|
||||||
**App-Version / DB-Schema:** App **`0.8.159`** u. a. **KI-Prompt-Zielarchitektur** + gemeinsames Modul **`ai_prompt_runtime`**; DB-Schema **`backend/version.py`** → `APP_VERSION`, `DB_SCHEMA_VERSION` (aktuell `20260530069`).
|
**App-Version / DB-Schema:** App **`0.8.166`** (KI Schnellanlage Suche+Anlage); DB **`20260531071`** — maßgeblich **`backend/version.py`**.
|
||||||
|
|
||||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||||
|
|
||||||
|
|
@ -89,16 +89,18 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
||||||
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
||||||
|
|
||||||
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.159**)
|
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.166**)
|
||||||
|
|
||||||
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
|
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
|
||||||
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
||||||
- **Runtime:** **`backend/ai_prompt_runtime.py`** — `AiPromptContextKind`, `load_ai_prompt_row` (gemeinsamer DB-Lesezugriff, kein Import von `exercise_ai`); **`exercise_ai`** nutzt `load_ai_prompt_row` fuer aktive Prompts
|
- **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter
|
||||||
- **DB:** Migration **`067`** **`ai_prompts`** (Slug **`exercise_summary`**, **`exercise_skill_suggestions`** — müssen **aktiv** sein); Migration **`069`** setzt **`default_template`** wo leer; Migration **`068`** **`ai_skill_retrieval_profiles`** (Seed Standard + ggf. Gewaltschutz-Fokus)
|
- **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
|
||||||
- **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff)
|
- **Prompt-Slugs:** `exercise_summary`, `exercise_skill_suggestions`, **`exercise_instruction_rewrite`** (Anleitung JSON, prägnant, HTML p/ul/ol/li)
|
||||||
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** nutzt gespeicherte `exercise_focus_areas` — **Pflege:** Superadmin **`/api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`), **`/api/admin/ai-prompts*`** (`routers/ai_prompts_admin.py`), UI **`/admin/ai-prompts`**
|
- **API:** `POST /api/exercises/ai/suggest` — **`include_instructions`**, Body **`preparation`**, **`trainer_notes`**; Response **`instructions.fields`**; **`POST …/ai/regenerate`** mit **`instructions`** in `regenerate`
|
||||||
- **Diagnose bei leerem Dialog / Fehlern:** Umgebungsvariable **`SHINKAN_AI_DEBUG=1`** auf der API; in den Logs dann **`AI_DEBUG`** (`shinkan.exercise_ai`) und **`[AI_DEBUG/openrouter]`** (`shinkan.openrouter`) mit Prompt-Längen, Token-Zahlen und ggf. JSON-Parse-Anfang
|
- **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`**
|
||||||
- **Frontend:** **`ExerciseFormPageRoot.jsx`**: „KI:“-Schaltflächen nur bei laufender Anfrage deaktiviert; vor einem neuen Lauf wird die Vorschau geschlossen (**keine dauergraue UI** nur wegen eines alten Modal-Zustands). **Pflege:** **`AdminAiPromptsPage.jsx`** (`/admin/ai-prompts`), **`AdminAiSkillRetrievalPage.jsx`** (`/admin/ai-skill-retrieval`)
|
- **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter`
|
||||||
|
- **Frontend 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 {
|
.exercises-page__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.exercises-page__header-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.exercises-ai-assistant-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text2);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
.exercises-ai-assistant-toggle:hover {
|
||||||
|
border-color: var(--accent-dark, rgba(29, 158, 117, 0.45));
|
||||||
|
}
|
||||||
|
.exercises-ai-assistant-toggle:has(input:checked) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-light, rgba(29, 158, 117, 0.12));
|
||||||
|
color: var(--accent-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.exercises-ai-assistant-toggle input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.exercises-ai-assistant-toggle__track {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.exercises-ai-assistant-toggle__track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.exercises-ai-assistant-toggle:has(input:checked) .exercises-ai-assistant-toggle__track {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.exercises-ai-assistant-toggle:has(input:checked) .exercises-ai-assistant-toggle__track::after {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
.exercises-page-toolbar-tabs {
|
.exercises-page-toolbar-tabs {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 SkillTreeMultiSelect from './SkillTreeMultiSelect'
|
||||||
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||||||
import CatalogRulePicker from './CatalogRulePicker'
|
import CatalogRulePicker from './CatalogRulePicker'
|
||||||
|
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
||||||
|
import ExerciseAiQuickCreateOffer from './ExerciseAiQuickCreateOffer'
|
||||||
|
import { useExerciseAiQuickCreateFields } from '../hooks/useExerciseAiQuickCreateFields'
|
||||||
|
import {
|
||||||
|
buildQuickCreateAiPreview,
|
||||||
|
buildQuickCreateExercisePayloadFromDraft,
|
||||||
|
aiPreviewToQuickCreateDraft,
|
||||||
|
} from '../utils/exerciseAiQuickCreate'
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100
|
||||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||||
|
|
||||||
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
||||||
|
|
||||||
/** Stub-Ziel für API-Validator (mind. Ziel oder Durchführung); Nutzer ergänzt Details in der Übungsbearbeitung. */
|
|
||||||
const QUICK_CREATE_GOAL_PLACEHOLDER =
|
|
||||||
'Aus der Trainingsplanung angelegt — bitte Ziel und Durchführung in der Übungsbearbeitung ergänzen.'
|
|
||||||
|
|
||||||
export default function ExercisePickerModal({
|
export default function ExercisePickerModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -57,12 +61,21 @@ export default function ExercisePickerModal({
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [hasMore, setHasMore] = useState(false)
|
const [hasMore, setHasMore] = useState(false)
|
||||||
const [multiPicked, setMultiPicked] = useState([])
|
const [multiPicked, setMultiPicked] = useState([])
|
||||||
const [quickOpen, setQuickOpen] = useState(false)
|
|
||||||
const [quickTitle, setQuickTitle] = useState('')
|
|
||||||
const [quickSummary, setQuickSummary] = useState('')
|
|
||||||
const [quickSaving, setQuickSaving] = useState(false)
|
const [quickSaving, setQuickSaving] = useState(false)
|
||||||
|
const [quickAiError, setQuickAiError] = useState('')
|
||||||
|
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||||
const pickerScrollRef = useRef(null)
|
const pickerScrollRef = useRef(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
title: quickTitle,
|
||||||
|
sketch: quickSketch,
|
||||||
|
focusAreaId: quickFocusAreaId,
|
||||||
|
setTitle: setQuickTitle,
|
||||||
|
setSketch: setQuickSketch,
|
||||||
|
setFocusAreaId: setQuickFocusAreaId,
|
||||||
|
resetQuickCreateFields,
|
||||||
|
} = useExerciseAiQuickCreateFields(debouncedSearch, { enabled: open && enableQuickCreateDraft })
|
||||||
|
|
||||||
const toggleMultiPick = (ex) => {
|
const toggleMultiPick = (ex) => {
|
||||||
setMultiPicked((prev) =>
|
setMultiPicked((prev) =>
|
||||||
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
|
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
|
||||||
|
|
@ -79,6 +92,13 @@ export default function ExercisePickerModal({
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [aiSearchInput])
|
}, [aiSearchInput])
|
||||||
|
|
||||||
|
const showQuickCreateOffer =
|
||||||
|
enableQuickCreateDraft &&
|
||||||
|
catalogsReady &&
|
||||||
|
!loading &&
|
||||||
|
debouncedSearch.length >= 3 &&
|
||||||
|
list.length === 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -122,10 +142,10 @@ export default function ExercisePickerModal({
|
||||||
setList([])
|
setList([])
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
setMultiPicked([])
|
setMultiPicked([])
|
||||||
setQuickOpen(false)
|
resetQuickCreateFields()
|
||||||
setQuickTitle('')
|
|
||||||
setQuickSummary('')
|
|
||||||
setQuickSaving(false)
|
setQuickSaving(false)
|
||||||
|
setQuickAiError('')
|
||||||
|
setQuickCreateDraft(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
|
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
|
||||||
|
|
@ -285,43 +305,83 @@ export default function ExercisePickerModal({
|
||||||
|
|
||||||
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
|
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
|
||||||
|
|
||||||
const submitQuickCreate = async () => {
|
const adoptExistingExercise = async (ex) => {
|
||||||
|
if (!ex?.id) return
|
||||||
|
if (multiSelect && typeof onSelectExercises === 'function') {
|
||||||
|
await Promise.resolve(onSelectExercises([ex]))
|
||||||
|
} else if (typeof onSelectExercise === 'function') {
|
||||||
|
await Promise.resolve(onSelectExercise(ex))
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runQuickCreateAiSuggest = async () => {
|
||||||
const title = (quickTitle || '').trim()
|
const title = (quickTitle || '').trim()
|
||||||
if (title.length < 3) {
|
if (title.length < 3) {
|
||||||
alert('Titel: mindestens 3 Zeichen.')
|
alert('Titel: mindestens 3 Zeichen.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const summaryRaw = (quickSummary || '').trim()
|
const sketch = (quickSketch || '').trim()
|
||||||
|
|
||||||
|
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
|
||||||
|
if (!Number.isFinite(focusId) || focusId < 1) {
|
||||||
|
alert('Bitte einen Fokusbereich wählen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
|
||||||
|
const focusHint = (focusRow?.name || '').trim()
|
||||||
|
|
||||||
|
setQuickAiError('')
|
||||||
|
setQuickCreateDraft(null)
|
||||||
setQuickSaving(true)
|
setQuickSaving(true)
|
||||||
try {
|
try {
|
||||||
const created = await api.createExercise({
|
const aiRes = await api.suggestExerciseAi({
|
||||||
title,
|
title,
|
||||||
summary: summaryRaw || null,
|
goal: sketch || undefined,
|
||||||
goal: QUICK_CREATE_GOAL_PLACEHOLDER,
|
execution: '',
|
||||||
execution: null,
|
preparation: '',
|
||||||
visibility: 'private',
|
trainer_notes: '',
|
||||||
status: 'draft',
|
focus_area_hint: focusHint || undefined,
|
||||||
equipment: [],
|
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
|
||||||
focus_areas_multi: [],
|
include_summary: true,
|
||||||
training_styles_multi: [],
|
include_skills: true,
|
||||||
training_types_multi: [],
|
include_instructions: true,
|
||||||
target_groups_multi: [],
|
|
||||||
age_groups: [],
|
|
||||||
skills: [],
|
|
||||||
club_id: null,
|
|
||||||
})
|
})
|
||||||
if (!created?.id) {
|
|
||||||
throw new Error('Anlegen fehlgeschlagen')
|
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
|
||||||
|
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
|
||||||
|
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
|
||||||
}
|
}
|
||||||
if (multiSelect && typeof onSelectExercises === 'function') {
|
setQuickCreateDraft(
|
||||||
await Promise.resolve(onSelectExercises([created]))
|
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
|
||||||
} else if (typeof onSelectExercise === 'function') {
|
)
|
||||||
await Promise.resolve(onSelectExercise(created))
|
|
||||||
}
|
|
||||||
onClose()
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
alert(e.message || 'Übung konnte nicht angelegt werden')
|
const msg = e?.message || String(e)
|
||||||
|
setQuickAiError(msg)
|
||||||
|
alert(msg || 'KI-Vorschlag fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setQuickSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyQuickCreateDraft = async () => {
|
||||||
|
if (!quickCreateDraft) return
|
||||||
|
|
||||||
|
setQuickSaving(true)
|
||||||
|
setQuickAiError('')
|
||||||
|
try {
|
||||||
|
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
|
||||||
|
const created = await api.createExercise(payload)
|
||||||
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||||
|
setQuickCreateDraft(null)
|
||||||
|
await adoptExistingExercise(created)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
const msg = e?.message || String(e)
|
||||||
|
setQuickAiError(msg)
|
||||||
|
alert(msg || 'Übung konnte nicht angelegt werden')
|
||||||
} finally {
|
} finally {
|
||||||
setQuickSaving(false)
|
setQuickSaving(false)
|
||||||
}
|
}
|
||||||
|
|
@ -353,75 +413,6 @@ export default function ExercisePickerModal({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enableQuickCreateDraft ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '10px 1rem 12px',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
flexShrink: 0,
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onClick={() => setQuickOpen((v) => !v)}
|
|
||||||
aria-expanded={quickOpen}
|
|
||||||
>
|
|
||||||
{quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung anlegen (Entwurf, privat)'}
|
|
||||||
</button>
|
|
||||||
{quickOpen ? (
|
|
||||||
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
|
|
||||||
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
|
|
||||||
Wird mit Freigabelevel <strong>privat</strong> und Status <strong>Entwurf</strong> gespeichert und
|
|
||||||
erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den
|
|
||||||
Ablauf übernommen.
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<label className="form-label" htmlFor="ex-picker-quick-title">
|
|
||||||
Titel
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="ex-picker-quick-title"
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
value={quickTitle}
|
|
||||||
onChange={(e) => setQuickTitle(e.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
minLength={3}
|
|
||||||
maxLength={300}
|
|
||||||
placeholder="z. B. Partnerübung Abwehr"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="form-label" htmlFor="ex-picker-quick-summary">
|
|
||||||
Kurzbeschreibung
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="ex-picker-quick-summary"
|
|
||||||
className="form-input"
|
|
||||||
rows={3}
|
|
||||||
value={quickSummary}
|
|
||||||
onChange={(e) => setQuickSummary(e.target.value)}
|
|
||||||
placeholder="Optional: grobe Idee, Kontext aus der Planung …"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'flex-end' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={quickSaving || (quickTitle || '').trim().length < 3}
|
|
||||||
onClick={submitQuickCreate}
|
|
||||||
>
|
|
||||||
{quickSaving ? 'Wird angelegt…' : 'Entwurf anlegen und übernehmen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -602,7 +593,28 @@ export default function ExercisePickerModal({
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
</div>
|
</div>
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>Keine Treffer.</p>
|
showQuickCreateOffer ? (
|
||||||
|
<ExerciseAiQuickCreateOffer
|
||||||
|
searchLabel={debouncedSearch}
|
||||||
|
title={quickTitle}
|
||||||
|
onTitleChange={setQuickTitle}
|
||||||
|
sketch={quickSketch}
|
||||||
|
onSketchChange={setQuickSketch}
|
||||||
|
focusAreaId={quickFocusAreaId}
|
||||||
|
onFocusAreaChange={setQuickFocusAreaId}
|
||||||
|
focusAreas={catalogs.focusAreas}
|
||||||
|
catalogsReady={catalogsReady}
|
||||||
|
busy={quickSaving}
|
||||||
|
error={quickAiError}
|
||||||
|
onRunAi={runQuickCreateAiSuggest}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||||
|
{debouncedSearch.length >= 3
|
||||||
|
? 'Keine Treffer.'
|
||||||
|
: 'Suchbegriff eingeben (mind. 3 Zeichen) …'}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
|
||||||
|
|
@ -770,6 +782,20 @@ export default function ExercisePickerModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ExerciseAiSuggestPreviewModal
|
||||||
|
draft={quickCreateDraft}
|
||||||
|
onDraftChange={setQuickCreateDraft}
|
||||||
|
onDiscard={() => setQuickCreateDraft(null)}
|
||||||
|
onApply={applyQuickCreateDraft}
|
||||||
|
focusAreas={catalogs.focusAreas}
|
||||||
|
skillsCatalog={catalogs.skills}
|
||||||
|
dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
|
||||||
|
hint="Texte sind formatiert — passe Titel, Kurzfassung, Anleitung und Fähigkeiten an, dann speichern und übernehmen."
|
||||||
|
applyLabel={quickSaving ? 'Wird angelegt…' : 'Anlegen und übernehmen'}
|
||||||
|
applyDisabled={quickSaving}
|
||||||
|
zIndex={2100}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,13 @@ function aiPlainSummaryToMinimalHtml(text) {
|
||||||
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('')
|
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INSTRUCTION_AI_FIELD_DEFS = [
|
||||||
|
{ key: 'goal', label: 'Ziel' },
|
||||||
|
{ key: 'execution', label: 'Durchführung' },
|
||||||
|
{ key: 'preparation', label: 'Vorbereitung / Aufbau' },
|
||||||
|
{ key: 'trainer_notes', label: 'Hinweise für Trainer' },
|
||||||
|
]
|
||||||
|
|
||||||
function cloneExerciseSkillRows(rows) {
|
function cloneExerciseSkillRows(rows) {
|
||||||
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
|
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
|
||||||
}
|
}
|
||||||
|
|
@ -110,9 +117,16 @@ function buildNormalizedAiSkillRowFromApi(sug) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotSkills, apiRes }) {
|
function buildExerciseAiSuggestionPreview({
|
||||||
const summaryRequested = mode !== 'skills'
|
mode,
|
||||||
const skillsRequested = mode !== 'summary'
|
snapshotSummaryHtml,
|
||||||
|
snapshotSkills,
|
||||||
|
snapshotInstructions,
|
||||||
|
apiRes,
|
||||||
|
}) {
|
||||||
|
const summaryRequested = mode !== 'skills' && mode !== 'instructions'
|
||||||
|
const skillsRequested = mode !== 'summary' && mode !== 'instructions'
|
||||||
|
const instructionsRequested = mode === 'instructions'
|
||||||
|
|
||||||
let summaryAfterHtml = null
|
let summaryAfterHtml = null
|
||||||
let summaryAfterPlain = ''
|
let summaryAfterPlain = ''
|
||||||
|
|
@ -141,8 +155,29 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const instructionChoices = []
|
||||||
|
if (instructionsRequested && apiRes.instructions?.fields) {
|
||||||
|
const fields = apiRes.instructions.fields
|
||||||
|
const snap = snapshotInstructions || {}
|
||||||
|
for (const def of INSTRUCTION_AI_FIELD_DEFS) {
|
||||||
|
const afterHtml = fields[def.key]
|
||||||
|
if (!afterHtml || !String(afterHtml).trim()) continue
|
||||||
|
const beforeHtml = snap[def.key] || ''
|
||||||
|
instructionChoices.push({
|
||||||
|
key: def.key,
|
||||||
|
field: def.key,
|
||||||
|
label: def.label,
|
||||||
|
beforePlain: stripHtmlToText(beforeHtml).trim(),
|
||||||
|
afterHtml: String(afterHtml),
|
||||||
|
afterPlain: stripHtmlToText(afterHtml).trim(),
|
||||||
|
include: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
|
const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
|
||||||
const hasSkillChoices = skillChoices.length > 0
|
const hasSkillChoices = skillChoices.length > 0
|
||||||
|
const hasInstructionChoices = instructionChoices.length > 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
|
|
@ -151,10 +186,13 @@ function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotS
|
||||||
summaryAfterPlain,
|
summaryAfterPlain,
|
||||||
summaryAfterHtml,
|
summaryAfterHtml,
|
||||||
skillChoices,
|
skillChoices,
|
||||||
|
instructionChoices,
|
||||||
hasSummaryProposal,
|
hasSummaryProposal,
|
||||||
hasSkillChoices,
|
hasSkillChoices,
|
||||||
|
hasInstructionChoices,
|
||||||
summaryRequested,
|
summaryRequested,
|
||||||
skillsRequested,
|
skillsRequested,
|
||||||
|
instructionsRequested,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1027,20 +1065,96 @@ function ExerciseFormPageRoot() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runExerciseAiInstructionRewrite = async () => {
|
||||||
|
const title = (formData.title || '').trim()
|
||||||
|
const snapshotInstructions = {
|
||||||
|
goal: formData.goal || '',
|
||||||
|
execution: formData.execution || '',
|
||||||
|
preparation: formData.preparation || '',
|
||||||
|
trainer_notes: formData.trainer_notes || '',
|
||||||
|
}
|
||||||
|
const hasSource =
|
||||||
|
!!title ||
|
||||||
|
Object.values(snapshotInstructions).some((html) => stripHtmlToText(html || '').trim())
|
||||||
|
if (!hasSource) {
|
||||||
|
toast.error('Titel oder mindestens ein Anleitungsfeld ausfüllen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusHint = (formData.focus_areas_multi || [])
|
||||||
|
.map((row) => {
|
||||||
|
const id = row?.focus_area_id
|
||||||
|
const fa = focusAreas.find((x) => Number(x.id) === Number(id))
|
||||||
|
return (fa?.name || '').trim()
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
const focusAreasContext = [...(formData.focus_areas_multi || [])]
|
||||||
|
.map((row) => ({
|
||||||
|
focus_area_id: Number(row?.focus_area_id),
|
||||||
|
is_primary: !!row?.is_primary,
|
||||||
|
}))
|
||||||
|
.filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const p = Number(!!b.is_primary) - Number(!!a.is_primary)
|
||||||
|
if (p !== 0) return p
|
||||||
|
return a.focus_area_id - b.focus_area_id
|
||||||
|
})
|
||||||
|
|
||||||
|
setAiSuggestionPreview(null)
|
||||||
|
setAiSuggestBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await api.suggestExerciseAi({
|
||||||
|
title,
|
||||||
|
goal: snapshotInstructions.goal,
|
||||||
|
execution: snapshotInstructions.execution,
|
||||||
|
preparation: snapshotInstructions.preparation,
|
||||||
|
trainer_notes: snapshotInstructions.trainer_notes,
|
||||||
|
focus_area_hint: focusHint || undefined,
|
||||||
|
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
|
||||||
|
include_summary: false,
|
||||||
|
include_skills: false,
|
||||||
|
include_instructions: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const preview = buildExerciseAiSuggestionPreview({
|
||||||
|
mode: 'instructions',
|
||||||
|
snapshotInstructions,
|
||||||
|
apiRes: res,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!preview.hasInstructionChoices) {
|
||||||
|
toast.info('Die KI lieferte keinen verwertbaren Anleitungs-Vorschlag.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiSuggestionPreview(preview)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err?.message || String(err))
|
||||||
|
} finally {
|
||||||
|
setAiSuggestBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const applyExerciseAiSuggestionPreview = () => {
|
const applyExerciseAiSuggestionPreview = () => {
|
||||||
const p = aiSuggestionPreview
|
const p = aiSuggestionPreview
|
||||||
if (!p) return
|
if (!p) return
|
||||||
const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
|
const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
|
||||||
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after)
|
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after)
|
||||||
|
const instrToApply = (p.instructionChoices || []).filter((c) => c.include && c.afterHtml)
|
||||||
|
|
||||||
if (!takeSummary && skillsToMerge.length === 0) {
|
if (!takeSummary && skillsToMerge.length === 0 && instrToApply.length === 0) {
|
||||||
toast.error('Bitte mindestens eine Kurzfassung oder eine Fähigkeit zur Übernahme auswählen.')
|
toast.error('Bitte mindestens einen Vorschlag zur Übernahme auswählen.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (takeSummary) {
|
if (takeSummary) {
|
||||||
updateFormField('summary', p.summaryAfterHtml)
|
updateFormField('summary', p.summaryAfterHtml)
|
||||||
}
|
}
|
||||||
|
for (const c of instrToApply) {
|
||||||
|
updateFormField(c.field, c.afterHtml)
|
||||||
|
}
|
||||||
if (skillsToMerge.length > 0) {
|
if (skillsToMerge.length > 0) {
|
||||||
setFormDirty(true)
|
setFormDirty(true)
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
|
|
@ -2145,6 +2259,29 @@ function ExerciseFormPageRoot() {
|
||||||
title="Anleitung"
|
title="Anleitung"
|
||||||
hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)."
|
hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)."
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '12px' }}
|
||||||
|
disabled={aiSuggestBusy}
|
||||||
|
onClick={() => runExerciseAiInstructionRewrite()}
|
||||||
|
>
|
||||||
|
KI: Anleitung überarbeiten
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||||
|
Überarbeitet Ziel, Durchführung, Vorbereitung und Trainer-Hinweise — prägnant und strukturiert. Vorschau
|
||||||
|
im Dialog; nichts wird automatisch gespeichert.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Ziel *</label>
|
<label className="form-label">Ziel *</label>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
|
|
@ -2780,7 +2917,13 @@ function ExerciseFormPageRoot() {
|
||||||
minHeight: '72px',
|
minHeight: '72px',
|
||||||
}
|
}
|
||||||
const canApplySomething =
|
const canApplySomething =
|
||||||
(p.applySummary && p.summaryAfterHtml) || p.skillChoices.some((c) => c.include)
|
(p.applySummary && p.summaryAfterHtml) ||
|
||||||
|
p.skillChoices.some((c) => c.include) ||
|
||||||
|
(p.instructionChoices || []).some((c) => c.include && c.afterHtml)
|
||||||
|
const dialogTitle =
|
||||||
|
p.instructionsRequested
|
||||||
|
? 'KI: Anleitung überarbeiten'
|
||||||
|
: 'KI-Vorschlag übernehmen'
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|
@ -2808,11 +2951,94 @@ function ExerciseFormPageRoot() {
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>KI-Vorschlag übernehmen</h3>
|
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>
|
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>
|
||||||
Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.
|
{p.instructionsRequested
|
||||||
|
? 'Vergleichen und nur die gewünschten Felder übernehmen. Eingebettete Medien bleiben erhalten, wenn die KI sie nicht erwähnt.'
|
||||||
|
: 'Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{p.hasInstructionChoices ? (
|
||||||
|
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-instructions-heading">
|
||||||
|
<div
|
||||||
|
id="ai-preview-instructions-heading"
|
||||||
|
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
|
||||||
|
>
|
||||||
|
Anleitung ({p.instructionChoices.length}{' '}
|
||||||
|
{p.instructionChoices.length === 1 ? 'Feld' : 'Felder'})
|
||||||
|
</div>
|
||||||
|
{p.instructionChoices.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.key}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={c.include}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAiSuggestionPreview((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
instructionChoices: prev.instructionChoices.map((x) =>
|
||||||
|
x.key === c.key ? { ...x, include: e.target.checked } : x,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{c.label} übernehmen
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
|
||||||
|
Aktuell (Plaintext)
|
||||||
|
</div>
|
||||||
|
<div style={summaryBoxSx}>{c.beforePlain || '(leer)'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
|
||||||
|
KI-Vorschlag
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...summaryBoxSx,
|
||||||
|
borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.afterPlain || '(leer)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{p.hasSummaryProposal ? (
|
{p.hasSummaryProposal ? (
|
||||||
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
|
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
|
||||||
<div
|
<div
|
||||||
|
|
@ -3032,7 +3258,7 @@ function ExerciseFormPageRoot() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
||||||
<strong>KI-Unterstützung:</strong> OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung
|
<strong>KI-Unterstützung:</strong> OpenRouter-Vorschläge für Kurzfassung, Fähigkeiten und Anleitung
|
||||||
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern
|
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern
|
||||||
wie gewohnt.
|
wie gewohnt.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
|
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import api from '../../utils/api'
|
import api from '../../utils/api'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
|
import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
|
||||||
|
|
@ -11,6 +12,14 @@ import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
|
||||||
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
||||||
import ExercisePeekModal from '../ExercisePeekModal'
|
import ExercisePeekModal from '../ExercisePeekModal'
|
||||||
import NavStateLink from '../NavStateLink'
|
import NavStateLink from '../NavStateLink'
|
||||||
|
import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer'
|
||||||
|
import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal'
|
||||||
|
import {
|
||||||
|
buildQuickCreateAiPreview,
|
||||||
|
buildQuickCreateExercisePayloadFromDraft,
|
||||||
|
aiPreviewToQuickCreateDraft,
|
||||||
|
} from '../../utils/exerciseAiQuickCreate'
|
||||||
|
import { useExerciseAiQuickCreateFields } from '../../hooks/useExerciseAiQuickCreateFields'
|
||||||
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
||||||
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
||||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||||
|
|
@ -37,6 +46,7 @@ const EXERCISES_PAGE_TABS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
function ExercisesListPageRoot() {
|
function ExercisesListPageRoot() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const { user, checkAuth } = useAuth()
|
const { user, checkAuth } = useAuth()
|
||||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||||
const isSuperadmin = user?.role === 'superadmin'
|
const isSuperadmin = user?.role === 'superadmin'
|
||||||
|
|
@ -78,6 +88,21 @@ function ExercisesListPageRoot() {
|
||||||
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
|
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
|
||||||
const [peekExercise, setPeekExercise] = useState(null)
|
const [peekExercise, setPeekExercise] = useState(null)
|
||||||
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
|
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
|
||||||
|
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
|
||||||
|
const [quickSaving, setQuickSaving] = useState(false)
|
||||||
|
const [quickAiError, setQuickAiError] = useState('')
|
||||||
|
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
title: quickTitle,
|
||||||
|
sketch: quickSketch,
|
||||||
|
focusAreaId: quickFocusAreaId,
|
||||||
|
setTitle: setQuickTitle,
|
||||||
|
setSketch: setQuickSketch,
|
||||||
|
setFocusAreaId: setQuickFocusAreaId,
|
||||||
|
} = useExerciseAiQuickCreateFields(debouncedSearch, {
|
||||||
|
enabled: pageTab === 'list' && (aiQuickCreateEnabled || debouncedSearch.length >= 3),
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) return
|
if (!user?.id) return
|
||||||
|
|
@ -151,6 +176,13 @@ function ExercisesListPageRoot() {
|
||||||
loadMore,
|
loadMore,
|
||||||
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
|
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
|
||||||
|
|
||||||
|
const showQuickCreateOffer =
|
||||||
|
pageTab === 'list' &&
|
||||||
|
catalogsReady &&
|
||||||
|
!listFetching &&
|
||||||
|
(aiQuickCreateEnabled ||
|
||||||
|
(exercises.length === 0 && selectedEntries.length === 0 && debouncedSearch.length >= 3))
|
||||||
|
|
||||||
const selectedIds = useMemo(
|
const selectedIds = useMemo(
|
||||||
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
|
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
|
||||||
[selectedEntries]
|
[selectedEntries]
|
||||||
|
|
@ -304,6 +336,82 @@ function ExercisesListPageRoot() {
|
||||||
|
|
||||||
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), [])
|
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), [])
|
||||||
|
|
||||||
|
const runQuickCreateAiSuggest = useCallback(async () => {
|
||||||
|
const title = (quickTitle || '').trim()
|
||||||
|
if (title.length < 3) {
|
||||||
|
alert('Titel: mindestens 3 Zeichen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sketch = (quickSketch || '').trim()
|
||||||
|
|
||||||
|
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
|
||||||
|
if (!Number.isFinite(focusId) || focusId < 1) {
|
||||||
|
alert('Bitte einen Fokusbereich wählen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
|
||||||
|
const focusHint = (focusRow?.name || '').trim()
|
||||||
|
|
||||||
|
setQuickAiError('')
|
||||||
|
setQuickCreateDraft(null)
|
||||||
|
setQuickSaving(true)
|
||||||
|
try {
|
||||||
|
const aiRes = await api.suggestExerciseAi({
|
||||||
|
title,
|
||||||
|
goal: sketch || undefined,
|
||||||
|
execution: '',
|
||||||
|
preparation: '',
|
||||||
|
trainer_notes: '',
|
||||||
|
focus_area_hint: focusHint || undefined,
|
||||||
|
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
|
||||||
|
include_summary: true,
|
||||||
|
include_skills: true,
|
||||||
|
include_instructions: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
|
||||||
|
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
|
||||||
|
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
|
||||||
|
}
|
||||||
|
setQuickCreateDraft(
|
||||||
|
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
const msg = e?.message || String(e)
|
||||||
|
setQuickAiError(msg)
|
||||||
|
alert(msg || 'KI-Vorschlag fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setQuickSaving(false)
|
||||||
|
}
|
||||||
|
}, [quickTitle, quickSketch, quickFocusAreaId, catalogs.focusAreas])
|
||||||
|
|
||||||
|
const applyQuickCreateDraft = useCallback(async () => {
|
||||||
|
if (!quickCreateDraft) return
|
||||||
|
|
||||||
|
setQuickSaving(true)
|
||||||
|
setQuickAiError('')
|
||||||
|
try {
|
||||||
|
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
|
||||||
|
const created = await api.createExercise(payload)
|
||||||
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||||
|
setQuickCreateDraft(null)
|
||||||
|
setAiQuickCreateEnabled(false)
|
||||||
|
setExercises((prev) => [created, ...prev])
|
||||||
|
navigate(`/exercises/${created.id}/edit`, {
|
||||||
|
state: { returnContext: exercisesModuleReturnContext },
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
const msg = e?.message || String(e)
|
||||||
|
setQuickAiError(msg)
|
||||||
|
alert(msg || 'Übung konnte nicht angelegt werden')
|
||||||
|
} finally {
|
||||||
|
setQuickSaving(false)
|
||||||
|
}
|
||||||
|
}, [quickCreateDraft, setExercises, navigate, exercisesModuleReturnContext])
|
||||||
|
|
||||||
const bulkVisibilityOptions = useMemo(() => {
|
const bulkVisibilityOptions = useMemo(() => {
|
||||||
const base = [
|
const base = [
|
||||||
{ id: '', label: '— nicht ändern —' },
|
{ id: '', label: '— nicht ändern —' },
|
||||||
|
|
@ -486,13 +594,27 @@ function ExercisesListPageRoot() {
|
||||||
<div className="exercises-page__header">
|
<div className="exercises-page__header">
|
||||||
<h1 className="page-title exercises-page__title">Übungen</h1>
|
<h1 className="page-title exercises-page__title">Übungen</h1>
|
||||||
{pageTab === 'list' ? (
|
{pageTab === 'list' ? (
|
||||||
<NavStateLink
|
<div className="exercises-page__header-actions">
|
||||||
to="/exercises/new"
|
<label
|
||||||
returnContext={exercisesModuleReturnContext}
|
className="exercises-ai-assistant-toggle"
|
||||||
className="btn btn-primary"
|
title="Neue Übung per KI vorschlagen — Titel, optional Kurzbeschreibung, Fokusbereich"
|
||||||
>
|
>
|
||||||
+ Neu
|
<input
|
||||||
</NavStateLink>
|
type="checkbox"
|
||||||
|
checked={aiQuickCreateEnabled}
|
||||||
|
onChange={(e) => setAiQuickCreateEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="exercises-ai-assistant-toggle__track" aria-hidden="true" />
|
||||||
|
<span>Neu mit KI-Assistent</span>
|
||||||
|
</label>
|
||||||
|
<NavStateLink
|
||||||
|
to="/exercises/new"
|
||||||
|
returnContext={exercisesModuleReturnContext}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
+ Neu
|
||||||
|
</NavStateLink>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span aria-hidden="true" />
|
<span aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
|
|
@ -537,6 +659,28 @@ function ExercisesListPageRoot() {
|
||||||
onToggleSelectAllPage={toggleSelectAllPage}
|
onToggleSelectAllPage={toggleSelectAllPage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showQuickCreateOffer ? (
|
||||||
|
<ExerciseAiQuickCreateOffer
|
||||||
|
searchLabel={debouncedSearch || undefined}
|
||||||
|
title={quickTitle}
|
||||||
|
onTitleChange={setQuickTitle}
|
||||||
|
sketch={quickSketch}
|
||||||
|
onSketchChange={setQuickSketch}
|
||||||
|
focusAreaId={quickFocusAreaId}
|
||||||
|
onFocusAreaChange={setQuickFocusAreaId}
|
||||||
|
focusAreas={catalogs.focusAreas}
|
||||||
|
catalogsReady={catalogsReady}
|
||||||
|
busy={quickSaving}
|
||||||
|
error={quickAiError}
|
||||||
|
onRunAi={runQuickCreateAiSuggest}
|
||||||
|
hint={
|
||||||
|
aiQuickCreateEnabled
|
||||||
|
? 'Titel aus Suche oder manuell; Kurzbeschreibung optional — leer für freien KI-Vorschlag, ausgefüllt als deine Ausgangsidee.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ExerciseListBulkToolbar
|
<ExerciseListBulkToolbar
|
||||||
selectedCount={selectedIds.size}
|
selectedCount={selectedIds.size}
|
||||||
bulkMaxIds={BULK_MAX_IDS}
|
bulkMaxIds={BULK_MAX_IDS}
|
||||||
|
|
@ -625,11 +769,15 @@ function ExercisesListPageRoot() {
|
||||||
Lade Übungen…
|
Lade Übungen…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : exercises.length === 0 && selectedEntries.length === 0 ? (
|
) : exercises.length === 0 && selectedEntries.length === 0 && !showQuickCreateOffer ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
|
<p className="exercises-empty-text">
|
||||||
|
{debouncedSearch.length >= 3
|
||||||
|
? 'Keine Übungen gefunden.'
|
||||||
|
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : exercises.length === 0 && selectedEntries.length === 0 && showQuickCreateOffer ? null : (
|
||||||
<>
|
<>
|
||||||
{selectedEntries.length > 0 ? (
|
{selectedEntries.length > 0 ? (
|
||||||
<section className="exercises-selection-section" data-testid="exercises-selection-section">
|
<section className="exercises-selection-section" data-testid="exercises-selection-section">
|
||||||
|
|
@ -696,6 +844,19 @@ function ExercisesListPageRoot() {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ExerciseAiSuggestPreviewModal
|
||||||
|
draft={quickCreateDraft}
|
||||||
|
onDraftChange={setQuickCreateDraft}
|
||||||
|
onDiscard={() => setQuickCreateDraft(null)}
|
||||||
|
onApply={applyQuickCreateDraft}
|
||||||
|
focusAreas={catalogs.focusAreas}
|
||||||
|
skillsCatalog={catalogs.skills}
|
||||||
|
dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
|
||||||
|
hint="Texte sind formatiert — passe sie an und lege die Übung als Entwurf an."
|
||||||
|
applyLabel={quickSaving ? 'Wird angelegt…' : 'Übung anlegen'}
|
||||||
|
applyDisabled={quickSaving}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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 [draftName, setDraftName] = useState('')
|
||||||
const [draftDesc, setDraftDesc] = useState('')
|
const [draftDesc, setDraftDesc] = useState('')
|
||||||
const [draftTemplate, setDraftTemplate] = useState('')
|
const [draftTemplate, setDraftTemplate] = useState('')
|
||||||
|
const [draftOpenrouterModel, setDraftOpenrouterModel] = useState('')
|
||||||
const [draftActive, setDraftActive] = useState(true)
|
const [draftActive, setDraftActive] = useState(true)
|
||||||
|
|
||||||
const [pvTitle, setPvTitle] = useState('Testübung')
|
const [pvTitle, setPvTitle] = useState('Testübung')
|
||||||
|
|
@ -74,6 +75,9 @@ export default function AdminAiPromptsPage() {
|
||||||
setDraftName(d.display_name || '')
|
setDraftName(d.display_name || '')
|
||||||
setDraftDesc(d.description || '')
|
setDraftDesc(d.description || '')
|
||||||
setDraftTemplate(d.template || '')
|
setDraftTemplate(d.template || '')
|
||||||
|
setDraftOpenrouterModel(
|
||||||
|
typeof d.openrouter_model === 'string' ? d.openrouter_model : ''
|
||||||
|
)
|
||||||
setDraftActive(!!d.active)
|
setDraftActive(!!d.active)
|
||||||
setPvPreview(null)
|
setPvPreview(null)
|
||||||
}
|
}
|
||||||
|
|
@ -96,6 +100,7 @@ export default function AdminAiPromptsPage() {
|
||||||
display_name: draftName,
|
display_name: draftName,
|
||||||
description: draftDesc,
|
description: draftDesc,
|
||||||
active: draftActive,
|
active: draftActive,
|
||||||
|
openrouter_model: draftOpenrouterModel.trim(),
|
||||||
})
|
})
|
||||||
await loadList()
|
await loadList()
|
||||||
const nd = await api.getAdminAiPrompt(detail.id)
|
const nd = await api.getAdminAiPrompt(detail.id)
|
||||||
|
|
@ -201,6 +206,11 @@ export default function AdminAiPromptsPage() {
|
||||||
inaktiv
|
inaktiv
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{p.openrouter_model ? (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text3)' }} title="OpenRouter-Modell für diesen Prompt">
|
||||||
|
Model: <code>{p.openrouter_model}</code>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
|
{p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -231,6 +241,17 @@ export default function AdminAiPromptsPage() {
|
||||||
onChange={(e) => setDraftDesc(e.target.value)}
|
onChange={(e) => setDraftDesc(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">OpenRouter-Modell (optional)</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Leer = Server-OPENROUTER_MODEL · z.B. anthropic/claude-3.5-haiku"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={draftOpenrouterModel}
|
||||||
|
onChange={(e) => setDraftOpenrouterModel(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
|
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
|
||||||
Aktiv
|
Aktiv
|
||||||
|
|
|
||||||
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