Implement AI Skill Retrieval Profiles and Enhance Exercise AI Functionality
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s

- Introduced migration 068 for `ai_skill_retrieval_profiles`, enabling configurable weights and quotes for skill catalog prioritization in exercise AI suggestions.
- Updated the `POST /api/exercises/ai/suggest` endpoint to include an optional `focus_areas_context` field, allowing for enhanced context in AI-generated suggestions.
- Enhanced the `exercise_ai` module to utilize context-based skill selection, incorporating scoring, category caps, and keyword patches for improved AI responses.
- Updated the ExerciseFormPageRoot component to pass focus area context to the AI suggestion API, streamlining user interaction with AI-generated content.
- Incremented version numbers in `backend/version.py` to reflect the latest changes and ensure accurate tracking in the changelog.
This commit is contained in:
Lars 2026-05-22 09:49:08 +02:00
parent e5291256d0
commit 294b09a5d9
11 changed files with 802 additions and 70 deletions

View File

@ -6,6 +6,8 @@
**Autor:** Claude Code
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
**Verwandt (Skill-Katalog in Übungs-KI):** `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md` — Tabelle **`ai_skill_retrieval_profiles`** (`config`-JSON ergänzt inhaltliche Prompt-/Katalog-Steuerung neben Platzhaltern).
---
## 1. Konzept

View File

@ -160,7 +160,38 @@ KI gibt Vorschläge
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
**Request Body:**
**Required Fields:** mindestens `goal` ODER `execution`
**Optional Skill-Katalogpriorisierung (Stand 068):**
```json
{
"focus_areas_context": [
{ "focus_area_id": 3, "is_primary": true },
{ "focus_area_id": 1, "is_primary": false }
],
"focus_area_hint": "Karate, Kumite…"
}
```
- `focus_areas_context`: IDs aus Stammdatum **Fokusbereiche**; Primär soll zuerst stehen (`is_primary`). Ohne Feld oder leere Liste gilt das DB-Profil **`is_default`** (`ai_skill_retrieval_profiles`).
- `focus_area_hint`: bleibt lesbarer Text für den Prompt (bestehende Prompts).
**Minimal-Beispiel (Mit Fokus für Retrieval):**
```json
{
"title": "Maai - Distanzübung",
"goal": "…",
"execution": "…",
"focus_areas_context": [ { "focus_area_id": 1, "is_primary": true } ]
}
```
**Minimal-Beispiel ( ohne Fokus — nur Texts):**
```json
{
"title": "Maai - Distanzübung",
@ -169,8 +200,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
}
```
**Required Fields:** mindestens `goal` ODER `execution` (je länger, desto besser)
**Response:** `200 OK`
```json
{

View File

@ -13,7 +13,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
| exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC |
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; Sendung an OpenRouter |
| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; OpenRouter — suggest optional `focus_areas_context` für Retrieval-Profile |
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
| dashboard | `GET /api/dashboard/kpis` | ja | `get_tenant_context` | wie `GET /api/exercises` + `GET /api/training-units` | Aggregat für Dashboard-Kurzüberblick (ein Roundtrip) |
@ -39,7 +39,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
Letzte Änderung: 2026-05-22 — `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` (Übungs-KI, kein Persist durch Endpunkt).
Letzte Änderung: 2026-05-29 — gleiche Endpunkte; `POST /api/exercises/ai/suggest` ergänzt um optionales `focus_areas_context` für `ai_skill_retrieval_profiles` (Migration 068).
---

View File

@ -1,8 +1,8 @@
# Umsetzungsplan KI bei Übungen (stufenweise, Driftschutz)
**Version:** 0.1
**Datum:** 2026-05-22
**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand)
**Version:** 0.2
**Datum:** 2026-05-29
**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · **`working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`** · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand)
---
@ -10,10 +10,11 @@
1. **Spec vor Code:** Request/Response-Felder und Statuscodes an `KI_FEATURES_SPEC.md` ausrichten; Abweichungen zuerst Spec oder dieses Dokument anpassen.
2. **Prompts in der DB:** Keine produktionskritischen Prompt-Langtexte nur im Code; Defaults per **Migration** in `ai_prompts`, Anpassung durch Admins über vorgesehene Oberfläche (später) oder SQL.
3. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen.
4. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec).
5. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`.
6. **Schema:** Neue DB-Objekte nur nummerierte Migration `backend/migrations/067_*.sql` (oder folgend); `DB_SCHEMA_VERSION` in `backend/version.py` anheben.
3. **Skill-Retrieval-Profile:** Gewichte/Quotes in **`ai_skill_retrieval_profiles.config`** — Spezifikation `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; kein zweites gleichzeitiges Truth-Repo im Sourcecode außer defensiver Fallback `_FALLBACK_RETRIEVAL_CONFIG` in `exercise_ai.py`.
4. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen.
5. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec).
6. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`.
7. **Schema:** Neue DB-Objekte nur nummerierte Migration **`backend/migrations/`** (aktuell bis **068**) und `DB_SCHEMA_VERSION` anheben.
---
@ -26,10 +27,11 @@
| **S2** | `httpx`-Client OpenRouter; Modul lädt Prompt, ersetzt Platzhalter, parst Antwort | Unit-/Smoke: 503 ohne Key |
| **S3** | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` | OpenAPI/Handtest mit Key |
| **S4** | Frontend: KI-Vorschlag, **Änderungsdialog** (Vorschau, Kurzfassung wählbar, Fähigkeiten pro Zeile an-/abwählbar), dann Übernahme ins Formular | Manuelle UX-Prüfung |
| **S4b** | **Skill-Retrieval:** Migration **`ai_skill_retrieval_profiles`**, `focus_areas_context` am **`POST …/ai/suggest`**, `exercise_ai` kontextbezogener Katalog (Gewichte, Caps, Keyword-Patches) | Migration 068 angelegt; Smoke mit Gewaltschutz / ohne Fokus |
| **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config |
| **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics |
**Aktueller Implementierungsstand nach Merge:** S0S4 anstreben; S5/S6 nicht Teil dieses Laufs.
**Aktueller Implementierungsstand:** **S4 + S4b** im Code (`exercise_ai` + Formular übermittelt `focus_areas_context`).
---
@ -47,7 +49,7 @@
- **2026-05-22:** Initial; S1S4 als erster Umsetzungspfad.
- **2026-05-22:** S1S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen.
- **2026-05-22:** UX: Übernahmedialog für KI-Vorschläge (Vorschau, selektive Übernahme) im Übungsformular (`ExerciseFormPageRoot`).
- **2026-05-29:** **S4b:** Migration **068**, `ai_skill_retrieval_profiles`; suggest `focus_areas_context`; Frontend sendet gesetzte Fokusbereiche; Spec `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`.
---
@ -55,7 +57,11 @@
**Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert).
**Erledigt (2026-05-29):** Migration **`068`** / Profil **`ai_skill_retrieval_profiles`** (Standard + Profil Gewaltschutz wenn `focus_areas.name` vorhanden); **`exercise_ai`** — Score/Kategorie-Zapfen/Text-Overlap/Keyword-Zuschläge; **API:** `ExerciseAiSuggestBody.focus_areas_context`; **Regenerate** nutzt DB-Fokuszeilen.
**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen.
**Bewusst noch nicht:** automatische KI beim Speichern (**S5**), Setzen von `summary_ai_generated` bei manuellen UI-Änderungen, Prompt-Admin-UI, Rate-Limits.
**Offen nächste Schritte Pflege/Umsetzung:** weitere Retrieval-Profile (z.B. Karate-/Fitness-Schwerpunkt) per SQL später Admin-UI; optionales Feld **`skills.ai_context`** Kurzbeschreibung für KI; automatische KI beim Speichern (**S5**); Prompt-/Profil-Admin-UI ohne SQL; Rate-Limits.
**Bewusst noch nicht (`summary_ai_generated`):** zurücksetzen bei manueller Kurzfassung im UI; Admin-Pflege `ai_skill_retrieval_profiles`.

View File

@ -0,0 +1,120 @@
# KI Skill-Retrieval-Profile (`ai_skill_retrieval_profiles`)
**Version:** 0.1
**Datum:** 2026-05-29
**Status:** Umsetzung gestartet (Migration **068**)
**Ziel:** Für `POST /api/exercises/ai/suggest` (Skill-Katalogauszug) **Gewichte und Quoten** steuerbar machen:
- gebunden an **Übungs-Fokusbereich** (`focus_areas.id`),
- ein **Standardprofil** ohne Fokus,
- **optional zusammengeführte** Profile bei mehreren Fokusbereichen,
- **optional Keyword-Übersteuerungen** aus Ziel/Durchführung (z.B. Rollenspiel vs. Befreiung).
**Technische Basis:** Skills mit `skills.main_category_id``skill_main_categories.slug` (`karate` | `allgemeine`) und `skills.category_id``skill_categories.slug` (`kondition`, `selbstverteidigung`, …).
**Bezüge:** `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md` · `backend/exercise_ai.py`
---
## 1. Datenmodell
### Tabelle `ai_skill_retrieval_profiles`
| Spalte | Typ | Beschreibung |
|--------|-----|--------------|
| `id` | serial | Primärschlüssel |
| `focus_area_id` | int NULL FK → `focus_areas(id)` ON DELETE SET NULL | **`NULL`** nur für Standardeintrag möglich (siehe `is_default`) |
| `is_default` | boolean | Genau **eine** Zeile mit `true` |
| `name` | varchar | Kurzer Name (Admin später) |
| `description` | text | Hinweise für Pflege |
| `active` | boolean | Nur aktive werden geladen |
| `config` | jsonb | Siehe §2 |
**Constraints / Indizes**
- Eindeutig: `(focus_area_id)` WHERE `focus_area_id IS NOT NULL`
- Eindeutig: `(is_default)` WHERE `is_default = true`
---
## 2. JSON-Konfiguration `config.version = 1`
Alle Schlüssel **optional**; fehlende Werte fallen auf **einprogrammierten Fallback** in `exercise_ai.py` zurück (entspricht bisher grob „neutral“).
### 2.1 Gewichtungen (Ranking)
| Schlüssel | Typ | Bedeutung |
|-----------|-----|------------|
| `main_slug_weights` | `object[str, float]` | Multiplikator pro Hauptkategorie-Slug (`karate`, `allgemeine`) |
| `category_slug_weights` | `object[str, float]` | Multiplikator pro `skill_categories.slug` |
Basis-Score (vereinfacht):
`(importance oder 3) × main_w × cat_w × text_overlap_bonus × importance_multiplier`
### 2.2 Kapazitätsbegrenzung (Liste)
`_MAX_SKILLS_CATALOG_LINES` (aktuell **240**) Zeilen Gesamt:
| Schlüssel | Typ | Bedeutung |
|-----------|-----|------------|
| `category_max_share` | `object[str, float]` | Max. Anteil dieser **Unterkategorie** am Endergebnis (01), z.B. `{ "kondition": 0.25 }` |
| `main_min_share` | `object[str, float]` | Mindest-Zielanteil Hauptkategorie beim **Auswahl-Greedy** (weich; Rest nach Score aufgefüllt) |
### 2.3 Text / Token-Sparen
| Schlüssel | Typ | Standard | Bedeutung |
|-----------|-----|----------|------------|
| `description_plain_max_len` | int | 160 | Gekürzte Beschreibung pro Zeile |
| `karate_relevance_max_len` | int | **0** oder 80 | **`0`** = Feld `karate_relevance`/`relevance_level` in der Promptzeile **weglassen** |
### 2.4 Keyword-Overrides (optional)
Liste `keyword_overrides`: jedes Element:
```json
{
"keywords_any": ["befreiung", "haltegriff"],
"case_insensitive": true,
"patch": {
"category_slug_weights": { "selbstverteidigung": 2.5 },
"category_max_share": { "koordination": 0.1 }
}
}
```
Textsuche in verkettetem Korpus **Titel, Ziel, Durchführung, Focus-Hint** (bereits plaintext). Reihenfolge: erst Basis-Profile zusammenmergen, dann **alle treffenden Overrides**`patch`Objekte **flach zusammenführen** (Gewichte multiplikativ übereinander, Caps den strengsten Wert nehmen aktuelle Implementierung im Code dokumentiert).
---
## 3. Mehrere Fokusbereiche auf der Übung
Request-Body: `focus_areas_context: [{ "focus_area_id": n, "is_primary": bool }, …]`
**Aktuelle Merge-Strategie (v1):** Profile laden → **gleichgewichtete Mittelwert-Bildung** der numerischen Gewichte / Caps (implementiert für `main_slug_weights`, `category_slug_weights`, `category_max_share`, `main_min_share`, `*_max_len`). Anschließend **Keyword-Overrides** anwenden.
**Primär-Fokus:** Im Frontend soll die **primäre** Zeile aus `focus_areas_multi` **zuerst** in der Liste stehen; die Merge-Strategie kann später zu „Primär dominate“ erweitert werden.
Ohne Kontext oder ohne Treffer auf aktive Profile: **nur Standardprofil** (`is_default`).
---
## 4. Seed-Daten (Migration)
- **`is_default=true`:** ausgewogene Standard-Gewichte, moderate Caps auf `kondition`/`koordination`, Karate-Relevanz gekürzt.
- **`Gewaltschutz`:** `focus_area_id` per `(SELECT id FROM focus_areas WHERE name = 'Gewaltschutz' LIMIT 1)` — höhere Gewichte für `kognition`, `psychische_faehigkeiten`, `soziale_faehigkeiten`, `selbstverteidigung`; gedrosseltes `kondition`/`koordination`; `karate_relevance_max_len`: 0; Keyword-Patches wie oben können nachgeschärft werden.
Weitere Profile (Karate-Schwerpunkt etc.) später per Admin-SQL oder UI.
---
## 5. API
`ExerciseAiSuggestBody` erweitert um **`focus_areas_context`** (Liste). Feld **`focus_area_hint`** bleibt für den **Prompt-Kontext** (bestehende Prompts).
`POST …/ai/regenerate` nutzt später dieselbe Retrieval-Logik aus den Detail-Daten der Übung (**To-do:** dort `focus_areas_context` aus `exercise_focus_areas` ableiten).
---
## 6. Changelog
- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration.

View File

@ -1,12 +1,16 @@
"""
KI-Vorschlaege fuer Uebungsformular: Laedt Prompts aus ai_prompts, ruft OpenRouter auf.
Keine persistente Aenderung an exercises nur Response-DTO fuer das Frontend.
Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, Fallback-Heuristik).
"""
from __future__ import annotations
import copy
import json
import math
import re
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple
from fastapi import HTTPException
@ -24,6 +28,26 @@ _LEGACY_SKILL_LEVEL_SLUG = {
}
_ALLOWED_SKILL_INTENSITY = frozenset({"niedrig", "mittel", "hoch"})
_TAG_RE = re.compile(r"<[^>]+>", re.IGNORECASE)
_TOKEN_FIND = re.compile(r"[a-zäöüß0-9]+", re.IGNORECASE)
_MAX_PLAIN_FIELD = 28_000
_MAX_SKILLS_CATALOG_LINES = 240
_MAX_SUMMARY_CHARS = 220
_FALLBACK_RETRIEVAL_CONFIG: Dict[str, Any] = {
"version": 1,
"importance_multiplier": 1.0,
"text_overlap_bonus": 2.0,
"main_slug_weights": {"karate": 1.0, "allgemeine": 1.0},
"category_slug_weights": {},
"category_max_share": {"kondition": 0.38, "koordination": 0.35},
"main_min_share": {},
"description_plain_max_len": 160,
"karate_relevance_max_len": 72,
"keyword_overrides": [],
}
def _normalize_exercise_skill_level(value) -> Optional[str]:
if value is None:
@ -50,12 +74,6 @@ def _normalize_exercise_skill_intensity(value) -> str:
return key
return "mittel"
_TAG_RE = re.compile(r"<[^>]+>", re.IGNORECASE)
_MAX_PLAIN_FIELD = 28_000
_MAX_SKILLS_CATALOG_LINES = 240
_MAX_SUMMARY_CHARS = 220
def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) -> str:
if not html:
@ -67,6 +85,399 @@ def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD)
return t
def _corpus_tokens(*parts: str) -> frozenset:
hay = " ".join(p.strip() for p in parts if p and p.strip())
ws = {_m.group(0).lower() for _m in _TOKEN_FIND.finditer(hay)}
return frozenset(w for w in ws if len(w) > 1)
def _ai_profiles_table_ready(cur) -> bool:
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_skill_retrieval_profiles",))
row = cur.fetchone()
if row is None:
return False
val = row["t"] if isinstance(row, dict) else row[0]
return val is not None and str(val).strip() != ""
def _average_float_dict(dicts: Sequence[Mapping[str, Any]], *, fallback: float) -> Dict[str, float]:
keys: set = set()
for d in dicts:
keys |= set(d.keys())
out: Dict[str, float] = {}
for k in keys:
vals = []
for d in dicts:
if k not in d or d[k] is None:
continue
try:
vals.append(float(d[k]))
except (TypeError, ValueError):
continue
out[k] = (sum(vals) / len(vals)) if vals else fallback
return out
def _merge_retrieval_configs(configs: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
base = copy.deepcopy(_FALLBACK_RETRIEVAL_CONFIG)
if not configs:
return base
base["main_slug_weights"] = _average_float_dict(
[c.get("main_slug_weights") or {} for c in configs],
fallback=1.0,
)
for slug in ("karate", "allgemeine"):
base["main_slug_weights"].setdefault(slug, 1.0)
base["category_slug_weights"] = _average_float_dict(
[c.get("category_slug_weights") or {} for c in configs],
fallback=1.0,
)
base["category_max_share"] = _average_float_dict(
[c.get("category_max_share") or {} for c in configs],
fallback=1.0,
)
base["main_min_share"] = _average_float_dict(
[c.get("main_min_share") or {} for c in configs],
fallback=0.0,
)
ims = []
tbs = []
dmx = []
krm = []
for c in configs:
try:
if c.get("importance_multiplier") is not None:
ims.append(float(c["importance_multiplier"]))
except (TypeError, ValueError):
continue
try:
if c.get("text_overlap_bonus") is not None:
tbs.append(float(c["text_overlap_bonus"]))
except (TypeError, ValueError):
continue
try:
if c.get("description_plain_max_len") is not None:
dmx.append(int(c["description_plain_max_len"]))
except (TypeError, ValueError):
continue
try:
if c.get("karate_relevance_max_len") is not None:
krm.append(int(c["karate_relevance_max_len"]))
except (TypeError, ValueError):
continue
if ims:
base["importance_multiplier"] = sum(ims) / len(ims)
if tbs:
base["text_overlap_bonus"] = sum(tbs) / len(tbs)
if dmx:
base["description_plain_max_len"] = int(round(sum(dmx) / len(dmx)))
if krm:
base["karate_relevance_max_len"] = int(round(sum(krm) / len(krm)))
overrides: List[Any] = []
for c in configs:
overrides.extend(c.get("keyword_overrides") or [])
base["keyword_overrides"] = overrides
return base
def _mul_weight_dict(target: MutableMapping[str, float], patch: Mapping[str, Any]) -> None:
for k, v in patch.items():
try:
mul = float(v)
except (TypeError, ValueError):
continue
target[k] = float(target.get(k, 1.0)) * mul
def _apply_keyword_overrides(cfg: Dict[str, Any], corpus_lower: str) -> None:
caps = cfg.setdefault("category_max_share", {})
for ov in cfg.get("keyword_overrides") or []:
keys_any = ov.get("keywords_any") or []
if not keys_any or not corpus_lower.strip():
continue
hay = corpus_lower.lower() if corpus_lower else ""
hit = False
for kw in keys_any:
ks = str(kw or "").strip()
if not ks:
continue
ks_l = ks.lower()
hit = ks_l in hay
if hit:
break
if not hit:
continue
patch = ov.get("patch") or {}
_mul_weight_dict(cfg.setdefault("category_slug_weights", {}), patch.get("category_slug_weights") or {})
_mul_weight_dict(cfg.setdefault("main_slug_weights", {}), patch.get("main_slug_weights") or {})
for slug, mx in (patch.get("category_max_share") or {}).items():
try:
mx_f = float(mx)
except (TypeError, ValueError):
continue
cur = float(caps.get(slug, 1.0))
caps[slug] = min(cur, mx_f)
def _ordered_focus_ids(focus_ctx: Optional[Sequence[Tuple[int, bool]]]) -> List[int]:
"""Primär zuerst, dann stabil nach ID."""
if not focus_ctx:
return []
seen = set()
ordered: List[Tuple[int, bool]] = []
for fid, isp in sorted(focus_ctx, key=lambda x: (not x[1], x[0])):
try:
i = int(fid)
except (TypeError, ValueError):
continue
if i < 1 or i in seen:
continue
seen.add(i)
ordered.append((i, bool(isp)))
return [fid for fid, _ in ordered]
def _load_merged_retrieval_config(
cur, focus_ctx: Optional[Sequence[Tuple[int, bool]]]
) -> Dict[str, Any]:
if not _ai_profiles_table_ready(cur):
return copy.deepcopy(_FALLBACK_RETRIEVAL_CONFIG)
loaded: List[Dict[str, Any]] = []
for fid in _ordered_focus_ids(focus_ctx):
cur.execute(
"""
SELECT config
FROM ai_skill_retrieval_profiles
WHERE active = true AND focus_area_id = %s
LIMIT 1
""",
(fid,),
)
rw = cur.fetchone()
if not rw:
continue
raw = rw["config"] if isinstance(rw, dict) else rw[0]
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
continue
if isinstance(raw, dict):
loaded.append(raw)
if not loaded:
cur.execute(
"""
SELECT config
FROM ai_skill_retrieval_profiles
WHERE active = true AND is_default = true
LIMIT 1
"""
)
rw = cur.fetchone()
if rw:
raw = rw["config"] if isinstance(rw, dict) else rw[0]
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
raw = None
if isinstance(raw, dict):
loaded.append(raw)
return _merge_retrieval_configs(loaded)
def _fetch_all_active_skills_for_catalog(cur) -> List[Dict[str, Any]]:
cur.execute(
"""
SELECT s.id,
s.name,
s.category,
s.description,
s.karate_relevance,
s.relevance_level,
s.importance,
COALESCE(m.slug, '') AS main_slug,
COALESCE(c.slug, '') AS category_slug,
c.name AS subcategory_name
FROM skills s
LEFT JOIN skill_main_categories m ON m.id = s.main_category_id
LEFT JOIN skill_categories c ON c.id = s.category_id
WHERE (s.status IS NULL OR s.status = 'active')
"""
)
return [dict(r) for r in cur.fetchall()]
def _score_skill_row(
row: Mapping[str, Any],
cfg: Mapping[str, Any],
corpus_tokens: frozenset,
) -> float:
main_slug = str(row.get("main_slug") or "").strip().lower()
cat_slug = str(row.get("category_slug") or "").strip().lower()
main_w = float((cfg.get("main_slug_weights") or {}).get(main_slug, 1.0))
cat_w = float((cfg.get("category_slug_weights") or {}).get(cat_slug, 1.0))
try:
imp = int(row["importance"]) if row.get("importance") is not None else 3
except (TypeError, ValueError):
imp = 3
imp = max(1, min(5, imp))
imp_mult = float(cfg.get("importance_multiplier") or 1.0)
base = float(imp) * imp_mult * max(main_w, 0.05) * max(cat_w, 0.05)
name = strip_html_to_plain(row.get("name"), max_len=400)
dsc = strip_html_to_plain(row.get("description"), max_len=520)
search_blob = " ".join(
[
name,
dsc,
cat_slug.replace("_", " "),
str(row.get("category") or ""),
str(row.get("subcategory_name") or ""),
]
).lower()
overlaps = sum(1 for t in corpus_tokens if t and t in search_blob)
tob = float(cfg.get("text_overlap_bonus") or 0.0)
return base + overlaps * tob
def _category_cap_limits(cfg: Mapping[str, Any], n_max: int) -> Dict[str, int]:
out: Dict[str, int] = {}
mx = cfg.get("category_max_share") or {}
if not isinstance(mx, dict):
return out
for slug, raw in mx.items():
ks = str(slug or "").strip()
if not ks:
continue
try:
sh = float(raw)
except (TypeError, ValueError):
continue
if 0 < sh < 1.0:
out[ks] = max(1, int(math.floor(sh * n_max)))
elif sh >= 1.0:
out[ks] = n_max + 99999
else:
continue
return out
def _pick_catalog_rows(rows_scored: List[Tuple[float, Dict[str, Any]]], cfg: Mapping[str, Any]) -> List[Dict[str, Any]]:
"""rows_scored: (score, row_dict) ohne Sortierung-Anforderung."""
cap_limits = _category_cap_limits(cfg, _MAX_SKILLS_CATALOG_LINES)
ordered = sorted(rows_scored, key=lambda x: (-x[0], str(x[1].get("name") or "")))
picked: List[Dict[str, Any]] = []
picked_ids: set = set()
cat_counts: Dict[str, int] = {}
def under_cap(cat_slug: str) -> bool:
if not cat_slug or cat_slug not in cap_limits:
return True
return cat_counts.get(cat_slug, 0) < cap_limits[cat_slug]
# Pass 1: Cap respektieren
for _sc, rw in ordered:
if len(picked) >= _MAX_SKILLS_CATALOG_LINES:
break
sid = rw["id"]
if sid in picked_ids:
continue
cslug = str(rw.get("category_slug") or "").strip().lower()
if cslug and not under_cap(cslug):
continue
picked.append(rw)
picked_ids.add(sid)
if cslug:
cat_counts[cslug] = cat_counts.get(cslug, 0) + 1
# Pass 2: auffüllen
if len(picked) < _MAX_SKILLS_CATALOG_LINES:
for _sc, rw in ordered:
if len(picked) >= _MAX_SKILLS_CATALOG_LINES:
break
sid = rw["id"]
if sid in picked_ids:
continue
picked.append(rw)
picked_ids.add(sid)
return picked[:_MAX_SKILLS_CATALOG_LINES]
def _format_skill_catalog_line(row: Mapping[str, Any], cfg: Mapping[str, Any]) -> str:
rid = int(row["id"])
nm = (row.get("name") or "").strip() or f"Skill #{rid}"
cat_legacy = str(row.get("category") or "").strip()
sub = str(row.get("subcategory_name") or "").strip()
main_slug = str(row.get("main_slug") or "").strip()
cats = " / ".join(x for x in (main_slug.upper() if main_slug else "", cat_legacy, sub) if x)
dmax = int(cfg.get("description_plain_max_len") or 160)
dsc = strip_html_to_plain(row.get("description"), max_len=max(40, min(400, dmax)))
krmax = int(cfg.get("karate_relevance_max_len") or 0)
kr = strip_html_to_plain(row.get("karate_relevance"), max_len=min(280, krmax)) if krmax > 0 else ""
rel = row.get("relevance_level")
rel_s = str(rel).strip() if rel is not None else ""
parts = [
f"- id={rid} | name={nm}",
f" | kategorie={cats or '-'}",
f" | beschreibung={dsc or '-'}",
]
if krmax > 0 and (kr.strip() or rel_s):
parts.append(f" | karate_relevanz={kr or '-'} | relevanz_stufe={rel_s or '-'}")
return "".join(parts)
def _safe_int_importance(value: Any) -> int:
try:
iv = int(value)
except (TypeError, ValueError):
return 0
return max(1, min(5, iv)) if iv else 0
def build_contextual_skills_catalog_block(
cur,
*,
title: Optional[str],
goal_plain: str,
execution_plain: str,
focus_hint: Optional[str],
focus_ctx: Optional[Sequence[Tuple[int, bool]]],
) -> str:
cfg = _load_merged_retrieval_config(cur, focus_ctx)
corpus_lower = " ".join([title or "", goal_plain or "", execution_plain or "", focus_hint or ""]).lower()
_apply_keyword_overrides(cfg, corpus_lower)
tok = _corpus_tokens(title or "", goal_plain, execution_plain, focus_hint or "")
skill_rows = _fetch_all_active_skills_for_catalog(cur)
scored: List[Tuple[float, Dict[str, Any]]] = []
for r in skill_rows:
scored.append((_score_skill_row(r, cfg, tok), r))
picked = _pick_catalog_rows(scored, cfg)
picked.sort(
key=lambda r: (
-_safe_int_importance(r.get("importance")),
str(r.get("name") or "").lower(),
)
)
lines = [_format_skill_catalog_line(row, cfg) for row in picked]
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]:
cur.execute(
"""
@ -93,56 +504,17 @@ def _render_template(template: str, ctx: Dict[str, str]) -> str:
return out
def _build_skills_catalog_block(cur) -> str:
cur.execute(
"""
SELECT s.id, s.name, s.category, s.description, s.karate_relevance, s.relevance_level,
sc.name AS subcategory_name
FROM skills s
LEFT JOIN skill_categories sc ON s.category_id = sc.id
WHERE (s.status IS NULL OR s.status = 'active')
ORDER BY s.importance DESC NULLS LAST, s.name
LIMIT %s
""",
(_MAX_SKILLS_CATALOG_LINES,),
)
lines: List[str] = []
for r in cur.fetchall():
rid = int(r["id"])
nm = (r.get("name") or "").strip() or f"Skill #{rid}"
cat = (r.get("category") or "").strip()
sub = (r.get("subcategory_name") or "").strip()
dsc = strip_html_to_plain(r.get("description"), max_len=320)
kr = strip_html_to_plain(r.get("karate_relevance"), max_len=200)
rel = r.get("relevance_level")
rel_s = ""
if rel is not None:
rel_s = str(rel)
cats = " / ".join(x for x in (cat, sub) if x)
blob = (
f"- id={rid} | name={nm} | kategorie={cats or '-'}"
f" | beschreibung={dsc or '-'} | karate_relevanz={kr or '-'}"
f" | relevanz_stufe={rel_s or '-'}"
)
lines.append(blob)
return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)"
def _extract_json_array(text: str) -> Any:
s = text.strip()
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
if s.endswith("```"):
s = s[:-3].strip()
# array whole string
if s.startswith("["):
end = s.rfind("]")
if end > 0:
s = s[: end + 1]
return json.loads(s)
# object wrapping array
if s.startswith("{"):
obj = json.loads(s)
if isinstance(obj, dict):
@ -219,7 +591,6 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
item["confidence"] = conf_f
out.append(item)
# max 5
return out[:5]
@ -240,6 +611,7 @@ def run_exercise_ai_suggestion(
goal: Optional[str],
execution: Optional[str],
focus_area_hint: Optional[str],
focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None,
want_summary: bool,
want_skills: bool,
) -> Dict[str, Any]:
@ -285,7 +657,14 @@ def run_exercise_ai_suggestion(
status_code=503,
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
)
catalog = _build_skills_catalog_block(cur)
catalog = build_contextual_skills_catalog_block(
cur,
title=t_title,
goal_plain=g_plain,
execution_plain=e_plain,
focus_hint=focus or None,
focus_ctx=focus_areas_context,
)
ctx = {
"exercise_title": t_title or "-",
"exercise_focus_area": focus or "-",
@ -318,3 +697,10 @@ def run_exercise_ai_suggestion(
result["skills"] = skills
return result
__all__ = [
"build_contextual_skills_catalog_block",
"run_exercise_ai_suggestion",
"strip_html_to_plain",
]

View File

@ -0,0 +1,125 @@
-- Migration 068: KI Skill-Retrieval-Profile pro Fokusbereich (+ Standardprofil)
-- Purpose: Gewichtungen/Quota fuer exercise_ai Skill-Katalog (OpenRouter Kontext)
CREATE TABLE IF NOT EXISTS ai_skill_retrieval_profiles (
id SERIAL PRIMARY KEY,
focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
name VARCHAR(200) NOT NULL,
description TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_focus_area
ON ai_skill_retrieval_profiles (focus_area_id)
WHERE focus_area_id IS NOT NULL AND active = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_default_only
ON ai_skill_retrieval_profiles (is_default)
WHERE is_default IS TRUE AND active = TRUE;
COMMENT ON TABLE ai_skill_retrieval_profiles IS
'Gewichte/Quota fuer Skill-Katalog in exercise_ai; optional gebunden an focus_areas, genau eine is_default=TRUE';
INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config)
VALUES (
NULL,
TRUE,
'Standard',
'Kein/Undefinierter Fokusbereich: neutrale Gewichte mit sanften Caps auf sehr breite Unterkategorien.',
TRUE,
'{
"version": 1,
"importance_multiplier": 1,
"text_overlap_bonus": 2,
"main_slug_weights": { "karate": 1, "allgemeine": 1 },
"category_slug_weights": {},
"category_max_share": {
"kondition": 0.38,
"koordination": 0.35
},
"main_min_share": {},
"description_plain_max_len": 160,
"karate_relevance_max_len": 72,
"keyword_overrides": [
{
"keywords_any": ["rollenspiel", "szenario", "deesk", "diskussion"],
"case_insensitive": true,
"patch": {
"category_slug_weights": {
"psychische_faehigkeiten": 1.65,
"soziale_faehigkeiten": 1.65,
"kognition": 1.4
},
"category_max_share": {
"kondition": 0.08,
"koordination": 0.1
}
}
},
{
"keywords_any": ["befreiung", "haltegriff", "greifer", "umklammer"],
"case_insensitive": true,
"patch": {
"category_slug_weights": {
"selbstverteidigung": 2.2,
"koordination": 0.9
},
"main_slug_weights": { "karate": 1.35 }
}
}
]
}'::jsonb
);
INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config)
SELECT
fa.id,
FALSE,
'Gewaltschutz',
'Kaum klassische Sportfaehigkeit; Gewicht auf Deeskalation, Kognition/Soziales; SV-Schwerpunkt per Keywords verstaerken.',
TRUE,
'{
"version": 1,
"importance_multiplier": 1,
"text_overlap_bonus": 2.25,
"main_slug_weights": { "karate": 1.08, "allgemeine": 1.06 },
"category_slug_weights": {
"kognition": 1.72,
"psychische_faehigkeiten": 1.78,
"soziale_faehigkeiten": 1.78,
"selbstverteidigung": 1.82,
"kondition": 0.32,
"koordination": 0.4
},
"category_max_share": {
"kondition": 0.12,
"koordination": 0.16
},
"main_min_share": {},
"description_plain_max_len": 150,
"karate_relevance_max_len": 0,
"keyword_overrides": [
{
"keywords_any": ["befreiung", "haltegriff", "greifer"],
"case_insensitive": true,
"patch": {
"category_slug_weights": {
"selbstverteidigung": 3.25,
"koordination": 1.08
},
"main_slug_weights": { "karate": 1.5 }
}
}
]
}'::jsonb
FROM focus_areas fa
WHERE fa.name = 'Gewaltschutz'
AND (fa.status IS NULL OR fa.status = 'active')
AND NOT EXISTS (
SELECT 1 FROM ai_skill_retrieval_profiles p
WHERE p.focus_area_id = fa.id AND p.active = TRUE
)
LIMIT 1;

View File

@ -358,11 +358,22 @@ class ExerciseMediaFromAsset(BaseModel):
media_type: Optional[str] = None
class ExerciseAiFocusCtx(BaseModel):
"""Fokusbereich fuer Skill-Kataloggewichte (Migration 068 ai_skill_retrieval_profiles)."""
focus_area_id: int = Field(..., ge=1)
is_primary: Optional[bool] = False
class ExerciseAiSuggestBody(BaseModel):
title: Optional[str] = Field(None, max_length=300)
goal: Optional[str] = Field(None, max_length=64000)
execution: Optional[str] = Field(None, max_length=128000)
focus_area_hint: Optional[str] = Field(None, max_length=1200)
focus_areas_context: Optional[list[ExerciseAiFocusCtx]] = Field(
None,
description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung",
)
include_summary: bool = True
include_skills: bool = True
@ -2254,6 +2265,22 @@ def list_exercises_like_get(
)
def _focus_areas_ai_ctx_from_detail(exercise: Dict[str, Any]) -> list[tuple[int, bool]]:
rows: list[tuple[int, bool]] = []
for row in exercise.get("focus_areas") or []:
if not isinstance(row, dict):
continue
try:
fid = int(row.get("focus_area_id"))
except (TypeError, ValueError):
continue
if fid < 1:
continue
rows.append((fid, bool(row.get("is_primary"))))
rows.sort(key=lambda x: (not x[1], x[0]))
return rows
def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str:
parts: List[str] = []
for row in exercise.get("focus_areas") or []:
@ -2279,12 +2306,17 @@ def exercise_ai_suggest_endpoint(
_ = tenant.profile_id
with get_db() as conn:
cur = get_cursor(conn)
fctx = None
if body.focus_areas_context:
fctx = [(x.focus_area_id, bool(x.is_primary)) for x in body.focus_areas_context]
payload = run_exercise_ai_suggestion(
cur,
title=(body.title or "").strip(),
goal=body.goal,
execution=body.execution,
focus_area_hint=(body.focus_area_hint or "").strip() or None,
focus_areas_context=fctx,
want_summary=body.include_summary,
want_skills=body.include_skills,
)
@ -2310,6 +2342,7 @@ def exercise_ai_regenerate_endpoint(
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
focus = _focus_area_hint_from_detail(exercise)
fctx = _focus_areas_ai_ctx_from_detail(exercise)
payload = run_exercise_ai_suggestion(
cur,
@ -2317,6 +2350,7 @@ def exercise_ai_regenerate_endpoint(
goal=exercise.get("goal"),
execution=exercise.get("execution"),
focus_area_hint=focus or None,
focus_areas_context=fctx or None,
want_summary=want_summary,
want_skills=want_skills,
)

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.152"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260522067"
APP_VERSION = "0.8.153"
BUILD_DATE = "2026-05-29"
DB_SCHEMA_VERSION = "20260529068"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -22,7 +22,7 @@ MODULE_VERSIONS = {
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.29.0", # POST exercises/ai/suggest + …/ai/regenerate (OpenRouter); exercise_ai; is_primary fuer exercise_skills
"exercises": "2.30.0", # Migration 068 ai_skill_retrieval_profiles; suggest focus_areas_context; exercise_ai Kontext-Katalog + Gewichtungen
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -37,6 +37,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.153",
"date": "2026-05-29",
"changes": [
"Migration 068: ai_skill_retrieval_profiles — konfigurierbare Gewichte/Quotes fuer Skill-Katalog in exercise_ai",
"POST /api/exercises/ai/suggest: optionales Body-Feld focus_areas_context; regenerate nutzt gespeicherte Fokusbereiche",
"exercise_ai: kontextbezogene Skill-Auswahl (Score, Kategorie-Caps), Keyword-Patches wie Rollenspiel vs. Haltegriff/Befreiung",
],
},
{
"version": "0.8.152",
"date": "2026-05-22",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-20
**App-Version / DB-Schema:** App **`0.8.149`** (Einheiten-Editor Vollseite), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
**Stand:** 2026-05-29
**App-Version / DB-Schema:** App **`0.8.153`** (KI Skill-Retrieval-Profile), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
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**.
@ -88,6 +88,14 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.153**)
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
- **DB:** Migration **`068`** Tabelle **`ai_skill_retrieval_profiles`** (Konfig **`config`**) mit Seed „Standard“ + „Gewaltschutz“ (wenn Focus `Gewaltschutz` in `focus_areas` existiert)
- **`exercise_ai`:** Gewichtungen, KategorieAnteilCaps (~Token), Keyword-Patches aus Ziel/Durchführung (z.B. Rollenspiel vs. Befreiung/Haltegriff)
- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** verwendet gespeicherte `exercise_focus_areas` automatisch für dieselbe Retrieval-Logik
- **Frontend:** `ExerciseFormPageRoot.jsx` übergibt `focus_areas_context` aus Einordnung; KI-Übernahmedialog nach API-Antwort
---
## 3. Trainingsrahmenprogramm & PlanungsBlueprint (kurz)

View File

@ -980,6 +980,18 @@ function ExerciseFormPageRoot() {
const snapshotSummaryHtml = formData.summary || ''
const snapshotSkills = cloneExerciseSkillRows(formData.skills)
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
})
setAiSuggestBusy(true)
try {
const res = await api.suggestExerciseAi({
@ -987,6 +999,7 @@ function ExerciseFormPageRoot() {
goal: formData.goal || '',
execution: formData.execution || '',
focus_area_hint: focusHint || undefined,
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
include_summary: summaryOn,
include_skills: skillsOn,
})