From e4451e1362dfe68f0cb08f6acebecc589678286a Mon Sep 17 00:00:00 2001
From: Lars
Date: Fri, 22 May 2026 07:52:31 +0200
Subject: [PATCH 01/10] Enhance Exercise Management and AI Integration
- Updated the exercise form to include a tabbed navigation structure, improving user experience with sections for Stammdaten, Anleitung, Einordnung, Varianten, and Medien & Mehr.
- Introduced the concept of **Freigabelevel** (visibility level) in the UI, replacing previous terminology for clarity and consistency across components.
- Implemented new AI endpoints for exercise suggestions and regeneration, allowing for dynamic content generation without direct database writes.
- Removed the legacy `is_primary` flag from exercise skills in the UI, ensuring that intensity levels (`niedrig`, `mittel`, `hoch`) are the primary focus for skill management.
- Enhanced the variant management process with improved saving mechanisms and UI updates to reflect changes more intuitively.
---
.claude/docs/PROJECT_STATUS.md | 4 +-
.../AI_EXERCISE_ASSISTANT_VISION.md | 100 ++++++
.claude/docs/functional/DOMAIN_MODEL.md | 2 +-
.../docs/functional/SHINKAN_REQUIREMENTS.md | 2 +
.../library/FEATURES_DELIVERED_2026-Q2.md | 59 +++-
.../ACCESS_LAYER_AND_GOVERNANCE_PLAN.md | 20 +-
.../docs/technical/AI_PROMPT_SYSTEM_SPEC.md | 3 +-
.../technical/AI_TRAINING_PLANNING_CONCEPT.md | 46 ++-
.claude/docs/technical/EXERCISES_API_SPEC.md | 58 +++-
.../docs/technical/EXERCISES_ARCHITECTURE.md | 17 +-
.../technical/EXERCISES_FRONTEND_ROUTING.md | 32 +-
.claude/docs/technical/KI_FEATURES_SPEC.md | 9 +-
.../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 5 +-
.../AI_EXERCISE_IMPLEMENTATION_PLAN.md | 58 ++++
backend/db.py | 9 +-
backend/exercise_ai.py | 320 ++++++++++++++++++
.../067_ai_prompts_exercise_assistant.sql | 141 ++++++++
backend/openrouter_chat.py | 100 ++++++
backend/requirements.txt | 1 +
backend/routers/exercises.py | 109 +++++-
backend/version.py | 15 +-
docs/FACHLICHE_NUTZERFUNKTIONEN.md | 13 +-
docs/HANDOVER.md | 11 +-
frontend/src/api/exercises.js | 19 +-
.../exercises/ExerciseFormPageRoot.jsx | 165 ++++++++-
25 files changed, 1240 insertions(+), 78 deletions(-)
create mode 100644 .claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md
create mode 100644 .claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
create mode 100644 backend/exercise_ai.py
create mode 100644 backend/migrations/067_ai_prompts_exercise_assistant.sql
create mode 100644 backend/openrouter_chat.py
diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md
index e4a793f..3ab8915 100644
--- a/.claude/docs/PROJECT_STATUS.md
+++ b/.claude/docs/PROJECT_STATUS.md
@@ -83,7 +83,9 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
- [x] **Varianten** (CRUD, Reorder, Voraussetzung) + Anzeige im Detail
- [x] **Progressionsgraph zwischen Übungen** (Bibliotheks-Container, Kanten, Sequenz-Bulk, Varianten-Knoten — Zwischenstand, siehe TRAINING_FRAMEWORK_SPEC §4)
- [x] Medien (Upload/Embed, rollenabhängige Größenlimits)
-- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen)
+- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen; **Freigabelevel** als UI-Begriff für `visibility`)
+- [x] **Übungsformular:** Registerkarten (Stammdaten … Medien & Mehr), kompakte Chip-Editoren, Varianten-Speichern über Aktionsleiste
+- [x] **Fähigkeiten-Intensität** ohne Primär-Flag (`niedrig`/`mittel`/`hoch`; Backend `is_primary` immer false)
- [x] Exercise Blocks (Bausteine)
- [x] Saved Searches (wo implementiert)
diff --git a/.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md b/.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md
new file mode 100644
index 0000000..4b786ba
--- /dev/null
+++ b/.claude/docs/functional/AI_EXERCISE_ASSISTANT_VISION.md
@@ -0,0 +1,100 @@
+# KI-Unterstützung bei Übungen – Produkt-Vision
+
+**Version:** 0.1
+**Datum:** 2026-05-22
+**Status:** Zielbild / Anforderungsgrundlage (nicht gleich Ist-Spec – technische Schnittstellen: **`technical/KI_FEATURES_SPEC.md`**, **`technical/AI_PROMPT_SYSTEM_SPEC.md`**, **`technical/AI_TRAINING_PLANNING_CONCEPT.md` §1.1**)
+**Zielgruppe:** Product, Trainer-UX, später Admin-Werkzeuge
+
+---
+
+## 1. Übergeordnete Prinzipien
+
+1. **Immer Vorschlag, nie blind überschreiben**
+ Die KI liefert **Vorschläge** (Änderungen, Ergänzungen, Strukturen). Bestehende Inhalte werden **nicht** still ersetzt. Übernahme erfolgt durch den Nutzer: **teilweise** (Felder/Stellen/Blöcke) oder **komplett** („Vorschlag gesamt akzeptieren“).
+
+2. **Granulare Anforderung im Editor**
+ Innerhalb einer Übung soll KI-Unterstützung **feldbasiert oder bereichsbasiert** auslösbar sein (z. B. nur „Anleitung schärfen“, nur „Fähigkeiten“, nur „Variantenrahmen“) **oder** als **Komplettüberarbeitung** mit klarem Warnhinweis (Umfang/transparenter Diff).
+
+3. **Nachweisliche Herkunft**
+ Übernommene KI-Inhalte werden technisch dort abgebildet, wo bereits vorgesehen (z. B. **`summary_ai_generated`**, **`exercise_skills.ai_suggested`**) und um analogen Hinweis für weitergehende Textfelder/Varianten **erweitert**, sobald Implementierung konkret wird.
+
+---
+
+## 2. Funktionsbereiche (Vision)
+
+### 2.1 Von der Idee zur kompletten Übung („Zielausbau“)
+
+**Einstieg minimal:** Kurzbeschreibung oder Stichwort, **Ziel** („was soll erreicht werden?“), wenige **Rahmenparameter** (z. B. Fokusbereich, Trainingszeit, Teilnehmerzahl, Alter, Platzausstattung, Sicherheitshinweise – konkrete Dropdowns/Freifelder in UX später festlegen).
+
+**KI-Aufgabe:** aus diesem dünnen Kontext einen **übernehmbaren Entwurf** einer **ganzen Übung** erzeugen: Titel‑Vorschlag, Ziel-/Durchführungstext, Sicherheit/Organisation, ggf. Trainerhinweise – **immer als Vorschlagspaket**, nicht als Speicher ohne Bestätigung.
+
+**Abgrenzung:** Kombinationsübungen / komplexe Methodenprofile können **phasenweise** später einbezogen werden (Verweis Fachspez Trainingsmodule).
+
+### 2.2 Anleitung (Durchführung / „Ausführung“) maximal hilfreich
+
+**Ziel:** Die **Ausführungs-/Anleitungsbereiche** sollen sich **didaktisch klar**, **teilbar** und **wieder verwendbar** lesen – ohne den Trainer zu entmindigen.
+
+**KI-Aufgabe:** Überarbeitungsvorschlag für Struktur (nummerierte Schritte, Zeiten pro Block, häufige Fehler, Progressionshinweise innerhalb der Übung wo sinnvoll). **Selektiver** Aufruf: nur diese Felder oder nur ein markierter Abschnitt (wenn UX Textauswahl unterstützt).
+
+### 2.3 Kurzbeschreibung (`summary`)
+
+**KI-Aufgabe:** Aus den **relevanten Übungstexten** eine **Liste-/Karte-taugliche** Kurzfassung generieren — wie in **`KI_FEATURES_SPEC.md`** beschrieben, mit **Ablehnen / Bearbeiten / Übernehmen**.
+
+### 2.4 Einordnung – primär **Fähigkeiten**
+
+**KI-Aufgabe:** automatische Erkennung und **Zuordnung** zum **globale Skills-Katalog** inklusive:
+
+- **Intensität** (`exercise_skills`)
+- **Skill-Level**: `required_level` / `target_level` nach **kanonischen Slugs** (Backend-konform)
+- **`is_primary`** / Priorisierung wo fachlich sinnvoll
+
+**Prompt-Kontext für Qualität:** Stammfelder wie `skills.description`, **`karate_relevance`**, **`relevance_level`**, **`focus_areas`**, optional **`skill_level_definitions`** nur für eine **kurze Kandidatenliste** (zweite Runde möglich) – keine vollständigen Romane für den gesamten Katalog auf einmal.
+
+### 2.5 Varianten (optional, später prioritär erwägenswert)
+
+**Vision:** Aus Ziel-/Durchführungstext **mehrere sinnvolle Ausprägungen** als **Übungsvarianten** vorschlagen oder einzelne erzeugen (**progression**, **Schwierigkeit**, andere Paararbeit, Gerätevariation) mit **übernehmbarem** Datenmodell gleich dem bestehenden `exercise_variants`.
+
+**Randbedingungen:** Validierung gegen Übungstyp (Kombinationsübungen ohne Varianten laut Produktstand), keine Halluzination fremder IDs.
+
+---
+
+## 3. Kontextbezug später: Nachbearbeitung aus der Trainingsplanung
+
+**Vision:** Hinweise aus der **Nachbearbeitung** einer Trainingseinheit (Ist‑Minuten, Trainer-Notizen, Abweichungen „was lief nicht?“ – je nach Datenmodell) fließen **optional** als Kontext in eine **erneute KI-Überarbeitung der betroffenen Übung** ein („Übung aus den Erfahrungen der Gruppe verbessern“).
+
+**Konsequenz technisch später:** Zugriffsrechte, Mandant, keine unzulässige Verknüpfung personenbezogener Sportlerdaten; Aggregation auf **Einheit-/Gruppe** und **bereits dokumentierte Trainer-Insights**.
+
+---
+
+## 4. Admin: Massenverarbeitung und Analyse
+
+**Vision für Plattform-/Vereins-Admins:**
+
+| Thema | Richtungsziel |
+|-------|----------------|
+| **Massenverarbeitung** | Batch: z. B. Zusammenfassungen nachziehen, fehlende Skills vorschlagen, einheitlicher Stil bei importiertem Bestand — immer mit **Review-Queue**, nicht ohne menschliche Freigabe skalierungskritisch. |
+| **Analyse / Qualität** | Werkzeugkasten oder Berichte: **welche Übungen** sollten überarbeitet werden? z. B. leere/kurze `summary`, fehlende `goal`/`execution`, **fehlende oder widersprüchliche Skill-Zuordnung**, Import-Herkunft ohne Plausibilität, Kombi-Slots unvollständig, sehr alte Imports. |
+| **Lückenkarten** | Z. B. Abgleich gegen **Skill-Discovery**/Profil-Analysen („keine Übung deckt Fähigkeit X ab“ auf gewähltem Korpus); Verbindung zu **`skill-discovery`** entscheidend später im Detail (kein automatischer Rewrite ohne Policy). |
+
+**Governance:** Sichtbarkeit (`official`, Verein), Rechte (**Superadmin** vs. Vereinsinhalt), Audit der KI-Anwendung bei Massenjobs.
+
+---
+
+## 5. Phasierung (überarbeitungsfähig)
+
+| Phase | Inhalt |
+|-------|--------|
+| **P0** | KI-Service + Prompts aus DB + **Suggestion-only** UX; Kern: **Summary** + **Skills** (wie Spec-Minimum), **ein Feld / Komplettpaket mit Diff** nach UX. |
+| **P1** | **Anleitung überarbeiten** + **„von Idee zur Übung“** (Zielausbau) mit Rahmenparameter-Form |
+| **P2** | **Variantenvorschläge** mit strenger Validation |
+| **P3** | **Planungs-/Nachbereitungskontext** |
+| **P4** | **Admin** Massen-/Analyse (Queue + Reports + Governance) |
+
+---
+
+## 6. Offene Produkt-/Fachfragen
+
+- Minimaler **Parameterbau** beim Zielausbau (Pflicht vs. optional).
+- Umgang mit **Medien**/Inline-Verweisen beim KI-Text – nichts zerstören, Platzhalter erhalten (siehe Medien-Spec §11).
+- **Kombinationsübungen:** welche Teilaspekte dürfen KI anfassen?
+- Limits: **Tokens**, **Rate-Limits**, Kostenüberwachung pro Verein/global.
diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md
index d512d08..158d784 100644
--- a/.claude/docs/functional/DOMAIN_MODEL.md
+++ b/.claude/docs/functional/DOMAIN_MODEL.md
@@ -57,7 +57,7 @@ Haupt-Kategorie (KARATE / ALLGEMEINE)
- Selbstverteidigung ✓
- Gewaltschutz ✓
-**Technische Umsetzung:** M:N Beziehungen mit `is_primary` Flag.
+**Technische Umsetzung:** M:N-Beziehungen mit optionalem `is_primary`-Flag bei **Fokusbereichen, Stilrichtungen, Trainingsstilen und Zielgruppen** — nicht bei `exercise_skills` (dort nur Intensität `niedrig|mittel|hoch`).
### 3. Hierarchischer Kontext (§8.1)
diff --git a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
index 84c44ba..9141e4f 100644
--- a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
+++ b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md
@@ -12,6 +12,8 @@ Ausführliche fachliche Inhalte:
| [**Trainingsmodule & Kombinationsübungen (Fachspez V3)**](./Shinkan%20Trainingsmodule%20Kombinationsuebungen%20Spezifikation%20V2.md) | Produktlogik Module/Kombinationen, **Methoden-Archetypen**, **Coaching-Stufen (§ 10.4)**, kanonische Archetyp-IDs **§ 10.2.1**, **Anhang A** Implementierungsabgleich |
| [**Umsetzungsplan Trainingsmodule & Kombination**](../working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md) | Phase 1–5, Coaching-Pakete 4a–4d, Verweis auf Code-Stand |
| [**Technischer Entwurf Module/Kombination**](../technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md) | API/Daten-Ideen; aktueller Coach-/Archetyp-Abgleich im Kopfabschnitt |
+| [**KI-Unterstützung Übungen (Vision)**](./AI_EXERCISE_ASSISTANT_VISION.md) | Zielbild Zielausbau, Vorschlags-UX (teilweise/komplett), Skills/Varianten, später Planungskontext, Admin-Masse/Qualität |
+| [**KI Übungen – Umsetzungsplan**](../working/AI_EXERCISE_IMPLEMENTATION_PLAN.md) | Stufen S0–S6, Driftschutz-Regeln, Checkliste gegen Specs |
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) (Abschnitt 12), Repo-Root **`docs/HANDOVER.md`**, **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`**.
diff --git a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md
index e6f1af9..df5e5cb 100644
--- a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md
+++ b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md
@@ -68,7 +68,7 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`)
- Tabs **Liste** · **Progressionsgraphen** (`ExerciseProgressionGraphPanel`): Graphen anlegen/bearbeiten, Kanten inkl. Sequenz-Bulk und Tabellenansicht.
-- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, Sichtbarkeit, Status).
+- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, **Freigabelevel**, Status).
- **Filter-Chips** unter der Suchleiste; Klick entfernt einen Filter; Badge am Filter-Button = Anzahl Chips.
- **Kein Vollbild-Spinner** bei jeder Suche: nur noch **`listFetching`** — Suchfelder bleiben im DOM (**Fokus/Cursor** bleiben erhalten); Liste zeigt optional „Aktualisiere Treffer…“.
- **`
Date: Fri, 22 May 2026 09:49:08 +0200
Subject: [PATCH 04/10] Implement AI Skill Retrieval Profiles and Enhance
Exercise AI Functionality
- 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.
---
.../docs/technical/AI_PROMPT_SYSTEM_SPEC.md | 2 +
.claude/docs/technical/KI_FEATURES_SPEC.md | 35 +-
.../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 4 +-
.../AI_EXERCISE_IMPLEMENTATION_PLAN.md | 28 +-
.../AI_SKILL_RETRIEVAL_PROFILES_SPEC.md | 120 +++++
backend/exercise_ai.py | 482 ++++++++++++++++--
.../068_ai_skill_retrieval_profiles.sql | 125 +++++
backend/routers/exercises.py | 34 ++
backend/version.py | 17 +-
docs/HANDOVER.md | 12 +-
.../exercises/ExerciseFormPageRoot.jsx | 13 +
11 files changed, 802 insertions(+), 70 deletions(-)
create mode 100644 .claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
create mode 100644 backend/migrations/068_ai_skill_retrieval_profiles.sql
diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
index baada4d..ba8b968 100644
--- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
+++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md
@@ -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
diff --git a/.claude/docs/technical/KI_FEATURES_SPEC.md b/.claude/docs/technical/KI_FEATURES_SPEC.md
index 2b590e7..0ca3fb7 100644
--- a/.claude/docs/technical/KI_FEATURES_SPEC.md
+++ b/.claude/docs/technical/KI_FEATURES_SPEC.md
@@ -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
{
diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
index 3b89aa4..c592e11 100644
--- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
+++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
@@ -13,7 +13,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
| 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 A–C.
**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).
---
diff --git a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
index 9c5e042..1fbc161 100644
--- a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
+++ b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md
@@ -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:** S0–S4 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; S1–S4 als erster Umsetzungspfad.
- **2026-05-22:** S1–S4 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).
-**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.
+**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.
-**Bewusst noch nicht:** automatische KI beim Speichern (**S5**), Setzen von `summary_ai_generated` bei manuellen UI-Änderungen, Prompt-Admin-UI, Rate-Limits.
+**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.
+
+**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`.
diff --git a/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md b/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
new file mode 100644
index 0000000..b9411ec
--- /dev/null
+++ b/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
@@ -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 (0–1), 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.
diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py
index 55e07ab..255ad4c 100644
--- a/backend/exercise_ai.py
+++ b/backend/exercise_ai.py
@@ -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",
+]
diff --git a/backend/migrations/068_ai_skill_retrieval_profiles.sql b/backend/migrations/068_ai_skill_retrieval_profiles.sql
new file mode 100644
index 0000000..51fef7c
--- /dev/null
+++ b/backend/migrations/068_ai_skill_retrieval_profiles.sql
@@ -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;
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 8b9c5a6..d79b0b9 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -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,
)
diff --git a/backend/version.py b/backend/version.py
index 70d9a29..c0896ac 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -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",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 741eed2..fba1dad 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -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, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff)
+- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** 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 & Planungs‑Blueprint (kurz)
diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx
index 3dcbfd4..13f9329 100644
--- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx
+++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx
@@ -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,
})
--
2.43.0
From 286c36e9d7b32ffa8f0440b4baa8c88604fe239f Mon Sep 17 00:00:00 2001
From: Lars
Date: Fri, 22 May 2026 09:57:39 +0200
Subject: [PATCH 05/10] Document Superadmin API for AI Skill Retrieval Profiles
and Update Access Layer
- Added documentation for the new Superadmin CRUD endpoints for managing AI Skill Retrieval Profiles (`/api/admin/ai-skill-retrieval-profiles*`).
- Updated the ACCESS_LAYER_ENDPOINT_AUDIT.md to include the new Superadmin API and its exempt status.
- Registered the ai_skill_retrieval_admin router in the backend and updated versioning to reflect the changes.
- Enhanced the frontend with a new Admin page for AI Skill Retrieval, including navigation and API integration for profile management.
---
.../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 4 +-
.../AI_SKILL_RETRIEVAL_PROFILES_SPEC.md | 5 +-
backend/main.py | 3 +-
backend/routers/ai_skill_retrieval_admin.py | 370 +++++++++++++++
backend/scripts/check_access_layer_hints.py | 1 +
backend/version.py | 11 +-
docs/HANDOVER.md | 8 +-
frontend/src/App.jsx | 9 +
frontend/src/components/AdminPageNav.jsx | 3 +-
.../src/pages/AdminAiSkillRetrievalPage.jsx | 435 ++++++++++++++++++
frontend/src/utils/api.js | 32 ++
11 files changed, 871 insertions(+), 10 deletions(-)
create mode 100644 backend/routers/ai_skill_retrieval_admin.py
create mode 100644 frontend/src/pages/AdminAiSkillRetrievalPage.jsx
diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
index c592e11..1937e1d 100644
--- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
+++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
@@ -34,17 +34,19 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
+| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
**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-29 — gleiche Endpunkte; `POST /api/exercises/ai/suggest` ergänzt um optionales `focus_areas_context` für `ai_skill_retrieval_profiles` (Migration 068).
+Letzte Änderung: 2026-05-29 — Superadmin-CRUD `/api/admin/ai-skill-retrieval-profiles*` dokumentiert; `POST /api/exercises/ai/suggest` mit optionalem `focus_areas_context` (Migration 068).
---
### Changelog (Fortführung)
+- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert.
diff --git a/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md b/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
index b9411ec..bbe719a 100644
--- a/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
+++ b/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
@@ -111,10 +111,11 @@ Weitere Profile (Karate-Schwerpunkt etc.) später per Admin-SQL oder UI.
`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).
+`POST …/ai/regenerate` nutzt gespeicherte `exercise_focus_areas` zur gleichen Retrieval-Logik wie Suggest.
----
+**Pflege der Profile:** Superadmin ohne Mandantenwahl — **`GET|POST /api/admin/ai-skill-retrieval-profiles`**, **`GET|PUT|DELETE /api/admin/ai-skill-retrieval-profiles/{id}`** (`routers/ai_skill_retrieval_admin.py`); Web-UI Superadmin unter **`/admin/ai-skill-retrieval`**.
## 6. Changelog
+- **2026-05-29:** Superadmin-Pflege-Endpoints + UI‑Route dokumentiert (`/admin/ai-skill-retrieval`).
- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration.
diff --git a/backend/main.py b/backend/main.py
index d15db0b..32e5519 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
-from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
+from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_skill_retrieval_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@@ -220,6 +220,7 @@ app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)
app.include_router(legal_documents.router)
app.include_router(content_reports.router)
+app.include_router(ai_skill_retrieval_admin.router)
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für
/